mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:51:21 +00:00
M-EGUI037: reconcile WordCraft main and preserve IME editing
This commit is contained in:
commit
a605fa5ca7
116 files changed
+7133
-528
No files matched your search
@@ -25,6 +25,7 @@ jobs:
|
||||
app-name: wordcraft
|
||||
app-title: WordCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
Generated
+184
-39
@@ -827,6 +827,15 @@ dependencies = [
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -977,18 +986,30 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f7cc8a7df15e824b02cf73859567a6242d3a7de4#f7cc8a7df15e824b02cf73859567a6242d3a7de4"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
dependencies = [
|
||||
"intl_pluralrules",
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unic-langid",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -997,7 +1018,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -1580,6 +1601,17 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2348,6 +2380,23 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
@@ -2356,11 +2405,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
@@ -2372,6 +2442,9 @@ dependencies = [
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -2381,6 +2454,32 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
@@ -2410,6 +2509,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2417,6 +2519,30 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -2492,15 +2618,6 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "intl_pluralrules"
|
||||
version = "7.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
|
||||
dependencies = [
|
||||
"unic-langid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.2"
|
||||
@@ -2650,7 +2767,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -2749,6 +2866,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -3637,12 +3757,24 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5365,24 +5497,6 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
|
||||
dependencies = [
|
||||
"unic-langid-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid-impl"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
|
||||
dependencies = [
|
||||
"tinystr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -5407,6 +5521,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
@@ -5455,6 +5578,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -6564,6 +6693,7 @@ dependencies = [
|
||||
name = "wordcraft-geom"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -6635,6 +6765,7 @@ dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6656,11 +6787,13 @@ name = "wordcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6724,11 +6857,20 @@ dependencies = [
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wuff"
|
||||
@@ -6860,9 +7002,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
@@ -7055,15 +7197,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
@@ -7073,9 +7218,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
version = "0.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+2
-2
@@ -54,7 +54,7 @@ harfrust = "0.13"
|
||||
wuff = "0.2.9"
|
||||
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 = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
@@ -112,7 +112,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 = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
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" }
|
||||
|
||||
@@ -101,7 +101,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
|
||||
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
|
||||
9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef. The backend retains its MIT OR Apache-2.0 licensing
|
||||
17744430be327d0257496a6a31c8d38fe20a6756. The backend retains its MIT OR Apache-2.0 licensing
|
||||
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
|
||||
The backend patch prevents finalization during an unwind and rejects a writer after failed
|
||||
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
|
||||
|
||||
+36
-2
@@ -1,8 +1,10 @@
|
||||
# WordCraft roadmap
|
||||
|
||||
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting tLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; preLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -118,7 +120,7 @@ Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`,
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian proofing is not included. Hungarian (`hu`) has a
|
||||
complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete
|
||||
2,448-entry catalog, also without proofing. Croatian (`hr`, #1271) has a complete catalog.
|
||||
Proofing: English, Polish, Portuguese, Italian, Croatian, Turkish and Russian (spelling, grammar; hyphenation except Croatian), plus Czech and Indonesian (system Hunspell spelling, generic checks), chosen per run from its language; Bulgarian and German hyphenation only. Word ships
|
||||
Proofing: English, Polish, Portuguese, Italian, Croatian, Turkish and Russian (spelling, grammar; hyphenation except Croatian), plus Czech, Indonesian and Spanish (system Hunspell spelling, generic checks; Spanish per variety: Spain, Mexico, Latin America, Uruguay), chosen per run from its language; Bulgarian and German hyphenation only. Word ships
|
||||
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -136,15 +138,29 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087).
|
||||
- 2026-10-23: cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison.
|
||||
- 2026-10-23: cu20we: paragraph-relative box floats start above effective space before; header, footer, cell and text-box layout regressions pass. Framed paragraph positioning is unchanged; no new native Word comparison.
|
||||
|
||||
- 2026-10-23: DOCX: a document whose default font size is unset keeps its size when saved and reopened. The size was left out of the file, and the reader read the missing size as 10 pt while the document resolved it as 11 pt; the file now says 11 pt (`DEFAULT_FONT_SIZE`). Files from other programs without a size are read as before.
|
||||
- 2026-10-23: Insert › Table: hovering the grid shows the table on the page where it would go, as Word's Live Preview does, and the preview follows the pointer from square to square. It is laid out on a copy of the document, so nothing changes, and Undo has nothing new, until a square is clicked (#3931).
|
||||
- 2026-10-24: Exact line spacing fixes the floating-table contact boundary independently of paragraph-mark metrics (#3977). Both row selection and word skipping use it; original regressions cover oversized and undersized marks, contact and one-twip offsets in modes 11/12/14/15. Shape behavior is unchanged.
|
||||
- 2026-10-24: A line touching a floating table at its bottom edge keeps clear of the table (#3977), in both row-span selection and word skipping. Shapes keep their strict overlap rule. Original Source Sans 3 boundary and narrow-strip controls cover modes 11/12/14/15; the expected Word distinction is cyril265's Word for Mac 16.114.1 observation.
|
||||
- 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document.
|
||||
- 2026-10-24: AutoFormat As You Type has "Built-in Heading styles" (off by default): pressing Enter twice after a line of five or fewer words that ends in a letter or digit makes it Heading 1, or Heading 2 or 3 when the line starts with one or two tabs.
|
||||
- 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison.
|
||||
- 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982).
|
||||
- 2026-10-24: Spelling: with Ignore web and file addresses on, a web or e-mail address after a no-break space or an ideographic space is skipped, as it is after a plain space. Its words were marked as misspelled.
|
||||
- 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663).
|
||||
- 2026-10-24: AutoFormat As You Type has "Set left- and first-indent with tabs and backspaces" (off by default): Tab at the start of a paragraph with text indents its first line, Tab at the start of a later line indents the whole paragraph, and Backspace at the start takes the indents back.
|
||||
- 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637).
|
||||
- 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools). Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged
|
||||
- 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed.
|
||||
- 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891).
|
||||
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
|
||||
- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs.
|
||||
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
|
||||
- 2026-10-24: Track Changes: the border-line (`---` and the others) and `+---+` table AutoFormats mark the typed characters deleted instead of removing them, so Reject All brings them back. Characters the same author typed with tracking on are removed, as a tracked Backspace removes them. A border at the top of a story is a tracked formatting change too, as the border on the paragraph above already was. The table is inserted untracked, as Insert › Table inserts one. A line that holds a tracked deletion no longer triggers either AutoFormat.
|
||||
|
||||
Four geometry-dependent layout controls request Source Sans 3 explicitly. Installed substitute fonts no longer choose their line breaks. Assertions and renderer font fallback policy are unchanged.
|
||||
|
||||
@@ -169,7 +185,11 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. |
|
||||
| 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only |
|
||||
| 2026-10-25 | #4085 review: highlight toggling checks the full target with constant resolved-run storage; a different colour after 10,000 runs prevents clearing. |
|
||||
| 2026-10-24 | #4085: Ctrl+Alt+H toggles the last chosen highlight colour; explicit colour setters remain idempotent. Mixed, inherited and pending formatting and Undo/Redo covered by command/key regressions. |
|
||||
| 2026-10-23 | Comment times show in the user's own time zone, as in Word: the Comments pane converts them (it showed the file's UTC time) and the balloons show them under each author (they showed none). The file's UTC time (Word's `w16cex:dateUtc`) wins over `w:date`, a time naming its zone is converted, and one without a zone shows as written (#2322, #2337) |
|
||||
| 2026-10-22 | Compare results opened as new or revised untitled documents start without the previous document's version snapshots. A comparison or mailing result rejected by protection keeps the snapshots and save target with the original document. |
|
||||
| 2026-10-20 | The Document Inspector's removals are refused while the document tracks changes: they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back |
|
||||
| 2026-10-20 | A version snapshot belongs to the document it was taken from: the snapshots went with the document, so a restore can only ever bring back a version of the document that is open |
|
||||
@@ -594,13 +614,27 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
|
||||
## Revision history
|
||||
|
||||
> 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) |
|
||||
| 2026-10-23 | minor | A command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it. |
|
||||
| 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged |
|
||||
| 2026-10-23 | trivial | cu20we: box float paragraph origins corrected, synthetic regressions added |
|
||||
| 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged |
|
||||
| 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: AutoCorrect corrects a word typed after a no-break space or an ideographic space |
|
||||
| 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). |
|
||||
| 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). |
|
||||
| 2026-10-23 | trivial | Progress log: interface translations from storytold/craft-i18n through the `craft-i18n` crate |
|
||||
| 2026-10-23 | trivial | Progress log: comment times in local time (#2322, #2337) |
|
||||
| 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) |
|
||||
| 2026-10-23 | trivial | Progress log: a relationship the markup names without the prefix is read as it wrote (#3411 follow-up) |
|
||||
| 2026-10-21 | trivial | Progress log: a document with many relationships opens in the time its part takes (#3411) |
|
||||
| 2026-10-23 | trivial | Progress log: an empty paragraph with a hidden mark draws no list number (#3982) |
|
||||
| 2026-10-22 | minor | Progress log: Spanish proofing (#3360, #3553) |
|
||||
| 2026-10-23 | trivial | Progress log: Update Table keeps a two-column TOC from a localized Word in its columns (#3889) |
|
||||
| 2026-10-22 | minor | Compare's new and revised untitled results clear the previous document's version snapshots; a rejected protected comparison or mailing result preserves the snapshots and file identity. |
|
||||
| 2026-10-22 | minor | The CLI author flag and the Options user name field cannot change the session name after protection starts (#3279 follow-up). |
|
||||
| 2026-10-21 | trivial | Progress log: a word with no break opportunity in it no longer makes the layout quadratic (#3412) |
|
||||
|
||||
@@ -379,6 +379,14 @@ fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
|
||||
raw.get(at + 1).map(|a| PathBuf::from(a.as_ref()))
|
||||
}
|
||||
|
||||
/// Whether `addr` (`HOST:PORT`, as `--connect` gives it) is at least shaped like an address: a
|
||||
/// host of some kind and a numeric port. Malformed addresses fail at startup — a lazy remote
|
||||
/// would otherwise retry one on every call and never say why nothing connects.
|
||||
fn valid_connect_addr(addr: &str) -> bool {
|
||||
let Some((host, port)) = addr.rsplit_once(':') else { return false };
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
}
|
||||
|
||||
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let script_path = script_operand(args);
|
||||
let (args, file) = text_args(args)?;
|
||||
@@ -582,11 +590,27 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
|
||||
}
|
||||
Some(addr) => {
|
||||
let remote = wordcraft_mcp::Remote::connect(&addr).map_err(|e| format!("can't reach the WordCraft app at {addr}: {e}"))?;
|
||||
if let Some(w) = remote.key_warning() {
|
||||
eprintln!("wordcraft-cli: {w}");
|
||||
// A malformed address must fail at startup, not on every tool call the lazy
|
||||
// remote would retry it with.
|
||||
if !valid_connect_addr(&addr) {
|
||||
return Err(format!("mcp: --connect needs HOST:PORT with a numeric port, got `{addr}`").into());
|
||||
}
|
||||
match wordcraft_mcp::Remote::connect(&addr) {
|
||||
Ok(remote) => {
|
||||
eprintln!("wordcraft-cli mcp: connected to WordCraft at {addr}");
|
||||
if let Some(w) = remote.key_warning() {
|
||||
eprintln!("wordcraft-cli: {w}");
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
// The app may start after the client does: serve anyway and connect on the
|
||||
// first call, or the client marks the server failed for its whole session
|
||||
// (#4078).
|
||||
Err(e) => {
|
||||
eprintln!("wordcraft-cli mcp: WordCraft is not reachable at {addr} yet ({e}); will retry on each call");
|
||||
Box::new(wordcraft_mcp::Remote::lazy(&addr))
|
||||
}
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
None => {
|
||||
let mut headless = wordcraft_mcp::Headless::default();
|
||||
@@ -807,6 +831,9 @@ mod tests {
|
||||
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
|
||||
// The app's user name is the person's own; a bridge flag must not rewrite it.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
|
||||
// A malformed address fails at startup, not on every retried call.
|
||||
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
|
||||
// The flag goes through the same protection check as the command.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
|
||||
@@ -29,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
|
||||
//!
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
//! - Save As, and File › Export to another document format (which runs Save As): `true` for a
|
||||
//! format that keeps everything (.docx and the other Word packages, .odt, .rtf). The document
|
||||
//! is now that file; the next Save sends its name with `false`.
|
||||
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
|
||||
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
|
||||
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the parent too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
pub fn set_author(app: &mut WordApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
@@ -15,6 +18,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -25,17 +25,35 @@ pub fn restore(app: &mut WordApp, stored: Option<&str>) {
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
last: Option<String>,
|
||||
/// A host page's name for the user (`?author=`) and the user's own, which is stored in its
|
||||
/// place while the user keeps the host's.
|
||||
host_author: Option<(String, String)>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(stored: Option<String>) -> Self {
|
||||
Self { last: stored }
|
||||
Self { last: stored, host_author: None }
|
||||
}
|
||||
|
||||
/// The session signs with `host`, a host page's name for the user, but `own` stays the
|
||||
/// stored name. A name the user then chooses in File › Options is stored as usual.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub fn keep_own_author(&mut self, host: &str, own: &str) {
|
||||
if host != own {
|
||||
self.host_author = Some((host.to_string(), own.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON to store when the app's preferences differ from what was last written.
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let json = match serde_json::to_string(&app.prefs()) {
|
||||
let mut prefs = app.prefs();
|
||||
if let Some((host, own)) = &self.host_author
|
||||
&& prefs.author == *host
|
||||
{
|
||||
prefs.author = own.clone();
|
||||
}
|
||||
let json = match serde_json::to_string(&prefs) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("preferences: cannot serialize: {e}");
|
||||
@@ -60,6 +78,29 @@ mod tests {
|
||||
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
|
||||
}
|
||||
|
||||
/// A host page's name for the user signs the visit but isn't stored; a name the user then
|
||||
/// chooses is.
|
||||
#[test]
|
||||
fn host_author_is_not_stored() {
|
||||
let mut a = app();
|
||||
a.session.author = "Grace Hopper".into();
|
||||
let mut sync = PrefsSync::new(None);
|
||||
let stored = sync.changed(&a).unwrap();
|
||||
a.session.author = "rob".into();
|
||||
sync.keep_own_author("rob", "Grace Hopper");
|
||||
a.run("ui.theme", json!({"value": "dark"})).unwrap();
|
||||
let json = sync.changed(&a).expect("the theme changed");
|
||||
assert_ne!(json, stored);
|
||||
let mut next = app();
|
||||
restore(&mut next, Some(&json));
|
||||
assert_eq!(next.session.author, "Grace Hopper");
|
||||
a.session.author = "Rob Hones".into();
|
||||
let json = sync.changed(&a).expect("the user chose a name");
|
||||
let mut next = app();
|
||||
restore(&mut next, Some(&json));
|
||||
assert_eq!(next.session.author, "Rob Hones");
|
||||
}
|
||||
|
||||
/// #2132: dark mode chosen on one visit is there after a reload.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
|
||||
@@ -27,9 +27,15 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
|
||||
if bridge.is_none()
|
||||
&& let Err(e) = guard_unload(dirty.clone(), saves.clone())
|
||||
{
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
let mut options = eframe::WebOptions::default();
|
||||
@@ -56,12 +62,17 @@ pub fn start() {
|
||||
// The browser's locale picks the new document's paper, the units and the
|
||||
// decimal separator (#838).
|
||||
let region = wordcraft_ui_egui::i18n::system_region();
|
||||
let sample = query().contains("sample");
|
||||
// In host mode the parent sends the document to edit; no sample meanwhile.
|
||||
let sample = app_bridge.is_none() && query().contains("sample");
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let messages = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
@@ -74,15 +85,31 @@ pub fn start() {
|
||||
let doc = app.session.blank_document();
|
||||
app.session.set_document(doc);
|
||||
}
|
||||
let prefs = crate::prefs::PrefsSync::new(stored);
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored);
|
||||
// No document to show: start on File › Home, as the desktop app does (#766).
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
// The host's name for the user signs this visit's work, but isn't stored as the
|
||||
// browser's own preference, which a visit outside the host would restore.
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
let own = app.session.author.clone();
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
prefs.keep_own_author(&app.session.author, &own);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -96,6 +123,10 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
|
||||
@@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
|
||||
@@ -95,7 +95,8 @@ pub fn formats() -> Vec<String> {
|
||||
|
||||
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
|
||||
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
|
||||
/// why (#833). The UI checks the sizes before inserting anything.
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -109,6 +110,19 @@ pub fn picture() -> Option<ClipboardPicture> {
|
||||
{
|
||||
return Some(ClipboardPicture::Files(files));
|
||||
}
|
||||
decoded_picture(&mut clipboard)
|
||||
}
|
||||
|
||||
/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats
|
||||
/// with no size limits (#3623).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
platform::picture()
|
||||
}
|
||||
|
||||
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
match clipboard.get_image() {
|
||||
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
|
||||
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
|
||||
@@ -187,6 +201,7 @@ mod platform {
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use clipboard_win::{Clipboard, EnumFormats, raw};
|
||||
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
|
||||
@@ -205,8 +220,31 @@ mod platform {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
|
||||
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
|
||||
/// it holds neither.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else {
|
||||
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
|
||||
};
|
||||
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
|
||||
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
|
||||
}
|
||||
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
|
||||
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
|
||||
}
|
||||
|
||||
/// A clipboard format's bytes, refused past the largest picture a paste takes.
|
||||
fn read(format: u32) -> Result<Vec<u8>, String> {
|
||||
match raw::size(format) {
|
||||
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
|
||||
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
|
||||
None => return Err("the clipboard picture is empty".into()),
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn discover() -> Discover {
|
||||
}
|
||||
|
||||
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
|
||||
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
|
||||
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
|
||||
@@ -176,5 +176,8 @@ display_name = "Mulai"
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
|
||||
[people.matstrange]
|
||||
sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
@@ -13,6 +13,41 @@ use crate::graphic::PathSeg;
|
||||
use crate::para::{InlineObject, ShapeKind};
|
||||
use crate::wordart::TextWarp;
|
||||
|
||||
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LineCap {
|
||||
#[default]
|
||||
Flat,
|
||||
Square,
|
||||
Round,
|
||||
}
|
||||
|
||||
impl LineCap {
|
||||
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"sq" => Self::Square,
|
||||
"rnd" => Self::Round,
|
||||
_ => Self::Flat,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the canonical DrawingML spelling used when saving the outline.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Flat => "flat",
|
||||
Self::Square => "sq",
|
||||
Self::Round => "rnd",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the cap can be omitted from older-compatible serialized shape properties.
|
||||
pub fn is_flat(&self) -> bool {
|
||||
*self == Self::Flat
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a shape's properties. Empty (the default) for most shapes.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -41,6 +76,9 @@ pub struct ShapeExtra {
|
||||
/// The outline dash pattern; absent in older saved documents means solid.
|
||||
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
|
||||
pub dash: crate::line_dash::LineDash,
|
||||
/// The outline ends; older saved documents and absent DrawingML caps are flat.
|
||||
#[serde(skip_serializing_if = "LineCap::is_flat")]
|
||||
pub cap: LineCap,
|
||||
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -272,6 +310,18 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::para::{Float, GroupChild};
|
||||
|
||||
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
|
||||
#[test]
|
||||
fn older_shape_json_defaults_to_flat_caps() {
|
||||
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(extra.cap, LineCap::Flat);
|
||||
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
|
||||
for cap in [LineCap::Square, LineCap::Round] {
|
||||
let extra = ShapeExtra { cap, ..Default::default() };
|
||||
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
|
||||
}
|
||||
}
|
||||
|
||||
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
|
||||
InlineObject::Shape {
|
||||
kind,
|
||||
|
||||
+155
-1
@@ -180,6 +180,95 @@ pub fn para_block(p: Paragraph) -> Arc<Block> {
|
||||
Arc::new(Block::Para(p))
|
||||
}
|
||||
|
||||
/// The same blocks: the same content. The blocks are compared by address first — the
|
||||
/// document is copy-on-write, so a block that changed was copied and has another address —
|
||||
/// and a block that was copied but holds the same content is compared by value.
|
||||
fn same_blocks(a: &Blocks, b: &Blocks) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y))
|
||||
}
|
||||
|
||||
/// The same block by value. A table's cells compare their blocks by address first too: an edit in
|
||||
/// one cell copies the whole table, which mustn't mean comparing every cell by value.
|
||||
fn same_block(a: &Block, b: &Block) -> bool {
|
||||
match (a, b) {
|
||||
(Block::Table(x), Block::Table(y)) => same_table(x, y),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same table. Every field is listed, so a new one does not build until someone decides
|
||||
/// whether a change to it counts.
|
||||
fn same_table(a: &Table, b: &Table) -> bool {
|
||||
let Table { props, grid, rows, controls } = a;
|
||||
let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b;
|
||||
props == other_props
|
||||
&& grid == other_grid
|
||||
&& controls == other_controls
|
||||
&& rows.len() == other_rows.len()
|
||||
&& rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y))
|
||||
}
|
||||
|
||||
fn same_row(a: &Row, b: &Row) -> bool {
|
||||
let Row { props, cells, controls } = a;
|
||||
let Row { props: other_props, cells: other_cells, controls: other_controls } = b;
|
||||
props == other_props
|
||||
&& controls == other_controls
|
||||
&& cells.len() == other_cells.len()
|
||||
&& cells.iter().zip(other_cells).all(|(x, y)| {
|
||||
let Cell { props, blocks, controls } = x;
|
||||
let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y;
|
||||
props == other_props && controls == other_controls && same_blocks(blocks, other_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same building blocks, their content compared by address first.
|
||||
fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|(x, y)| {
|
||||
let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x;
|
||||
let BuildingBlock {
|
||||
name: other_name,
|
||||
gallery: other_gallery,
|
||||
category: other_category,
|
||||
description: other_description,
|
||||
guid: other_guid,
|
||||
style: other_style,
|
||||
types: other_types,
|
||||
behaviors: other_behaviors,
|
||||
blocks: other_blocks,
|
||||
} = y;
|
||||
name == other_name
|
||||
&& gallery == other_gallery
|
||||
&& category == other_category
|
||||
&& description == other_description
|
||||
&& guid == other_guid
|
||||
&& style == other_style
|
||||
&& types == other_types
|
||||
&& behaviors == other_behaviors
|
||||
&& same_blocks(blocks, other_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same parts: the same keys, and each part the same.
|
||||
fn same_parts(a: &BTreeMap<u32, Part>, b: &BTreeMap<u32, Part>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2))
|
||||
}
|
||||
|
||||
/// The same part: the same kind, text body and blocks. Every field is listed, so a new one
|
||||
/// does not build until someone decides whether a change to it counts.
|
||||
fn same_part(a: &Part, b: &Part) -> bool {
|
||||
let Part { kind, blocks, body } = a;
|
||||
let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b;
|
||||
kind == other_kind && body == other_body && same_blocks(blocks, other_blocks)
|
||||
}
|
||||
|
||||
/// The same bytes: the same content. The bytes are compared by address first — a changed
|
||||
/// picture was copied, so it has another address — and a picture that was copied but holds
|
||||
/// the same bytes is compared by value.
|
||||
fn same_bytes(a: &BTreeMap<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
|
||||
}
|
||||
|
||||
/// Which story a position is in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -357,7 +446,8 @@ pub struct Comment {
|
||||
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub durable_id: Option<String>,
|
||||
/// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there.
|
||||
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, where `date` is local time),
|
||||
/// or the time WordCraft stamped the comment with.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date_utc: Option<String>,
|
||||
}
|
||||
@@ -839,6 +929,70 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document holds the same content as `other`: the same blocks, the same
|
||||
/// styles, lists, settings, comments and the rest.
|
||||
///
|
||||
/// The blocks are compared by address first: the document is copy-on-write, so a block
|
||||
/// that changed was copied and has another address. Comparing them by value would walk
|
||||
/// the whole document, and this is asked after every command.
|
||||
///
|
||||
/// Every field is listed, so a new one does not build until someone decides whether a
|
||||
/// change to it counts (the same rule the session's edit snapshot follows).
|
||||
pub fn same_content(&self, other: &Document) -> bool {
|
||||
let Document {
|
||||
body,
|
||||
last_section,
|
||||
parts,
|
||||
styles,
|
||||
numbering,
|
||||
comments,
|
||||
revisions,
|
||||
settings,
|
||||
core,
|
||||
sources,
|
||||
custom_props,
|
||||
footnote_separators,
|
||||
endnote_separators,
|
||||
media,
|
||||
passthrough,
|
||||
glossary,
|
||||
} = self;
|
||||
let Document {
|
||||
body: other_body,
|
||||
last_section: other_section,
|
||||
parts: other_parts,
|
||||
styles: other_styles,
|
||||
numbering: other_numbering,
|
||||
comments: other_comments,
|
||||
revisions: other_revisions,
|
||||
settings: other_settings,
|
||||
core: other_core,
|
||||
sources: other_sources,
|
||||
custom_props: other_props,
|
||||
footnote_separators: other_footnotes,
|
||||
endnote_separators: other_endnotes,
|
||||
media: other_media,
|
||||
passthrough: other_passthrough,
|
||||
glossary: other_glossary,
|
||||
} = other;
|
||||
same_blocks(body, other_body)
|
||||
&& same_parts(parts, other_parts)
|
||||
&& last_section == other_section
|
||||
&& styles == other_styles
|
||||
&& numbering == other_numbering
|
||||
&& comments == other_comments
|
||||
&& revisions == other_revisions
|
||||
&& settings == other_settings
|
||||
&& core == other_core
|
||||
&& sources == other_sources
|
||||
&& custom_props == other_props
|
||||
&& footnote_separators == other_footnotes
|
||||
&& endnote_separators == other_endnotes
|
||||
&& same_bytes(media, other_media)
|
||||
&& same_bytes(passthrough, other_passthrough)
|
||||
&& same_glossary(glossary, other_glossary)
|
||||
}
|
||||
|
||||
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
|
||||
match s {
|
||||
StoryRef::Body => Some(&self.body),
|
||||
|
||||
@@ -141,6 +141,72 @@ fn clone_is_shallow() {
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "a\nb");
|
||||
}
|
||||
|
||||
/// [`Document::same_content`] is what tells the engine that a command changed nothing: it
|
||||
/// must see every change, and it must not call two different documents the same.
|
||||
#[test]
|
||||
fn same_content_sees_every_change() {
|
||||
let d = Document::from_text("Hello\nWorld");
|
||||
assert!(d.same_content(&d.clone()), "a copy is the same document");
|
||||
// The text changed.
|
||||
let mut e = d.clone();
|
||||
e.insert_text(&Pos::body(0, 0), "x", &CharProps::default()).unwrap();
|
||||
assert!(!d.same_content(&e), "the text changed");
|
||||
// The formatting changed.
|
||||
let mut e = d.clone();
|
||||
e.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
|
||||
assert!(!d.same_content(&e), "the formatting changed");
|
||||
// The styles changed.
|
||||
let mut e = d.clone();
|
||||
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();
|
||||
e.numbering.add_list(ListKind::Numbered);
|
||||
assert!(!d.same_content(&e), "a list was added");
|
||||
// The settings changed.
|
||||
let mut e = d.clone();
|
||||
e.settings.track_changes = true;
|
||||
assert!(!d.same_content(&e), "a setting changed");
|
||||
// The metadata changed.
|
||||
let mut e = d.clone();
|
||||
e.core.title = "Draft".into();
|
||||
assert!(!d.same_content(&e), "the title changed");
|
||||
// A part changed.
|
||||
let mut e = d.clone();
|
||||
e.add_part(PartKind::Header, vec![para_block(Paragraph::new())]);
|
||||
assert!(!d.same_content(&e), "a part was added");
|
||||
// A picture changed.
|
||||
let mut e = d.clone();
|
||||
e.media.insert("i.png".into(), Arc::new(vec![1, 2, 3]));
|
||||
assert!(!d.same_content(&e), "a picture was added");
|
||||
// A revision was recorded.
|
||||
let mut e = d.clone();
|
||||
e.revisions.push(Revision::new(RevisionKind::Insert, "Mira", "2026-10-23T00:00:00Z"));
|
||||
assert!(!d.same_content(&e), "a revision was recorded");
|
||||
// A comment was added.
|
||||
let mut e = d.clone();
|
||||
e.comments.insert(1, Comment { author: "Mira".into(), ..Default::default() });
|
||||
assert!(!d.same_content(&e), "a comment was added");
|
||||
// A paragraph that was marked as changed but holds the same content is the same.
|
||||
let mut e = d.clone();
|
||||
e.para_mut(StoryRef::Body, &Path::top(0)).unwrap().touch();
|
||||
assert!(d.same_content(&e), "the paragraph only changed its counter");
|
||||
// A table copied for an edit: the same until a cell's content changes.
|
||||
let mut d = Document::from_text("Hello");
|
||||
d.body.insert(0, Arc::new(Block::Table(Table::new(2, 2, 200.0))));
|
||||
let mut e = d.clone();
|
||||
Arc::make_mut(&mut e.body[0]);
|
||||
assert!(!Arc::ptr_eq(&d.body[0], &e.body[0]));
|
||||
assert!(d.same_content(&e), "the table was only copied");
|
||||
let Block::Table(t) = Arc::make_mut(&mut e.body[0]) else { panic!("a table") };
|
||||
t.rows[1].cells[1].blocks[0] = para_block(Paragraph::with_text("x", CharProps::default()));
|
||||
assert!(!d.same_content(&e), "a cell's text changed");
|
||||
// A building block was added.
|
||||
let mut e = d.clone();
|
||||
e.glossary.push(BuildingBlock { name: "Sign-off".into(), ..Default::default() });
|
||||
assert!(!d.same_content(&e), "a building block was added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip() {
|
||||
let mut d = Document::from_text("Hello\nWorld");
|
||||
|
||||
@@ -258,6 +258,14 @@ impl Package {
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
|
||||
pub fn xml_strict(&self, name: &str) -> Result<Option<El>, DocxError> {
|
||||
match self.get(name) {
|
||||
Some(b) => xml::parse_strict(b).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
|
||||
match self.get(name) {
|
||||
Some(b) => xml::parse(b).map(Some),
|
||||
|
||||
@@ -395,7 +395,7 @@ mod tests {
|
||||
// A hyperlink whose relationship id is written without the prefix: the reader resolves
|
||||
// it (the alias `El::attr` accepts), so the part it names is not carried over twice.
|
||||
let named = package(
|
||||
r#"<w:p><w:r><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
|
||||
r#"<w:p><w:hyperlink id="rIdDoc1"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>"#,
|
||||
&[("rIdDoc1", "keep.bin")],
|
||||
&[("word/keep.bin", b"kept")],
|
||||
);
|
||||
|
||||
@@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
let root_rels = pkg.rels("");
|
||||
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let Some(root) = pkg.xml(&main)? else {
|
||||
let Some(root) = pkg.xml_strict(&main)? else {
|
||||
return Err(DocxError::MissingPart(main));
|
||||
};
|
||||
if root.name != "w:document" {
|
||||
@@ -513,7 +513,9 @@ fn toc_control_to_toc(body: &mut Blocks) {
|
||||
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
|
||||
}
|
||||
}
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
@@ -901,6 +903,24 @@ impl Reader<'_> {
|
||||
{
|
||||
self.pc.alias.insert(id.to_string(), "Normal".into());
|
||||
}
|
||||
// Word in other languages names the TOC styles' ids in that language (`toc 1` is `Sumrio1`
|
||||
// in Portuguese): they take the engine's ids, which Update Table and the writer's TOC
|
||||
// recognise the entries by (#3889). Not when the file or an earlier style has that id.
|
||||
let mut taken: Vec<String> = root.children("w:style").take(10_000).filter_map(|s| s.attr("w:styleId")).map(str::to_string).collect();
|
||||
for s in root.children("w:style").take(10_000).filter(|s| s.attr("w:type").is_none_or(|t| t == "paragraph")) {
|
||||
let (Some(id), Some(name)) = (s.attr("w:styleId"), s.child_val("w:name")) else { continue };
|
||||
let canonical = match name.trim().to_ascii_lowercase().as_str() {
|
||||
"toc heading" => "TOCHeading".to_string(),
|
||||
n => match n.strip_prefix("toc ").and_then(|d| d.parse::<u8>().ok()).filter(|d| (1..=9).contains(d)) {
|
||||
Some(d) => format!("TOC{d}"),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
if !taken.contains(&canonical) && !self.pc.alias.contains_key(id) {
|
||||
taken.push(canonical.clone());
|
||||
self.pc.alias.insert(id.to_string(), canonical);
|
||||
}
|
||||
}
|
||||
let mut sheet = StyleSheet::empty();
|
||||
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
|
||||
if let Some(dd) = root.child("w:docDefaults") {
|
||||
|
||||
@@ -1066,6 +1066,10 @@ impl Reader<'_> {
|
||||
|
||||
// ---- drawings ----
|
||||
|
||||
/// Read a `w:drawing` frame and dispatch its payload to the picture, shape or graphic reader.
|
||||
/// Frame dimensions are converted from EMUs to bounded point values; placement, alternative
|
||||
/// text and drawing ids are carried into the object. Returns `None` when the frame or payload
|
||||
/// cannot be read. Ink written by WordCraft keeps its tool's caps rather than an outline cap.
|
||||
fn read_drawing(&mut self, sc: &mut StoryCtx, d: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let (c, anchored) = match d.child("wp:inline") {
|
||||
Some(c) => (c, false),
|
||||
@@ -1142,13 +1146,16 @@ impl Reader<'_> {
|
||||
let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
|
||||
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
|
||||
// A freeform: whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
|
||||
if let InlineObject::Shape { freeform: Some(f), extra, .. } = &mut obj
|
||||
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
|
||||
&& let Some(tool) = super::freeform::ink_tool(name)
|
||||
{
|
||||
let f = Arc::make_mut(f);
|
||||
f.ink = Some(tool);
|
||||
f.order = super::freeform::ink_order(name);
|
||||
// Ink's round ends belong to its tool, not the optional shape outline. Keep
|
||||
// its canonical model unchanged when reading the round cap the writer adds.
|
||||
extra.cap = Default::default();
|
||||
}
|
||||
obj
|
||||
};
|
||||
@@ -1461,6 +1468,10 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a shape from `wps:wsp`, using frame dimensions `w` and `h` in points and the caller's
|
||||
/// placement. Reads geometry, paint, effects and connector ends, and creates a text-box story
|
||||
/// when present within the nesting limit. Explicit outline caps are preserved; missing or
|
||||
/// unknown caps use flat ends, without resolving theme-inherited caps.
|
||||
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject {
|
||||
let sppr = wsp.child("wps:spPr");
|
||||
float.set_spin(xfrm_spin(sppr));
|
||||
@@ -1533,7 +1544,12 @@ impl Reader<'_> {
|
||||
{
|
||||
part.body.auto_color = Some(rgb);
|
||||
}
|
||||
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
|
||||
let mut extra = ShapeExtra {
|
||||
dash: super::shape_style::line_dash(ln),
|
||||
cap: wordcraft_doc::connector::LineCap::from_ooxml(ln.and_then(|e| e.attr("cap")).unwrap_or("flat")),
|
||||
outline_fill: paint.outline_fill,
|
||||
..ShapeExtra::default()
|
||||
};
|
||||
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
|
||||
// rounded rectangles keep the default.
|
||||
if prst == "roundRect" {
|
||||
@@ -1728,6 +1744,15 @@ impl Reader<'_> {
|
||||
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
|
||||
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
|
||||
let fill = vml_fill(shape, shapetype);
|
||||
let endcap = shape
|
||||
.child("v:stroke")
|
||||
.and_then(|s| s.attr("endcap"))
|
||||
.or_else(|| shapetype.and_then(|t| t.child("v:stroke")).and_then(|s| s.attr("endcap")));
|
||||
let cap = match endcap {
|
||||
Some("square") => wordcraft_doc::connector::LineCap::Square,
|
||||
Some("round") => wordcraft_doc::connector::LineCap::Round,
|
||||
_ => wordcraft_doc::connector::LineCap::Flat,
|
||||
};
|
||||
let kind = match shape.local() {
|
||||
"line" => Some(ShapeKind::Line),
|
||||
"rect" => Some(ShapeKind::Rectangle),
|
||||
@@ -1830,7 +1855,7 @@ impl Reader<'_> {
|
||||
story,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
});
|
||||
}
|
||||
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
|
||||
@@ -1859,11 +1884,11 @@ impl Reader<'_> {
|
||||
story: None,
|
||||
freeform: Some(Arc::new(f)),
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
});
|
||||
}
|
||||
let kind = kind?;
|
||||
let mut extra = ShapeExtra::default();
|
||||
let mut extra = ShapeExtra { cap, ..Default::default() };
|
||||
if kind == ShapeKind::Line {
|
||||
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
|
||||
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
|
||||
|
||||
@@ -1135,9 +1135,9 @@ fn write_comment_ids(
|
||||
},
|
||||
};
|
||||
used.insert(durable.clone());
|
||||
// A local `w:date` with a zone designator is UTC already; otherwise only the file's own.
|
||||
// Only a known UTC time: `w:date` is local time, often with a `Z` it doesn't mean.
|
||||
// Remove date and time on save (#1062): no comment date here either.
|
||||
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone().or_else(|| c.date.ends_with('Z').then(|| c.date.clone())) };
|
||||
let utc = if doc.settings.remove_date_time { None } else { c.date_utc.clone() };
|
||||
rows.push((pid.clone(), durable, utc));
|
||||
}
|
||||
let mc = "http://schemas.openxmlformats.org/markup-compatibility/2006";
|
||||
|
||||
@@ -1097,7 +1097,8 @@ impl Writer<'_> {
|
||||
match stroke {
|
||||
Some(c) => {
|
||||
let width = emu(stroke_width.clamp(0.0, 100.0));
|
||||
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
|
||||
let cap = if ink { "rnd" } else { extra.cap.ooxml() };
|
||||
let ln: &[(&str, &str)] = if ink || !extra.cap.is_flat() { &[("w", width.as_str()), ("cap", cap)] } else { &[("w", width.as_str())] };
|
||||
w.open("a:ln", ln);
|
||||
// An outline gradient read from a file, while the outline keeps its colour (#1500).
|
||||
let grad = extra.outline_fill.as_ref().filter(|f| !ink && f.main_color() == *c);
|
||||
@@ -1122,7 +1123,9 @@ impl Writer<'_> {
|
||||
w.close("a:ln");
|
||||
}
|
||||
None => {
|
||||
w.open("a:ln", &[]);
|
||||
let cap = extra.cap.ooxml();
|
||||
let ln: &[(&str, &str)] = if extra.cap.is_flat() { &[] } else { &[("cap", cap)] };
|
||||
w.open("a:ln", ln);
|
||||
w.empty("a:noFill", &[]);
|
||||
line_dash(w, &extra.dash);
|
||||
w.close("a:ln");
|
||||
@@ -1720,12 +1723,11 @@ pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
|
||||
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
|
||||
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
|
||||
/// The heading may end a section: entries in columns follow a continuous break (#3889).
|
||||
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
let start = bl.iter().position(|b| {
|
||||
b.as_para().is_some_and(|p| {
|
||||
p.section.is_none()
|
||||
&& p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty()))
|
||||
})
|
||||
b.as_para()
|
||||
.is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if is_toc(instr) && result.is_empty())))
|
||||
})?;
|
||||
let entries = bl[start + 1..]
|
||||
.iter()
|
||||
|
||||
+38
-4
@@ -309,6 +309,16 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
|
||||
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
|
||||
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, DocxError> {
|
||||
parse_with(bytes, extra, false)
|
||||
}
|
||||
|
||||
/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end
|
||||
/// tag or an element left open at the end of the data is an error, not a cue to stop reading.
|
||||
pub fn parse_strict(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
parse_with(bytes, &[], true)
|
||||
}
|
||||
|
||||
fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result<El, DocxError> {
|
||||
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
|
||||
let transcoded = utf16_to_utf8(bytes);
|
||||
let bytes = match &transcoded {
|
||||
@@ -321,7 +331,7 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
c.trim_text_start = false;
|
||||
c.trim_text_end = false;
|
||||
c.expand_empty_elements = false;
|
||||
c.check_end_names = false;
|
||||
c.check_end_names = strict;
|
||||
}
|
||||
let mut stack: Vec<El> = Vec::new();
|
||||
let mut root: Option<El> = None;
|
||||
@@ -336,6 +346,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
};
|
||||
match ev {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
if strict && root.is_some() && stack.is_empty() {
|
||||
return Err(DocxError::Xml("more than one root element".into()));
|
||||
}
|
||||
count += 1;
|
||||
if count > MAX_ELEMENTS {
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
@@ -423,18 +436,23 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(c) = ch {
|
||||
push_text(top, &c);
|
||||
match ch {
|
||||
Some(c) => push_text(top, &c),
|
||||
None if strict => return Err(DocxError::Xml("an entity is not defined".into())),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
if root.is_some() && stack.is_empty() {
|
||||
if !strict && root.is_some() && stack.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if strict && !stack.is_empty() {
|
||||
return Err(DocxError::Xml("an element is not closed".into()));
|
||||
}
|
||||
// Unclosed elements at EOF: close them (lenient).
|
||||
while let Some(el) = stack.pop() {
|
||||
attach(&mut stack, &mut root, el);
|
||||
@@ -554,6 +572,22 @@ pub fn body_ns() -> Vec<(String, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_parsing_rejects_a_dropped_entity_and_trailing_markup() {
|
||||
let bad = [&b"<w:t>before &unknown; after</w:t>"[..], b"<a></a><b></b>", b"<a></a><b>"];
|
||||
for xml in bad {
|
||||
assert!(parse_strict(xml).is_err(), "{}", String::from_utf8_lossy(xml));
|
||||
}
|
||||
assert!(
|
||||
parse_strict(
|
||||
b"<a>x & y</a>
|
||||
"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||||
// The relationship namespace, and VML's `o:relid`.
|
||||
|
||||
@@ -683,6 +683,32 @@ fn a_style_defined_twice_is_its_last_definition() {
|
||||
assert_eq!(h.para.align, None, "nothing kept from the first definition");
|
||||
}
|
||||
|
||||
/// #3889: Word in another language writes the TOC styles under ids in that language (`toc 1` is
|
||||
/// `Sumrio1` in Portuguese). They take the engine's ids, which Update Table and the writer know
|
||||
/// the entries by, with their formatting and references; an id the file already uses stays.
|
||||
#[test]
|
||||
fn localized_toc_style_ids_take_the_engine_ids() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="CabealhodoSumrio"><w:name w:val="TOC Heading"/><w:next w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Sumrio1"><w:name w:val="toc 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:spacing w:after="100"/></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Sumrio2"><w:name w:val="toc 2"/><w:basedOn w:val="Sumrio1"/><w:pPr><w:ind w:left="220"/></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="TOC3"><w:name w:val="toc 3"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Sumrio3"><w:name w:val="toc 3"/></w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let p = |st: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr><w:r><w:t>{st}</w:t></w:r></w:p>"#);
|
||||
let body = ["CabealhodoSumrio", "Sumrio1", "Sumrio2", "Sumrio3"].map(p).concat();
|
||||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let ids: Vec<Option<&str>> = paras(&d).iter().map(|p| p.props.style.as_deref()).collect();
|
||||
assert_eq!(ids, [Some("TOCHeading"), Some("TOC1"), Some("TOC2"), Some("Sumrio3")]);
|
||||
let toc2 = d.styles.get("TOC2").unwrap();
|
||||
assert_eq!((toc2.name.as_str(), toc2.based_on.as_deref(), toc2.para.indent_left), ("TOC 2", Some("TOC1"), Some(11.0)));
|
||||
assert_eq!(d.styles.get("TOC1").unwrap().para.space_after, Some(5.0));
|
||||
assert!(d.styles.get("Sumrio1").is_none() && d.styles.get("TOC3").is_some() && d.styles.get("Sumrio3").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_image_and_inline_drawing() {
|
||||
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
|
||||
@@ -3687,6 +3713,50 @@ fn shape_dashes_import_and_save() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve explicit caps, default missing/unknown caps, and retain caps on unpainted outlines.
|
||||
#[test]
|
||||
fn shape_caps_import_and_round_trip() {
|
||||
use wordcraft_doc::connector::LineCap;
|
||||
for (attr, expected) in
|
||||
[("", LineCap::Flat), ("flat", LineCap::Flat), ("sq", LineCap::Square), ("rnd", LineCap::Round), ("unknown", LineCap::Flat)]
|
||||
{
|
||||
for outline in [true, false] {
|
||||
let cap = if attr.is_empty() { String::new() } else { format!(r#" cap="{attr}""#) };
|
||||
let fill = if outline { r#"<a:solidFill><a:srgbClr val="C00000"/></a:solidFill>"# } else { "<a:noFill/>" };
|
||||
let sppr = format!(r#"<a:prstGeom prst="line"/><a:noFill/><a:ln w="12700"{cap}>{fill}</a:ln>"#);
|
||||
let body = anchored_wsp("Cap probe", 889000, 0, &sppr, "", "<wps:cNvSpPr/>");
|
||||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
for doc in [&d, &saved] {
|
||||
let InlineObject::Shape { extra, .. } = ¶s(doc)[0].objects[0] else { panic!("shape") };
|
||||
assert_eq!(extra.cap, expected, "{attr:?}, outline={outline}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve direct and shapetype-inherited VML end caps through a DrawingML save.
|
||||
#[test]
|
||||
fn vml_line_caps_import_and_round_trip() {
|
||||
use wordcraft_doc::connector::LineCap;
|
||||
for (attr, expected) in [("", LineCap::Flat), ("flat", LineCap::Flat), ("square", LineCap::Square), ("round", LineCap::Round)] {
|
||||
for inherited in [false, true] {
|
||||
let cap = if attr.is_empty() { String::new() } else { format!(r#" endcap="{attr}""#) };
|
||||
let stroke = format!("<v:stroke{cap}/>");
|
||||
let (base, own) = if inherited { (stroke.as_str(), "") } else { ("", stroke.as_str()) };
|
||||
let body = format!(
|
||||
r##"<w:p><w:r><w:pict><v:shapetype id="probe" o:spt="32">{base}</v:shapetype><v:shape type="#probe" style="width:40pt;height:1pt" strokecolor="#ff0000">{own}</v:shape></w:pict></w:r></w:p>"##
|
||||
);
|
||||
let d = read_body(&body);
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
for doc in [&d, &saved] {
|
||||
let InlineObject::Shape { extra, .. } = ¶s(doc)[0].objects[0] else { panic!("shape") };
|
||||
assert_eq!(extra.cap, expected, "{attr:?}, inherited={inherited}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_end_name_and_id_trim() {
|
||||
let body = r#"<w:p>
|
||||
|
||||
@@ -68,6 +68,25 @@ fn missing_or_wrong_main_part() {
|
||||
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "ok");
|
||||
}
|
||||
|
||||
fn body_zip(body: &str) -> Vec<u8> {
|
||||
doc_zip(format!("<w:document {W_NS}><w:body>{body}</w:body></w:document>").as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_main_part_is_an_error_not_a_truncated_document() {
|
||||
let cases = [
|
||||
("a less-than sign in a run", "<w:p><w:r><w:t>before a <- b after</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>"),
|
||||
("a paragraph left open", "<w:p><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>second</w:t></w:r><w:p><w:r><w:t>third</w:t></w:r></w:p>"),
|
||||
("a mismatched end tag", "<w:p><w:r><w:t>one</w:t></w:p></w:r><w:p><w:r><w:t>two</w:t></w:r></w:p>"),
|
||||
];
|
||||
for (what, body) in cases {
|
||||
assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}");
|
||||
}
|
||||
let whole = "<w:p><w:r><w:t>a <- b</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>";
|
||||
let d = read(&body_zip(whole)).unwrap();
|
||||
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "a <- b\nlast", "well-formed text still reads whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_rejected() {
|
||||
let mut x = format!("<w:document {W_NS}><w:body>");
|
||||
|
||||
@@ -249,6 +249,21 @@ fn comments_without_durable_ids_get_unique_ones() {
|
||||
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
|
||||
}
|
||||
|
||||
/// A local `w:date` with a `Z` (older files have no `dateUtc`) isn't saved as the UTC time either,
|
||||
/// so it doesn't shift by the user's offset after a round trip.
|
||||
#[test]
|
||||
fn a_local_date_with_a_z_is_not_saved_as_utc() {
|
||||
let mut doc = wordcraft_docx::read(&comments_package(false)).unwrap();
|
||||
for c in doc.comments.values_mut() {
|
||||
c.date.push('Z');
|
||||
}
|
||||
let files = save(&doc);
|
||||
let (_, dates) = written_ids(&files);
|
||||
assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
assert!(back.comments.values().all(|c| c.date_utc.is_none() && c.date.ends_with('Z')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_references_use_the_documents_annotation_reference_style() {
|
||||
let files = save(&wordcraft_docx::read(&comments_package(true)).unwrap());
|
||||
|
||||
@@ -15,7 +15,7 @@ workspace = true
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
|
||||
@@ -864,6 +864,25 @@ mod editing_tests {
|
||||
assert_eq!(typed, None, "a placeholder style localized in id and name");
|
||||
}
|
||||
|
||||
/// Typed text with line breaks in it (`type_text` with paragraphs, a typed paste) is the
|
||||
/// Enter key pressed between the lines, so a one-line control, where Enter does nothing, stays
|
||||
/// one paragraph, and a multi-line one splits (#3973: the line breaks went in as one command
|
||||
/// and split a one-line control).
|
||||
#[test]
|
||||
fn typed_line_breaks_do_not_split_a_one_line_control() {
|
||||
for (multi_line, paragraphs) in [(false, 1), (true, 2)] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "text.insert", json!({"text": "Name: "}));
|
||||
run(&mut s, "developer.plainText", json!({"title": "Name", "tag": "name"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "name", "multiLine": multi_line}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Ada\rLovelace", "raw": true}));
|
||||
assert_eq!(s.doc.body.len(), paragraphs, "multi-line control: {multi_line}");
|
||||
let r = s.doc.control_at(&s.sel.focus).unwrap();
|
||||
assert_eq!(r.control.tag, "name", "still inside the control");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_replaces_the_placeholder_and_emptying_brings_it_back() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -532,6 +532,16 @@ fn keep_lasso(s: &mut Session, sel: LassoSelection) {
|
||||
s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel });
|
||||
}
|
||||
|
||||
/// After a command that changed nothing, which puts the revision back from `touched` to `rev`: a
|
||||
/// lasso selection the command kept for the revision it would have reached stays selected.
|
||||
pub(crate) fn rekey_lasso(s: &mut Session, touched: u64, rev: u64) {
|
||||
if let Some(l) = s.view.draw.lasso.as_mut()
|
||||
&& (l.rev == touched || l.rev == touched.wrapping_add(1))
|
||||
{
|
||||
l.rev = rev;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule).
|
||||
fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool {
|
||||
let n = poly.len();
|
||||
@@ -1081,6 +1091,15 @@ mod tests {
|
||||
let objs = ink(&s);
|
||||
let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count();
|
||||
assert_eq!(blue, 1);
|
||||
// Recolouring to the colour it has changes nothing, and the selection stays; an edit
|
||||
// elsewhere ends it.
|
||||
s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap();
|
||||
assert_eq!(s.undo_depth(), steps + 2);
|
||||
assert_eq!(super::lasso_selection(&s).map(|l| l.items.clone()), Some(vec![sel.clone()]));
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert!(super::lasso_selection(&s).is_none());
|
||||
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [260, 80], [260, 180], [80, 180]]})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
// Deleted: only the selected stroke goes, and the selection with it.
|
||||
s.run("draw.lassoDelete", &json!({})).unwrap();
|
||||
assert_eq!(ink(&s).len(), 1);
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
/// Word's font size list.
|
||||
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
|
||||
|
||||
/// Register character-formatting commands, their parameters and keyboard bindings.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
|
||||
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
|
||||
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
|
||||
.params(
|
||||
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
|
||||
r#"{"color"?: "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"} (explicit colour sets it; omitted toggles the last chosen colour, initially yellow)"#,
|
||||
)
|
||||
// ⌘⌥H hides other apps on macOS.
|
||||
.key(super::non_mac("Mod+Alt+H")),
|
||||
@@ -228,7 +229,8 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Resolved formatting of every character in the target (or the caret).
|
||||
/// Resolved target formatting, sampled up to a paragraph/column-row boundary after 10,000 runs.
|
||||
/// Empty targets use pending caret formatting or the selected text box paragraph mark.
|
||||
fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(rows) = column_rows(s) {
|
||||
@@ -391,18 +393,73 @@ fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
apply(s, &|x| x.underline_color = rgb)
|
||||
}
|
||||
|
||||
/// Test the entire highlight target without the formatting-state query's run cap.
|
||||
/// Resolve one run at a time and stop at the first mismatch, without collecting them.
|
||||
fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
let matches = |style: Option<&str>, props: &CharProps| s.doc.styles.resolve_char(style, props).highlight == h.rgb();
|
||||
let mut seen = false;
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
if a == b {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
|
||||
for path in s.doc.paths_between(&a, &b) {
|
||||
let Some(p) = s.doc.para(a.story, &path) else { continue };
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
let to = if path == b.path { b.off } else { p.len() };
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > from && r.start < to {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
|
||||
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
|
||||
}
|
||||
|
||||
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
|
||||
/// the last choice off only when the whole formatting target already has that colour.
|
||||
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::str(v, "color").unwrap_or("yellow");
|
||||
let h = Highlight::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|h| {
|
||||
h.ooxml().eq_ignore_ascii_case(c)
|
||||
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|
||||
|| format!("{h:?}").eq_ignore_ascii_case(c)
|
||||
})
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
|
||||
apply(s, &|x| x.highlight = Some(h))
|
||||
let color = v.get("color").map(|_| p::req_str(v, "color")).transpose()?;
|
||||
let h = match color {
|
||||
None => s.last_highlight,
|
||||
Some(c) => Highlight::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|h| {
|
||||
h.ooxml().eq_ignore_ascii_case(c)
|
||||
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|
||||
|| format!("{h:?}").eq_ignore_ascii_case(c)
|
||||
})
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?,
|
||||
};
|
||||
let remove = color.is_none() && target_has_highlight(s, h);
|
||||
let value = if remove { Highlight::None } else { h };
|
||||
let result = apply(s, &|x| x.highlight = Some(value))?;
|
||||
s.last_highlight = h;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle
|
||||
|
||||
@@ -227,8 +227,8 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
|
||||
}
|
||||
|
||||
/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it
|
||||
/// (TIFF included: the `image` crate decodes it).
|
||||
/// The media extension of an encoded image by its magic bytes, as the picture probe accepted it
|
||||
/// (TIFF included when its compression is one the `image` crate decodes within limits).
|
||||
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
|
||||
match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
@@ -249,14 +249,15 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
|
||||
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
|
||||
let ext = image_ext(&bytes);
|
||||
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
|
||||
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
|
||||
size
|
||||
} else {
|
||||
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). A picture that
|
||||
// is never decoded (a TIFF with JPEG strips, #3645) isn't inserted.
|
||||
let (natural_w, natural_h, ext) = match wordcraft_geom::picture::probe(&bytes) {
|
||||
Ok(pic) => (pic.width as f32 * 0.75, pic.height as f32 * 0.75, image_ext(&bytes)),
|
||||
Err(wordcraft_geom::picture::Refusal::Unsupported(why)) => return Err(CmdError::Failed(why.into())),
|
||||
Err(_) => match wordcraft_render::metafile_size(&bytes) {
|
||||
Some(size) => size,
|
||||
None => return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into())),
|
||||
},
|
||||
};
|
||||
let key = s.doc.add_media(bytes, ext);
|
||||
// Fitted to the text width.
|
||||
@@ -1050,6 +1051,36 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
|
||||
fn jpeg_strip_tiff() -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut t), image::ImageFormat::Tiff).unwrap();
|
||||
assert_eq!(&t[..4], b"II*\0");
|
||||
let ifd = u32::from_le_bytes(t[4..8].try_into().unwrap()) as usize;
|
||||
let n = usize::from(u16::from_le_bytes([t[ifd], t[ifd + 1]]));
|
||||
let e = (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| t[e..e + 2] == 259u16.to_le_bytes()).unwrap();
|
||||
t[e + 8..e + 10].copy_from_slice(&7u16.to_le_bytes());
|
||||
t
|
||||
}
|
||||
|
||||
/// #3645: a TIFF with JPEG strips is never decoded, so it is neither inserted nor swapped in;
|
||||
/// before, its readable header was all insertion looked at.
|
||||
#[test]
|
||||
fn tiffs_with_jpeg_strips_are_refused() {
|
||||
let tiff = jpeg_strip_tiff();
|
||||
assert_eq!(wordcraft_render::image_size(&tiff), Some((4, 4)), "its header reads");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let e = s.run("insert.picture", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
|
||||
assert!(e.contains("TIFF"), "{e}");
|
||||
assert!(s.doc.media.is_empty());
|
||||
let mut plain = Vec::new();
|
||||
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut plain), image::ImageFormat::Tiff).unwrap();
|
||||
s.run("insert.picture", &json!({"data": base64_encode(&plain)})).unwrap();
|
||||
let e = s.run("picture.change", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
|
||||
assert!(e.contains("TIFF"), "{e}");
|
||||
assert_eq!(s.doc.media.len(), 1);
|
||||
}
|
||||
|
||||
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
|
||||
/// body, not on page 1 (#3331).
|
||||
#[test]
|
||||
|
||||
@@ -1501,6 +1501,45 @@ mod tests {
|
||||
assert_eq!(parse_csv("a;b\n1;2")[1], vec!["1", "2"]);
|
||||
}
|
||||
|
||||
/// #4001: finishing a merge opens a new document, with a new undo history. It is not an
|
||||
/// edit of the open document, even when the merged document holds the same content.
|
||||
#[test]
|
||||
fn finishing_a_merge_opens_a_new_document_with_a_new_history() {
|
||||
let mut s = Session::new(Document::from_text("Hello"));
|
||||
s.run("text.insert", &json!({"text": "!"})).unwrap();
|
||||
assert_eq!(s.undo_depth(), 1);
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
// One recipient and no merge fields: the merged document holds the same content.
|
||||
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
|
||||
let id = s.document_id();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_ne!(s.document_id(), id, "the document was replaced");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello");
|
||||
assert_eq!(s.undo_depth(), 0, "the new document starts with an empty undo history");
|
||||
// Undo cannot bring the old document's text back.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello", "the old text does not come back");
|
||||
}
|
||||
|
||||
/// #4004: finishing a merge to a file saves the merged copy there and leaves the open
|
||||
/// document alone: it changes nothing, so it is not an edit.
|
||||
#[test]
|
||||
fn finishing_a_merge_to_a_file_is_not_an_edit() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-merge-to-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let out = dir.join("merged.docx");
|
||||
let mut s = Session::new(Document::from_text("Hello"));
|
||||
s.run("text.insert", &json!({"text": "!"})).unwrap();
|
||||
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
|
||||
let (depth, dirty, text) = (s.undo_depth(), s.dirty, s.doc.plain_text(StoryRef::Body));
|
||||
s.run("mailings.finish", &json!({"path": out.to_string_lossy()})).unwrap();
|
||||
assert!(out.exists(), "the merged copy was written");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), text, "the open document is unchanged");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
assert_eq!(s.dirty, dirty, "the unsaved-changes flag is unchanged");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_delimiter_ignores_quoted_text() {
|
||||
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
|
||||
|
||||
@@ -387,8 +387,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
|
||||
let mut at = s.sel.focus.clone();
|
||||
let mut first = true;
|
||||
let mut inserted = false;
|
||||
// Typed line breaks are the Enter key between the lines: in a one-line control it does
|
||||
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
|
||||
// its breaks, as before).
|
||||
let no_breaks = enter && controls::refuses_paragraph(s);
|
||||
for part in text.split(['\r', '\u{2029}']) {
|
||||
if !first {
|
||||
if !first && !no_breaks {
|
||||
at = split_para_as(s, &at, enter)?;
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// `blur` asserts on a subnormal width.
|
||||
img = img.blur(-sharpen / 25.0);
|
||||
}
|
||||
img
|
||||
Ok(img)
|
||||
})
|
||||
})
|
||||
.params(r#"{"brightness"?: -100..100, "contrast"?: -100..100, "sharpen"?: -100..100}"#)
|
||||
@@ -146,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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| recolor(img, &mode, sat))
|
||||
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),
|
||||
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for px in rgba.pixels_mut() {
|
||||
px.0 = [c.0, c.1, c.2, px.0[3]];
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
Ok(image::DynamicImage::ImageRgba8(rgba))
|
||||
})
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB"}"#)
|
||||
@@ -186,20 +186,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for px in rgba.pixels_mut() {
|
||||
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
Ok(image::DynamicImage::ImageRgba8(rgba))
|
||||
})
|
||||
})
|
||||
.params(r#"{"percent": 0..100}"#)
|
||||
.when(has_picture),
|
||||
CommandSpec::new("picture.removeBackground", "Remove Background", "Picture Format › Adjust", |s, v| {
|
||||
let tol = p::f32(v, "tolerance").unwrap_or(30.0).clamp(0.0, 255.0);
|
||||
adjust(s, move |img| remove_background(img, tol))
|
||||
adjust(s, move |img| Ok(remove_background(img, tol)))
|
||||
})
|
||||
.params(r#"{"tolerance"?: 0..255}"#)
|
||||
.when(has_picture),
|
||||
CommandSpec::new("picture.compress", "Compress Pictures", "Picture Format › Adjust", |s, v| {
|
||||
let max = p::u64(v, "maxPixels").unwrap_or(1600).clamp(32, 20000) as u32;
|
||||
adjust(s, move |img| if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img })
|
||||
adjust(s, move |img| Ok(if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img }))
|
||||
})
|
||||
.params(r#"{"maxPixels"?: n}"#)
|
||||
.when(has_picture),
|
||||
@@ -212,7 +212,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
|
||||
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
@@ -1182,7 +1185,7 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
|
||||
}
|
||||
|
||||
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
|
||||
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
|
||||
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)
|
||||
@@ -1191,13 +1194,14 @@ fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImag
|
||||
/// 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.
|
||||
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
|
||||
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()))?;
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
|
||||
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
|
||||
return Err(CmdError::Failed("picture is too large to edit".into()));
|
||||
}
|
||||
let out = f(img);
|
||||
// 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 {
|
||||
wordcraft_geom::picture::Refusal::TooLarge { .. } => too_large(),
|
||||
e => CmdError::Failed(format!("can't decode the picture: {e}")),
|
||||
})?;
|
||||
let out = f(img)?;
|
||||
let mut png = Vec::new();
|
||||
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let key = s.doc.add_media(png, "png");
|
||||
@@ -1221,6 +1225,25 @@ fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn too_large() -> CmdError {
|
||||
CmdError::Failed("picture is too large to edit".into())
|
||||
}
|
||||
|
||||
/// A `w` × `h` bitmap of `fill` for an edit to draw on, refused when the picture it makes would be
|
||||
/// too large to draw (two RGBA buffers within the decode budget), before it is allocated (#3611).
|
||||
fn canvas(w: u32, h: u32, fill: [u8; 4]) -> Result<image::RgbaImage, CmdError> {
|
||||
if !wordcraft_geom::decode_fits(w, h, wordcraft_geom::MAX_DECODED_BYTES) {
|
||||
return Err(too_large());
|
||||
}
|
||||
Ok(image::RgbaImage::from_pixel(w, h, image::Rgba(fill)))
|
||||
}
|
||||
|
||||
/// `w` and `h` grown by `pad` on every side, None past `u32`.
|
||||
fn grown(w: u32, h: u32, pad: u32) -> Option<(u32, u32)> {
|
||||
let twice = pad.checked_mul(2)?;
|
||||
Some((w.checked_add(twice)?, h.checked_add(twice)?))
|
||||
}
|
||||
|
||||
/// What Artistic Effects, Picture Styles and Picture Border drew on a picture's bitmap: the
|
||||
/// bitmap they started from and the effect, style and border on it, so the next one draws again
|
||||
/// from that bitmap instead of over the last result. Picking a second style replaces the first,
|
||||
@@ -1267,12 +1290,12 @@ fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) -> CmdResult {
|
||||
None => img,
|
||||
};
|
||||
if let Some(st) = &l.style {
|
||||
img = picture_style(img, st);
|
||||
img = picture_style(img, st)?;
|
||||
}
|
||||
if let Some((c, w)) = l.border {
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255]);
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255])?;
|
||||
}
|
||||
img
|
||||
Ok(img)
|
||||
})?;
|
||||
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
|
||||
&& let Some(drawn) = s.doc.media.get(&media).cloned()
|
||||
@@ -1356,12 +1379,22 @@ fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImag
|
||||
/// Flood the border-connected region close to the corner colour with transparency.
|
||||
fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
|
||||
let mut rgba = img.to_rgba8();
|
||||
clear_background(&mut rgba, tol);
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
}
|
||||
|
||||
/// [`remove_background`] on `rgba`, a row span at a time: besides the border's seeds, the stack
|
||||
/// holds one for each run of matching pixels beside a filled span, instead of four for every
|
||||
/// pixel filled (#3647). Returns the most seeds it held.
|
||||
fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
|
||||
let (w, h) = rgba.dimensions();
|
||||
if w == 0 || h == 0 {
|
||||
return image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
let bg = rgba.get_pixel(0, 0).0;
|
||||
let Some(bg) = rgba.get_pixel_checked(0, 0).map(|p| p.0) else { return 0 };
|
||||
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
|
||||
let at = |x: u32, y: u32| y as usize * w as usize + x as usize;
|
||||
// Not cleared yet, and close to the corner colour.
|
||||
let open = |rgba: &image::RgbaImage, seen: &[bool], x: u32, y: u32| {
|
||||
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
|
||||
};
|
||||
let mut seen = vec![false; (w as usize) * (h as usize)];
|
||||
let mut stack: Vec<(u32, u32)> = Vec::new();
|
||||
for x in 0..w {
|
||||
@@ -1372,46 +1405,55 @@ fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage
|
||||
stack.push((0, y));
|
||||
stack.push((w - 1, y));
|
||||
}
|
||||
let mut most = stack.len();
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
let i = (y as usize) * (w as usize) + x as usize;
|
||||
if seen.get(i).copied().unwrap_or(true) {
|
||||
if !open(rgba, &seen, x, y) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sv) = seen.get_mut(i) {
|
||||
*sv = true;
|
||||
let (mut x0, mut x1) = (x, x);
|
||||
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
|
||||
x0 -= 1;
|
||||
}
|
||||
let p = rgba.get_pixel(x, y).0;
|
||||
if !close(p) {
|
||||
continue;
|
||||
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
|
||||
x1 += 1;
|
||||
}
|
||||
rgba.put_pixel(x, y, image::Rgba([p[0], p[1], p[2], 0]));
|
||||
if x > 0 {
|
||||
stack.push((x - 1, y));
|
||||
for xi in x0..=x1 {
|
||||
if let Some(s) = seen.get_mut(at(xi, y)) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(p) = rgba.get_pixel_mut_checked(xi, y) {
|
||||
p.0[3] = 0;
|
||||
}
|
||||
}
|
||||
if x + 1 < w {
|
||||
stack.push((x + 1, y));
|
||||
}
|
||||
if y > 0 {
|
||||
stack.push((x, y - 1));
|
||||
}
|
||||
if y + 1 < h {
|
||||
stack.push((x, y + 1));
|
||||
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
|
||||
let mut xi = x0;
|
||||
while xi <= x1 {
|
||||
if open(rgba, &seen, xi, ny) {
|
||||
stack.push((xi, ny));
|
||||
while xi <= x1 && open(rgba, &seen, xi, ny) {
|
||||
xi += 1;
|
||||
}
|
||||
} else {
|
||||
xi += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
most = most.max(stack.len());
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
most
|
||||
}
|
||||
|
||||
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> image::DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let mut out = image::RgbaImage::from_pixel(w + 2 * border, h + 2 * border, image::Rgba(color));
|
||||
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
|
||||
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
|
||||
let mut out = canvas(w, h, color)?;
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
|
||||
image::DynamicImage::ImageRgba8(out)
|
||||
Ok(image::DynamicImage::ImageRgba8(out))
|
||||
}
|
||||
|
||||
fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
||||
fn picture_style(img: image::DynamicImage, style: &str) -> Result<image::DynamicImage, CmdError> {
|
||||
let m = (img.width().max(img.height()) / 40).max(4);
|
||||
match style {
|
||||
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255]), m / 2, [40, 40, 40, 255]),
|
||||
Ok(match style {
|
||||
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255])?, m / 2, [40, 40, 40, 255])?,
|
||||
"rounded" | "softEdge" => {
|
||||
let mut rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
@@ -1434,18 +1476,47 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
||||
"shadow" => {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let off = m;
|
||||
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
|
||||
let shadow = image::DynamicImage::ImageRgba8(shadow);
|
||||
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
image::imageops::overlay(&mut padded, &shadow.to_rgba8(), (off + off / 2) as i64, (off + off / 2) as i64);
|
||||
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
|
||||
image::imageops::overlay(&mut out, &blurred, 0, 0);
|
||||
let (pw, ph) = grown(w, h, off).ok_or_else(too_large)?;
|
||||
let mut out = canvas(pw, ph, [0, 0, 0, 0])?;
|
||||
// A rectangle of alpha 90 the picture's size, one and a half margins in, blurred. Its
|
||||
// blur is the product of its sides' blurs along each axis, so it is drawn from two
|
||||
// profiles, not by blurring every pixel with a kernel that grows with the picture (#3647).
|
||||
let (sigma, start) = (off as f32 / 2.0, off + off / 2);
|
||||
let (xs, ys) = (blurred_span(pw, start, w, sigma), blurred_span(ph, start, h, sigma));
|
||||
for (x, y, p) in out.enumerate_pixels_mut() {
|
||||
let k = xs.get(x as usize).copied().unwrap_or(0.0) * ys.get(y as usize).copied().unwrap_or(0.0);
|
||||
p.0[3] = (90.0 * k).round() as u8;
|
||||
}
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
|
||||
image::DynamicImage::ImageRgba8(out)
|
||||
}
|
||||
_ => frame(img, m, [255, 255, 255, 255]),
|
||||
_ => frame(img, m, [255, 255, 255, 255])?,
|
||||
})
|
||||
}
|
||||
|
||||
/// How much of a span `len` long from `start` covers each of `n` positions once blurred with the
|
||||
/// Gaussian `image::imageops::blur` uses for `sigma` (outside the `n` positions counts as clear):
|
||||
/// 0 to 1. Each position is one difference of the kernel's running sums.
|
||||
fn blurred_span(n: u32, start: u32, len: u32, sigma: f32) -> Vec<f32> {
|
||||
let kernel = gaussian_kernel(sigma);
|
||||
let mut sums = vec![0.0f32];
|
||||
for k in &kernel {
|
||||
sums.push(sums.last().copied().unwrap_or(0.0) + k);
|
||||
}
|
||||
let (size, r) = (kernel.len() as i64, (kernel.len() / 2) as i64);
|
||||
let sum_to = |k: i64| sums.get(k.clamp(0, size) as usize).copied().unwrap_or(0.0);
|
||||
let (start, end) = (i64::from(start), i64::from(start) + i64::from(len));
|
||||
// Position i takes weight k from i + k - r, inside the span for k in [start - i + r, end - i + r).
|
||||
(0..i64::from(n)).map(|i| sum_to(end - i + r) - sum_to(start - i + r)).collect()
|
||||
}
|
||||
|
||||
/// The normalized kernel `image::imageops::blur` (0.25) uses for `sigma`: odd, about 6.7 σ wide.
|
||||
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
|
||||
let size = ((((sigma - 0.8) / 0.3 + 1.0) * 2.0 + 1.0).max(3.0) as usize) | 1;
|
||||
let mean = (size / 2) as f32;
|
||||
let weights: Vec<f32> = (0..size).map(|x| (-0.5 * ((x as f32 - mean) / sigma).powi(2)).exp()).collect();
|
||||
let total: f32 = weights.iter().sum();
|
||||
weights.into_iter().map(|v| v / total).collect()
|
||||
}
|
||||
|
||||
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
|
||||
@@ -1982,6 +2053,112 @@ mod tests {
|
||||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// A PNG whose header (valid CRC) declares `w` × `h` pixels, with one pixel of data.
|
||||
fn header_only_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = png(8, 8);
|
||||
b[16..20].copy_from_slice(&w.to_be_bytes());
|
||||
b[20..24].copy_from_slice(&h.to_be_bytes());
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &byte in &b[12..29] {
|
||||
crc ^= u32::from(byte);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
b[29..33].copy_from_slice(&(!crc).to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_edits_refuse_an_over_budget_header_before_decoding() {
|
||||
// Inserting reads the size only; every edit then refuses it from the header (#3644, #3647).
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&header_only_png(20_000, 20_000))})).unwrap();
|
||||
let media = s.doc.media.len();
|
||||
for (id, v) in [
|
||||
("picture.corrections", json!({"brightness": 10})),
|
||||
("picture.color", json!({"mode": "sepia"})),
|
||||
("picture.removeBackground", json!({})),
|
||||
("picture.style", json!({"style": "shadow"})),
|
||||
] {
|
||||
let e = s.run(id, &v).unwrap_err().to_string();
|
||||
assert!(e.contains("too large to edit"), "{id}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.media.len(), media, "nothing was added");
|
||||
}
|
||||
|
||||
/// The Shadow style before #3647: the rectangle blurred with `image`'s Gaussian.
|
||||
fn blurred_shadow(img: &image::DynamicImage) -> image::RgbaImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let off = (w.max(h) / 40).max(4);
|
||||
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
|
||||
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
image::imageops::overlay(&mut padded, &shadow, (off + off / 2) as i64, (off + off / 2) as i64);
|
||||
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
|
||||
image::imageops::overlay(&mut out, &blurred, 0, 0);
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// #3647: the shadow drawn from two profiles looks like the blur it replaces.
|
||||
#[test]
|
||||
fn the_shadow_style_matches_the_blur_it_replaces() {
|
||||
for (w, h) in [(80, 40), (400, 220), (37, 300)] {
|
||||
let img = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
image::Rgba([(x * 7) as u8, (y * 5) as u8, 90, if (x + y) % 3 == 0 { 255 } else { 120 }])
|
||||
}));
|
||||
let new = picture_style(img.clone(), "shadow").unwrap().to_rgba8();
|
||||
let old = blurred_shadow(&img);
|
||||
assert_eq!(new.dimensions(), old.dimensions());
|
||||
let worst = new.pixels().zip(old.pixels()).flat_map(|(a, b)| a.0.into_iter().zip(b.0).map(|(a, b)| a.abs_diff(b))).max();
|
||||
assert!(worst.is_some_and(|d| d <= 2), "{w} x {h}: {worst:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3647: Remove Background fills a span at a time, so its stack stays near the border's seeds
|
||||
/// instead of growing with the picture, and it clears the same region.
|
||||
#[test]
|
||||
fn remove_background_keeps_a_small_stack() {
|
||||
let mut uniform = image::RgbaImage::from_pixel(2000, 2000, image::Rgba([250, 250, 250, 255]));
|
||||
let most = clear_background(&mut uniform, 30.0);
|
||||
assert!(most <= 4 * (2000 + 2000) + 8, "{most}");
|
||||
assert!(uniform.pixels().all(|p| p.0[3] == 0));
|
||||
// A square ring keeps what it encloses: only background reachable from the border goes.
|
||||
let mut ring = image::RgbaImage::from_fn(60, 60, |x, y| {
|
||||
let d = (x as i32 - 30).abs().max((y as i32 - 30).abs());
|
||||
if (10..14).contains(&d) { image::Rgba([200, 20, 20, 255]) } else { image::Rgba([255, 255, 255, 255]) }
|
||||
});
|
||||
clear_background(&mut ring, 30.0);
|
||||
assert_eq!(ring.get_pixel(0, 0).0, [255, 255, 255, 0], "outside: cleared, colour kept");
|
||||
assert_eq!(ring.get_pixel(5, 30).0[3], 0);
|
||||
assert_eq!(ring.get_pixel(30, 18).0[3], 255, "the ring");
|
||||
assert_eq!(ring.get_pixel(30, 30).0[3], 255, "inside the ring");
|
||||
assert_eq!(clear_background(&mut image::RgbaImage::new(0, 0), 30.0), 0);
|
||||
}
|
||||
|
||||
/// #3611: a style or border is drawn only when the picture it makes can still be drawn. On a
|
||||
/// 1 × 2,000,000 picture Thick Frame asked for about 1.8 TB and aborted the process.
|
||||
#[test]
|
||||
fn picture_styles_and_borders_check_the_size_they_draw() {
|
||||
let mut thin = Vec::new();
|
||||
image::GrayImage::new(1, 2_000_000).write_to(&mut std::io::Cursor::new(&mut thin), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&thin)})).unwrap();
|
||||
let media = s.doc.media.len();
|
||||
for (id, v) in [
|
||||
("picture.style", json!({"style": "thickFrame"})),
|
||||
("picture.style", json!({"style": "shadow"})),
|
||||
("picture.style", json!({})),
|
||||
("picture.border", json!({"width": 200})),
|
||||
] {
|
||||
let e = s.run(id, &v).unwrap_err().to_string();
|
||||
assert!(e.contains("too large to edit"), "{id} {v}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.media.len(), media, "nothing was drawn");
|
||||
assert_eq!(grown(u32::MAX, 1, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_pipeline() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -398,7 +398,7 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
};
|
||||
let para = s.doc.para_mut(a.story, &path)?;
|
||||
match num {
|
||||
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
|
||||
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) }),
|
||||
None => {
|
||||
para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab);
|
||||
para.props.cancel_char_indents(true, false, false);
|
||||
@@ -810,7 +810,7 @@ fn outline_step(s: &mut Session, promote: bool) -> CmdResult {
|
||||
let list = p.props.numbering.filter(|n| n.num != 0);
|
||||
let target = match (list, heading_level(s, &p.props)) {
|
||||
(Some(n), _) => {
|
||||
let level = if promote { n.level.saturating_sub(1) } else { (n.level + 1).min(8) };
|
||||
let level = if promote { n.level.saturating_sub(1) } else { n.level.saturating_add(1).min(8) };
|
||||
if level != n.level {
|
||||
let para = s.doc.para_mut(a.story, &path)?;
|
||||
para.props.numbering = Some(NumRef { num: n.num, level });
|
||||
|
||||
@@ -21,9 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
|
||||
if let Some((start, end)) = toc_range(s) {
|
||||
for i in (start..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
remove_toc(s, start, end)?;
|
||||
s.clamp_selection();
|
||||
}
|
||||
sel_result(s)
|
||||
@@ -339,6 +337,36 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
|
||||
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
|
||||
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
|
||||
/// with the table.
|
||||
fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
|
||||
for i in (start + 1..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
|
||||
Ok(p) if p.section.is_some() => {
|
||||
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
|
||||
start + 1
|
||||
}
|
||||
_ => {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
|
||||
start
|
||||
}
|
||||
};
|
||||
let titled = start
|
||||
.checked_sub(1)
|
||||
.and_then(|i| s.doc.body.get(i))
|
||||
.and_then(|b| b.as_para())
|
||||
.is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none());
|
||||
if titled {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?;
|
||||
return Ok(at - 1);
|
||||
}
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
/// The caret is in the table of contents (its heading or an entry), where Word's context menu
|
||||
/// offers Update Field for the whole table.
|
||||
pub fn caret_in_toc(s: &Session) -> bool {
|
||||
@@ -393,15 +421,24 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
|
||||
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
|
||||
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
|
||||
let i = at.path.last();
|
||||
// Or one holding only the end of Word's TOC control, still styled as an entry: Update Table
|
||||
// would delete it with the old entries anyway.
|
||||
let leftover = |p: &Paragraph| {
|
||||
p.is_empty()
|
||||
|| (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd))
|
||||
&& p.text.chars().all(|c| c == OBJ)
|
||||
&& p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
};
|
||||
if at.path.depth() == 0
|
||||
&& i > 0
|
||||
&& s.doc.para_at(&at).is_some_and(|p| p.is_empty())
|
||||
&& s.doc.para_at(&at).is_some_and(leftover)
|
||||
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
|
||||
// field was (the field is saved opening in the first entry), so the next save and update
|
||||
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
|
||||
// range TOC is reused the same way, so deleting it later deletes the TOC field.
|
||||
// range TOC is reused the same way, so deleting it later deletes the TOC field. So is one
|
||||
// ending the section before, as above entries in columns (#3889).
|
||||
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
|
||||
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty() && p.section.is_none())
|
||||
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty())
|
||||
})
|
||||
{
|
||||
s.doc.remove_block(StoryRef::Body, &at.path)?;
|
||||
@@ -474,9 +511,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
Some((start, end)) => {
|
||||
for i in (start..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
let start = remove_toc(s, start, end)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
|
||||
placeholder = true;
|
||||
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 });
|
||||
@@ -585,10 +620,13 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
|
||||
for i in (start + 1..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
// The text width of the TOC's own section, not the caret's: after Select All the caret sits
|
||||
// at the end of the document, possibly in a wider (landscape) section.
|
||||
let section = s.doc.section_index_of(start);
|
||||
let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width());
|
||||
// The column width of the TOC's own section, not the caret's: after Select All the caret sits
|
||||
// at the end of the document, possibly in a wider (landscape) section. In columns, the first
|
||||
// column's, as Word does (#3889); after a field paragraph ending a section, the next section's.
|
||||
let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some());
|
||||
let section = s.doc.section_index_of(start + usize::from(after_break));
|
||||
let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w);
|
||||
let width = s.doc.sections().get(section).map_or_else(|| column(&super::page::sect(s)), |(_, x)| column(x));
|
||||
let entries = toc_entries(s, &f);
|
||||
let links: Vec<Option<String>> = if f.has('h') {
|
||||
let mut v = Vec::new();
|
||||
|
||||
@@ -141,9 +141,20 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))])?;
|
||||
let id = comment_id(s)?;
|
||||
let initials = s.user_initials();
|
||||
s.doc
|
||||
.comments
|
||||
.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: None, resolved: false, part, ..Default::default() });
|
||||
let now = now_iso();
|
||||
s.doc.comments.insert(
|
||||
id,
|
||||
Comment {
|
||||
author: s.author.clone(),
|
||||
initials,
|
||||
date: now.clone(),
|
||||
date_utc: Some(now),
|
||||
parent: None,
|
||||
resolved: false,
|
||||
part,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let props = Default::default();
|
||||
s.doc.insert_object(&b, InlineObject::CommentEnd { id }, &props)?;
|
||||
s.doc.insert_object(&a, InlineObject::CommentStart { id }, &props)?;
|
||||
@@ -170,9 +181,19 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))])?;
|
||||
let id = comment_id(s)?;
|
||||
let initials = s.user_initials();
|
||||
let now = now_iso();
|
||||
s.doc.comments.insert(
|
||||
id,
|
||||
Comment { author: s.author.clone(), initials, date: now_iso(), parent: Some(parent), resolved: false, part, ..Default::default() },
|
||||
Comment {
|
||||
author: s.author.clone(),
|
||||
initials,
|
||||
date: now.clone(),
|
||||
date_utc: Some(now),
|
||||
parent: Some(parent),
|
||||
resolved: false,
|
||||
part,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -363,20 +384,8 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
|
||||
/// the section break on it), `props` the paragraph's own formatting change.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve_para(
|
||||
s: &mut Session,
|
||||
story: StoryRef,
|
||||
path: &wordcraft_doc::Path,
|
||||
from: usize,
|
||||
to: usize,
|
||||
mark: bool,
|
||||
props: bool,
|
||||
accept: bool,
|
||||
) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
/// Accept or reject the tracked formatting and text of `from..to` in one paragraph (not its mark).
|
||||
fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, props: bool, accept: bool) -> Result<(), CmdError> {
|
||||
if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) {
|
||||
fmt_revisions::resolve_para(&mut para.props, accept);
|
||||
para.touch();
|
||||
@@ -397,6 +406,24 @@ fn resolve_para(
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
|
||||
/// the section break on it), `props` the paragraph's own formatting change.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve_para(
|
||||
s: &mut Session,
|
||||
story: StoryRef,
|
||||
path: &wordcraft_doc::Path,
|
||||
from: usize,
|
||||
to: usize,
|
||||
mark: bool,
|
||||
props: bool,
|
||||
accept: bool,
|
||||
) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
resolve_runs(para, from, to, props, accept)?;
|
||||
if !mark {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -513,6 +540,28 @@ pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Accept or reject the tracked text and paragraph marks of the top-level paragraphs in a block
|
||||
/// list that is not a story (a note separator).
|
||||
fn resolve_loose_blocks(blocks: &mut wordcraft_doc::Blocks, accept: bool) -> Result<(), CmdError> {
|
||||
for b in blocks.iter_mut() {
|
||||
let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) else { continue };
|
||||
let len = p.len();
|
||||
resolve_runs(p, 0, len, true, accept)?;
|
||||
if p.mark.fmt_change.is_some() || p.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
|
||||
fmt_revisions::resolve_char(&mut p.mark, accept);
|
||||
if let Some(sec) = p.section.as_deref_mut() {
|
||||
fmt_revisions::resolve_section(sec, accept);
|
||||
}
|
||||
}
|
||||
// A mark can't be removed here (no neighbour to join), so it just stops being a revision.
|
||||
p.mark.ins = None;
|
||||
p.mark.del = None;
|
||||
p.touch();
|
||||
}
|
||||
fmt_revisions::resolve_containers(blocks, accept, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
|
||||
for st in stories {
|
||||
@@ -527,6 +576,13 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
||||
fmt_revisions::resolve_containers(&mut part.blocks, accept, 0);
|
||||
}
|
||||
fmt_revisions::resolve_section(&mut s.doc.last_section, accept);
|
||||
// The note separators are block lists of their own, not stories; they must not keep ids of
|
||||
// revisions that are about to be dropped.
|
||||
for sep in [&mut s.doc.footnote_separators, &mut s.doc.endnote_separators] {
|
||||
for blocks in [&mut sep.separator, &mut sep.continuation_separator, &mut sep.continuation_notice].into_iter().flatten() {
|
||||
resolve_loose_blocks(blocks, accept)?;
|
||||
}
|
||||
}
|
||||
s.doc.revisions.clear();
|
||||
s.clamp_selection();
|
||||
sel_result(s)
|
||||
|
||||
@@ -243,14 +243,15 @@ fn autofit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pub(crate) fn sync_cell_widths(t: &mut Table) {
|
||||
let grid = t.grid.clone();
|
||||
for row in &mut t.rows {
|
||||
let mut g = 0usize;
|
||||
// A row's cells start after the grid columns it skips (`w:gridBefore`).
|
||||
let mut g = row.first_grid();
|
||||
for c in &mut row.cells {
|
||||
let span = c.span();
|
||||
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
|
||||
if w > 0.0 {
|
||||
c.props.width = Some(w);
|
||||
}
|
||||
g += span;
|
||||
g = g.saturating_add(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,6 +517,9 @@ pub(super) fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
let k = before / total;
|
||||
t.grid.iter_mut().for_each(|w| *w *= k);
|
||||
}
|
||||
// The inserted cells carry the pre-scale column width, and the layout prefers a cell width
|
||||
// over the grid: sync them, or the shrink never reaches the layout or the saved `w:tcW`.
|
||||
sync_cell_widths(t);
|
||||
fix_caret(s, &tp, r, if right { c + 1 } else { c })
|
||||
}
|
||||
|
||||
|
||||
+169
-17
@@ -254,9 +254,12 @@ enum EnterAutoFormat {
|
||||
Border(Border),
|
||||
/// "+---+-----+": a one-row table; the dash count of each column.
|
||||
Table(Vec<usize>),
|
||||
/// A short line before an empty paragraph: Heading 1, 2 or 3.
|
||||
Heading(u8),
|
||||
}
|
||||
|
||||
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###".
|
||||
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". A line
|
||||
/// holding a tracked deletion stays as it is.
|
||||
fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
let para = s.doc.para_at(at)?;
|
||||
let text = para.text.as_str();
|
||||
@@ -267,6 +270,7 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
|| text.len() < 3
|
||||
|| !text.chars().all(|x| x == c)
|
||||
|| para.props.numbering.is_some_and(|n| n.num != 0)
|
||||
|| para.run_ranges().any(|(_, r)| r.del.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -283,8 +287,8 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
}
|
||||
|
||||
/// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs
|
||||
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold
|
||||
/// leave the text alone.
|
||||
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold,
|
||||
/// or a line holding a tracked deletion, leave the text alone.
|
||||
fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
let para = s.doc.para_at(at)?;
|
||||
let text = para.text.as_str();
|
||||
@@ -293,6 +297,7 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
|| at.path.0.len() != 1
|
||||
|| at.off != text.len()
|
||||
|| para.props.numbering.is_some_and(|n| n.num != 0)
|
||||
|| para.run_ranges().any(|(_, c)| c.del.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -307,8 +312,76 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
Some(cols)
|
||||
}
|
||||
|
||||
/// AutoFormat Built-in Heading styles: Enter in an empty body paragraph whose previous paragraph
|
||||
/// is a line of Normal text with one to five words that ends in a letter or digit and holds no
|
||||
/// tracked deletion. The line becomes Heading 1, or Heading 2 or 3 when it starts with one or
|
||||
/// two tabs.
|
||||
fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
|
||||
let ac = &s.prefs.autocorrect;
|
||||
let cur = s.doc.para_at(at)?;
|
||||
let i = at.path.last();
|
||||
if !ac.enabled || !ac.heading_styles || at.story != StoryRef::Body || at.path.0.len() != 1 || i == 0 || !cur.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
|
||||
let plain = |p: &wordcraft_doc::Paragraph| {
|
||||
let r = s.doc.styles.resolve_para(&p.props);
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
|
||||
};
|
||||
let text = prev.text.trim_start_matches('\t');
|
||||
let tabs = prev.text.len() - text.len();
|
||||
if !plain(cur)
|
||||
|| !plain(prev)
|
||||
|| !prev.objects.is_empty()
|
||||
|| prev.section.is_some()
|
||||
|| prev.run_ranges().any(|(_, c)| c.del.is_some())
|
||||
|| tabs > 2
|
||||
|| !(1..=5).contains(&text.split_whitespace().count())
|
||||
|| text.starts_with(char::is_whitespace)
|
||||
|| text.contains(char::is_control)
|
||||
|| !text.ends_with(char::is_alphanumeric)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let level = tabs as u8 + 1;
|
||||
// Formatting limited to the document's styles: a locked heading style isn't applied.
|
||||
let id = format!("Heading{level}");
|
||||
if super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting && s.doc.styles.get(&id).is_some_and(|st| st.locked) {
|
||||
return None;
|
||||
}
|
||||
Some(level)
|
||||
}
|
||||
|
||||
/// Give the paragraph before the empty one at `at` Heading `level` without its leading tabs;
|
||||
/// the caret stays in the empty paragraph. With Track Changes on, the tabs are marked deleted
|
||||
/// (or removed, when this author typed them) and the style change is a formatting revision.
|
||||
fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result<(), CmdError> {
|
||||
let prev = at.path.with_last(at.path.last().saturating_sub(1));
|
||||
let Some(before) = s.doc.para(at.story, &prev).cloned() else { return Ok(()) };
|
||||
let tabs = before.text.len() - before.text.trim_start_matches('\t').len();
|
||||
let start = Pos { story: at.story, path: prev.clone(), off: 0 };
|
||||
s.sel = Selection { anchor: start.clone(), focus: Pos { off: tabs, ..start } };
|
||||
delete_selection(s)?;
|
||||
super::para::set_heading(s, at.story, &prev, Some(level))?;
|
||||
// Removed tabs change the text, and the formatting comparison after the command only looks
|
||||
// at paragraphs that keep theirs, so the style change is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
|
||||
{
|
||||
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &prev)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
|
||||
p.touch();
|
||||
}
|
||||
s.sel = Selection::caret(at.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turn the border-line characters into a bottom border on the paragraph above (or this one
|
||||
/// at the top of a story).
|
||||
/// at the top of a story). With Track Changes on, the characters are marked deleted (or
|
||||
/// removed, when this author typed them) and the border is a formatting revision.
|
||||
fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> {
|
||||
let set_bottom = |p: &mut wordcraft_doc::Paragraph| {
|
||||
let mut b = p.props.borders.unwrap_or_default();
|
||||
@@ -318,31 +391,52 @@ fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdE
|
||||
};
|
||||
let i = at.path.last();
|
||||
let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None };
|
||||
let cur = s.doc.para_mut(at.story, &at.path)?;
|
||||
let len = cur.len();
|
||||
cur.delete(0, len)?;
|
||||
let start = Pos { off: 0, ..at.clone() };
|
||||
let end = delete_typed_line(s, at)?;
|
||||
match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) {
|
||||
Some(q) => {
|
||||
// The characters' paragraph becomes the one typing continues in.
|
||||
set_bottom(s.doc.para_mut(at.story, &q)?);
|
||||
s.sel = Selection::caret(start);
|
||||
s.sel = Selection::caret(end);
|
||||
}
|
||||
None => {
|
||||
let before = s.doc.para(at.story, &at.path).map(|p| p.props.clone());
|
||||
set_bottom(s.doc.para_mut(at.story, &at.path)?);
|
||||
let new = split_para(s, &start)?;
|
||||
let new = split_para(s, &end)?;
|
||||
let p = s.doc.para_mut(new.story, &new.path)?;
|
||||
p.props.borders = None;
|
||||
p.touch();
|
||||
// The split changes the block count, and the formatting comparison after the command
|
||||
// skips a container whose block count changed, so the border is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
|
||||
{
|
||||
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
|
||||
p.touch();
|
||||
}
|
||||
s.sel = Selection::caret(new);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the AutoFormat characters of the paragraph at `at`, tracked when Track Changes is on.
|
||||
/// Returns the end of what is left: the paragraph's end, after any characters marked deleted.
|
||||
fn delete_typed_line(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
|
||||
let len = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
|
||||
s.sel = Selection { anchor: Pos { off: 0, ..at.clone() }, focus: Pos { off: len, ..at.clone() } };
|
||||
delete_selection(s)?;
|
||||
let left = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
|
||||
Ok(Pos { off: left, ..at.clone() })
|
||||
}
|
||||
|
||||
/// Replace the "+---+" characters with a one-row table across the text width, each column as
|
||||
/// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret
|
||||
/// goes into the first cell.
|
||||
/// goes into the first cell. With Track Changes on, the characters are marked deleted (or
|
||||
/// removed, when this author typed them); the table is inserted untracked, as Insert › Table
|
||||
/// inserts one.
|
||||
fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> {
|
||||
let total = cols.iter().sum::<usize>().max(1) as f32;
|
||||
let block = at.path.0.first().copied().unwrap_or(0) as usize;
|
||||
@@ -357,9 +451,7 @@ fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), Cmd
|
||||
c.props.width = Some(w);
|
||||
}
|
||||
}
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
let len = p.len();
|
||||
p.delete(0, len)?;
|
||||
delete_typed_line(s, at)?;
|
||||
s.doc.insert_block(at.story, &at.path, Block::Table(t))?;
|
||||
let mut first = at.path.0.clone();
|
||||
first.extend([0, 0, 0]);
|
||||
@@ -607,7 +699,10 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
let mut at = delete_selection(s)?;
|
||||
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
|
||||
let auto = border_line(s, &at)
|
||||
.map(EnterAutoFormat::Border)
|
||||
.or_else(|| table_line(s, &at).map(EnterAutoFormat::Table))
|
||||
.or_else(|| heading_line(s, &at).map(EnterAutoFormat::Heading));
|
||||
if let Some(auto) = auto {
|
||||
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
|
||||
// and the Enter stays. Record that plain Enter as its own undo step.
|
||||
@@ -619,6 +714,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
match auto {
|
||||
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
|
||||
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
|
||||
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
|
||||
}
|
||||
s.goal_x = None;
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
@@ -636,7 +732,9 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
|
||||
/// left- and first-indent with tabs and backspaces" on, at the start of a line of a paragraph with
|
||||
/// text: indent. Else a tab.
|
||||
fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
|
||||
let story = s.sel.focus.story;
|
||||
@@ -666,10 +764,13 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
&& n.num != 0
|
||||
{
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) });
|
||||
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
|
||||
para.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
|
||||
return indent_by_tab(s, &f);
|
||||
}
|
||||
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
|
||||
// Multiple paragraphs selected: increase indent.
|
||||
return super::para::indent(s, &Value::Null);
|
||||
@@ -677,6 +778,52 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
ins_char(s, '\t')
|
||||
}
|
||||
|
||||
/// Is AutoFormat As You Type's "Set left- and first-indent with tabs and backspaces" on? Not
|
||||
/// while the document limits formatting to its styles: the indents are direct formatting.
|
||||
fn tab_indent_on(s: &Session) -> bool {
|
||||
let ac = &s.prefs.autocorrect;
|
||||
ac.enabled && ac.tab_indent && !(super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting)
|
||||
}
|
||||
|
||||
/// Does Tab at `f` indent instead of typing a tab? Only at the start of a line of a paragraph
|
||||
/// with text. Not before the first line of a list item numbered by its style, where Tab types a
|
||||
/// tab character as with the option off.
|
||||
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
|
||||
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
|
||||
if f.off == 0 {
|
||||
return !s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0);
|
||||
}
|
||||
let l = s.layout();
|
||||
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
|
||||
}
|
||||
|
||||
/// Tab before the first line indents the first line by one default tab stop; before any other
|
||||
/// line, or when the first line is indented or hanging already, it indents the whole paragraph.
|
||||
/// Before a later line of a list item, numbered directly or by its style, it takes the item down
|
||||
/// a level.
|
||||
fn indent_by_tab(s: &mut Session, f: &Pos) -> CmdResult {
|
||||
let Some(para) = s.doc.para_at(f) else { return sel_result(s) };
|
||||
let r = s.doc.styles.resolve_para(¶.props);
|
||||
if f.off > 0
|
||||
&& para.props.numbering.is_none()
|
||||
&& let Some(n) = r.numbering.filter(|n| n.num != 0)
|
||||
{
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if f.off == 0 && r.indent_first == 0.0 && r.indent_chars[2] == 0.0 {
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.indent_first = Some(tab);
|
||||
p.props.cancel_char_indents(false, false, true);
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
super::para::indent(s, &Value::Null)
|
||||
}
|
||||
|
||||
fn back_tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
|
||||
let story = s.sel.focus.story;
|
||||
@@ -770,6 +917,11 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if tab_indent_on(s) && (para.props.indent_left.is_some_and(|x| x > 0.0) || para.props.indent_left_chars.is_some_and(|x| x > 0.0)) {
|
||||
// Set left- and first-indent with tabs and backspaces: the paragraph's own left indent
|
||||
// (in points or characters) goes back one tab stop at a time before the paragraphs join.
|
||||
return super::para::outdent(s, &Value::Null);
|
||||
}
|
||||
let i = f.path.last();
|
||||
if i == 0 {
|
||||
return sel_result(s); // start of a story or cell
|
||||
|
||||
@@ -103,6 +103,14 @@ pub struct AutoCorrectPrefs {
|
||||
pub numbering: bool,
|
||||
/// Border lines.
|
||||
pub border_lines: bool,
|
||||
/// Set left- and first-indent with tabs and backspaces: Tab at the start of a line of a
|
||||
/// paragraph with text indents it instead of typing a tab, and Backspace at its start takes
|
||||
/// the indent back. Off by default.
|
||||
pub tab_indent: bool,
|
||||
/// Built-in Heading styles: a second Enter after a line of five or fewer words that ends in a
|
||||
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
|
||||
/// Off by default.
|
||||
pub heading_styles: bool,
|
||||
/// The user's entries: added, or replacing a built-in one.
|
||||
pub entries: Vec<(String, String)>,
|
||||
/// Built-in entries the user deleted.
|
||||
@@ -138,6 +146,8 @@ impl Default for AutoCorrectPrefs {
|
||||
bullets: true,
|
||||
numbering: true,
|
||||
border_lines: true,
|
||||
tab_indent: false,
|
||||
heading_styles: false,
|
||||
entries: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
@@ -327,6 +337,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
("bullets", &mut ac.bullets),
|
||||
("numbering", &mut ac.numbering),
|
||||
("borderLines", &mut ac.border_lines),
|
||||
("tabIndent", &mut ac.tab_indent),
|
||||
("headingStyles", &mut ac.heading_styles),
|
||||
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
|
||||
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
|
||||
] {
|
||||
@@ -400,6 +412,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"bullets": ac.bullets,
|
||||
"numbering": ac.numbering,
|
||||
"borderLines": ac.border_lines,
|
||||
"tabIndent": ac.tab_indent,
|
||||
"headingStyles": ac.heading_styles,
|
||||
"autoAddFirstLetter": ac.auto_add_first_letter,
|
||||
"autoAddInitialCaps": ac.auto_add_initial_caps,
|
||||
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
|
||||
@@ -437,7 +451,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
.params(
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
|
||||
@@ -466,18 +480,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
|
||||
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
|
||||
// Validate a restore before anything is saved: a rejected command must leave the
|
||||
// version list as it was. A `restore` that is there but not a non-negative integer
|
||||
// is refused, not read as absent.
|
||||
let restore = if v.get("restore").is_some_and(|x| !x.is_null()) {
|
||||
let i = p::u64(v, "restore").ok_or_else(|| CmdError::Params("restore (non-negative integer) is required".into()))?;
|
||||
// A `u64` that doesn't fit in `usize` names no version: `as usize` would wrap it
|
||||
// to a small index on a 32-bit target and restore the wrong document.
|
||||
let i = usize::try_from(i).map_err(|_| CmdError::Params("no such version".into()))?;
|
||||
let version = s.versions.get(i).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
|
||||
// The restore replaces the whole document, including the content the protection
|
||||
// kept read-only, so the protection is stopped first (with its password, when it
|
||||
// has one) — a restore must not go around it.
|
||||
super::protect::check_version_restore(s)?;
|
||||
Some(version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(label) = p::str(v, "save") {
|
||||
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
|
||||
if s.versions.len() > 50 {
|
||||
s.versions.remove(0);
|
||||
}
|
||||
}
|
||||
if let Some(i) = p::u64(v, "restore") {
|
||||
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
|
||||
// The restore replaces the whole document, including the content the protection
|
||||
// kept read-only, so the protection is stopped first (with its password, when it
|
||||
// has one) — a restore must not go around it.
|
||||
super::protect::check_version_restore(s)?;
|
||||
if let Some((_, _, d)) = restore {
|
||||
s.checkpoint("Restore Version");
|
||||
// A snapshot's own protection is stale: it was taken at some earlier moment, and
|
||||
// restoring it must not bring a protection back that the live document doesn't
|
||||
@@ -875,7 +901,9 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
}
|
||||
let trigger = if on_enter { 0 } else { last.len_utf8() };
|
||||
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
|
||||
let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
|
||||
// The word starts after the last white-space character, whatever its width (a no-break
|
||||
// space is two bytes and an ideographic space is three).
|
||||
let start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
|
||||
let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
|
||||
if word.is_empty() {
|
||||
return Ok(());
|
||||
|
||||
@@ -37,6 +37,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing)
|
||||
.params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
|
||||
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
|
||||
.pure(),
|
||||
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
@@ -171,6 +174,7 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||||
s.view.read_column_width = None;
|
||||
s.view.read_zoom = 1.0;
|
||||
s.view.read_line_spacing = None;
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
}
|
||||
s.view.mode = m;
|
||||
s.view.read_mode = read;
|
||||
@@ -213,6 +217,31 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"spacing": spacing}))
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
|
||||
/// are unchanged.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ReadPageColor {
|
||||
/// White paper; the document's page color and page picture are not shown.
|
||||
#[default]
|
||||
None,
|
||||
/// Sepia paper (Word's RGB 112, 66, 20 at 80% transparency over white); the text keeps its colors.
|
||||
Sepia,
|
||||
/// A dark page with light text, as View › Switch Modes draws it.
|
||||
Inverse,
|
||||
}
|
||||
|
||||
fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let color = match v.get("value").and_then(Value::as_str) {
|
||||
Some("none") => ReadPageColor::None,
|
||||
Some("sepia") => ReadPageColor::Sepia,
|
||||
Some("inverse") => ReadPageColor::Inverse,
|
||||
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
|
||||
};
|
||||
s.view.read_page_color = color;
|
||||
Ok(json!({"pageColor": color}))
|
||||
}
|
||||
|
||||
/// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%.
|
||||
pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option<Zoom> {
|
||||
let fit = match v.fit.as_str() {
|
||||
|
||||
@@ -202,6 +202,8 @@ mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_fonts;
|
||||
#[cfg(test)]
|
||||
mod tests_ime_noop;
|
||||
#[cfg(test)]
|
||||
mod tests_revision_checks;
|
||||
#[cfg(test)]
|
||||
mod tests_typing;
|
||||
|
||||
@@ -39,7 +39,11 @@ pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String>
|
||||
continue;
|
||||
}
|
||||
let page = |t: &str| -> Result<usize, String> {
|
||||
let n: usize = t.parse().map_err(|_| format!("`{item}` isn't a page or a range like 2-4"))?;
|
||||
let not_a_page = || format!("`{item}` isn't a page or a range like 2-4");
|
||||
if !t.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(not_a_page());
|
||||
}
|
||||
let n: usize = t.parse().map_err(|_| not_a_page())?;
|
||||
if n == 0 || n > total {
|
||||
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
|
||||
}
|
||||
@@ -194,7 +198,7 @@ mod tests {
|
||||
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
|
||||
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
|
||||
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
|
||||
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", &"1,".repeat(400)] {
|
||||
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] {
|
||||
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
|
||||
}
|
||||
assert!(parse_page_range("1", 0).is_err());
|
||||
|
||||
+166
-16
@@ -178,6 +178,9 @@ pub struct ViewState {
|
||||
pub read_zoom: f32,
|
||||
#[serde(skip)]
|
||||
pub read_line_spacing: Option<f32>,
|
||||
/// Read Mode's Page Color: the paper the reading columns are drawn on.
|
||||
#[serde(skip)]
|
||||
pub read_page_color: crate::cmd::view::ReadPageColor,
|
||||
pub dark_mode: bool,
|
||||
pub show_markup: bool,
|
||||
pub track_changes_pane: bool,
|
||||
@@ -265,6 +268,7 @@ impl Default for ViewState {
|
||||
read_column_width: None,
|
||||
read_zoom: default_read_zoom(),
|
||||
read_line_spacing: None,
|
||||
read_page_color: Default::default(),
|
||||
dark_mode: false,
|
||||
show_markup: true,
|
||||
track_changes_pane: false,
|
||||
@@ -324,8 +328,9 @@ struct Undo {
|
||||
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
|
||||
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
|
||||
/// open typing group, the pending
|
||||
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises
|
||||
/// and the dirty flag. See [`Session::edit_snapshot`].
|
||||
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises,
|
||||
/// whether the group has a step of its own, and the dirty flag. See
|
||||
/// [`Session::edit_snapshot`].
|
||||
pub struct EditSnapshot {
|
||||
doc: Document,
|
||||
sel: Selection,
|
||||
@@ -341,6 +346,7 @@ pub struct EditSnapshot {
|
||||
equation_join_next: bool,
|
||||
/// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects.
|
||||
ime_text_join: Option<Selection>,
|
||||
join_anchor: bool,
|
||||
dirty: bool,
|
||||
/// The body's replacement epoch then: a previewed replacement that is rolled back is not a
|
||||
/// replacement after all.
|
||||
@@ -373,6 +379,8 @@ pub struct Session {
|
||||
pub view: ViewState,
|
||||
/// Formatting picked with a collapsed caret, applied to the next typed text.
|
||||
pub pending: Option<CharProps>,
|
||||
/// Last highlighter choice for the shortcut; palette state, outside document undo.
|
||||
pub(crate) last_highlight: wordcraft_doc::props::Highlight,
|
||||
pub path: Option<std::path::PathBuf>,
|
||||
/// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file
|
||||
/// name, not document metadata, so it is not part of the document or its undo history.
|
||||
@@ -431,6 +439,10 @@ pub struct Session {
|
||||
/// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an
|
||||
/// intervening edit owns its own checkpoint instead of being merged into the deletion.
|
||||
ime_text_join: Option<Selection>,
|
||||
/// Whether the undo group has a step of its own for the next joined command to merge
|
||||
/// into. A command that changed nothing leaves the group without one, so the next
|
||||
/// command in the group starts the step the group needs.
|
||||
join_anchor: bool,
|
||||
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
|
||||
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
|
||||
rev: u64,
|
||||
@@ -742,10 +754,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start an editing session at the document beginning with empty history and
|
||||
/// default view, preferences and a yellow highlighter independent of document undo.
|
||||
pub fn new(doc: Document) -> Self {
|
||||
Self::with_registry(doc, Arc::new(crate::cmd::registry()))
|
||||
}
|
||||
|
||||
/// Initialise session defaults while sharing the immutable command registry with previews.
|
||||
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
@@ -754,6 +769,7 @@ impl Session {
|
||||
sel: Selection::caret(start),
|
||||
view: ViewState::default(),
|
||||
pending: None,
|
||||
last_highlight: wordcraft_doc::props::Highlight::Yellow,
|
||||
path: None,
|
||||
file_name: None,
|
||||
user_dict_path: None,
|
||||
@@ -780,6 +796,7 @@ impl Session {
|
||||
join_next: false,
|
||||
equation_join_next: false,
|
||||
ime_text_join: None,
|
||||
join_anchor: false,
|
||||
autocap: None,
|
||||
rev: 1,
|
||||
protect_facts: std::sync::Mutex::new(None),
|
||||
@@ -1098,6 +1115,7 @@ impl Session {
|
||||
copy.path = self.path.clone();
|
||||
copy.file_name = self.file_name.clone();
|
||||
copy.author = self.author.clone();
|
||||
copy.last_highlight = self.last_highlight;
|
||||
copy.system_region = self.system_region.clone();
|
||||
copy.editing_lang = self.editing_lang.clone();
|
||||
// The copy shares the paragraph layouts, so only what the command changed is laid out.
|
||||
@@ -1163,10 +1181,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot for undo before a change.
|
||||
pub fn checkpoint(&mut self, label: &str) {
|
||||
/// Snapshot for undo before a change. Returns whether it took a step: typing that is
|
||||
/// already in an open step is not a new one.
|
||||
pub fn checkpoint(&mut self, label: &str) -> bool {
|
||||
if label == "Typing" && self.typing_open {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
self.typing_open = label == "Typing";
|
||||
self.history.push(Arc::new(Undo {
|
||||
@@ -1181,6 +1200,9 @@ impl Session {
|
||||
self.undo_evicted += 1;
|
||||
}
|
||||
self.redo.clear();
|
||||
// The newest step is what a joined command merges into.
|
||||
self.join_anchor = true;
|
||||
true
|
||||
}
|
||||
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
|
||||
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
|
||||
@@ -1191,6 +1213,7 @@ impl Session {
|
||||
self.undo_evicted += 1;
|
||||
}
|
||||
self.typing_open = false;
|
||||
self.join_anchor = true;
|
||||
}
|
||||
/// Make the next mutating command part of the previous undo step instead of a new one, so a
|
||||
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
|
||||
@@ -1372,6 +1395,7 @@ impl Session {
|
||||
self.history.clear();
|
||||
self.redo.clear();
|
||||
self.typing_open = false;
|
||||
self.join_anchor = false;
|
||||
}
|
||||
|
||||
/// The version snapshots belong to the document they were taken from: keeping the old ones
|
||||
@@ -1400,6 +1424,18 @@ impl Session {
|
||||
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
|
||||
}
|
||||
|
||||
/// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan
|
||||
/// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's
|
||||
/// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the
|
||||
/// writer's Spanish from the first word (#3360).
|
||||
pub fn region_spanish(&self) -> String {
|
||||
let r = self.region();
|
||||
(matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty())
|
||||
.then(|| format!("es-{}", r.territory))
|
||||
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
|
||||
.unwrap_or_else(|| "es-ES".into())
|
||||
}
|
||||
|
||||
/// The unit rulers and dialogs show lengths in: the user's choice, else the region's.
|
||||
pub fn units(&self) -> wordcraft_geom::Unit {
|
||||
self.prefs.units.unwrap_or_else(|| self.region().units())
|
||||
@@ -1452,8 +1488,9 @@ impl Session {
|
||||
/// may want to take it back without a trace (cheap: the document's blocks are shared).
|
||||
///
|
||||
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
|
||||
/// the limit), `redo`, the open typing group, the pending `join_next_undo` and
|
||||
/// `join_next_equation_edit` promises and `dirty`. `rev`,
|
||||
/// the limit), `redo`, the open typing group, the pending `join_next_undo`,
|
||||
/// `join_next_equation_edit` and body replacement promises, whether the typing group has a
|
||||
/// step of its own, and `dirty`. `rev`,
|
||||
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
|
||||
/// covered: a caller that lets the command change them (the view, the find state, the
|
||||
/// clipboard, macros, the file path…) saves and puts them back itself.
|
||||
@@ -1471,6 +1508,7 @@ impl Session {
|
||||
sel,
|
||||
view: _,
|
||||
pending: _,
|
||||
last_highlight: _,
|
||||
path: _,
|
||||
file_name: _,
|
||||
// The custom dictionary file, like the file path: host wiring, not an edit.
|
||||
@@ -1500,6 +1538,7 @@ impl Session {
|
||||
join_next,
|
||||
equation_join_next,
|
||||
ime_text_join,
|
||||
join_anchor,
|
||||
autocap: _,
|
||||
rev: _,
|
||||
protect_facts: _,
|
||||
@@ -1566,6 +1605,7 @@ impl Session {
|
||||
join_next: *join_next,
|
||||
equation_join_next: *equation_join_next,
|
||||
ime_text_join: ime_text_join.clone(),
|
||||
join_anchor: *join_anchor,
|
||||
dirty: *dirty,
|
||||
ime_body_epoch: *ime_body_epoch,
|
||||
}
|
||||
@@ -1586,6 +1626,7 @@ impl Session {
|
||||
join_next,
|
||||
equation_join_next,
|
||||
ime_text_join,
|
||||
join_anchor,
|
||||
dirty,
|
||||
ime_body_epoch,
|
||||
} = snap;
|
||||
@@ -1606,6 +1647,7 @@ impl Session {
|
||||
self.equation_join_next = equation_join_next;
|
||||
self.ime_text_join = ime_text_join;
|
||||
self.ime_body_epoch = ime_body_epoch;
|
||||
self.join_anchor = join_anchor;
|
||||
self.touch();
|
||||
self.dirty = dirty;
|
||||
}
|
||||
@@ -1638,6 +1680,26 @@ impl Session {
|
||||
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
|
||||
}
|
||||
|
||||
/// Put the revision back to `rev` after a command that changed nothing or failed. What the
|
||||
/// command cached at the revision it touched to is dropped: that number comes round again
|
||||
/// with the next edit, for a different document.
|
||||
fn restore_rev(&mut self, rev: u64) {
|
||||
if self.rev == rev {
|
||||
return;
|
||||
}
|
||||
self.rev = rev;
|
||||
if self.layout.as_ref().is_some_and(|l| l.0 != rev) {
|
||||
self.layout = None;
|
||||
}
|
||||
if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) {
|
||||
self.find.highlights = None;
|
||||
}
|
||||
let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) {
|
||||
*facts = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
|
||||
fn protect_facts(&self) -> crate::cmd::protect::Facts {
|
||||
let key = (self.document_id, self.rev, self.author.clone());
|
||||
@@ -1729,6 +1791,15 @@ impl Session {
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether the command changed nothing: the document, the selection, the formatting
|
||||
/// waiting at the caret and the column selection are as they were before it ran.
|
||||
///
|
||||
/// A command that changes nothing is not an edit: it adds no undo step, marks no
|
||||
/// unsaved changes, forces no relayout and leaves the redo stack alone (#23).
|
||||
fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option<ColumnBlock>, pending: &Option<CharProps>) -> bool {
|
||||
doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending
|
||||
}
|
||||
|
||||
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
|
||||
/// control channel). Mutating commands are undoable; a failed command leaves the document as
|
||||
/// it was; a panic inside a command becomes an error.
|
||||
@@ -1805,10 +1876,25 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The formatting waiting at the caret (⌘B with nothing selected): an undo step restores
|
||||
// it, so a command that only changes it is an edit.
|
||||
let pending_before = spec.mutates.then(|| self.pending.clone());
|
||||
// Which document is open. A command that replaces the document (a mail merge) opens a
|
||||
// new one, with a new undo history: it is not an edit of this document, whatever the
|
||||
// new document holds.
|
||||
let document_id_before = self.document_id;
|
||||
// Whether the document has unsaved changes, and the revision its layout is keyed on: a
|
||||
// command that changes nothing leaves both as they were, and a failed one leaves no
|
||||
// trace.
|
||||
let dirty_before = spec.mutates.then_some(self.dirty);
|
||||
let rev_restore = spec.mutates.then_some(self.rev);
|
||||
// These mutating commands can replace the open document and discard its saved versions.
|
||||
// Keep the versions until the command and the protected-region post-check have both
|
||||
// succeeded; rejected commands must leave the old document and its snapshots together.
|
||||
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.finish" | "mailings.envelopes" | "mailings.labels");
|
||||
// `mailings.finish` with a path saves the merged copy there and leaves the open
|
||||
// document alone, so it replaces nothing.
|
||||
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels")
|
||||
|| (id == "mailings.finish" && crate::p::str(params, "path").is_none());
|
||||
let versions_before = replaces_document.then(|| self.versions.clone());
|
||||
let identity_before = replaces_document.then(|| {
|
||||
(
|
||||
@@ -1825,10 +1911,15 @@ impl Session {
|
||||
if spec.mutates && !typing {
|
||||
self.typing_open = false;
|
||||
}
|
||||
// Whether this command pushed an undo step (a no-op command's step is dropped below,
|
||||
// leaving its undo group without one).
|
||||
let mut pushed = false;
|
||||
if spec.mutates {
|
||||
let label = if typing { "Typing" } else { spec.label };
|
||||
if !join || self.history.is_empty() {
|
||||
self.checkpoint(label);
|
||||
// A joined command merges into its group's step; a group whose first command
|
||||
// changed nothing has none, so the next command in it starts one (#4001).
|
||||
if !join || self.history.is_empty() || !self.join_anchor {
|
||||
pushed = self.checkpoint(label);
|
||||
}
|
||||
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
|
||||
// Non-mutating commands other than caret movement keep the typing group.
|
||||
@@ -1866,18 +1957,64 @@ impl Session {
|
||||
}
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
let mut no_op = false;
|
||||
if spec.mutates {
|
||||
// Formatting changed while tracking becomes a formatting revision.
|
||||
if let Some((before, ..)) = &before_doc {
|
||||
crate::cmd::fmt_revisions::record(self, id, before);
|
||||
}
|
||||
self.touch();
|
||||
self.doc.ensure_nonempty();
|
||||
self.doc.prune_text_boxes();
|
||||
// Glued connectors follow the shapes they join.
|
||||
if crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self) {
|
||||
// Whether the command left the document, the selection and the formatting
|
||||
// waiting at the caret as they were. A command that replaces the document —
|
||||
// a mail merge, a comparison — is not an edit of this one, whatever the new
|
||||
// document holds: `replaces_document` names the commands that may replace
|
||||
// it, and `document_id` catches any other that does (through
|
||||
// `set_document`, which starts a new history the old one must not overwrite).
|
||||
let unchanged = |s: &Session| match (&before_doc, &pending_before) {
|
||||
(Some((doc, sel, _, _, _, _, column)), Some(pending)) => {
|
||||
!replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let changed = !unchanged(self);
|
||||
if changed {
|
||||
// The layout the connectors are rerouted against is the new one.
|
||||
self.touch();
|
||||
}
|
||||
// Glued connectors follow the shapes they join.
|
||||
let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self);
|
||||
if rerouted {
|
||||
self.touch();
|
||||
}
|
||||
// A command that changed nothing is not an edit (#23): it adds no undo
|
||||
// step, marks no unsaved changes, forces no relayout and leaves the redo
|
||||
// stack alone. What it left behind is compared with what it started from,
|
||||
// after the reroute: a reroute that moved a connector is an edit of its own.
|
||||
no_op = !changed && !rerouted && unchanged(self);
|
||||
if no_op {
|
||||
// The step the dispatch pushed is not an edit: put the stacks back as
|
||||
// they were, so the redo stack survives and no step is evicted. The
|
||||
// group it belonged to is left without a step of its own, so the next
|
||||
// command in it starts one.
|
||||
if let Some((_, _, history, redo, typing_open, evicted, _)) = &before_doc {
|
||||
self.history = history.clone();
|
||||
self.redo = redo.clone();
|
||||
self.typing_open = *typing_open;
|
||||
self.undo_evicted = *evicted;
|
||||
}
|
||||
if let Some(dirty) = dirty_before {
|
||||
self.dirty = dirty;
|
||||
}
|
||||
if let Some(rev) = rev_restore {
|
||||
let touched = self.rev;
|
||||
self.restore_rev(rev);
|
||||
crate::cmd::draw::rekey_lasso(self, touched, rev);
|
||||
}
|
||||
if pushed {
|
||||
self.join_anchor = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.clamp_selection();
|
||||
let mut ime_outcome = self.ime_edit_outcome;
|
||||
@@ -1886,7 +2023,10 @@ impl Session {
|
||||
}
|
||||
// An unclassified mutating command is conservatively a state change. It can make
|
||||
// a frame Other, but it can never mint a receipt because `classified` is false.
|
||||
if !ime_outcome.classified && mutates {
|
||||
// A command that changed nothing is not an edit (#23), so it is not an IME
|
||||
// state change either: otherwise Backspace at the document start would still
|
||||
// signal an application change and move the settled IME revision.
|
||||
if !ime_outcome.classified && mutates && !no_op {
|
||||
ime_outcome.state_changed = true;
|
||||
}
|
||||
ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before;
|
||||
@@ -1896,7 +2036,7 @@ impl Session {
|
||||
self.ime_state_seen = Some(caret);
|
||||
self.reset_ime_state_revision();
|
||||
}
|
||||
if !self.ime_origin && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
|
||||
if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
|
||||
self.pending_ime_application_change = true;
|
||||
}
|
||||
if sel_before.focus.story == StoryRef::Body && self.in_header_footer() {
|
||||
@@ -1907,7 +2047,7 @@ impl Session {
|
||||
if !spec.mutates && self.sel != sel_before {
|
||||
self.typing_open = false;
|
||||
}
|
||||
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") {
|
||||
if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") {
|
||||
self.note_edit();
|
||||
}
|
||||
// Outline view: collapsed headings follow edits; the caret stays where it shows.
|
||||
@@ -1952,6 +2092,16 @@ impl Session {
|
||||
// The steps the command pushed out are back, so they no longer count as gone.
|
||||
self.undo_evicted = ev;
|
||||
self.column = c;
|
||||
// Its step is gone with them: its undo group has none to merge into.
|
||||
if pushed {
|
||||
self.join_anchor = false;
|
||||
}
|
||||
}
|
||||
if let Some(dirty) = dirty_before {
|
||||
self.dirty = dirty;
|
||||
}
|
||||
if let Some(rev) = rev_restore {
|
||||
self.restore_rev(rev);
|
||||
}
|
||||
if let Some(b) = blocks_before {
|
||||
self.building_blocks = b;
|
||||
|
||||
+573
-4
@@ -275,6 +275,136 @@ fn typing_enter_undo() {
|
||||
assert_eq!(text(&s), "Hello world");
|
||||
}
|
||||
|
||||
/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes
|
||||
/// and no relayout.
|
||||
#[test]
|
||||
fn a_command_that_changes_nothing_is_not_an_edit() {
|
||||
let mut s = s();
|
||||
let rev = s.rev();
|
||||
// Backspace at the start of the document: there is nothing to delete.
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(s.undo_depth(), 0, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
assert_eq!(s.rev(), rev, "no relayout");
|
||||
// Typing nothing is the same.
|
||||
run(&mut s, "text.insert", json!({"text": ""}));
|
||||
assert_eq!(s.undo_depth(), 0, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
// A command that changes something is an edit.
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
assert_eq!(s.undo_depth(), 1, "one undo step");
|
||||
assert!(s.dirty, "the document is edited");
|
||||
// Setting the alignment a paragraph already has changes nothing.
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
// A command that changes nothing leaves the unsaved-changes flag and the layout revision as
|
||||
// they were, even when it calls `touch` itself: a saved document is not marked edited for
|
||||
// nothing, and its layout is not laid out again.
|
||||
let mut clean = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut clean, "para.align", json!({"value": "left"}));
|
||||
clean.dirty = false;
|
||||
let rev = clean.rev();
|
||||
run(&mut clean, "para.align", json!({"value": "left"}));
|
||||
assert!(!clean.dirty, "no unsaved changes");
|
||||
assert_eq!(clean.rev(), rev, "no relayout");
|
||||
assert_eq!(clean.undo_depth(), 1, "only the first align is a step");
|
||||
}
|
||||
|
||||
/// Five hundred commands that change nothing used to fill the whole undo stack and push the
|
||||
/// user's own edits out of it, so they could never be undone again (#23).
|
||||
#[test]
|
||||
fn no_op_commands_do_not_evict_the_undo_history() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
for _ in 0..600 {
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
}
|
||||
assert_eq!(s.undo_depth(), 1, "the typing step is still there");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "", "the typed text is still undoable");
|
||||
}
|
||||
|
||||
/// A command that changes nothing does not throw the redo stack away (#23).
|
||||
#[test]
|
||||
fn a_no_op_command_keeps_the_redo_stack() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.can_redo());
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert!(s.can_redo(), "the redo stack survives a command that changed nothing");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "Hello");
|
||||
}
|
||||
|
||||
/// Undo and Redo move between steps even when a step's document is the one showing: they are
|
||||
/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step.
|
||||
#[test]
|
||||
fn undo_moves_past_a_step_that_changed_nothing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
s.checkpoint("Nothing");
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth - 1);
|
||||
assert!(s.can_redo());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "");
|
||||
}
|
||||
|
||||
/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag):
|
||||
/// a group whose first command changes nothing has no step to merge into, so the next command
|
||||
/// in it starts the group's step instead of merging into an older edit (#23 follow-up).
|
||||
#[test]
|
||||
fn a_joined_command_starts_its_group_when_the_first_changed_nothing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top));
|
||||
// Portrait on a portrait page changes nothing.
|
||||
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
|
||||
assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit");
|
||||
// The next command of the group starts the step the group needs.
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.margins", json!({"left": 100.0}));
|
||||
assert_eq!(s.doc.last_section.margin_left, 100.0);
|
||||
assert_eq!(s.undo_depth(), depth + 1, "the group's one step");
|
||||
// A command that changes nothing in the middle of the group leaves that step alone, and
|
||||
// the next one merges into it.
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.margins", json!({"top": 60.0}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "still the group's one step");
|
||||
// Undo takes the whole group back and no more: the typing is still there.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from");
|
||||
assert_eq!(text(&s), "Hello");
|
||||
}
|
||||
|
||||
/// Bold with nothing selected arms the formatting for the next word: the document is the
|
||||
/// same, so only the caret's formatting changed — and that is an edit (#23).
|
||||
#[test]
|
||||
fn a_command_that_only_arms_the_caret_formatting_is_an_edit() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
|
||||
// Undo takes the armed formatting back, as Word does (docs/typing-parity.md).
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None));
|
||||
}
|
||||
|
||||
/// The Undo button's list (#701): several steps undone at once, newest first.
|
||||
#[test]
|
||||
fn undo_to_takes_several_steps_back_at_once() {
|
||||
@@ -514,7 +644,10 @@ fn failed_command_restores_history_changed_by_nested_commands() {
|
||||
// Comments are stamped with the time to the second, and the two sessions compared
|
||||
// are built a moment apart, so leave the stamp out of the comparison.
|
||||
let mut doc = s.doc.clone();
|
||||
doc.comments.values_mut().for_each(|c| c.date.clear());
|
||||
doc.comments.values_mut().for_each(|c| {
|
||||
c.date.clear();
|
||||
c.date_utc = None;
|
||||
});
|
||||
out.push((s.undo_labels(), doc));
|
||||
if !s.can_undo() {
|
||||
return out;
|
||||
@@ -1674,6 +1807,70 @@ fn tables_commands() {
|
||||
assert!(s.run("table.merge", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let widths = |s: &Session| {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
(t.grid.clone(), t.rows[0].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect::<Vec<_>>())
|
||||
};
|
||||
let (grid_before, _) = widths(&s);
|
||||
assert!(grid_before.iter().all(|w| *w > 0.0));
|
||||
run(&mut s, "table.insertColumnRight", json!({}));
|
||||
let (grid_after, cells) = widths(&s);
|
||||
// The table keeps its total width, and each cell carries its grid columns' width: the
|
||||
// layout and the saved `w:tcW` see the same shrink the grid got.
|
||||
let sum = |v: &[f32]| v.iter().sum::<f32>();
|
||||
assert!((sum(&grid_after) - sum(&grid_before)).abs() < 0.5, "{grid_after:?} vs {grid_before:?}");
|
||||
assert_eq!(cells.len(), grid_after.len());
|
||||
for (g, w) in grid_after.iter().zip(cells.iter()) {
|
||||
assert!((w - g).abs() < 0.01, "cell width {w} vs grid {g}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that skips grid columns (`w:gridBefore`) gives its cells the widths of the columns
|
||||
/// they sit in, not of the first ones.
|
||||
#[test]
|
||||
fn cell_widths_follow_the_grid_after_skipped_columns() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
|
||||
let i = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap();
|
||||
let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(&mut s.doc.body[i]) else { panic!("no table") };
|
||||
t.grid = vec![100.0, 200.0, 300.0];
|
||||
t.rows[1].props.grid_before = 1;
|
||||
t.rows[1].cells.pop();
|
||||
crate::cmd::table::sync_cell_widths(t);
|
||||
let widths: Vec<f32> = t.rows[1].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect();
|
||||
assert_eq!(widths, [200.0, 300.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_version_restore_saves_nothing() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Original text."));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
|
||||
s.run("file.versions", &json!({"save": "before"})).unwrap();
|
||||
// One command asking for both a save and a restore the protection refuses: the save must
|
||||
// not happen either.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// A bad index is refused before anything is saved, too.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 99})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such version"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// A restore that is there but not a non-negative integer is refused, not read as absent:
|
||||
// {"save": "mid", "restore": -1} must not save quietly without restoring.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("restore (non-negative integer)"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// An index too large for `usize` names no version: on a 32-bit target `as usize` would wrap
|
||||
// it to 0 and restore the first version instead.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 4294967296u64})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such version"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
}
|
||||
|
||||
/// #3931: the page as a command would leave it, laid out without running it on the document:
|
||||
/// Insert › Table's grid previews the table it would insert. The document, the caret, the undo
|
||||
/// history and Repeat stay as they were.
|
||||
@@ -2873,6 +3070,40 @@ fn toc_and_fields() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
|
||||
}
|
||||
|
||||
/// A comment or reply made here carries its UTC time as `date_utc` too, so it shows in local time
|
||||
/// and is saved as `dateUtc`.
|
||||
#[test]
|
||||
fn new_comments_and_replies_carry_their_utc_time() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Some text"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let id = run(&mut s, "review.newComment", json!({"text": "one"}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "review.reply", json!({"id": id, "text": "two"}));
|
||||
assert_eq!(s.doc.comments.len(), 2);
|
||||
for c in s.doc.comments.values() {
|
||||
assert!(c.date.ends_with('Z'), "{}", c.date);
|
||||
assert_eq!(c.date_utc.as_deref(), Some(c.date.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Update Table with nothing to change lays the document out at the revisions it touches, then
|
||||
/// puts the revision back. The next edits reach those numbers again with a different document, so
|
||||
/// the layout must be the new one.
|
||||
#[test]
|
||||
fn a_command_that_changes_nothing_leaves_no_stale_layout() {
|
||||
let mut s = Session::new(crate::sample::report());
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "references.toc", json!({}));
|
||||
let (rev, steps) = (s.rev(), s.undo_depth());
|
||||
let pages = s.layout().pages.len();
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
assert_eq!((s.rev(), s.undo_depth()), (rev, steps), "Update Table changed nothing");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "A"}));
|
||||
run(&mut s, "text.insert", json!({"text": "word ".repeat(4000)}));
|
||||
assert!(s.layout().pages.len() > pages, "the layout has the inserted text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_refresh_after_save_and_reopen() {
|
||||
for extension in ["docx", "json"] {
|
||||
@@ -3089,6 +3320,172 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph style `id` named `name`, based on Normal.
|
||||
fn style_xml(id: &str, name: &str, ppr: &str, rpr: &str) -> String {
|
||||
format!(
|
||||
r#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{name}"/><w:basedOn w:val="Normal"/><w:pPr>{ppr}</w:pPr><w:rPr>{rpr}</w:rPr></w:style>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A DOCX package with `body` and the paragraph `styles` after a default Normal style.
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
const RELS: &str = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
|
||||
const TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, value) in [
|
||||
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
|
||||
(
|
||||
"word/styles.xml",
|
||||
format!(
|
||||
r#"<w:styles {W}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w:styles>"#
|
||||
),
|
||||
),
|
||||
(
|
||||
"word/_rels/document.xml.rels",
|
||||
format!(r#"<Relationships {RELS}><Relationship Id="rId1" Type="{TYPE}/styles" Target="styles.xml"/></Relationships>"#),
|
||||
),
|
||||
(
|
||||
"_rels/.rels",
|
||||
format!(r#"<Relationships {RELS}><Relationship Id="main" Type="{TYPE}/officeDocument" Target="word/document.xml"/></Relationships>"#),
|
||||
),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(value.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// #3891: Update Table on a TOC Word wrote in Portuguese keeps the formatting of its TOC styles,
|
||||
/// stored under the ids `Sumrio1`/`Sumrio3` (`toc 1`/`toc 3`): bold Arial at level 1, italic and
|
||||
/// indented at level 3.
|
||||
#[test]
|
||||
fn update_table_keeps_the_formatting_of_localized_toc_styles() {
|
||||
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
|
||||
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let body = [
|
||||
p("CabealhodoSumrio", &t("Contents")),
|
||||
p("Sumrio1", &format!("{begin}{}", t("Chapter\t1"))),
|
||||
p("Sumrio3", &t("Detail\t1")),
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
|
||||
p("Ttulo1", &t("Chapter")),
|
||||
p("Ttulo3", &t("Detail")),
|
||||
]
|
||||
.concat();
|
||||
let styles = [
|
||||
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
|
||||
style_xml("Ttulo3", "heading 3", r#"<w:outlineLvl w:val="2"/>"#, ""),
|
||||
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
|
||||
style_xml("Sumrio1", "toc 1", "", r#"<w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:b/>"#),
|
||||
style_xml("Sumrio3", "toc 3", r#"<w:ind w:left="220"/>"#, "<w:i/>"),
|
||||
]
|
||||
.concat();
|
||||
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let entries: Vec<(String, bool, bool, f32)> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
.map(|p| {
|
||||
let c = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
|
||||
(c.font, c.bold, c.italic, s.doc.styles.resolve_para(&p.props).indent_left)
|
||||
})
|
||||
.collect();
|
||||
let level3 = entries.get(1).map(|e| e.0.clone()).unwrap_or_default();
|
||||
assert_eq!(entries, [("Arial".to_string(), true, false, 0.0), (level3, false, true, 11.0)], "{}", text(&s));
|
||||
}
|
||||
|
||||
/// #3889: Word's TOC in two columns, written by Word in Portuguese. The TOC heading ends a
|
||||
/// one-column section; the entries, styled `Sumrio1`/`Sumrio2` (`toc 1`/`toc 2`), fill a continuous
|
||||
/// two-column one, and the field's end and the control's end share a paragraph after them. Update
|
||||
/// Table keeps the entries' styles, puts the page numbers at the first column's right edge (3888
|
||||
/// twips, as Word's own entries have them) and adds no blank line above the first column; update,
|
||||
/// save and reopen keeps it so.
|
||||
#[test]
|
||||
fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table() {
|
||||
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
|
||||
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
|
||||
let tab = r#"<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="3888"/></w:tabs>"#;
|
||||
let entry = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/>{tab}</w:pPr>{inner}</w:p>"#);
|
||||
let sect = |cols: &str, kind: &str| {
|
||||
format!(
|
||||
r#"<w:sectPr>{kind}<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1417" w:right="1701" w:bottom="1417" w:left="1701" w:header="708" w:footer="708" w:gutter="0"/><w:cols {cols} w:space="708"/></w:sectPr>"#
|
||||
)
|
||||
};
|
||||
let continuous = r#"<w:type w:val="continuous"/>"#;
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-2" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let toc = [
|
||||
p("CabealhodoSumrio", &t("Contents")),
|
||||
format!("<w:p><w:pPr>{}</w:pPr></w:p>", sect("", "")),
|
||||
entry("Sumrio1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
entry("Sumrio2", &t("Section\t1")),
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
|
||||
]
|
||||
.concat();
|
||||
let body = [
|
||||
format!(
|
||||
r#"<w:sdt><w:sdtPr><w:id w:val="-1360965913"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>{toc}</w:sdtContent></w:sdt>"#
|
||||
),
|
||||
format!(r#"<w:p><w:pPr>{}</w:pPr></w:p>"#, sect(r#"w:num="2""#, continuous)),
|
||||
p("Ttulo1", &t("Chapter One")),
|
||||
p("Ttulo2", &t("Section")),
|
||||
sect("", ""),
|
||||
]
|
||||
.concat();
|
||||
let styles = [
|
||||
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
|
||||
style_xml("Ttulo2", "heading 2", r#"<w:outlineLvl w:val="1"/>"#, ""),
|
||||
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
|
||||
style_xml("Sumrio1", "toc 1", r#"<w:spacing w:after="100"/>"#, ""),
|
||||
style_xml("Sumrio2", "toc 2", r#"<w:spacing w:after="100"/><w:ind w:left="220"/>"#, ""),
|
||||
]
|
||||
.concat();
|
||||
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
|
||||
let mut blocks = None;
|
||||
for round in 0..3 {
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let entries: Vec<(usize, &wordcraft_doc::Paragraph)> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| b.as_para().map(|p| (i, p)))
|
||||
.filter(|(_, p)| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
.collect();
|
||||
// The chapters' section starts a new page.
|
||||
let found: Vec<(Option<&str>, String)> = entries.iter().map(|(_, p)| (p.props.style.as_deref(), p.plain_text())).collect();
|
||||
assert_eq!(found, [(Some("TOC1"), "Chapter One\t2".to_string()), (Some("TOC2"), "Section\t2".to_string())], "round {round}: {}", text(&s));
|
||||
assert_eq!(s.doc.styles.get("TOC2").and_then(|st| st.para.indent_left), Some(11.0), "round {round}: the document's own TOC 2");
|
||||
for (_, e) in &entries {
|
||||
let right = e.props.tabs.as_ref().and_then(|t| t.iter().find(|t| t.align == wordcraft_doc::props::TabAlign::Right)).unwrap();
|
||||
assert!((right.pos - 194.4).abs() < 0.1, "round {round}: page numbers at the first column's edge, not the page's: {}", right.pos);
|
||||
}
|
||||
// The paragraph above the first entry ends the one-column section: no blank line tops the
|
||||
// first column.
|
||||
let above = entries[0].0 - 1;
|
||||
assert!(s.doc.body[above].as_para().unwrap().section.is_some(), "round {round}: a blank line above the entries: {}", text(&s));
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "round {round}: the document grew: {}", text(&s));
|
||||
blocks = Some(s.doc.body.len());
|
||||
}
|
||||
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
|
||||
let columns = |s: &Session| s.doc.sections().iter().map(|(_, x)| x.columns.count).collect::<Vec<_>>();
|
||||
assert_eq!(columns(&s), [1, 2, 1]);
|
||||
// The heading goes with the table: one "Contents" after Replace, none after Remove.
|
||||
let titles = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.plain_text() == "Contents").count();
|
||||
run(&mut s, "references.toc", json!({"levels": 2}));
|
||||
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
|
||||
assert_eq!(titles(&s), 1, "{}", text(&s));
|
||||
run(&mut s, "references.removeToc", json!({}));
|
||||
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
|
||||
assert_eq!(titles(&s), 0, "{}", text(&s));
|
||||
}
|
||||
|
||||
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
|
||||
/// range TOC doesn't claim the menu, so it never refreshes a different table.
|
||||
#[test]
|
||||
@@ -5181,6 +5578,32 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
|
||||
assert_eq!(s.view.read_line_spacing, None);
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
|
||||
/// document alone, is not saved, and starts at None on each entry to Read Mode.
|
||||
#[test]
|
||||
fn reading_page_color_is_a_transient_view_setting() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
let mut s = s();
|
||||
let original = s.doc.clone();
|
||||
let dirty = s.dirty;
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
|
||||
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
|
||||
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
|
||||
assert_eq!(s.doc, original);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
run(&mut s, "view.printLayout", json!({}));
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
|
||||
#[test]
|
||||
fn numeral_mode_sets_gets_and_rejects_junk() {
|
||||
@@ -7032,6 +7455,10 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() {
|
||||
("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC),
|
||||
("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC),
|
||||
("id_ID", INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC),
|
||||
// The others the tests install, so reloading from the folder keeps their words.
|
||||
("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC),
|
||||
("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC),
|
||||
("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC),
|
||||
// #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache
|
||||
// alone), so a run of this test overlapping czech_proofing_through_the_review_commands
|
||||
// would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same
|
||||
@@ -7055,6 +7482,11 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() {
|
||||
assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy())));
|
||||
assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy())));
|
||||
assert_eq!(file("id"), Some(json!(dir.join("id_ID.dic").to_string_lossy())));
|
||||
// Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order).
|
||||
let spanish: Vec<serde_json::Value> = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect();
|
||||
assert_eq!(spanish.len(), 4);
|
||||
assert_eq!(spanish.first(), Some(&json!(dir.join("es_ES.dic").to_string_lossy())));
|
||||
assert_eq!(spanish.last(), Some(&json!(dir.join("es_UY.dic").to_string_lossy())));
|
||||
// Polish text is checked against the folder's dictionary.
|
||||
run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
@@ -7247,6 +7679,119 @@ fn new_documents_follow_the_regions_english() {
|
||||
assert_eq!(s.region_english(), None);
|
||||
}
|
||||
|
||||
/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a
|
||||
/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere.
|
||||
#[test]
|
||||
fn new_documents_follow_the_regions_spanish() {
|
||||
let mut s = s();
|
||||
s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8");
|
||||
assert_eq!(s.region_spanish(), "es-UY");
|
||||
// The ISO 639-2 code is Spanish too (CodeRabbit review on #3562).
|
||||
s.system_region = wordcraft_geom::Region::from_tag("spa_MX.UTF-8");
|
||||
assert_eq!(s.region_spanish(), "es-MX");
|
||||
s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8");
|
||||
s.set_editing_language(Some(&s.region_spanish()));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows");
|
||||
for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] {
|
||||
run(&mut s, "file.options", json!({"region": tag}));
|
||||
assert_eq!(s.region_spanish(), spanish, "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms,
|
||||
// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules).
|
||||
const SPANISH_TEST_AFF: &str =
|
||||
"SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n";
|
||||
const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n";
|
||||
const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n";
|
||||
|
||||
fn spanish_test_dictionaries() {
|
||||
use wordcraft_proof::ProofLang;
|
||||
for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] {
|
||||
wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to
|
||||
/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered.
|
||||
#[test]
|
||||
fn spanish_proofing_through_the_review_commands() {
|
||||
spanish_test_dictionaries();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es_es", "default": true}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES"));
|
||||
let issues = run(&mut s, "review.issues", json!({}));
|
||||
let arr = issues.as_array().unwrap();
|
||||
assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}");
|
||||
let has = |text: &str, kind: &str, sugg: &str| {
|
||||
arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
|
||||
};
|
||||
assert!(has("prueva", "spelling", "prueba"), "{issues}");
|
||||
assert!(has("corector", "spelling", "corrector"), "{issues}");
|
||||
assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}");
|
||||
assert_eq!(arr.len(), 3, "{issues}");
|
||||
assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva");
|
||||
run(&mut s, "review.applySuggestion", json!({"text": "prueba"}));
|
||||
assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s));
|
||||
run(&mut s, "review.addToDictionary", json!({"word": "corector"}));
|
||||
let left = run(&mut s, "review.issues", json!({}));
|
||||
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
|
||||
}
|
||||
|
||||
/// #3562 review: Add to Dictionary and Ignore All get the issue's own text, decomposed (NFD) as
|
||||
/// typed; the word is then accepted decomposed and composed, and its issue range was the typed
|
||||
/// bytes.
|
||||
#[test]
|
||||
fn add_and_ignore_all_accept_both_normalization_forms() {
|
||||
spanish_test_dictionaries();
|
||||
let mut s = s();
|
||||
let text = "esto zqanade\u{301}do y zqignora\u{301}do";
|
||||
run(&mut s, "document.setText", json!({"text": text}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es-ES"}));
|
||||
let issues = run(&mut s, "review.issues", json!({}));
|
||||
let words: Vec<(String, u64)> = issues
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|i| i["kind"] == "spelling")
|
||||
.map(|i| (i["text"].as_str().unwrap().to_string(), i["start"]["off"].as_u64().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(words, [("zqanade\u{301}do".to_string(), 5), ("zqignora\u{301}do".to_string(), text.find("zqign").unwrap() as u64)], "{issues}");
|
||||
run(&mut s, "review.addToDictionary", json!({"word": words[0].0}));
|
||||
run(&mut s, "review.ignoreAll", json!({"word": words[1].0}));
|
||||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty());
|
||||
// The composed spellings are the same words.
|
||||
run(&mut s, "document.setText", json!({"text": "esto zqanadédo y zqignorádo"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es-ES"}));
|
||||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty());
|
||||
wordcraft_proof::remove_word("zqanadédo");
|
||||
}
|
||||
|
||||
/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags.
|
||||
#[test]
|
||||
fn uruguayan_spanish_accepts_vos_forms() {
|
||||
spanish_test_dictionaries();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es-UY"}));
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY");
|
||||
let spelling = |s: &mut Session| -> Vec<String> {
|
||||
let issues = run(s, "review.issues", json!({}));
|
||||
issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect()
|
||||
};
|
||||
assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es-ES"}));
|
||||
assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]);
|
||||
}
|
||||
|
||||
/// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary.
|
||||
#[test]
|
||||
fn czech_proofing_through_the_review_commands() {
|
||||
@@ -7286,10 +7831,11 @@ fn czech_proofing_through_the_review_commands() {
|
||||
/// Review › Language and the proofing commands work in Polish end to end: the language makes
|
||||
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
|
||||
/// suggestions come from it, and a suggestion fixes the text.
|
||||
const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
|
||||
const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
|
||||
|
||||
fn italian_test_dictionary() {
|
||||
let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
|
||||
let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
|
||||
wordcraft_proof::it::install_dictionary(aff, dic).unwrap();
|
||||
wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_DIC).unwrap();
|
||||
}
|
||||
|
||||
/// Review › Language and the proofing commands work in Italian end to end: the language makes
|
||||
@@ -8630,6 +9176,29 @@ fn a_version_snapshot_does_not_survive_an_untitled_comparison() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A comparison with an `original` replaces the open document in place — the handler loads the
|
||||
/// original into `s.doc` without `set_document` — so `document_id` does not move and two
|
||||
/// identical comparisons produce identical documents. A comparison is an edit of its own all
|
||||
/// the same: the `replaces_document` guard keeps its undo step (#4004 review).
|
||||
#[test]
|
||||
fn a_repeated_comparison_is_still_an_edit() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("original.docx");
|
||||
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
|
||||
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
|
||||
let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."});
|
||||
s.run("review.compare", &compare).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
assert!(s.dirty);
|
||||
s.run("review.compare", &compare).unwrap();
|
||||
assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own");
|
||||
assert!(s.dirty, "the comparison result is unsaved");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A rejected comparison leaves the current document and its snapshots together.
|
||||
#[test]
|
||||
fn a_rejected_comparison_keeps_the_current_versions() {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the
|
||||
//! migration's input-method application-change and state-revision tracking.
|
||||
//!
|
||||
//! Kept in its own module so the merged `tests.rs` is not touched.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and
|
||||
/// must not look like one to the input method: no effect flags, no application-change signal, no
|
||||
/// settled-revision move, and no history/dirty/revision side effects.
|
||||
#[test]
|
||||
fn a_no_op_command_is_not_an_ime_state_change() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
assert!(s.pending.is_none(), "caret movement dropped any pending formatting");
|
||||
s.take_pending_ime_application_change();
|
||||
let revision = s.ime_state_revision().expect("an IME revision");
|
||||
let rev = s.rev();
|
||||
let depth = s.undo_depth();
|
||||
let dirty = s.dirty;
|
||||
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}");
|
||||
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
|
||||
assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change");
|
||||
assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move");
|
||||
assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev));
|
||||
}
|
||||
|
||||
/// `document.setText` with identical text replaces the body under the same document id, so the
|
||||
/// IME replacement epoch must advance — but because nothing observable changed it is not an edit
|
||||
/// and must not signal an application-origin change (main's #23 no-op rule).
|
||||
#[test]
|
||||
fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Same body"}));
|
||||
s.take_pending_ime_application_change();
|
||||
let epoch = s.ime_body_epoch();
|
||||
let document_id = s.document_id();
|
||||
let rev = s.rev();
|
||||
let depth = s.undo_depth();
|
||||
|
||||
run(&mut s, "document.setText", json!({"text": "Same body"}));
|
||||
|
||||
assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id");
|
||||
assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");
|
||||
assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change");
|
||||
assert_eq!((s.undo_depth(), s.rev()), (depth, rev));
|
||||
}
|
||||
@@ -401,3 +401,74 @@ fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() {
|
||||
assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Slot {
|
||||
FootnoteSeparator,
|
||||
EndnoteSeparator,
|
||||
FootnoteContinuationSeparator,
|
||||
EndnoteContinuationNotice,
|
||||
}
|
||||
|
||||
const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice];
|
||||
|
||||
fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option<wordcraft_doc::Blocks> {
|
||||
match slot {
|
||||
Slot::FootnoteSeparator => &mut doc.footnote_separators.separator,
|
||||
Slot::EndnoteSeparator => &mut doc.endnote_separators.separator,
|
||||
Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator,
|
||||
Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice,
|
||||
}
|
||||
}
|
||||
|
||||
/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0).
|
||||
fn tracked_separator_session(slot: Slot) -> Session {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.revisions.push(wordcraft_doc::Revision {
|
||||
kind: wordcraft_doc::RevisionKind::Insert,
|
||||
author: "Alice".into(),
|
||||
date: String::new(),
|
||||
move_name: None,
|
||||
});
|
||||
let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() };
|
||||
let p = wordcraft_doc::Paragraph::with_text("SEP", props);
|
||||
*slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]);
|
||||
Session::new(doc)
|
||||
}
|
||||
|
||||
fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph {
|
||||
slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_all_resolves_note_separator_revisions() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
let p = separator_para(&mut s, slot);
|
||||
assert_eq!(p.plain_text(), "SEP", "{slot:?}");
|
||||
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_resolves_note_separator_revisions() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tracked_change_does_not_alias_separator_revision() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
s.author = "Bob".into();
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "NEW "}));
|
||||
let p = separator_para(&mut s, slot);
|
||||
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Create an empty session for command-driven typing fixtures.
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
/// Execute a registry command and report its id on a test failure.
|
||||
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
@@ -39,10 +41,12 @@ fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Expected paragraph text and list membership for typing assertions.
|
||||
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
|
||||
(text.to_string(), level, level.is_some())
|
||||
}
|
||||
|
||||
/// Create a session and enter its fixture through the actual typing commands.
|
||||
fn typed(keys: &str) -> Session {
|
||||
let mut s = s();
|
||||
typ(&mut s, keys);
|
||||
@@ -489,6 +493,25 @@ fn enter_finishes_a_word_for_autocorrect() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n");
|
||||
}
|
||||
|
||||
/// A word after a no-break space or an ideographic space is corrected as after a plain space:
|
||||
/// replacements, sentence capitals, ordinals and links.
|
||||
#[test]
|
||||
fn autocorrect_finds_the_word_after_a_wide_space() {
|
||||
let s = typed("a\u{a0}teh x\u{3000}teh ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the ");
|
||||
let s = typed("end.\u{a0}hello end.\u{3000}world ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World ");
|
||||
let s = typed("the\u{a0}1st and\u{3000}22nd ");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript);
|
||||
assert!(sup("1st") && sup("22nd"));
|
||||
let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then ");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone();
|
||||
assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log"));
|
||||
assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinals_become_superscript() {
|
||||
let s = typed("the 1st and 22nd and 13th but not 1th ");
|
||||
@@ -568,6 +591,71 @@ fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() {
|
||||
assert_eq!(p.props_of_char(m).sym_font, None);
|
||||
}
|
||||
|
||||
/// Whether every character of the body paragraph `i` is marked deleted.
|
||||
fn all_deleted(s: &Session, i: usize, text: &str) -> bool {
|
||||
s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
|
||||
let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
|
||||
let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some());
|
||||
// Characters that were there before Track Changes was turned on are marked deleted.
|
||||
let mut s = typed("Above\n---");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\n");
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s));
|
||||
// Enter again after the deleted characters is a plain Enter.
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(texts(&s), ["above", "---", ""]);
|
||||
assert_eq!(s.sel.focus, Pos::body(2, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(texts(&s), ["above", "---"]);
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(texts(&s), ["above", ""]);
|
||||
assert!(bottom(&s, 0).is_some());
|
||||
// At the top of the document the border goes on the characters' paragraph, and typing
|
||||
// continues in a new one.
|
||||
let mut s = typed("---");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\nText");
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s));
|
||||
assert_eq!(texts(&s)[1], "text");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(texts(&s), ["---"]);
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
// Typed with Track Changes on, the characters go and the border is still a change.
|
||||
let mut s = typed("");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "---\n");
|
||||
assert_eq!(texts(&s), ["", ""]);
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
|
||||
let mut s = typed("+--+------+");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\nA");
|
||||
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
|
||||
assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell");
|
||||
assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s));
|
||||
// Enter after the deleted characters makes no second table.
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}}));
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_line_autoformat() {
|
||||
let s = typed("Above\n---\nBelow");
|
||||
@@ -600,6 +688,155 @@ fn undo_right_after_a_border_line_brings_the_characters_back() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---");
|
||||
}
|
||||
|
||||
/// The first paragraph's (left, first-line) indents as set on the paragraph itself.
|
||||
fn indents(s: &Session) -> (Option<f32>, Option<f32>) {
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
(p.props.indent_left, p.props.indent_first)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_backspace_at_a_paragraph_start_set_its_indents() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "Some text");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
// The first Tab indents the first line, the next one the whole paragraph.
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (None, Some(36.0)));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), Some(36.0)));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
|
||||
// Backspace takes the first-line indent back, then the left indent, and stops there.
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(indents(&s), (Some(36.0), Some(0.0)));
|
||||
typ(&mut s, "\u{8}\u{8}");
|
||||
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
|
||||
// Each Tab is one undo step.
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
|
||||
// A left indent in characters goes back one tab stop too, instead of the paragraphs joining:
|
||||
// six characters of 11 or 12 points are 66 or 72 points, two tab stops.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One\nTwo");
|
||||
let second = Pos::body(1, 0);
|
||||
s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0);
|
||||
s.sel = crate::Selection::caret(second.clone());
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo");
|
||||
let p = s.doc.para_at(&second).unwrap();
|
||||
assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0)));
|
||||
// With formatting limited to the document's styles the option is off: Tab types a tab
|
||||
// character at the start of the first line and of a later one, and Backspace at the start
|
||||
// keeps the paragraph's left indent.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "Two");
|
||||
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0);
|
||||
typ(&mut s, "\u{8}\u{8}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
// An empty paragraph takes a tab character.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "\tText");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// At the start of a list item numbered by its style, Tab types a tab character, as with the
|
||||
// option off.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// With the option off (the default), Tab types a tab character.
|
||||
let mut s = self::s();
|
||||
typ(&mut s, "Some text");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, &"Words that run on past the end of one line. ".repeat(6));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.down", json!({}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
assert!(s.sel.focus.off > 0, "the caret is on the second line");
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
|
||||
// After a line break too; inside a line Tab still types a tab character.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "Two");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two");
|
||||
// Before a later line of a list item numbered by its style, Tab takes it down a level, as
|
||||
// for a list item numbered directly.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.end", json!({}));
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "More");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props.numbering.map(|n| n.level), Some(1));
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// A list level past the last one, from a style or set directly, goes to the last level.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
|
||||
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.end", json!({}));
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "More");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num;
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
s.sel = crate::Selection::caret(Pos::body(1, 0));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.down", json!({}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\"");
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
run(&mut s, "outline.demote", json!({}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_and_hyphen_line_becomes_a_table() {
|
||||
let mut s = typed("+--+------+\nA");
|
||||
@@ -621,6 +858,118 @@ fn plus_and_hyphen_line_becomes_a_table() {
|
||||
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
|
||||
}
|
||||
|
||||
/// Body paragraphs as (text, paragraph style).
|
||||
fn styled(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect()
|
||||
}
|
||||
|
||||
fn with_heading_styles(keys: &str) -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
typ(&mut s, keys);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_enters_after_a_short_line_make_it_a_heading() {
|
||||
let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string));
|
||||
// No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in
|
||||
// the paragraph after the heading.
|
||||
for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] {
|
||||
let s = with_heading_styles(keys);
|
||||
let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default();
|
||||
assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}");
|
||||
}
|
||||
// Five words is the most; one Enter only ends the line.
|
||||
let s = with_heading_styles("One two three four five\n\n");
|
||||
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
|
||||
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
|
||||
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
|
||||
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
|
||||
let s = with_heading_styles(keys);
|
||||
assert!(
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))),
|
||||
"{keys:?}: {:?}",
|
||||
styled(&s)
|
||||
);
|
||||
}
|
||||
let mut q = self::s();
|
||||
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
run(&mut q, "para.style", json!({"style": "Quote"}));
|
||||
typ(&mut q, "Quoted\n\n");
|
||||
assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote"));
|
||||
// A list item numbered by the Normal style is not body text either.
|
||||
let mut q = self::s();
|
||||
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
|
||||
if let Some(normal) = q.doc.styles.get_mut("Normal") {
|
||||
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
}
|
||||
typ(&mut q, "Item\n\n");
|
||||
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
|
||||
// Off by default.
|
||||
let s = typed("Introduction\n\n");
|
||||
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
|
||||
let mut s = with_heading_styles("\tMethods\n\n");
|
||||
assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]);
|
||||
assert_eq!(s.sel.focus, Pos::body(1, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays.
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n");
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()));
|
||||
assert_eq!(s.sel.focus, Pos::body(2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_heading_autoformat_can_be_rejected() {
|
||||
// With Track Changes on, the tab is marked deleted and the style change is a formatting
|
||||
// revision, so Reject All brings the line back as it was.
|
||||
let mut s = with_heading_styles("\tMethods\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2")));
|
||||
assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted");
|
||||
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None)));
|
||||
// A tab this author inserted is removed outright, and the style change is still tracked.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
|
||||
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
|
||||
// A line holding a tracked deletion is left alone.
|
||||
let mut s = with_heading_styles("Old title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}}));
|
||||
typ(&mut s, "\u{8}\u{8}\u{8}");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_heading_style_is_not_applied_as_you_type() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]}));
|
||||
typ(&mut s, "Locked\n\n\tOpen\n\n");
|
||||
let st = styled(&s);
|
||||
assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}");
|
||||
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_bullets() {
|
||||
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
|
||||
@@ -799,6 +1148,20 @@ fn typing_then_selecting_then_typing_are_two_undo_steps() {
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_the_start_of_the_document_is_not_an_edit() {
|
||||
// #23: a key that changes nothing adds no undo step, marks no unsaved changes and
|
||||
// leaves the redo stack alone.
|
||||
let mut s = typed("alpha");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
s.dirty = false;
|
||||
let depth = s.undo_depth();
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_bold_at_the_caret_turns_bold_typing_off_again() {
|
||||
// #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold.
|
||||
@@ -2511,3 +2874,117 @@ fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
/// #4085: the registered shortcut toggles the last selected highlight, while explicit
|
||||
/// colour commands remain idempotent setters for the palette, CLI and automation.
|
||||
#[test]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn highlight_shortcut_toggles_the_last_colour() {
|
||||
let mut s = typed("Alpha Beta");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone();
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), json!("FFFF00"));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(colour(&mut s), json!("FFFF00"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
assert_eq!(colour(&mut s), json!("0000FF"));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), json!("0000FF"));
|
||||
run(&mut s, "format.highlight", json!({"color":"none"}));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta");
|
||||
}
|
||||
|
||||
/// #4085: mixed selections take one colour; removing an inherited colour writes an
|
||||
/// explicit No Color, and a collapsed caret toggles pending formatting for future typing.
|
||||
#[test]
|
||||
fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
|
||||
let mut s = typed("Alpha Beta ");
|
||||
run(&mut s, "select.text", json!({"text":"Alpha"}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
|
||||
run(&mut s, "text.insert", json!({"text":"X"}));
|
||||
run(&mut s, "select.text", json!({"text":"X"}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
assert!(s.run("format.highlight", &json!({"color":123})).is_err());
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
|
||||
/// A differently coloured final paragraph must remain visible to the shortcut beyond
|
||||
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn highlight_toggle_checks_past_the_resolved_run_limit() {
|
||||
use wordcraft_doc::para::Run;
|
||||
use wordcraft_doc::props::{CharProps, Highlight};
|
||||
for count in [10_000, 10_001, 10_002] {
|
||||
for inherited in [false, true] {
|
||||
let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count)));
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count)
|
||||
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
|
||||
.collect();
|
||||
if inherited {
|
||||
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
|
||||
} else {
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
|
||||
}
|
||||
let original = doc.clone();
|
||||
let mut s = Session::new(doc);
|
||||
s.last_highlight = Highlight::Blue;
|
||||
run(&mut s, "select.all", json!({}));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert!(
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)),
|
||||
"count={count}, inherited={inherited}"
|
||||
);
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command preview uses the real session's remembered colour without changing its
|
||||
/// document, colour choice or undo history when the preview toggles highlighting off.
|
||||
#[test]
|
||||
fn highlight_preview_preserves_the_session_colour_and_document() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
let mut s = typed("Alpha");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
let original = serde_json::to_value(&s.doc).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap();
|
||||
assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
|
||||
assert_eq!(s.last_highlight, Highlight::Blue);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
@@ -76,6 +76,22 @@ fn moving_a_joined_shape_reroutes_its_connector() {
|
||||
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
|
||||
}
|
||||
|
||||
/// A command that changes nothing but reroutes a stale connector is still an edit: the
|
||||
/// reroute must not be left without an undo step (#23).
|
||||
#[test]
|
||||
fn a_rerouted_connector_is_still_undoable() {
|
||||
let mut s = joined();
|
||||
select(&mut s, 5);
|
||||
let before = connector_ends(&mut s);
|
||||
// A nudge by nothing moves nothing, but the connector is stale and gets rerouted.
|
||||
run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0}));
|
||||
assert_ne!(connector_ends(&mut s), before, "the connector was rerouted");
|
||||
assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own");
|
||||
// One undo takes the reroute back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_connector_end_glues_it_to_the_nearest_site() {
|
||||
let mut s = joined();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
|
||||
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
|
||||
//! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web
|
||||
//! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size
|
||||
//! budget. With `CRAFT_FONTS_REQUIRED` (releases), a native build also fails when the checkout
|
||||
//! has no font for a CJK interface language (Japanese, Simplified Chinese), as an outdated pin
|
||||
//! once did (#241). It only reads the local checkout: no network.
|
||||
//! (wasm32) builds embed only the Georgian interface face (about 230 KB), to stay within the web
|
||||
//! size budget; the Japanese one is 4.7 MB. With `CRAFT_FONTS_REQUIRED` (releases), a native
|
||||
//! build also fails when the checkout has no font for a CJK interface language (Japanese,
|
||||
//! Simplified Chinese), as an outdated pin once did (#241). It only reads the local checkout: no
|
||||
//! network.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -55,7 +56,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec<String>), String> {
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") {
|
||||
if wasm && !scripts.split(',').any(|s| s.trim() == "Geor") {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
@@ -1999,6 +1999,15 @@ mod tests {
|
||||
}
|
||||
// A Thai font with OpenType tables, as the per-character fallback prefers.
|
||||
assert!(f.has_layout_tables(), "{} shapes Thai", f.family);
|
||||
// Without system fonts and without craft-fonts' Thai face (built in since craft-fonts
|
||||
// 3d9ab3a1) no bundled face covers Thai: the caller keeps the per-character path.
|
||||
let bundled = FontDb::with_font_dirs(Vec::new());
|
||||
bundled.set_system_fallback(false);
|
||||
let latin = bundled.face(FALLBACK_FAMILY, "Regular");
|
||||
// Expected from the embedded font list, not from the database under test.
|
||||
let thai_bundled = crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Thai"));
|
||||
assert_eq!(bundled.fallback_for('น', 0).is_some(), thai_bundled);
|
||||
assert_eq!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_some(), thai_bundled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,6 +70,11 @@ pub fn korean_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Kore"))
|
||||
}
|
||||
|
||||
/// The craft-fonts Georgian (`"Geor"`) Regular face for the interface. Empty without craft-fonts.
|
||||
pub fn georgian_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Geor") && f.style == "Regular")
|
||||
}
|
||||
|
||||
/// The CJK interface faces, Regular styles only (the UI fakes no bold), with the Japanese UI,
|
||||
/// Chinese, and Korean faces ordered for the selected interface language. Mixing the faces within
|
||||
/// a line would mix glyph styles, so the first face should cover the interface language. Empty
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
image = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -2212,7 +2212,13 @@ impl<'a> Parser<'a> {
|
||||
let mut chars = arg.chars();
|
||||
let s = match chars.next() {
|
||||
Some(b) => format!("{}{}", compose(b, mark), chars.as_str()),
|
||||
None => mark.to_string(),
|
||||
// An accent over nothing prints the accent alone: the spacing form of the mark.
|
||||
None => match name {
|
||||
"`" | "^" | "~" => name.to_string(),
|
||||
"'" => "\u{B4}".to_string(),
|
||||
"\"" => "\u{A8}".to_string(),
|
||||
_ => mark.to_string(),
|
||||
},
|
||||
};
|
||||
self.text(&s);
|
||||
return;
|
||||
@@ -2826,6 +2832,14 @@ mod tests {
|
||||
paras(src).iter().map(|p| p.text()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_accent_group_is_plain_character() {
|
||||
let t = texts("\\begin{document}x\\^{}y \\~{}z 2\\^{}10 \\texttt{\\~{}/dir}\\end{document}");
|
||||
assert_eq!(t, vec!["x^y ~z 2^10 ~/dir".to_string()]);
|
||||
// The acute and the diaeresis alone are accents, not an apostrophe or a quotation mark.
|
||||
assert_eq!(texts("\\begin{document}\\'{} \\\"{}\\end{document}"), vec!["\u{B4} \u{A8}".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_begin_keeps_its_brace() {
|
||||
// `env_name` used to consume the `{` before giving up on a missing `}`.
|
||||
|
||||
@@ -1203,19 +1203,11 @@ pub fn image_px(data: &[u8]) -> Option<(u32, u32)> {
|
||||
image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_dimensions().ok()
|
||||
}
|
||||
|
||||
/// Largest allocation one decoded picture may make when it is re-encoded on export: the `image`
|
||||
/// crate's default, stated here so the bound doesn't depend on a library default that may change.
|
||||
const MAX_DECODE_ALLOC: u64 = 512 << 20;
|
||||
|
||||
/// Decode an encoded image with a bounded allocation, so a small file that declares huge
|
||||
/// dimensions can't exhaust memory when a picture is re-encoded on export. `None` when it would
|
||||
/// be too large or doesn't decode.
|
||||
/// Decode an encoded image within the budget pictures are drawn within, so a small file that
|
||||
/// declares huge dimensions can't exhaust memory when a picture is re-encoded on export. `None`
|
||||
/// when it would be too large or doesn't decode.
|
||||
pub fn decode_bounded(data: &[u8]) -> Option<image::DynamicImage> {
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
reader.limits(limits);
|
||||
reader.decode().ok()
|
||||
wordcraft_geom::picture::decode(data, wordcraft_geom::MAX_DECODED_BYTES).ok()
|
||||
}
|
||||
|
||||
/// A picture from bytes with an optional display size (points); the natural size (96 dpi) is
|
||||
|
||||
@@ -8,5 +8,10 @@ description = "WordCraft geometry and units: points, twips, EMU, inches, centime
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Picture decoding within a memory budget (`picture`), for the crates that decode pictures.
|
||||
image = ["dep:image"]
|
||||
|
||||
[dependencies]
|
||||
image = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "image")]
|
||||
pub mod picture;
|
||||
mod region;
|
||||
pub use region::Region;
|
||||
mod pixels;
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Decoding a picture within a memory budget. Every crate that decodes a document's or the
|
||||
//! clipboard's pictures goes through [`probe`] (or [`probe_dib`]): the header gives the format and
|
||||
//! size, and the decode is refused before anything is allocated when its peak would pass the
|
||||
//! budget. The peak counts what a decoder holds beside its output (a progressive JPEG's
|
||||
//! coefficients, #3644), and a picture whose decoder can't be held to a budget is not decoded at
|
||||
//! all (a TIFF with JPEG strips, #3645).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat};
|
||||
|
||||
/// Why a picture isn't decoded.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// Not a raster picture WordCraft decodes (a metafile, SVG or other bytes).
|
||||
NotAPicture,
|
||||
/// A kind of picture that is never decoded, because its decoder can't be held to a budget.
|
||||
Unsupported(&'static str),
|
||||
/// Decoding it would take more memory than the budget.
|
||||
TooLarge { width: u32, height: u32 },
|
||||
/// The header or the data is damaged.
|
||||
Broken(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Refusal {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Refusal::NotAPicture => f.write_str("not a supported picture (PNG, JPEG, GIF, WebP, BMP or TIFF)"),
|
||||
Refusal::Unsupported(what) => f.write_str(what),
|
||||
Refusal::TooLarge { width, height } => write!(f, "the picture is too large ({width} × {height} pixels)"),
|
||||
Refusal::Broken(why) => write!(f, "the picture is damaged ({why})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refusal {}
|
||||
|
||||
fn broken(e: image::ImageError) -> Refusal {
|
||||
Refusal::Broken(e.to_string())
|
||||
}
|
||||
|
||||
/// What a picture's header says: its format, its size and the memory decoding it takes. Reading
|
||||
/// it allocates no pixels.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Probe<'a> {
|
||||
bytes: Cow<'a, [u8]>,
|
||||
/// A DIB: a BMP without its file header, as the Windows clipboard holds one.
|
||||
dib: bool,
|
||||
pub format: ImageFormat,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
peak: u64,
|
||||
}
|
||||
|
||||
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
|
||||
/// codes) and PackBits. The `tiff` crate decodes a JPEG strip (7) with a decoder of its own and
|
||||
/// default limits, whatever size the TIFF declares, so such a TIFF is not decoded (#3645).
|
||||
const TIFF_COMPRESSIONS: [u16; 6] = [1, 4, 5, 8, 32946, 32773];
|
||||
|
||||
/// Read a picture's header: PNG, JPEG, GIF, WebP, BMP or TIFF.
|
||||
pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
|
||||
let format = image::guess_format(bytes).map_err(|_| Refusal::NotAPicture)?;
|
||||
let held = match format {
|
||||
ImageFormat::Png | ImageFormat::Gif | ImageFormat::WebP | ImageFormat::Bmp => 0,
|
||||
ImageFormat::Jpeg => jpeg_coefficients(bytes),
|
||||
ImageFormat::Tiff => {
|
||||
if !tiff_compression(bytes).is_some_and(|c| TIFF_COMPRESSIONS.contains(&c)) {
|
||||
return Err(Refusal::Unsupported("TIFF pictures with JPEG or an unknown compression can't be read"));
|
||||
}
|
||||
0
|
||||
}
|
||||
_ => return Err(Refusal::NotAPicture),
|
||||
};
|
||||
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
|
||||
let (width, height, peak) = measure(&decoder, held)?;
|
||||
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
|
||||
}
|
||||
|
||||
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
|
||||
/// one (`CF_DIB`, `CF_DIBV5`). Its layout is first made the one the decoder reads (see
|
||||
/// [`dib_layout`]).
|
||||
pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
|
||||
let dib = dib_layout(dib);
|
||||
let (width, height, peak) = {
|
||||
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
|
||||
measure(&decoder, 0)?
|
||||
};
|
||||
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
|
||||
}
|
||||
|
||||
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
|
||||
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
|
||||
let (width, height) = decoder.dimensions();
|
||||
if width == 0 || height == 0 {
|
||||
return Err(Refusal::Broken("it has no pixels".into()));
|
||||
}
|
||||
let native = u64::from(decoder.color_type().bytes_per_pixel());
|
||||
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
|
||||
Ok((width, height, peak))
|
||||
}
|
||||
|
||||
impl Probe<'_> {
|
||||
/// Bytes decoding takes at its peak.
|
||||
pub fn peak_bytes(&self) -> u64 {
|
||||
self.peak
|
||||
}
|
||||
|
||||
/// Whether decoding fits `budget` bytes.
|
||||
pub fn fits(&self, budget: u64) -> bool {
|
||||
self.peak <= budget
|
||||
}
|
||||
|
||||
/// Decode, refused before anything is allocated when the peak would pass `budget` bytes. The
|
||||
/// decoder is held to the header's size and to `budget` as well.
|
||||
pub fn decode(&self, budget: u64) -> Result<DynamicImage, Refusal> {
|
||||
if !self.fits(budget) {
|
||||
return Err(Refusal::TooLarge { width: self.width, height: self.height });
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(self.width);
|
||||
limits.max_image_height = Some(self.height);
|
||||
limits.max_alloc = Some(budget);
|
||||
if self.dib {
|
||||
let mut decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).map_err(broken)?;
|
||||
decoder.set_limits(limits).map_err(broken)?;
|
||||
return DynamicImage::from_decoder(decoder).map_err(broken);
|
||||
}
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format);
|
||||
reader.limits(limits);
|
||||
reader.decode().map_err(broken)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`probe`], then [`Probe::decode`] within `budget` bytes.
|
||||
pub fn decode(bytes: &[u8], budget: u64) -> Result<DynamicImage, Refusal> {
|
||||
probe(bytes)?.decode(budget)
|
||||
}
|
||||
|
||||
/// Bytes decoding a `width` × `height` picture holds at its peak: the decoder's output (`native`
|
||||
/// bytes a pixel) beside what the decoder keeps while it decodes (`held`), then the output beside
|
||||
/// its RGBA8 conversion, then that beside a premultiplied copy. None on overflow.
|
||||
fn peak_bytes(width: u32, height: u32, native: u64, held: u64) -> Option<u64> {
|
||||
let pixels = u64::from(width).checked_mul(u64::from(height))?;
|
||||
let decoding = pixels.checked_mul(native)?.checked_add(held)?;
|
||||
let converting = pixels.checked_mul(native.checked_add(4)?)?;
|
||||
let premultiplying = pixels.checked_mul(8)?;
|
||||
Some(decoding.max(converting).max(premultiplying))
|
||||
}
|
||||
|
||||
/// Bytes of DCT coefficients a progressive JPEG's decoder keeps for the whole picture while it
|
||||
/// reads the scans: zune-jpeg holds an `i16` per coefficient of every component, in whole MCUs.
|
||||
/// 0 for a baseline JPEG, decoded an MCU row at a time. Each frame header before the first scan
|
||||
/// counts and the largest wins; headers this can't follow are charged as the costliest frame.
|
||||
fn jpeg_coefficients(bytes: &[u8]) -> u64 {
|
||||
let mut at = 2;
|
||||
let mut most = 0u64;
|
||||
loop {
|
||||
if bytes.get(at) != Some(&0xFF) {
|
||||
return worst_coefficients(bytes).max(most);
|
||||
}
|
||||
while bytes.get(at) == Some(&0xFF) {
|
||||
at += 1;
|
||||
}
|
||||
let Some(&code) = bytes.get(at) else { return worst_coefficients(bytes).max(most) };
|
||||
at += 1;
|
||||
match code {
|
||||
// Markers without a length: start of image, TEM, restarts.
|
||||
0xD8 | 0x01 | 0xD0..=0xD7 => continue,
|
||||
// End of image, start of scan: every frame header is behind.
|
||||
0xD9 | 0xDA => return most,
|
||||
_ => {}
|
||||
}
|
||||
let len = match (bytes.get(at), bytes.get(at + 1)) {
|
||||
(Some(&hi), Some(&lo)) => usize::from(u16::from_be_bytes([hi, lo])),
|
||||
_ => 0,
|
||||
};
|
||||
let Some(segment) = bytes.get(at + 2..at + len).filter(|_| len >= 2) else { return worst_coefficients(bytes).max(most) };
|
||||
// SOF2, SOF6, SOF10 and SOF14 are the progressive frames.
|
||||
if matches!(code, 0xC2 | 0xC6 | 0xCA | 0xCE) {
|
||||
let Some(held) = frame_coefficients(segment) else { return worst_coefficients(bytes).max(most) };
|
||||
most = most.max(held);
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`jpeg_coefficients`] of one progressive frame header (after its length): the components'
|
||||
/// blocks per MCU (horizontal × vertical sampling) times the MCUs that cover the picture.
|
||||
fn frame_coefficients(sof: &[u8]) -> Option<u64> {
|
||||
let height = u64::from(u16::from_be_bytes([*sof.get(1)?, *sof.get(2)?]));
|
||||
let width = u64::from(u16::from_be_bytes([*sof.get(3)?, *sof.get(4)?]));
|
||||
let n = usize::from(*sof.get(5)?);
|
||||
let sampling: Vec<(u64, u64)> =
|
||||
sof.get(6..6 + 3 * n)?.as_chunks::<3>().0.iter().map(|&[_, s, _]| (u64::from(s >> 4).clamp(1, 4), u64::from(s & 15).clamp(1, 4))).collect();
|
||||
let (h_max, v_max) = sampling.iter().fold((1, 1), |(h, v), &(a, b)| (h.max(a), v.max(b)));
|
||||
let mcus = width.div_ceil(8 * h_max).checked_mul(height.div_ceil(8 * v_max))?;
|
||||
sampling.iter().try_fold(0u64, |sum, &(h, v)| sum.checked_add(mcus.checked_mul(2 * 64 * h * v)?))
|
||||
}
|
||||
|
||||
/// The coefficients of the costliest progressive frame of the size the image crate reads from
|
||||
/// `bytes`: four components, each sampled 4 × 4, in MCUs of 32 pixels a side.
|
||||
fn worst_coefficients(bytes: &[u8]) -> u64 {
|
||||
let Ok((w, h)) = image::ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_dimensions() else { return 0 };
|
||||
let mcus = u64::from(w).div_ceil(32) * u64::from(h).div_ceil(32);
|
||||
mcus.saturating_mul(2 * 64 * 16 * 4)
|
||||
}
|
||||
|
||||
/// The compression of a classic TIFF's first image (the one the image crate decodes): 1 when the
|
||||
/// tag is missing. None when the header can't be read.
|
||||
fn tiff_compression(bytes: &[u8]) -> Option<u16> {
|
||||
let le = match bytes.get(..4)? {
|
||||
b"II*\0" => true,
|
||||
b"MM\0*" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let u16_at = |at: usize| -> Option<u16> {
|
||||
let b = [*bytes.get(at)?, *bytes.get(at.checked_add(1)?)?];
|
||||
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
|
||||
};
|
||||
let u32_at = |at: usize| -> Option<u32> {
|
||||
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
|
||||
};
|
||||
let ifd = usize::try_from(u32_at(4)?).ok()?;
|
||||
for i in 0..usize::from(u16_at(ifd)?) {
|
||||
let entry = ifd.checked_add(2)?.checked_add(i.checked_mul(12)?)?;
|
||||
if u16_at(entry)? != 259 {
|
||||
continue;
|
||||
}
|
||||
if u32_at(entry.checked_add(4)?)? != 1 {
|
||||
return None;
|
||||
}
|
||||
return match u16_at(entry.checked_add(2)?)? {
|
||||
3 => u16_at(entry.checked_add(8)?),
|
||||
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok(),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Some(1)
|
||||
}
|
||||
|
||||
/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask
|
||||
/// is set but whose compression says `BI_RGB` is marked `BI_BITFIELDS` (with the usual colour
|
||||
/// masks when it gives none), so its alpha is kept as Windows programs mean it; arboard makes the
|
||||
/// same change. Bit fields then need the colour masks after the header too, where Windows puts a
|
||||
/// copy and the decoder skips them; a DIB without that copy (arboard writes one) gets it.
|
||||
fn dib_layout(mut dib: Vec<u8>) -> Vec<u8> {
|
||||
let u32_at = |d: &[u8], at: usize| d.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
let Some(size) = u32_at(&dib, 0).filter(|&s| s >= 108).and_then(|s| usize::try_from(s).ok()) else { return dib };
|
||||
let put = |d: &mut Vec<u8>, at: usize, v: u32| {
|
||||
if let Some(b) = d.get_mut(at..at + 4) {
|
||||
b.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
};
|
||||
if dib.get(14..16) == Some(&[32, 0]) && u32_at(&dib, 16) == Some(0) && u32_at(&dib, 52) == Some(0xFF00_0000) {
|
||||
put(&mut dib, 16, 3);
|
||||
if (40..52).step_by(4).all(|at| u32_at(&dib, at) == Some(0)) {
|
||||
put(&mut dib, 40, 0x00FF_0000);
|
||||
put(&mut dib, 44, 0x0000_FF00);
|
||||
put(&mut dib, 48, 0x0000_00FF);
|
||||
}
|
||||
}
|
||||
if u32_at(&dib, 16) == Some(3)
|
||||
&& size <= dib.len()
|
||||
&& let Some(masks) = dib.get(40..52).map(<[u8]>::to_vec)
|
||||
&& dib.get(size..size + 12) != Some(masks.as_slice())
|
||||
{
|
||||
dib.splice(size..size, masks);
|
||||
}
|
||||
dib
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn encode(img: impl Into<DynamicImage>, format: ImageFormat) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
img.into().write_to(&mut Cursor::new(&mut out), format).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A PNG whose header declares `w` × `h` pixels, with no pixel data.
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut png = encode(image::RgbaImage::new(1, 1), ImageFormat::Png);
|
||||
png[16..20].copy_from_slice(&w.to_be_bytes());
|
||||
png[20..24].copy_from_slice(&h.to_be_bytes());
|
||||
// The IHDR CRC covers the chunk type and data.
|
||||
let crc = crc32(&png[12..29]);
|
||||
png[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
png
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_pictures_decode_and_peaks_count_every_buffer() {
|
||||
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
|
||||
let p = probe(&png).unwrap();
|
||||
assert_eq!((p.format, p.width, p.height), (ImageFormat::Png, 16, 8));
|
||||
assert_eq!(p.peak_bytes(), 16 * 8 * 8, "RGBA8: the decode beside its premultiplied copy");
|
||||
assert!(p.fits(1024) && !p.fits(1023));
|
||||
assert_eq!(p.decode(1 << 20).unwrap().to_rgba8().get_pixel(3, 3).0, [1, 2, 3, 4]);
|
||||
assert_eq!(p.decode(1023), Err(Refusal::TooLarge { width: 16, height: 8 }));
|
||||
assert_eq!(peak_bytes(16, 8, 8, 0), Some(16 * 8 * 12), "RGBA16 beside its RGBA8 conversion");
|
||||
assert_eq!(peak_bytes(16, 8, 3, 16 * 8 * 8), Some(16 * 8 * 11), "coefficients beside the RGB output");
|
||||
assert_eq!(peak_bytes(u32::MAX, u32::MAX, 8, 0), None);
|
||||
for bytes in [&b"not a picture"[..], b"", b"\x89PNG"] {
|
||||
assert!(probe(bytes).is_err(), "{bytes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_header_is_refused_before_decoding() {
|
||||
let png = png_header(60_000, 60_000);
|
||||
let p = probe(&png).unwrap();
|
||||
assert_eq!((p.width, p.height), (60_000, 60_000));
|
||||
assert!(!p.fits(crate::MAX_DECODED_BYTES));
|
||||
assert_eq!(decode(&png, crate::MAX_DECODED_BYTES).unwrap_err(), Refusal::TooLarge { width: 60_000, height: 60_000 });
|
||||
}
|
||||
|
||||
/// A baseline JPEG turned progressive in its headers (SOF2, a DC-only first scan), `w` × `h`
|
||||
/// with its three components unsubsampled.
|
||||
fn progressive_header(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut jpeg = encode(image::RgbImage::from_pixel(16, 16, image::Rgb([200, 30, 30])), ImageFormat::Jpeg);
|
||||
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
|
||||
jpeg[sof + 1] = 0xC2;
|
||||
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
|
||||
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
|
||||
assert_eq!(jpeg[sof + 9], 3);
|
||||
for c in 0..3 {
|
||||
jpeg[sof + 11 + 3 * c] = 0x11;
|
||||
}
|
||||
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
|
||||
let n = usize::from(jpeg[sos + 4]);
|
||||
jpeg[sos + 6 + 2 * n] = 0;
|
||||
jpeg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_jpeg_coefficients_are_charged() {
|
||||
let jpeg = progressive_header(2048, 2048);
|
||||
let p = probe(&jpeg).unwrap();
|
||||
assert_eq!((p.width, p.height), (2048, 2048));
|
||||
// RGB output (3 bytes a pixel) beside 2 bytes a coefficient of three full-size components:
|
||||
// 9 bytes a pixel, more than the 8 two RGBA buffers take.
|
||||
assert_eq!(p.peak_bytes(), 2048 * 2048 * 9);
|
||||
assert!(!p.fits(2048 * 2048 * 8));
|
||||
// Baseline: decoded an MCU row at a time, nothing held.
|
||||
let baseline = encode(image::RgbImage::new(64, 64), ImageFormat::Jpeg);
|
||||
assert_eq!(probe(&baseline).unwrap().peak_bytes(), 64 * 64 * 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coefficients_follow_sampling_and_mcu_padding() {
|
||||
// P, height, width, components (id, sampling, table).
|
||||
let sof = |w: u16, h: u16, comps: &[u8]| {
|
||||
let mut s = vec![8];
|
||||
s.extend(h.to_be_bytes());
|
||||
s.extend(w.to_be_bytes());
|
||||
s.push((comps.len() / 3) as u8);
|
||||
s.extend(comps);
|
||||
s
|
||||
};
|
||||
// Four components, no subsampling: 2 bytes a coefficient, 8 a pixel.
|
||||
assert_eq!(frame_coefficients(&sof(64, 64, &[1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 4, 0x11, 0])), Some(64 * 64 * 8));
|
||||
// 4:2:0: MCUs of 16 × 16 holding 4 + 1 + 1 blocks; a 1-pixel-wide picture still takes one.
|
||||
assert_eq!(frame_coefficients(&sof(1, 16, &[1, 0x22, 0, 2, 0x11, 0, 3, 0x11, 0])), Some(128 * 6));
|
||||
let mut short = sof(1, 16, &[1, 0x22, 0, 2, 0x11, 0]);
|
||||
short[5] = 3;
|
||||
assert_eq!(frame_coefficients(&short), None, "three components declared, two there");
|
||||
// A marker walk over SOI, an APP0 segment, SOF2, then the scan.
|
||||
let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 4, 0, 0, 0xFF, 0xC2];
|
||||
let f = sof(64, 64, &[1, 0x11, 0]);
|
||||
jpeg.extend(((f.len() + 2) as u16).to_be_bytes());
|
||||
jpeg.extend(&f);
|
||||
jpeg.extend([0xFF, 0xDA]);
|
||||
assert_eq!(jpeg_coefficients(&jpeg), 64 * 64 * 2);
|
||||
}
|
||||
|
||||
/// A little-endian TIFF header whose first IFD holds just a compression tag.
|
||||
fn tiff_with_compression(compression: u16) -> Vec<u8> {
|
||||
let mut t = b"II*\0".to_vec();
|
||||
t.extend(8u32.to_le_bytes());
|
||||
t.extend(1u16.to_le_bytes());
|
||||
t.extend(259u16.to_le_bytes());
|
||||
t.extend(3u16.to_le_bytes());
|
||||
t.extend(1u32.to_le_bytes());
|
||||
t.extend(u32::from(compression).to_le_bytes());
|
||||
t.extend(0u32.to_le_bytes());
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiffs_with_jpeg_strips_are_never_decoded() {
|
||||
assert_eq!(tiff_compression(&tiff_with_compression(7)), Some(7));
|
||||
assert!(matches!(probe(&tiff_with_compression(7)), Err(Refusal::Unsupported(_))));
|
||||
assert!(matches!(probe(&tiff_with_compression(6)), Err(Refusal::Unsupported(_))), "old-style JPEG");
|
||||
let mut be = b"MM\0*\0\0\0\x08\0\x01\x01\x03\0\x03\0\0\0\x01\0\x07\0\0".to_vec();
|
||||
assert_eq!(tiff_compression(&be), Some(7), "big-endian: the short is left-justified");
|
||||
be.truncate(12);
|
||||
assert_eq!(tiff_compression(&be), None, "cut short");
|
||||
// An uncompressed TIFF reads as before.
|
||||
let tiff = encode(image::RgbImage::from_pixel(5, 3, image::Rgb([9, 8, 7])), ImageFormat::Tiff);
|
||||
assert_eq!(tiff_compression(&tiff), Some(1));
|
||||
assert_eq!(decode(&tiff, 1 << 20).unwrap().to_rgb8().get_pixel(4, 2).0, [9, 8, 7]);
|
||||
}
|
||||
|
||||
/// A BITMAPV5HEADER DIB of 32-bit pixels, bottom-up, with `alpha_mask`: `BI_RGB`, or with
|
||||
/// `bitfields` the usual masks and `BI_BITFIELDS`, their copy after the header when `copy`.
|
||||
fn dib(w: i32, h: i32, alpha_mask: u32, bitfields: bool, copy: bool, pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 124];
|
||||
d[0..4].copy_from_slice(&124u32.to_le_bytes());
|
||||
d[4..8].copy_from_slice(&w.to_le_bytes());
|
||||
d[8..12].copy_from_slice(&h.to_le_bytes());
|
||||
d[12..14].copy_from_slice(&1u16.to_le_bytes());
|
||||
d[14..16].copy_from_slice(&32u16.to_le_bytes());
|
||||
d[52..56].copy_from_slice(&alpha_mask.to_le_bytes());
|
||||
if bitfields {
|
||||
d[16..20].copy_from_slice(&3u32.to_le_bytes());
|
||||
for (at, m) in [(40, 0x00FF_0000u32), (44, 0x0000_FF00), (48, 0x0000_00FF)] {
|
||||
d[at..at + 4].copy_from_slice(&m.to_le_bytes());
|
||||
}
|
||||
if copy {
|
||||
let masks = d[40..52].to_vec();
|
||||
d.extend(masks);
|
||||
}
|
||||
}
|
||||
for p in pixels {
|
||||
d.extend(p);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_dibs_keep_their_alpha_in_every_layout() {
|
||||
// BGRA in memory: a half-transparent red pixel and an opaque blue one.
|
||||
let px = [[0, 0, 255, 128], [255, 0, 0, 255]];
|
||||
// `BI_RGB` with an alpha mask (Windows programs); bit fields without the masks' copy
|
||||
// after the header (arboard), and with it (Windows).
|
||||
for (bitfields, copy) in [(false, false), (true, false), (true, true)] {
|
||||
let p = probe_dib(dib(2, 1, 0xFF00_0000, bitfields, copy, &px)).unwrap();
|
||||
assert_eq!((p.format, p.width, p.height), (ImageFormat::Bmp, 2, 1));
|
||||
let rgba = p.decode(1 << 20).unwrap().to_rgba8();
|
||||
assert_eq!(rgba.get_pixel(0, 0).0, [255, 0, 0, 128], "{bitfields} {copy}");
|
||||
assert_eq!(rgba.get_pixel(1, 0).0, [0, 0, 255, 255], "{bitfields} {copy}");
|
||||
}
|
||||
// Without an alpha mask, `BI_RGB` pixels are opaque.
|
||||
let p = probe_dib(dib(2, 1, 0, false, false, &px)).unwrap();
|
||||
assert_eq!(p.decode(1 << 20).unwrap().to_rgba8().get_pixel(0, 0).0, [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_or_damaged_clipboard_dibs_are_refused() {
|
||||
let p = probe_dib(dib(60_000, 60_000, 0xFF00_0000, false, false, &[])).unwrap();
|
||||
assert_eq!((p.width, p.height), (60_000, 60_000));
|
||||
assert!(!p.fits(crate::MAX_DECODED_BYTES));
|
||||
assert!(matches!(p.decode(crate::MAX_DECODED_BYTES), Err(Refusal::TooLarge { .. })));
|
||||
assert!(probe_dib(vec![0; 8]).is_err());
|
||||
// Cut short or lying about its header: no pixels come out, and nothing panics.
|
||||
let mut short = dib(2, 1, 0xFF00_0000, true, false, &[]);
|
||||
short.truncate(100);
|
||||
let mut lying = dib(2, 1, 0xFF00_0000, true, false, &[[0; 4], [0; 4]]);
|
||||
lying[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
for d in [short, lying] {
|
||||
assert!(probe_dib(d).and_then(|p| p.decode(1 << 20)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ description = "WordCraft layout: shaping, line breaking, tabs, lists, tables, pa
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-proof = { workspace = true }
|
||||
|
||||
@@ -168,17 +168,12 @@ const MAX_PICTURE_SIDE: u32 = 8192;
|
||||
/// in unit coordinates of the cropped picture. `None` for pictures without transparency (or
|
||||
/// that don't decode): their rectangle is their outline.
|
||||
pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let pic = wordcraft_geom::picture::probe(bytes).ok()?;
|
||||
// Formats that can't be transparent aren't decoded.
|
||||
if matches!(reader.format(), None | Some(image::ImageFormat::Jpeg)) {
|
||||
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
|
||||
return None;
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_PICTURE_SIDE);
|
||||
limits.max_image_height = Some(MAX_PICTURE_SIDE);
|
||||
limits.max_alloc = Some(512 << 20);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?;
|
||||
let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?;
|
||||
if !img.color().has_alpha() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ pub enum Draw {
|
||||
stroke_width: f32,
|
||||
/// Alternating dash/space lengths in page points; empty means solid.
|
||||
dash: Vec<f32>,
|
||||
cap: wordcraft_doc::connector::LineCap,
|
||||
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
/// A rounded rectangle's corner ([`wordcraft_doc::connector::ShapeExtra::corner`]).
|
||||
@@ -206,7 +207,7 @@ pub enum Draw {
|
||||
/// wide, in `color` at opacity `alpha`.
|
||||
Ink { pts: Vec<(f32, f32)>, color: Rgb, width: f32, alpha: f32 },
|
||||
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
|
||||
Path { segs: Vec<PathSeg>, fill: Option<Paint>, stroke: Option<Rgb>, stroke_width: f32, dash: Vec<f32> },
|
||||
Path { segs: Vec<PathSeg>, fill: Option<Paint>, stroke: Option<Rgb>, stroke_width: f32, dash: Vec<f32>, cap: wordcraft_doc::connector::LineCap },
|
||||
/// An inline chart, diagram or described shape's draws, as one figure with its alt text (for
|
||||
/// tagged PDF). `kind` is the graphic's, `None` for a shape (see [`crate::shape_figure_alt`]).
|
||||
Figure { alt: String, kind: Option<GraphicKind>, draws: Vec<Draw> },
|
||||
@@ -836,6 +837,10 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
|
||||
}
|
||||
}
|
||||
|
||||
/// Append display items for the half-open line range `l0..l1` of a laid-out paragraph.
|
||||
/// `(x, y)` positions the paragraph's left origin and the first selected line's top; later lines
|
||||
/// retain their layout offsets. Includes glyphs, inline objects, decorations and requested markup.
|
||||
/// `down` keeps vertical East Asian glyphs upright before the caller turns the resulting draws.
|
||||
fn lines(
|
||||
doc: &Document,
|
||||
story: StoryRef,
|
||||
@@ -1324,6 +1329,7 @@ fn lines(
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
});
|
||||
}
|
||||
// Ruby (Phonetic Guide) text over its base text, in runs of one style.
|
||||
@@ -1549,10 +1555,18 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Dra
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
}),
|
||||
GraphicItem::Path { segs, fill, stroke, stroke_width } => {
|
||||
let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy));
|
||||
out.push(Draw::Path { segs, fill: fill.map(Paint::solid), stroke: *stroke, stroke_width: stroke_width * k, dash: Vec::new() })
|
||||
out.push(Draw::Path {
|
||||
segs,
|
||||
fill: fill.map(Paint::solid),
|
||||
stroke: *stroke,
|
||||
stroke_width: stroke_width * k,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
})
|
||||
}
|
||||
GraphicItem::Text { rect: r, .. } => out.extend(text_glyphs(doc, inside(r), it, alpha, k)),
|
||||
GraphicItem::Image { rect: r, media } => {
|
||||
@@ -1630,7 +1644,17 @@ fn shape_draws(
|
||||
return;
|
||||
}
|
||||
let Some(f) = freeform.filter(|_| kind == ShapeKind::Freeform) else {
|
||||
out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner: extra.corner, dash: extra.dash.pattern(stroke_width) });
|
||||
out.push(Draw::Shape {
|
||||
rect,
|
||||
kind,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
effects,
|
||||
corner: extra.corner,
|
||||
dash: extra.dash.pattern(stroke_width),
|
||||
cap: extra.cap,
|
||||
});
|
||||
return;
|
||||
};
|
||||
if f.is_ink() && opts.hide_ink {
|
||||
@@ -1662,7 +1686,14 @@ fn shape_draws(
|
||||
segs.push(PathSeg::Close);
|
||||
}
|
||||
let width = path_stroke_width(width);
|
||||
out.push(Draw::Path { segs, fill: if closed { fill.clone() } else { None }, stroke, stroke_width: width, dash: extra.dash.pattern(width) });
|
||||
out.push(Draw::Path {
|
||||
segs,
|
||||
fill: if closed { fill.clone() } else { None },
|
||||
stroke,
|
||||
stroke_width: width,
|
||||
dash: extra.dash.pattern(width),
|
||||
cap: extra.cap,
|
||||
});
|
||||
if !closed
|
||||
&& extra.arrow_end
|
||||
&& let Some(color) = stroke
|
||||
@@ -1680,6 +1711,10 @@ fn path_stroke_width(width: f32) -> f32 {
|
||||
if width.is_finite() && width > 0.0 { width.clamp(0.25, 200.0) } else { 0.75 }
|
||||
}
|
||||
|
||||
/// Make a filled triangular arrowhead at the last point, in the points' coordinate space.
|
||||
/// The last distinct segment supplies its direction and the bounded stroke width sets its size.
|
||||
/// Returns `None` if there is no usable final segment. The triangle has no outline, so the path's
|
||||
/// cap setting does not affect its appearance.
|
||||
fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Option<Draw> {
|
||||
let tip = *points.last()?;
|
||||
let (prev_x, prev_y) = points.iter().rev().skip(1).copied().find(|p| *p != tip)?;
|
||||
@@ -1695,7 +1730,7 @@ fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Optio
|
||||
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
|
||||
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
|
||||
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
|
||||
Some(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new() })
|
||||
Some(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() })
|
||||
}
|
||||
|
||||
/// A line or connector at `rect` (its unflipped frame: flips are the frame's turn), with its
|
||||
@@ -1722,9 +1757,19 @@ fn line_draws(
|
||||
})
|
||||
.collect();
|
||||
let width = path_stroke_width(stroke_width);
|
||||
out.push(Draw::Path { segs, fill: None, stroke, stroke_width: width, dash: extra.dash.pattern(width) });
|
||||
out.push(Draw::Path { segs, fill: None, stroke, stroke_width: width, dash: extra.dash.pattern(width), cap: extra.cap });
|
||||
}
|
||||
None => out.push(Draw::Shape { rect, kind, fill: None, stroke, stroke_width, effects, corner: None, dash: extra.dash.pattern(stroke_width) }),
|
||||
None => out.push(Draw::Shape {
|
||||
rect,
|
||||
kind,
|
||||
fill: None,
|
||||
stroke,
|
||||
stroke_width,
|
||||
effects,
|
||||
corner: None,
|
||||
dash: extra.dash.pattern(stroke_width),
|
||||
cap: extra.cap,
|
||||
}),
|
||||
}
|
||||
let Some(color) = stroke else { return };
|
||||
// Our line runs from the bottom-left corner to the top-right; connectors from the top-left
|
||||
@@ -1741,7 +1786,7 @@ fn line_draws(
|
||||
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
|
||||
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
|
||||
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
|
||||
out.push(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new() });
|
||||
out.push(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1838,6 +1883,7 @@ pub fn check_box(frame: Rect, checked: bool, color: Rgb, alpha: f32, out: &mut V
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
});
|
||||
if checked {
|
||||
out.extend([line(l, t, r, b, 0.48), line(r, t, l, b, 0.48)]);
|
||||
@@ -1970,6 +2016,7 @@ fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw {
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-24
@@ -700,6 +700,11 @@ enum Pass {
|
||||
/// Walk state while paginating.
|
||||
struct Ctx<'a> {
|
||||
doc: &'a Document,
|
||||
/// The body's sections ([`Document::sections`]) for one pass, with the block index each
|
||||
/// starts after: header and footer wrap work asks for them per page, and rebuilding the
|
||||
/// list walked the whole body every time, so a long document paid a body-length scan per
|
||||
/// page. The document does not change during a pass.
|
||||
sections: Vec<(usize, &'a SectionProps)>,
|
||||
cache: &'a mut LayoutCache,
|
||||
opts: &'a LayoutOptions,
|
||||
counters: Counters,
|
||||
@@ -1274,6 +1279,9 @@ fn layout_box(
|
||||
} else if i > 0 && before > 0.0 && prev_after > 0.0 && !ctx.doc.settings.sum_paragraph_spacing {
|
||||
before = (before - prev_after).max(0.0);
|
||||
}
|
||||
// Paragraph-relative floats start above the effective space before, as in the
|
||||
// body path. Keep text and wrap exclusions relative to the text's actual top.
|
||||
let para_top = y;
|
||||
y += if i == 0 { before } else { before.max(0.0) };
|
||||
// Floating objects anchored here: place them, then wrap the text around them.
|
||||
// One left out of the layout (hidden, or deleted in the final text) takes no room.
|
||||
@@ -1288,10 +1296,13 @@ fn layout_box(
|
||||
}
|
||||
if let Some(pin) = ctx.pin.as_ref().filter(|pin| in_cell && pin.story == story && float.outside_cell && page_anchored(float)) {
|
||||
let page = PageFrame { sect: &pin.sect, origin: (0.0, 0.0), number: pin.number, mirror: pin.mirror };
|
||||
pinned.insert(oi, float_rect(Some(page), (0.0, width), y, w, h, float));
|
||||
pinned.insert(oi, float_rect(Some(page), (0.0, width), para_top, w, h, float));
|
||||
continue;
|
||||
}
|
||||
let r = float_rect(frame, (0.0, width), y, w, h, float);
|
||||
// Only paragraph-relative placement excludes space-before. Line anchors
|
||||
// and frameless page/margin fallbacks retain the text-top origin.
|
||||
let float_y = if float.v_rel == Anchor::Paragraph { para_top } else { y };
|
||||
let r = float_rect(frame, (0.0, width), float_y, w, h, float);
|
||||
let outline = ctx.wrap_outline(o);
|
||||
excl.extend(wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())));
|
||||
floats.insert(oi, r);
|
||||
@@ -1317,7 +1328,7 @@ fn layout_box(
|
||||
prev_end = push_para(&mut items, story, &path, &pl, 0, pl.lines.len(), 0.0, y + lead, width, edges);
|
||||
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
|
||||
if !p.objects.is_empty() {
|
||||
let at = ObjFrame { page: frame, col: (0.0, width), para_y: y, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
|
||||
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
|
||||
let (back, front) = place_objects(ctx, story, &path, p, &pl, (0, pl.lines.len()), (0.0, y + lead), &at, &floats, depth);
|
||||
for placed in back.iter().chain(front.iter()) {
|
||||
if let Placed::Object { rect, spin, wrap, .. } = placed
|
||||
@@ -1358,11 +1369,11 @@ fn layout_box(
|
||||
Some(f) => {
|
||||
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
|
||||
let (r, area) = box_float_table_rect(frame, width, y, &tl, &f, legacy, &float_tables);
|
||||
excl.push((area, false, None, WrapText::BothSides));
|
||||
excl.push((area, false, None, WrapText::BothSides, true));
|
||||
float_tables.push(area);
|
||||
table_areas.push(area);
|
||||
if f.v_rel == Anchor::Page {
|
||||
page_table_wraps.push((area, false, None, WrapText::BothSides));
|
||||
page_table_wraps.push((area, false, None, WrapText::BothSides, true));
|
||||
}
|
||||
(r.x, r.y)
|
||||
}
|
||||
@@ -2301,6 +2312,7 @@ fn layout_pass(
|
||||
});
|
||||
let mut ctx = Ctx {
|
||||
doc,
|
||||
sections: doc.sections(),
|
||||
cache,
|
||||
opts,
|
||||
hide_deleted: opts.hides_deleted(StoryRef::Body),
|
||||
@@ -3097,8 +3109,9 @@ fn align_in((start, extent): (f32, f32), offset: f32, size: f32, align: Option<F
|
||||
}
|
||||
|
||||
/// An area text keeps clear of: its bounds, whether text may only go above and below it, and
|
||||
/// (Tight and Through wrapping) the outline text follows inside it, and the sides text may go on.
|
||||
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>, WrapText);
|
||||
/// (Tight and Through wrapping) the outline text follows inside it, the sides text may go on,
|
||||
/// and whether it comes from a floating table (inclusive line-bottom contact, #3977).
|
||||
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>, WrapText, bool);
|
||||
|
||||
/// Wrap areas a page starts with (#2048): by page index, the floating objects (block, object
|
||||
/// index) placed above the top of their anchor paragraph, with text before it on the page, and
|
||||
@@ -3111,14 +3124,15 @@ type EarlyFloats = HashMap<usize, Vec<(usize, usize, WrapArea)>>;
|
||||
/// out before it ([`settle_early`]).
|
||||
const MAX_REWRAP_PASSES: usize = 3;
|
||||
|
||||
/// Whether the text kept clear of `seed` is kept clear of `area` too: the same wrap side, `area`
|
||||
/// inside `seed`, and no outline on `seed` other than `area`'s own in the same place.
|
||||
/// Whether the text kept clear of `seed` is kept clear of `area` too: the same table-contact
|
||||
/// rule and wrap side, `area` inside `seed`, and no outline on `seed` other than `area`'s own
|
||||
/// in the same place.
|
||||
fn covers(seed: &WrapArea, area: &WrapArea) -> bool {
|
||||
let (s, r) = (seed.0, area.0);
|
||||
let inside = r.x >= s.x - 0.5 && r.y >= s.y - 0.5 && r.right() <= s.right() + 0.5 && r.bottom() <= s.bottom() + 0.5;
|
||||
let same_place = (r.x - s.x).abs() < 0.5 && (r.y - s.y).abs() < 0.5 && (r.w - s.w).abs() < 0.5 && (r.h - s.h).abs() < 0.5;
|
||||
let outline = seed.2.is_none() || (same_place && seed.2 == area.2);
|
||||
(seed.3 == area.3 || seed.1) && (seed.1 || !area.1) && inside && outline
|
||||
seed.4 == area.4 && (seed.3 == area.3 || seed.1) && (seed.1 || !area.1) && inside && outline
|
||||
}
|
||||
|
||||
/// In a pass no other follows ([`MAX_REWRAP_PASSES`]), a float at `r` with wrap area `area`
|
||||
@@ -3169,7 +3183,7 @@ fn merge_early(early: &mut EarlyFloats, found: EarlyFloats) {
|
||||
match have.iter_mut().find(|(eb, ek, _)| (*eb, *ek) == (b, k)) {
|
||||
Some((_, _, seed)) if !covers(seed, &area) => {
|
||||
let beside = seed.3 == area.3 && !seed.1 && !area.1;
|
||||
*seed = (seed.0.union(&area.0), !beside, None, area.3);
|
||||
*seed = (seed.0.union(&area.0), !beside, None, area.3, area.4);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => have.push((b, k, area)),
|
||||
@@ -3189,19 +3203,19 @@ fn wrap_area(r: Rect, float: &Float, outline: Option<&[(f32, f32)]>) -> Option<W
|
||||
if matches!(float.wrap, Wrap::Tight | Wrap::Through)
|
||||
&& let Some((area, c)) = outline.and_then(|o| contour::contour_area(r, float, o))
|
||||
{
|
||||
return Some((area, false, Some(Arc::new(c)), float.wrap_text));
|
||||
return Some((area, false, Some(Arc::new(c)), float.wrap_text, false));
|
||||
}
|
||||
let d = |v: f32| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 };
|
||||
let (side, top, bottom) = (d(float.dist), d(float.dist_top), d(float.dist_bottom));
|
||||
// A rotated object keeps text clear of its rotated bounds.
|
||||
let r = float.spin().bounds(r);
|
||||
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None, float.wrap_text))
|
||||
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None, float.wrap_text, false))
|
||||
}
|
||||
|
||||
/// Wrap areas relative to a paragraph whose column starts at `x0` and whose top is `y0`.
|
||||
fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec<para::Exclusion> {
|
||||
excl.iter()
|
||||
.map(|(r, tb, c, side)| para::Exclusion {
|
||||
.map(|(r, tb, c, side, table)| para::Exclusion {
|
||||
top: r.y - y0,
|
||||
bottom: r.bottom() - y0,
|
||||
left: r.x - x0,
|
||||
@@ -3209,6 +3223,7 @@ fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec<para::Exclusion> {
|
||||
top_bottom: *tb,
|
||||
contour: c.clone(),
|
||||
side: *side,
|
||||
table: *table,
|
||||
})
|
||||
.filter(|e| e.bottom > 0.0)
|
||||
.collect()
|
||||
@@ -3391,12 +3406,11 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapArea> {
|
||||
}
|
||||
ctx.footer_wraps.insert(key, Vec::new());
|
||||
let doc = ctx.doc;
|
||||
let sections = doc.sections();
|
||||
let Some((_, sect)) = sections.get(page.section) else { return Vec::new() };
|
||||
let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() };
|
||||
if sect.text_direction.is_turned() {
|
||||
return Vec::new();
|
||||
}
|
||||
let set = linked(§ions, page.section, |s| s.footers);
|
||||
let set = linked(&ctx.sections, page.section, |s| s.footers);
|
||||
let id = if sect.title_page && first {
|
||||
set.first
|
||||
} else if doc.settings.even_odd_headers && page.number.is_multiple_of(2) {
|
||||
@@ -3408,7 +3422,7 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapArea> {
|
||||
let Some(part) = doc.parts.get(&id) else { return Vec::new() };
|
||||
let blocks = part.blocks.clone();
|
||||
let x = sect.margin_left + sect.gutter;
|
||||
let base = defining_width(§ions, page.section, |s| s.footers, Some(id));
|
||||
let base = defining_width(&ctx.sections, page.section, |s| s.footers, Some(id));
|
||||
let counters = std::mem::take(&mut ctx.counters);
|
||||
let boxes = std::mem::take(&mut ctx.boxes);
|
||||
let chains = std::mem::take(&mut ctx.chains);
|
||||
@@ -3902,7 +3916,23 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
- new_notes.iter().map(|n| n.h).sum::<f32>()
|
||||
- if pb.notes.is_empty() && !new_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 }
|
||||
- extra;
|
||||
let bottom = pb.y + lead + (l.top + l.height - first.top);
|
||||
// Multiple spacing above single puts its extra leading below the text (see
|
||||
// `baseline_offset`), and Word lets that leading of a page's last line run into the
|
||||
// bottom margin: only the text has to fit (observed: a double-spaced 12pt line stays on
|
||||
// the page while its text ends above the margin, about half a line lower than when the
|
||||
// whole line must fit). Only the bottom margin takes that leading: a footer taller than
|
||||
// the margin, or footnotes, keep the whole line clear of them.
|
||||
let at_margin = pb.bottom >= pb.sect.page_h - pb.sect.margin_bottom - 0.01;
|
||||
let fit_h = match pl.rp.line_spacing {
|
||||
wordcraft_doc::props::LineSpacing::Multiple(m)
|
||||
if m > 1.0 && at_margin && pb.notes.is_empty() && new_notes.is_empty() && line_notes.is_empty() =>
|
||||
{
|
||||
// `baseline` is from the paragraph's top; the text ends a descent below it.
|
||||
(l.baseline - l.top + l.text_desc).clamp(0.0, l.height)
|
||||
}
|
||||
_ => l.height,
|
||||
};
|
||||
let bottom = pb.y + lead + (l.top + fit_h - first.top);
|
||||
// On a page with footnotes the paragraph's last line keeps its space after clear of
|
||||
// the separator (Word): if the space doesn't fit, the line goes to the next page (and,
|
||||
// with widow control, one more with it). Without notes the space after may run past
|
||||
@@ -4510,12 +4540,11 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
if let Some(areas) = ctx.header_tables.get(&key) {
|
||||
return areas.clone();
|
||||
}
|
||||
let sections = ctx.doc.sections();
|
||||
let Some((_, sect)) = sections.get(page.section) else { return Vec::new() };
|
||||
let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() };
|
||||
if sect.text_direction.is_turned() {
|
||||
return Vec::new();
|
||||
}
|
||||
let set = linked(§ions, page.section, |s| s.headers);
|
||||
let set = linked(&ctx.sections, page.section, |s| s.headers);
|
||||
let id = if sect.title_page && first {
|
||||
set.first
|
||||
} else if ctx.doc.settings.even_odd_headers && page.number.is_multiple_of(2) {
|
||||
@@ -4528,7 +4557,7 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
let blocks = part.blocks.clone();
|
||||
let x = sect.margin_left + sect.gutter;
|
||||
let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror };
|
||||
let base = defining_width(§ions, page.section, |s| s.headers, Some(id));
|
||||
let base = defining_width(&ctx.sections, page.section, |s| s.headers, Some(id));
|
||||
let counters = std::mem::take(&mut ctx.counters);
|
||||
let boxes = std::mem::take(&mut ctx.boxes);
|
||||
let chains = std::mem::take(&mut ctx.chains);
|
||||
@@ -4599,7 +4628,7 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa
|
||||
pg.float_tables.push(Path::top(block));
|
||||
}
|
||||
let area = r.inset(-dl, -dt, -dr, -db);
|
||||
pb.excl.push((area, false, None, WrapText::BothSides));
|
||||
pb.excl.push((area, false, None, WrapText::BothSides, true));
|
||||
pb.float_tables.push(area);
|
||||
return;
|
||||
}
|
||||
@@ -4916,6 +4945,8 @@ mod tests_pages;
|
||||
#[cfg(test)]
|
||||
mod tests_styleref;
|
||||
#[cfg(test)]
|
||||
mod tests_table_contact;
|
||||
#[cfg(test)]
|
||||
mod tests_textbox;
|
||||
#[cfg(test)]
|
||||
mod tests_vertical;
|
||||
@@ -592,6 +592,16 @@ pub struct Exclusion {
|
||||
pub contour: Option<Arc<crate::contour::Contour>>,
|
||||
/// The sides of the object text may go on (Square, Tight, Through).
|
||||
pub side: WrapText,
|
||||
/// A floating table counts a line touching its top as overlapping; shapes do not (#3977).
|
||||
pub table: bool,
|
||||
}
|
||||
|
||||
impl Exclusion {
|
||||
/// Whether this area keeps text out of the row, including exact top contact for tables.
|
||||
fn intersects_row(&self, top: f32, h: f32, table_h: f32, lo: f32, hi: f32) -> bool {
|
||||
let reaches = if self.table { self.top <= top + table_h } else { self.top < top + h };
|
||||
self.bottom > top && reaches && self.right > lo && self.left < hi
|
||||
}
|
||||
}
|
||||
|
||||
struct Builder<'a> {
|
||||
@@ -2116,6 +2126,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
.map(|st| (st.ascent + st.descent) * if let LineSpacing::Multiple(m) = rp.line_spacing { m } else { 1.0 })
|
||||
.unwrap_or(14.0)
|
||||
.max(1.0);
|
||||
// Exact spacing fixes the table-contact boundary even when the mark has other metrics.
|
||||
// Keep the existing estimate for shapes and for non-exact spacing.
|
||||
let table_h = if let LineSpacing::Exactly(h) = rp.line_spacing { h } else { est_h };
|
||||
// The rest of the current row: spans on the far side of floating objects, left to right.
|
||||
let mut row_rest: VecDeque<(f32, f32)> = VecDeque::new();
|
||||
let mut row_first = 0usize;
|
||||
@@ -2142,7 +2155,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
{
|
||||
lo = first_left.max(rp.indent_left) + dw;
|
||||
}
|
||||
match row_spans(exclusions, top, est_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) {
|
||||
match row_spans(exclusions, top, est_h, table_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) {
|
||||
Ok((Some((a, b)), rest)) => {
|
||||
narrowed = !rest.is_empty() || a > lo || b < base_right;
|
||||
row_rest = rest;
|
||||
@@ -2432,7 +2445,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
// Nothing on this row: go below the first object that ends, as when no span is wide enough.
|
||||
exclusions
|
||||
.iter()
|
||||
.filter(|e| e.bottom > top && e.top < top + est_h && e.right > row_lo && e.left < base_right)
|
||||
.filter(|e| e.intersects_row(top, est_h, table_h, row_lo, base_right))
|
||||
.map(|e| e.bottom)
|
||||
.reduce(f32::min)
|
||||
.unwrap_or(top + est_h)
|
||||
@@ -2832,8 +2845,8 @@ fn baseline_offset(spacing: LineSpacing, asc: f32, height: f32, desc: f32) -> f3
|
||||
/// The spans of a row at `top` (about `h` tall) between `lo` and `hi` that text can use around
|
||||
/// the exclusions, left to right (Square wrapping uses both sides of an object). `Err(y)`: none
|
||||
/// here, try again at `y` (below what's in the way).
|
||||
fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, lo: f32, hi: f32) -> Result<VecDeque<(f32, f32)>, f32> {
|
||||
let here: Vec<&Exclusion> = exclusions.iter().filter(|e| e.bottom > top && e.top < top + h && e.right > lo && e.left < hi).collect();
|
||||
fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, table_h: f32, lo: f32, hi: f32) -> Result<VecDeque<(f32, f32)>, f32> {
|
||||
let here: Vec<&Exclusion> = exclusions.iter().filter(|e| e.intersects_row(top, h, table_h, lo, hi)).collect();
|
||||
if let Some(below) = here.iter().filter(|e| e.top_bottom).map(|e| e.bottom).reduce(f32::max) {
|
||||
return Err(below);
|
||||
}
|
||||
|
||||
+110
-3
@@ -1011,6 +1011,26 @@ fn hidden_separators_with_automatic_hyphenation_lay_out_in_linear_time() {
|
||||
assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
|
||||
}
|
||||
|
||||
/// n one-line paragraphs, each starting a page.
|
||||
fn page_break_paragraphs(n: usize) -> Document {
|
||||
let mut d = Document::from_text(&"hello\n".repeat(n));
|
||||
for i in 0..n {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(true);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// A document of many paragraphs lays out in linear time. Every page asks the layout for the
|
||||
/// section properties (for the wrap areas of the headers and footers), and that used to rebuild
|
||||
/// the section list by walking the whole body, so a document of n paragraphs paid n body-length
|
||||
/// scans: a two-megabyte file took half a minute before the first page was drawn.
|
||||
#[test]
|
||||
fn a_document_of_many_paragraphs_lays_out_in_linear_time() {
|
||||
let (t1, tk, ratio) = layout_time_ratio(page_break_paragraphs, 2_500, &LayoutOptions::default());
|
||||
eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}");
|
||||
assert!(ratio < 24.0, "8x the paragraphs took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_hidden_float_anchors_lay_out_in_linear_time() {
|
||||
// n square-wrapped shapes, each anchored in its own hidden run (bold alternating, so runs do not
|
||||
@@ -5184,6 +5204,79 @@ fn floats_in_headers_and_table_cells_are_drawn() {
|
||||
assert!(c[0].x > 72.0 && c[0].x < 72.0 + 234.0, "inside the first cell: {:?}", c[0]);
|
||||
}
|
||||
|
||||
/// Paragraph anchors exclude space-before; line and frameless page/margin anchors retain it.
|
||||
#[test]
|
||||
fn box_float_origins_respect_vertical_anchor_with_space_before() {
|
||||
use wordcraft_doc::{
|
||||
PartKind,
|
||||
para::{Anchor, Float, Wrap},
|
||||
};
|
||||
for context in ["header", "footer", "cell", "textbox"] {
|
||||
for anchor in [
|
||||
Anchor::Paragraph,
|
||||
Anchor::Line,
|
||||
Anchor::Page,
|
||||
Anchor::Margin,
|
||||
Anchor::TopMargin,
|
||||
Anchor::BottomMargin,
|
||||
Anchor::InsideMargin,
|
||||
Anchor::OutsideMargin,
|
||||
] {
|
||||
if matches!(context, "header" | "footer") && !matches!(anchor, Anchor::Paragraph | Anchor::Line) {
|
||||
continue;
|
||||
}
|
||||
let gap = |before| {
|
||||
let mut d = Document::from_text("Body");
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Anchor", Default::default());
|
||||
p.props.space_before = Some(before);
|
||||
let float = Float { wrap: Wrap::BehindText, v_rel: anchor, y: 7.0, ..Default::default() };
|
||||
p.insert_object(0, rect_shape(37.0, 9.0, float), &Default::default()).unwrap();
|
||||
let story = match context {
|
||||
"header" | "footer" => {
|
||||
let kind = if context == "header" { PartKind::Header } else { PartKind::Footer };
|
||||
let id = d.add_part(kind, vec![wordcraft_doc::para_block(p)]);
|
||||
if context == "header" {
|
||||
d.last_section.headers.default = Some(id);
|
||||
} else {
|
||||
d.last_section.footers.default = Some(id);
|
||||
}
|
||||
StoryRef::Part(id)
|
||||
}
|
||||
"cell" => {
|
||||
let mut t = Table::new(1, 1, 468.0);
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
StoryRef::Body
|
||||
}
|
||||
_ => {
|
||||
let id = text_box(&mut d, 0, "", 200.0, 100.0, page_float(100.0, 200.0));
|
||||
d.parts.get_mut(&id).unwrap().blocks = vec![wordcraft_doc::para_block(p)];
|
||||
StoryRef::Part(id)
|
||||
}
|
||||
};
|
||||
let l = lay(&d);
|
||||
let page = &l.pages[0];
|
||||
let items = match context {
|
||||
"header" => &page.header,
|
||||
"footer" => &page.footer,
|
||||
_ => &page.items,
|
||||
};
|
||||
let y = items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Placed::Lines { story: s, y, path, .. } if *s == story && (context != "cell" || path.0.len() > 1) => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let r = shapes(items).into_iter().find(|r| (r.w - 37.0).abs() < 0.01).unwrap();
|
||||
r.y - y
|
||||
};
|
||||
let expected = if anchor == Anchor::Paragraph { -24.0 } else { 0.0 };
|
||||
assert!((gap(24.0) - gap(0.0) - expected).abs() < 0.01, "{context} {anchor:?}: float/text gap must change by {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inside a text box whose shape style gives its text a colour (`a:fontRef`, typically white on
|
||||
/// the accent fill), automatic text takes that colour instead of black; text with a colour of
|
||||
/// its own keeps it (Word, observed with synthetic documents).
|
||||
@@ -9199,15 +9292,29 @@ fn space_after_stays_clear_of_the_footnote_separator() {
|
||||
#[test]
|
||||
fn space_after_may_overflow_the_bottom_margin_without_notes() {
|
||||
// Without footnotes nothing changes: the last line only has to fit, its space after may run
|
||||
// past the bottom margin (Word).
|
||||
// past the bottom margin (Word). "Fit" means its text: the leading multiple spacing adds below
|
||||
// the text (this paragraph's 1.15) may run past the margin too (observed in Word at 1.15, 1.5
|
||||
// and double spacing; see `a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits`).
|
||||
let mut tight = 0;
|
||||
for before in 0..=300 {
|
||||
let (d, t, _) = space_after_doc(before as f32, false);
|
||||
let l = lay(&d);
|
||||
let bottom_margin = d.last_section.page_h - d.last_section.margin_bottom;
|
||||
let (pieces, _) = para_pieces(&l, t);
|
||||
let &(_, _, _, bottom) = pieces.last().unwrap();
|
||||
assert!(bottom <= bottom_margin + 0.01, "before {before}: {bottom}");
|
||||
let &(page, _, l1, bottom) = pieces.last().unwrap();
|
||||
let leading = l.pages[page]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, path, para, .. } if path.0 == [t as u32] => {
|
||||
para.lines.get(l1 - 1).map(|ln| ln.height - (ln.baseline - ln.top + ln.text_desc))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
.max(0.0);
|
||||
let bottom = bottom - leading;
|
||||
assert!(bottom <= bottom_margin + 0.01, "before {before}: the last line's text ends at {bottom}");
|
||||
if bottom > bottom_margin - 6.0 {
|
||||
tight += 1;
|
||||
}
|
||||
|
||||
@@ -569,3 +569,46 @@ fn a_roman_page_number_in_the_footer_stays_roman_with_the_page_count_in_the_body
|
||||
assert_eq!(glyphs, want.chars().count(), "page {}: the footer should show {want}", p.number);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
|
||||
// Multiple spacing puts its extra leading below the text, and Word lets that leading of a
|
||||
// page's last line run into the bottom margin: a multiple-spaced line stays on the page while
|
||||
// its text ends above the margin (observed in Word at 1.15, 1.5 and double spacing: the page
|
||||
// holds one more line than when the whole line box must fit, for shifts up to about the
|
||||
// leading). Each shift moves the text down; on every page the last line's text ends within
|
||||
// the margin, and the next line's would not.
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
for multiple in [1.15, 1.5, 2.0] {
|
||||
let mut past_margin = 0;
|
||||
for shift in (0..28).step_by(2) {
|
||||
let mut d = Document::from_text(&"Several words wrap onto many lines of a long paragraph. ".repeat(60));
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(Arc::make_mut) {
|
||||
p.props.line_spacing = Some(LineSpacing::Multiple(multiple));
|
||||
p.props.space_before = Some(shift as f32);
|
||||
p.props.widow_control = Some(false);
|
||||
}
|
||||
let bottom = d.last_section.page_h - d.last_section.margin_bottom;
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 2, "{multiple} shift {shift}: the paragraph should run onto a second page");
|
||||
let Some(Placed::Lines { y, para, l0, l1, .. }) =
|
||||
l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { story: StoryRef::Body, .. }))
|
||||
else {
|
||||
panic!("{multiple} shift {shift}: no body lines on page 1")
|
||||
};
|
||||
let (first, last, next) = (¶.lines[*l0], ¶.lines[*l1 - 1], ¶.lines[*l1]);
|
||||
let top = y + last.top - first.top;
|
||||
let text_end = top + (last.baseline - last.top) + last.text_desc;
|
||||
assert!(text_end <= bottom + 0.01, "{multiple} shift {shift}: the last line's text ends at {text_end}, below the margin at {bottom}");
|
||||
let next_text_end = top + last.height + (next.baseline - next.top) + next.text_desc;
|
||||
assert!(
|
||||
next_text_end > bottom + 0.01,
|
||||
"{multiple} shift {shift}: the next line's text would end at {next_text_end}, within the margin at {bottom}"
|
||||
);
|
||||
if top + last.height > bottom + 0.01 {
|
||||
past_margin += 1;
|
||||
}
|
||||
}
|
||||
assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! #3977: floating tables include a line's bottom edge; shapes keep a strict boundary.
|
||||
//! The expected distinction is cyril265's Word for Mac 16.114.1 observation in the issue.
|
||||
|
||||
use super::*;
|
||||
use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{HeightRule, LineSpacing, ParaProps, TableFloat};
|
||||
use wordcraft_doc::{CharProps, Paragraph, Pos, Table};
|
||||
|
||||
const LINE: f32 = 82.875;
|
||||
const CONTACT: f32 = 403.5;
|
||||
|
||||
fn document(mode: u32) -> Document {
|
||||
let mut d = Document::new();
|
||||
d.settings.compat_mode = mode;
|
||||
let ch = CharProps { font: Some("Source Sans 3".into()), size: Some(62.5), ..Default::default() };
|
||||
d.body = (1..=6)
|
||||
.map(|n| {
|
||||
let mut p = Paragraph::with_text(&format!("P{n}"), ch.clone());
|
||||
p.mark = ch.clone();
|
||||
p.props = ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(LineSpacing::Multiple(1.0)),
|
||||
widow_control: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
d
|
||||
}
|
||||
|
||||
fn add_table(d: &mut Document, top: f32, width: f32) {
|
||||
let mut t = Table::new(1, 1, width);
|
||||
t.props.style = None;
|
||||
t.props.width = Some(width);
|
||||
t.props.fixed = true;
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: top, ..Default::default() });
|
||||
t.rows[0].props.height = Some(50.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
|
||||
fn add_shape(d: &mut Document, top: f32, left: f32, width: f32, height: f32) {
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: width,
|
||||
h: height,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: left, y: top, ..Default::default() },
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let block = d.body.iter().position(|b| matches!(&**b, Block::Para(_))).unwrap();
|
||||
d.insert_object(&Pos::body(block, 0), shape, &Default::default()).unwrap();
|
||||
}
|
||||
|
||||
fn line_positions(d: &Document) -> Vec<(f32, f32, f32)> {
|
||||
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
let lines: Vec<_> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Placed::Lines { path, x, y, para, story: StoryRef::Body, .. } if path.0.len() == 1 => {
|
||||
assert_eq!(para.lines.len(), 1);
|
||||
let line = ¶.lines[0];
|
||||
// Placed::Lines::y is already the top of its first displayed line.
|
||||
Some((x + line.left, *y, line.height))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines.len(), 6);
|
||||
lines
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_tables_include_exact_line_bottom_contact_in_all_compatibility_modes() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
let control = line_positions(&document(mode));
|
||||
assert_eq!(control[3].1 + control[3].2, CONTACT);
|
||||
assert!(control.iter().enumerate().all(|(n, l)| l.1 == 72.0 + n as f32 * LINE && l.2 == LINE));
|
||||
for table in [false, true] {
|
||||
for twips in [-1, 0, 1] {
|
||||
let top = CONTACT + twips as f32 / 20.0;
|
||||
let mut d = document(mode);
|
||||
if table {
|
||||
add_table(&mut d, top, 468.0);
|
||||
} else {
|
||||
add_shape(&mut d, top, 72.0, 468.0, 50.0);
|
||||
}
|
||||
let lines = line_positions(&d);
|
||||
let moves = twips < 0 || (table && twips == 0);
|
||||
let expected = if moves { top + 50.0 } else { control[3].1 };
|
||||
assert!((lines[3].1 - expected).abs() < 0.001, "mode={mode}, table={table}, twips={twips}: {lines:?}");
|
||||
assert_eq!(&lines[..3], &control[..3]);
|
||||
// If P4 stays above, P5 is the first line that meets the object.
|
||||
let next = if moves { lines[3].1 + LINE } else { top + 50.0 };
|
||||
assert!((lines[4].1 - next).abs() < 0.001, "mode={mode}, table={table}, twips={twips}: {lines:?}");
|
||||
assert_eq!(lines[5].1, lines[4].1 + LINE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_skipping_a_narrow_strip_at_table_contact_resumes_when_the_table_ends() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
let mut d = document(mode);
|
||||
// Before contact the shape leaves 200 pt on the left. At contact the table takes
|
||||
// 168 pt of that, leaving a 32 pt strip, too narrow for P4. The table ends before
|
||||
// the shape: P4 must use the newly available left strip, not jump below the shape.
|
||||
add_table(&mut d, CONTACT, 168.0);
|
||||
add_shape(&mut d, 72.0, 272.0, 268.0, 500.0);
|
||||
let lines = line_positions(&d);
|
||||
assert!((lines[3].1 - (CONTACT + 50.0)).abs() < 0.001, "mode={mode}: {lines:?}");
|
||||
assert_eq!(lines[3].0, 72.0);
|
||||
assert!(lines[..3].iter().enumerate().all(|(n, l)| l.1 == 72.0 + n as f32 * LINE));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_spacing_does_not_make_mark_height_a_table_contact_boundary() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
for table in [false, true] {
|
||||
let mut d = Document::new();
|
||||
d.settings.compat_mode = mode;
|
||||
let mut p = Paragraph::with_text("P1", CharProps { font: Some("Source Sans 3".into()), size: Some(11.0), ..Default::default() });
|
||||
p.mark = CharProps { font: Some("Source Sans 3".into()), size: Some(62.5), ..Default::default() };
|
||||
p.props =
|
||||
ParaProps { line_spacing: Some(LineSpacing::Exactly(12.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
// The mark's natural 82.875 pt estimate ends far below the actual 12 pt line.
|
||||
let top = 72.0 + LINE;
|
||||
if table {
|
||||
add_table(&mut d, top, 468.0);
|
||||
} else {
|
||||
add_shape(&mut d, top, 72.0, 468.0, 50.0);
|
||||
}
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let (y, height) = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Lines { story: StoryRef::Body, path, y, para, .. } if path.0.len() == 1 => Some((*y, para.lines[0].height)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(height, 12.0);
|
||||
assert_eq!(y, 72.0, "mode={mode}, table={table}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_document(mode: u32, mark_size: f32) -> Document {
|
||||
let mut d = Document::new();
|
||||
d.settings.compat_mode = mode;
|
||||
let mut p = Paragraph::with_text("WWWWWWWWWWWW", CharProps { font: Some("Source Sans 3".into()), size: Some(11.0), ..Default::default() });
|
||||
p.mark = CharProps { font: Some("Source Sans 3".into()), size: Some(mark_size), ..Default::default() };
|
||||
p.props = ParaProps { line_spacing: Some(LineSpacing::Exactly(12.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
d
|
||||
}
|
||||
|
||||
fn exact_line(d: &Document) -> (f32, f32) {
|
||||
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Lines { story: StoryRef::Body, path, x, y, para, .. } if path.0 == vec![(d.body.len() - 1) as u32] => {
|
||||
assert_eq!(para.lines.len(), 1);
|
||||
assert_eq!(para.lines[0].height, 12.0);
|
||||
Some((x + para.lines[0].left, *y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_table_contact_uses_the_spacing_with_larger_and_smaller_marks() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
for mark in [4.0, 62.5] {
|
||||
for twips in [-1, 0, 1] {
|
||||
let mut d = exact_document(mode, mark);
|
||||
let top = 84.0 + twips as f32 / 20.0;
|
||||
add_table(&mut d, top, 468.0);
|
||||
let (_, y) = exact_line(&d);
|
||||
let expected = if twips <= 0 { top + 50.0 } else { 72.0 };
|
||||
assert!((y - expected).abs() < 0.001, "mode={mode}, mark={mark}, twips={twips}: {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_table_contact_uses_the_spacing_when_a_word_skips_a_strip() {
|
||||
for mode in [11, 12, 14, 15] {
|
||||
let mut d = exact_document(mode, 4.0);
|
||||
// Anchor the shape in an earlier paragraph so the tested word has no inline anchor.
|
||||
let Block::Para(p) = &*d.body[0] else { panic!("expected paragraph") };
|
||||
let mut anchor = Paragraph::with_text("P0", p.mark.clone());
|
||||
anchor.mark = p.mark.clone();
|
||||
anchor.props = p.props.clone();
|
||||
d.body.insert(0, wordcraft_doc::para_block(anchor));
|
||||
add_table(&mut d, 96.0, 168.0);
|
||||
add_shape(&mut d, 72.0, 272.0, 268.0, 500.0);
|
||||
// The 32 pt strip cannot hold the word. The table ends before the shape,
|
||||
// leaving 200 pt on the left, so skipping must stop at the table's bottom.
|
||||
assert_eq!(exact_line(&d), (72.0, 146.0), "mode={mode}");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub struct Remote {
|
||||
addr: String,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
/// Whether a connection ever succeeded (`lazy` starts without one; the app may open later).
|
||||
connected: bool,
|
||||
/// The connection leads to a loopback address: requests carry the control key.
|
||||
send_key: bool,
|
||||
/// The control key, looked up once per connection (the app picks a new one at every start).
|
||||
@@ -83,7 +85,7 @@ impl Remote {
|
||||
}
|
||||
|
||||
fn connect_with(addr: &str, find_key: KeyFinder) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, send_key: false, key: None, find_key };
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key };
|
||||
r.reconnect()?;
|
||||
if let Some(w) = r.key_warning() {
|
||||
log::warn!("{w}");
|
||||
@@ -91,12 +93,22 @@ impl Remote {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A remote that connects on first use: the app may start after the MCP server, and a
|
||||
/// server that exits at once reads as failed for the client's whole session (#4078).
|
||||
pub fn lazy(addr: &str) -> Self {
|
||||
Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key: Box::new(find_key) }
|
||||
}
|
||||
|
||||
pub fn addr(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Why requests go without the control key (the address is not loopback), if they do.
|
||||
pub fn key_warning(&self) -> Option<String> {
|
||||
// Nothing is known about the address until a connection succeeds.
|
||||
if !self.connected {
|
||||
return None;
|
||||
}
|
||||
(!self.send_key).then(|| {
|
||||
format!(
|
||||
"{} is not a loopback address, so the control key is not sent (it only goes to 127.0.0.0/8, ::1 and localhost) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel",
|
||||
@@ -118,6 +130,7 @@ impl Remote {
|
||||
self.send_key = may_send_key(&self.addr, s.peer_addr().ok());
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
self.connected = true;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
@@ -158,7 +171,10 @@ impl Remote {
|
||||
/// One request and its reply, with one retry on a new connection (the app may have
|
||||
/// restarted; a new connection also reads the key again).
|
||||
fn exchange(&mut self, id: u64, method: &str, params: &Value) -> Result<Value, String> {
|
||||
let unreachable = |addr: &str, e: std::io::Error| format!("WordCraft app at {addr} is not reachable: {e}");
|
||||
let unreachable = |addr: &str, e: std::io::Error, connected: bool| {
|
||||
let retry = if connected { String::new() } else { "; will retry on each call".to_string() };
|
||||
format!("WordCraft app at {addr} is not reachable: {e}{retry}")
|
||||
};
|
||||
let mut first = true;
|
||||
let reply = loop {
|
||||
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect() };
|
||||
@@ -174,7 +190,7 @@ impl Remote {
|
||||
Err(e) => {
|
||||
self.conn = None;
|
||||
if !first {
|
||||
return Err(unreachable(&self.addr, e));
|
||||
return Err(unreachable(&self.addr, e, self.connected));
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
@@ -219,7 +235,11 @@ impl Backend for Remote {
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
format!("connected to the WordCraft app at {}", self.addr)
|
||||
if self.connected {
|
||||
format!("connected to the WordCraft app at {}", self.addr)
|
||||
} else {
|
||||
format!("will connect to the WordCraft app at {} when it opens", self.addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +352,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lazy_remote_serves_once_the_app_opens() {
|
||||
// Nothing is listening: the call fails, but the server stays up and says it will retry.
|
||||
let mut r = Remote::lazy("127.0.0.1:1");
|
||||
r.find_key = fixed_key("k1");
|
||||
let e = r.call("document.inspect", json!({})).unwrap_err();
|
||||
assert!(e.contains("not reachable") && e.contains("will retry"), "{e}");
|
||||
assert!(r.describe().contains("will connect to"));
|
||||
// The app opens later: the next call connects and answers.
|
||||
let (port, seen) = fake_app(|_| Some((json!({"ok": true, "result": 7}), false)));
|
||||
r.addr = format!("127.0.0.1:{port}");
|
||||
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(7));
|
||||
assert!(r.key_warning().is_none());
|
||||
assert_eq!(seen.recv_timeout(WAIT).unwrap()["key"], "k1");
|
||||
assert!(r.describe().contains("connected to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_key_again_on_a_new_connection() {
|
||||
// The app restarted with a new key: the old key is refused (and the connection closed).
|
||||
|
||||
+88
-12
@@ -59,6 +59,77 @@ fn agent_writes_a_formatted_document() {
|
||||
assert!(p["percent"].as_f64().unwrap() > 50.0);
|
||||
}
|
||||
|
||||
/// The document's paragraphs as (text, in a list) from `inspect_document`.
|
||||
fn paragraphs(s: &mut Server) -> Vec<(String, bool)> {
|
||||
let doc = tool(s, "inspect_document", json!({}));
|
||||
doc["blocks"].as_array().unwrap().iter().map(|b| (b["text"].as_str().unwrap_or("").to_string(), !b["list"].is_null())).collect()
|
||||
}
|
||||
|
||||
/// `type_text` with `paragraphs` is one command: it used to be a `text.newParagraph` and a
|
||||
/// `text.insert` per line, each snapshotting the whole document for Undo (quadratic: 10,000 lines
|
||||
/// took 2.8 s; one command takes 7 ms) and each its own Undo step (#3973). The document it makes
|
||||
/// is the one the per-line way made, a CRLF is one break, and one Undo takes all of it back.
|
||||
#[test]
|
||||
fn type_text_paragraphs_is_one_step_and_makes_the_same_paragraphs() {
|
||||
let per_line = |s: &mut Server, text: &str| {
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
tool(s, "execute", json!({"command": "text.newParagraph"}));
|
||||
}
|
||||
if !line.is_empty() {
|
||||
tool(s, "type_text", json!({"text": line}));
|
||||
}
|
||||
}
|
||||
};
|
||||
let setup = |s: &mut Server, listed: bool| {
|
||||
tool(s, "new_document", json!({}));
|
||||
tool(s, "type_text", json!({"text": "Start"}));
|
||||
if listed {
|
||||
tool(s, "execute", json!({"command": "para.bullets"}));
|
||||
}
|
||||
};
|
||||
for listed in [false, true] {
|
||||
// Text AutoCorrect has nothing to change, so only the paragraphs are compared.
|
||||
for text in ["One\nTwo\nThree", "One\n\n\nTwo", "\nLead", "Trail\n", "A b c\n Indented\n\tTabbed", "X"] {
|
||||
let mut old = Server::new(Box::new(Headless::default()));
|
||||
setup(&mut old, listed);
|
||||
per_line(&mut old, text);
|
||||
let mut new = Server::new(Box::new(Headless::default()));
|
||||
setup(&mut new, listed);
|
||||
tool(&mut new, "type_text", json!({"text": text, "paragraphs": true}));
|
||||
assert_eq!(paragraphs(&mut new), paragraphs(&mut old), "{text:?} (in a list: {listed})");
|
||||
}
|
||||
}
|
||||
|
||||
// The lines are typed as they are: Enter's AutoCorrect (a capital for the next sentence, a
|
||||
// fixed typo) doesn't run on the breaks. The per-line way capitalised "two" (and not the last
|
||||
// line, which no Enter followed), so what was written depended on the line's place.
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "one\ntwo teh\nthree", "paragraphs": true}));
|
||||
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
|
||||
assert_eq!(texts, ["one", "two teh", "three"]);
|
||||
|
||||
// CRLF is one break per line end.
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "a\r\nb\r\nc", "paragraphs": true}));
|
||||
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
|
||||
assert_eq!(texts, ["a", "b", "c"]);
|
||||
|
||||
// One Undo step takes all the lines back.
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
tool(&mut s, "new_document", json!({}));
|
||||
tool(&mut s, "type_text", json!({"text": "keep"}));
|
||||
// Moving the caret ends the typing that "keep" was part of, so it isn't undone with the rest.
|
||||
tool(&mut s, "execute", json!({"command": "caret.docEnd"}));
|
||||
tool(&mut s, "type_text", json!({"text": "\none\ntwo\nthree\nfour", "paragraphs": true}));
|
||||
assert_eq!(paragraphs(&mut s).len(), 5);
|
||||
tool(&mut s, "execute", json!({"command": "edit.undo"}));
|
||||
let texts: Vec<String> = paragraphs(&mut s).into_iter().map(|p| p.0).collect();
|
||||
assert_eq!(texts, ["keep"], "one Undo removes every line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
@@ -120,17 +191,17 @@ fn headless_sessions_keep_the_dictionary_in_memory() {
|
||||
assert!(h.session.user_dict_path.is_none());
|
||||
}
|
||||
|
||||
/// A backend whose paragraph break is refused while text insertion would succeed.
|
||||
struct RefusesParagraph {
|
||||
calls: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
|
||||
/// A backend that refuses every text insertion (a protected document, say).
|
||||
struct RefusesInsert {
|
||||
calls: std::sync::Arc<std::sync::Mutex<Vec<(String, Value)>>>,
|
||||
}
|
||||
|
||||
impl crate::backend::Backend for RefusesParagraph {
|
||||
impl crate::backend::Backend for RefusesInsert {
|
||||
fn call(&mut self, _method: &str, params: Value) -> Result<Value, String> {
|
||||
let command = params["command"].as_str().unwrap_or("").to_string();
|
||||
self.calls.lock().unwrap().push(command.clone());
|
||||
self.calls.lock().unwrap().push((command.clone(), params["params"].clone()));
|
||||
match command.as_str() {
|
||||
"text.newParagraph" => Err("paragraph break refused".into()),
|
||||
"text.insert" => Err("editing refused".into()),
|
||||
_ => Ok(json!({"ok": true})),
|
||||
}
|
||||
}
|
||||
@@ -138,17 +209,22 @@ impl crate::backend::Backend for RefusesParagraph {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"refuses paragraphs".into()
|
||||
"refuses edits".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// #1356: a refused paragraph break is reported and the next line is not typed after it.
|
||||
/// #1356: a refused edit is reported and nothing is typed after it. The lines are one
|
||||
/// `text.insert` now (#3973), so a protected document refuses all of them at once, never a
|
||||
/// line at a time with the next one typed after the break that failed.
|
||||
#[test]
|
||||
fn type_text_stops_at_a_refused_paragraph_break() {
|
||||
fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() {
|
||||
let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() }));
|
||||
let mut s = Server::new(Box::new(RefusesInsert { calls: calls.clone() }));
|
||||
let r = call(&mut s, 11, "tools/call", json!({"name": "type_text", "arguments": {"text": "one\ntwo", "paragraphs": true}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}");
|
||||
assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]);
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("editing refused"), "{r}");
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "one command for all the lines: {calls:?}");
|
||||
assert_eq!(calls[0].0, "text.insert");
|
||||
assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break");
|
||||
}
|
||||
+6
-17
@@ -102,7 +102,7 @@ pub fn tool_definitions() -> Value {
|
||||
tool(
|
||||
"type_text",
|
||||
"Type text",
|
||||
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.",
|
||||
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (all in one step, one Undo; the line breaks are plain text breaks: no AutoCorrect or AutoFormat runs on them).",
|
||||
obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]),
|
||||
false
|
||||
),
|
||||
@@ -229,22 +229,11 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
"type_text" => {
|
||||
let t = st("text").unwrap_or("");
|
||||
if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let mut last = Ok(Value::Null);
|
||||
for (i, line) in t.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
last = exec(b, "text.newParagraph", json!({}));
|
||||
if last.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !line.is_empty() {
|
||||
last = exec(b, "text.insert", json!({"text": line, "raw": true}));
|
||||
}
|
||||
if last.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
wrap(last)
|
||||
// One command for all the lines: `text.insert` takes a carriage return as a
|
||||
// paragraph break. A command per line was quadratic in the document (each one
|
||||
// snapshots all of it for Undo: 10,000 lines took 2.8 s, 7 ms in one command),
|
||||
// and left an Undo step per line. CRLF is one break, not two (#3973).
|
||||
wrap(exec(b, "text.insert", json!({"text": t.replace("\r\n", "\n").replace('\n', "\r"), "raw": true})))
|
||||
} else {
|
||||
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
wordcraft-metafile = { workspace = true }
|
||||
|
||||
+25
-7
@@ -27,7 +27,6 @@ use std::num::NonZeroU16;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::{self, Metadata, WriteError, Writer, backend as krilla};
|
||||
use krilla::action::{Action, LinkAction};
|
||||
use krilla::annotation::{Annotation, LinkAnnotation, Target};
|
||||
use krilla::color::rgb;
|
||||
use krilla::destination::XyzDestination;
|
||||
@@ -1043,6 +1042,10 @@ impl Exporter<'_> {
|
||||
tree
|
||||
}
|
||||
|
||||
/// Paint one display item on the PDF surface in its current point-coordinate system.
|
||||
/// Nested clips and turns are pushed and popped around their child items. Shape and path
|
||||
/// outlines set their own dash and cap state, then clear their fill and stroke; ink retains
|
||||
/// round caps independently of the optional shape-outline setting.
|
||||
fn draw(&mut self, s: &mut Surface, d: &Draw) {
|
||||
match d {
|
||||
Draw::Figure { draws, .. } => {
|
||||
@@ -1077,7 +1080,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha, .. } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
|
||||
Draw::Image { rect, media, crop, alpha, .. } => self.picture(s, rect, media, crop, *alpha),
|
||||
Draw::Path { segs, fill: f, stroke, stroke_width, dash } => {
|
||||
Draw::Path { segs, fill: f, stroke, stroke_width, dash, cap } => {
|
||||
let mut pb = PathBuilder::new();
|
||||
append_path(&mut pb, &wordcraft_layout::display::seg_path(segs));
|
||||
let Some(p) = pb.finish() else { return };
|
||||
@@ -1087,6 +1090,7 @@ impl Exporter<'_> {
|
||||
// Width 0 (or none) is a hairline.
|
||||
width: if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
|
||||
dash: (!dash.is_empty()).then(|| StrokeDash { array: dash.clone(), offset: 0.0 }),
|
||||
line_cap: pdf_line_cap(*cap),
|
||||
..Default::default()
|
||||
}));
|
||||
if f.is_some() || stroke.is_some() {
|
||||
@@ -1125,7 +1129,7 @@ impl Exporter<'_> {
|
||||
s.draw_path(&p);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects, corner, dash } => {
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects, corner, dash, cap } => {
|
||||
let Some(p) = shape_path(*kind, rect, *corner) else { return };
|
||||
let can_fill = *kind != ShapeKind::Line;
|
||||
let fx = effects.sanitized();
|
||||
@@ -1150,6 +1154,7 @@ impl Exporter<'_> {
|
||||
paint: rgb::Color::new(c.0, c.1, c.2).into(),
|
||||
width: if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
|
||||
dash: (!dash.is_empty()).then(|| StrokeDash { array: dash.clone(), offset: 0.0 }),
|
||||
line_cap: pdf_line_cap(*cap),
|
||||
..Default::default()
|
||||
}));
|
||||
if f.is_some() && can_fill || stroke.is_some() {
|
||||
@@ -1595,11 +1600,11 @@ impl Exporter<'_> {
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
let url = target.trim();
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
// Unsafe targets (javascript:, file:, shares, other schemes) get no link; the text stays.
|
||||
match write::uri_target(target) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
}
|
||||
Target::Action(Action::Link(LinkAction::new(url.to_string())))
|
||||
};
|
||||
v.push(Annotation::new_link(LinkAnnotation::new(rect, t), Some(target.clone())));
|
||||
}
|
||||
@@ -1889,6 +1894,10 @@ fn path_from(b: &kurbo::BezPath) -> Option<Path> {
|
||||
/// Decode a picture for embedding (validated with the `image` crate first, so a broken file
|
||||
/// can't fail the whole export).
|
||||
fn load_image(data: &Arc<Vec<u8>>) -> Option<Image> {
|
||||
// The shared probe also charges what the decoder holds beside its output (#3644).
|
||||
if !wordcraft_geom::picture::probe(data).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES)) {
|
||||
return None;
|
||||
}
|
||||
load_image_within(data, wordcraft_geom::MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
@@ -1917,5 +1926,14 @@ fn load_image_within(data: &Arc<Vec<u8>>, budget: u64) -> Option<Image> {
|
||||
Some(Image::from_rgba8(dec.into_rgba8().into_raw(), w, h))
|
||||
}
|
||||
|
||||
/// Map the document cap to the PDF backend for both shape and path outlines.
|
||||
fn pdf_line_cap(cap: wordcraft_doc::connector::LineCap) -> LineCap {
|
||||
match cap {
|
||||
wordcraft_doc::connector::LineCap::Flat => LineCap::Butt,
|
||||
wordcraft_doc::connector::LineCap::Square => LineCap::Square,
|
||||
wordcraft_doc::connector::LineCap::Round => LineCap::Round,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -6,6 +6,49 @@ use wordcraft_doc::{Block, CharProps, Document, ListKind, Paragraph, Table, Wate
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Verify preset lines and connectors emit the requested PDF line-cap operator.
|
||||
#[test]
|
||||
fn shape_line_caps_reach_pdf_content() {
|
||||
use wordcraft_doc::{
|
||||
connector::{LineCap as ShapeCap, ShapeExtra},
|
||||
para::{Anchor, Float, ShapeKind, Wrap},
|
||||
};
|
||||
for kind in [ShapeKind::Line, ShapeKind::StraightConnector] {
|
||||
for (cap, operator) in [(ShapeCap::Flat, None), (ShapeCap::Round, Some("1 J")), (ShapeCap::Square, Some("2 J"))] {
|
||||
let shape = InlineObject::Shape {
|
||||
kind,
|
||||
w: 40.0,
|
||||
h: 0.0,
|
||||
fill: None,
|
||||
stroke: Some(wordcraft_doc::props::Rgb::BLACK),
|
||||
stroke_width: 10.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() },
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
};
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, shape, &Default::default()).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p)];
|
||||
let bytes = export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap();
|
||||
let raw = String::from_utf8_lossy(&bytes);
|
||||
// Content operators can share a line: PDF separates them by whitespace.
|
||||
let tokens: Vec<_> = raw.split_ascii_whitespace().collect();
|
||||
let has_operator = |op: &str| {
|
||||
let expected: Vec<_> = op.split_ascii_whitespace().collect();
|
||||
tokens.windows(2).any(|pair| pair == expected.as_slice())
|
||||
};
|
||||
if let Some(op) = operator {
|
||||
assert!(has_operator(op), "{kind:?} {cap:?}: missing PDF cap operator {op}");
|
||||
} else {
|
||||
assert!(!has_operator("1 J") && !has_operator("2 J"), "flat cap must remain butt");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([(x * 15) as u8, (y * 30) as u8, 200, 255]));
|
||||
let mut buf = Vec::new();
|
||||
@@ -1022,6 +1065,34 @@ fn a_crop_keeping_a_sliver_draws_the_source_that_much_wider() {
|
||||
assert!(whole > 0.0 && (cropped / whole - 1.0 / 0.03).abs() < 0.5, "emf: {whole} → {cropped}");
|
||||
}
|
||||
|
||||
/// A JPEG whose headers say progressive (SOF2, a DC-only first scan) and `w` × `h`, its three
|
||||
/// components unsubsampled, with no pixel data that size.
|
||||
fn progressive_jpeg_header(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
image::RgbImage::new(16, 16).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
|
||||
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
|
||||
jpeg[sof + 1] = 0xC2;
|
||||
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
|
||||
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
|
||||
for c in 0..3 {
|
||||
jpeg[sof + 11 + 3 * c] = 0x11;
|
||||
}
|
||||
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
|
||||
let n = usize::from(jpeg[sos + 4]);
|
||||
jpeg[sos + 6 + 2 * n] = 0;
|
||||
jpeg
|
||||
}
|
||||
|
||||
/// #3644: a progressive JPEG's coefficients count toward the budget, beside its output.
|
||||
#[test]
|
||||
fn progressive_jpeg_coefficients_count_toward_the_image_budget() {
|
||||
assert!(wordcraft_geom::decode_fits(7900, 7900, wordcraft_geom::MAX_DECODED_BYTES));
|
||||
assert!(load_image(&Arc::new(progressive_jpeg_header(7900, 7900))).is_none());
|
||||
let mut baseline = Vec::new();
|
||||
image::RgbImage::new(64, 64).write_to(&mut std::io::Cursor::new(&mut baseline), image::ImageFormat::Jpeg).unwrap();
|
||||
assert!(load_image(&Arc::new(baseline)).is_some(), "other JPEGs still embed");
|
||||
}
|
||||
|
||||
/// #1502: an inline picture's Figure carries its own alt text, not the "picture" fallback.
|
||||
#[test]
|
||||
fn inline_picture_figure_keeps_its_alt_text() {
|
||||
@@ -2315,3 +2386,28 @@ fn borders_are_dashed_with_words_patterns() {
|
||||
assert!(raw.contains("[6 2]0 d") || raw.contains("[6 2] 0 d"), "the dashed border");
|
||||
assert!(raw.contains("[3 1 1 1]0 d") || raw.contains("[3 1 1 1] 0 d"), "the dash-dot border");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_safe_link_targets_become_annotations() {
|
||||
use hayro_syntax::object::{Array, Dict, String as PdfString};
|
||||
|
||||
let mut d = Document::new();
|
||||
d.body = ["https://example.com/", "mailto:ada@example.com", "javascript:alert(1)", "file:///etc/passwd"]
|
||||
.into_iter()
|
||||
.map(|url| para_block(Paragraph::with_text("Click here", CharProps { link: Some(url.into()), ..Default::default() })))
|
||||
.collect();
|
||||
let bytes = export(&d, &PdfOptions::default()).unwrap();
|
||||
assert_eq!(squash(&extract_text(&bytes)[0]).matches("Click here").count(), 4, "unlinked text stays");
|
||||
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
|
||||
let uris: Vec<String> = pdf.pages()[0]
|
||||
.raw()
|
||||
.get::<Array>(b"Annots")
|
||||
.into_iter()
|
||||
.flat_map(|a| a.iter::<Dict>().collect::<Vec<_>>())
|
||||
.map(|a| {
|
||||
let uri = a.get::<Dict>(b"A").unwrap().get::<PdfString>(b"URI").unwrap();
|
||||
String::from_utf8_lossy(uri.as_ref()).into_owned()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(uris, ["https://example.com/", "mailto:ada@example.com"]);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -6,7 +6,8 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::affix::{AffixDict, Casing, capitalize, casing};
|
||||
|
||||
const MAX_LETTERS: usize = 40;
|
||||
/// Longest word suggestions are looked for, in letters (Spanish shares the bound).
|
||||
pub(crate) const MAX_LETTERS: usize = 40;
|
||||
const MAX_MAP_CHANGES: usize = 4;
|
||||
const MAX_MAP_VARIANTS: usize = 4096;
|
||||
/// Stems the scan of round 6 expands.
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
//! Spanish grammar checks with Spanish messages: the generic repeated-word and spacing checks,
|
||||
//! and the opening question and exclamation marks Spanish writes before a question or an
|
||||
//! exclamation (`¿Vienes?`, `¡Qué bien!`): a closing `?` or `!` whose sentence has no opening mark
|
||||
//! is flagged, with the opening mark offered at the sentence's start, and so is a space after an
|
||||
//! opening mark (`¿ Vienes?`). Either opening mark may pair with either closing one (`¡Qué
|
||||
//! dices?`), and `(?)` and `(!)`, which mark doubt or surprise, stand alone. No other
|
||||
//! Spanish-specific grammar, comma or sentence-capital rules are inferred.
|
||||
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
use crate::{Issue, IssueKind, ProofLang, words};
|
||||
|
||||
fn issue(start: usize, end: usize, message: String, suggestions: Vec<String>, lang: ProofLang) -> Issue {
|
||||
Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang }
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of Spanish text; `lang` is the variety (they explain the same in
|
||||
/// every one).
|
||||
pub fn check(text: &str, lang: ProofLang) -> Vec<Issue> {
|
||||
let mut issues = Vec::new();
|
||||
repeated_words(text, &words(text), lang, &mut issues);
|
||||
spaces(text, lang, &mut issues);
|
||||
opening_marks(text, lang, &mut issues);
|
||||
issues.sort_by_key(|i| (i.start, i.end));
|
||||
issues
|
||||
}
|
||||
|
||||
fn repeated_words(text: &str, ws: &[(usize, usize)], lang: ProofLang, v: &mut Vec<Issue>) {
|
||||
// Letters and the combining marks of a decomposed word (`tambie\u{301}n`); a composed and a
|
||||
// decomposed spelling of one word are the same word.
|
||||
let letters = |w: &str| w.chars().all(|c| c.is_alphabetic() || unicode_normalization::char::is_combining_mark(c));
|
||||
let folded = |w: &str| w.nfc().collect::<String>().to_lowercase();
|
||||
for pair in ws.windows(2) {
|
||||
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||
if !gap.is_empty() && gap.chars().all(char::is_whitespace) && letters(w0) && folded(w0) == folded(w1) {
|
||||
v.push(issue(*a0, *b1, format!("Palabra repetida: «{w1}»"), vec![w0.to_string()], lang));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spaces(text: &str, lang: ProofLang, v: &mut Vec<Issue>) {
|
||||
for (i, c) in text.char_indices() {
|
||||
// A space after an opening mark: `¿ Vienes?`.
|
||||
if matches!(c, '¿' | '¡')
|
||||
&& text.get(i + c.len_utf8()..).is_some_and(|r| r.starts_with(' ') && r.trim_start().chars().next().is_some_and(char::is_alphanumeric))
|
||||
{
|
||||
v.push(issue(i, i + c.len_utf8() + 1, "Espacio después de un signo de apertura".into(), vec![c.to_string()], lang));
|
||||
continue;
|
||||
}
|
||||
if c != ' ' {
|
||||
continue;
|
||||
}
|
||||
let rest = text.get(i + 1..).unwrap_or("");
|
||||
let mut it = rest.chars();
|
||||
let Some(p) = it.next() else { continue };
|
||||
let after = it.next();
|
||||
// A ratio/score keeps the spaces in digit-space-colon-space-digit.
|
||||
let ratio = p == ':'
|
||||
&& text.get(..i).and_then(|before| before.chars().next_back()).is_some_and(|c| c.is_ascii_digit())
|
||||
&& after == Some(' ')
|
||||
&& it.next().is_some_and(|c| c.is_ascii_digit());
|
||||
let flag = !ratio
|
||||
&& match p {
|
||||
',' => true,
|
||||
// `;)` and `;-)` are emoticons.
|
||||
';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')),
|
||||
// ` ...` is an ellipsis, ` .5` a number.
|
||||
'.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()),
|
||||
// ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space.
|
||||
':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '’' | '»' | '!' | '?')),
|
||||
_ => false,
|
||||
};
|
||||
if flag {
|
||||
v.push(issue(i, i + 1 + p.len_utf8(), "Espacio antes de un signo de puntuación".into(), vec![p.to_string()], lang));
|
||||
}
|
||||
}
|
||||
for (i, _) in text.match_indices(" ") {
|
||||
if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) {
|
||||
continue;
|
||||
}
|
||||
v.push(issue(i, i + 2, "Espacio doble".into(), vec![" ".into()], lang));
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the character `b` at byte `j` end what comes before it, so a question or exclamation after
|
||||
/// it starts there? Sentence ends, clause breaks, line breaks and the start of a quotation or
|
||||
/// parenthesis do, but not punctuation inside a word (`3.5`, `10:30`) or inside an address
|
||||
/// (`example.com`, `https:`), except a mark ending an address (`…/? Vienes mañana?`).
|
||||
fn ends_clause(text: &str, addresses: &[(usize, usize)], j: usize, b: char) -> bool {
|
||||
let after = j + b.len_utf8();
|
||||
if let Some((_, end)) = address_at(addresses, j) {
|
||||
return matches!(b, '?' | '!' | '.') && end == after;
|
||||
}
|
||||
let inside_word = matches!(b, '.' | ':' | ';') && text.get(after..).and_then(|r| r.chars().next()).is_some_and(char::is_alphanumeric);
|
||||
!inside_word && matches!(b, '.' | '?' | '!' | '…' | ':' | ';' | '\n' | '\r' | '\t' | '«' | '“' | '"' | '(' | '[' | '—')
|
||||
}
|
||||
|
||||
/// The one of the `addresses` ([`address_ranges`]) that byte `j` is inside, if any. The ranges come
|
||||
/// in text order and never overlap, so this is a binary search, O(log m) for m addresses: a paragraph
|
||||
/// of thousands of addresses costs O(n log m) when every character before a question mark asks
|
||||
/// (#3562 review), not the O(n m) of a scan per character.
|
||||
fn address_at(addresses: &[(usize, usize)], j: usize) -> Option<(usize, usize)> {
|
||||
let after_start = addresses.partition_point(|&(s, _)| s <= j);
|
||||
let (s, e) = *addresses.get(after_start.checked_sub(1)?)?;
|
||||
(s <= j && j < e).then_some((s, e))
|
||||
}
|
||||
|
||||
/// The byte ranges of the whitespace-delimited tokens of `text` that are web or e-mail addresses
|
||||
/// (`https://example.com/?`, `www.example.com`, `ana@example.com`), as spelling skips them.
|
||||
fn address_ranges(text: &str) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = None;
|
||||
// A final space ends the last token.
|
||||
for (i, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) {
|
||||
if !c.is_whitespace() {
|
||||
start = start.or(Some(i));
|
||||
} else if let Some(s) = start.take()
|
||||
&& text.get(s..i).is_some_and(crate::is_address)
|
||||
{
|
||||
out.push((s, i));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A closing `?` or `!` with no opening `¿` or `¡` before it. Openers are counted left to right, like
|
||||
/// brackets, and each closing mark takes one back (either opener pairs with either closer:
|
||||
/// `¡Qué dices?`), so what sits between them doesn't matter: quotes, parentheses, dashes, an
|
||||
/// ellipsis or an abbreviation's full stop (`¿Viene el Sr. García?`, `¿Leíste «Rayuela»?`) all belong
|
||||
/// to the question. Only a closing mark with nothing open is flagged, and then the sentence it
|
||||
/// closes starts after the last clause break ([`ends_clause`]). Marks in web and e-mail addresses
|
||||
/// aren't sentence punctuation, and neither is punctuation inside an address or a word
|
||||
/// (`example.com`, `3.5`, `10:30`) a sentence's start.
|
||||
fn opening_marks(text: &str, lang: ProofLang, v: &mut Vec<Issue>) {
|
||||
let addresses = address_ranges(text);
|
||||
// Openers not closed yet: a run of them (`¡¡`) counts once, like a run of closers (`!!`).
|
||||
let mut open = 0usize;
|
||||
for (i, c) in text.char_indices() {
|
||||
let before = text.get(..i).unwrap_or("");
|
||||
let prev = before.chars().next_back();
|
||||
let (opener, message) = match c {
|
||||
'¿' | '¡' => {
|
||||
if !matches!(prev, Some('¿' | '¡')) {
|
||||
open += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
'?' => ('¿', "Falta el signo de interrogación de apertura (¿)"),
|
||||
'!' => ('¡', "Falta el signo de exclamación de apertura (¡)"),
|
||||
_ => continue,
|
||||
};
|
||||
let next = text.get(i + 1..).and_then(|r| r.chars().next());
|
||||
// `?!` and `!!!` are handled at their first mark; `(?)` stands alone; `?q=`, `a!b` and a
|
||||
// mark ending an address (`https://example.com/?`) aren't sentence ends.
|
||||
if matches!(prev, Some('?' | '!'))
|
||||
|| (prev == Some('(') && next == Some(')'))
|
||||
|| next.is_some_and(char::is_alphanumeric)
|
||||
|| address_at(&addresses, i).is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if open > 0 {
|
||||
open -= 1;
|
||||
continue;
|
||||
}
|
||||
let from = before.char_indices().rev().find(|&(j, b)| matches!(b, '¿' | '¡') || ends_clause(text, &addresses, j, b));
|
||||
let start = from.map_or(0, |(j, b)| j + b.len_utf8());
|
||||
let Some(sentence) = text.get(start..i) else { continue };
|
||||
let lead = sentence.len() - sentence.trim_start().len();
|
||||
let start = start + lead;
|
||||
let Some(sentence) = text.get(start..i + 1) else { continue };
|
||||
if !sentence.chars().any(char::is_alphabetic) {
|
||||
continue;
|
||||
}
|
||||
v.push(issue(start, i + 1, message.into(), vec![format!("{opener}{sentence}")], lang));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn messages(text: &str) -> Vec<(String, String)> {
|
||||
check(text, ProofLang::Es).into_iter().map(|i| (text[i.start..i.end].to_string(), i.message)).collect()
|
||||
}
|
||||
|
||||
/// The missing-opening-mark issues of `text`: the flagged text and the suggestions offered.
|
||||
fn missing(text: &str) -> Vec<(String, Vec<String>)> {
|
||||
check(text, ProofLang::Es)
|
||||
.into_iter()
|
||||
.filter(|i| i.message.starts_with("Falta"))
|
||||
.map(|i| (text[i.start..i.end].to_string(), i.suggestions))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_words_and_spaces_have_spanish_messages() {
|
||||
let text = "Es es la casa , dijo.";
|
||||
let issues = check(text, ProofLang::EsMx);
|
||||
assert_eq!(issues.len(), 3, "{issues:?}");
|
||||
assert_eq!(issues[0].message, "Palabra repetida: «es»");
|
||||
assert_eq!(issues[0].suggestions, ["Es"]);
|
||||
assert!(issues.iter().any(|i| i.message == "Espacio antes de un signo de puntuación" && &text[i.start..i.end] == " ,"));
|
||||
assert!(issues.iter().any(|i| i.message == "Espacio doble"));
|
||||
assert!(issues.iter().all(|i| i.lang == ProofLang::EsMx && i.kind == IssueKind::Grammar));
|
||||
for text in ["La casa. Es grande", "Sr. García", "No, no lo sé", "Hola :) número .5 y puntos ...", "3 : 1", "teórico-práctico"] {
|
||||
assert!(check(text, ProofLang::Es).is_empty(), "{text}: {:?}", check(text, ProofLang::Es));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn questions_and_exclamations_need_their_opening_mark() {
|
||||
assert_eq!(messages("Vienes mañana?"), [("Vienes mañana?".into(), "Falta el signo de interrogación de apertura (¿)".into())]);
|
||||
assert_eq!(check("Vienes mañana?", ProofLang::Es)[0].suggestions, ["¿Vienes mañana?"]);
|
||||
assert_eq!(messages("Llegó. Qué bien!"), [("Qué bien!".into(), "Falta el signo de exclamación de apertura (¡)".into())]);
|
||||
assert_eq!(messages("Le preguntó: vienes?")[0].0, "vienes?");
|
||||
assert_eq!(messages("Dijo «vienes?»")[0].0, "vienes?");
|
||||
assert_eq!(messages("Hola!!!").len(), 1);
|
||||
assert_eq!(messages("Qué?!").len(), 1);
|
||||
for text in [
|
||||
"¿Vienes mañana?",
|
||||
"¡Qué bien!",
|
||||
"Si vienes, ¿me llamas?",
|
||||
"¡Qué dices?",
|
||||
"¿Qué?! ¡No!",
|
||||
"Nació en 1950 (?) en Montevideo.",
|
||||
"Lo dijo (!) en público.",
|
||||
"Ver https://example.com/?q=1 y a!b",
|
||||
"¿Qué? ¿Cómo?",
|
||||
"2 + 2 = ?",
|
||||
"",
|
||||
"?",
|
||||
] {
|
||||
assert!(messages(text).iter().all(|(_, m)| !m.starts_with("Falta")), "{text}: {:?}", messages(text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Maintainer review on #3562: a URL ending in `/?` isn't a question, and the dots and colons
|
||||
/// of an address or a word (`example.com`, `https:`, `10:30`) don't start the sentence the
|
||||
/// missing `¿` is offered for; ordinary questions are still checked.
|
||||
#[test]
|
||||
fn addresses_are_not_questions_or_sentence_starts() {
|
||||
for text in [
|
||||
"Ver https://example.com/?",
|
||||
"Ver https://example.com/? ¿Vienes?",
|
||||
"Ver http://example.com/buscar/? y www.example.com/?",
|
||||
"Escribí a ana@example.com!",
|
||||
"¿Viste https://example.com/?",
|
||||
] {
|
||||
assert!(missing(text).is_empty(), "{text}: {:?}", missing(text));
|
||||
}
|
||||
assert_eq!(
|
||||
missing("Mirá https://example.com/ y decime si vienes?"),
|
||||
[("Mirá https://example.com/ y decime si vienes?".to_string(), vec!["¿Mirá https://example.com/ y decime si vienes?".to_string()])]
|
||||
);
|
||||
assert_eq!(missing("Mirá example.com y decime si vienes?")[0].0, "Mirá example.com y decime si vienes?");
|
||||
assert_eq!(missing("Son las 10:30, vienes?")[0].0, "Son las 10:30, vienes?");
|
||||
assert_eq!(missing("Ver https://example.com/? Vienes mañana?"), [("Vienes mañana?".to_string(), vec!["¿Vienes mañana?".to_string()])]);
|
||||
assert_eq!(missing("Llegó. Vienes?")[0].0, "Vienes?", "a full stop before a space still ends a sentence");
|
||||
}
|
||||
|
||||
/// Maintainer review on #3562: a correctly opened question or exclamation is never flagged,
|
||||
/// whatever sits between its marks: quotes, an abbreviation's full stop, an ellipsis, a dash
|
||||
/// aside, parentheses, nested questions, a line break.
|
||||
#[test]
|
||||
fn correctly_opened_questions_are_not_flagged() {
|
||||
for text in [
|
||||
"¿Leíste «Rayuela»?",
|
||||
"¿Viene el Sr. García?",
|
||||
"¿Y si... no viene?",
|
||||
"¿Vendrás —preguntó Juan— mañana?",
|
||||
"¿Qué significa (aquí) la palabra?",
|
||||
"¿Has leído \"Rayuela\" de Cortázar?",
|
||||
"¿Leíste “Rayuela”?",
|
||||
"¿Es 'Rayuela' o 'Los premios'?",
|
||||
"¿Y tú, qué opinas… vienes?",
|
||||
"¿Dijo «¿qué?» o no?",
|
||||
"¿Viene la Dra. López, es decir, la jefa (la de 2.5 años)?",
|
||||
"¿Dónde: aquí o allí?",
|
||||
"¿Cuánto cuesta: 3,50 o a las 10:30?",
|
||||
"¿Vienes a las 10:30; o mejor mañana?",
|
||||
"¡Qué dices, Dr. Pérez!",
|
||||
"¡Qué dices, Dr. Pérez? ¡Basta!",
|
||||
"¡¡Hola!! ¿Qué tal?",
|
||||
"¡¡¡Basta!!!",
|
||||
"¿Vienes?!",
|
||||
"¿Qué?! ¡No!",
|
||||
"¿Vienes?\n¿O no?",
|
||||
"¿Vienes\nmañana?",
|
||||
"Ana dijo: «¿Vienes?» y yo: «Sí».",
|
||||
"—¿Vienes? —preguntó Juan.",
|
||||
"¿Viste https://example.com/? Dime.",
|
||||
"¿Es verdad (!) o no?",
|
||||
"Nació en 1950 (?) en Montevideo.",
|
||||
] {
|
||||
assert!(missing(text).is_empty(), "{text:?}: {:?}", missing(text));
|
||||
}
|
||||
}
|
||||
|
||||
/// A closing mark with nothing open is still flagged, after a sentence or a clause that closed
|
||||
/// an earlier question, and with the sentence it closes offered with its opening mark.
|
||||
#[test]
|
||||
fn closing_marks_with_nothing_open_are_still_flagged() {
|
||||
for (text, flagged, offered) in [
|
||||
("Vienes mañana?", "Vienes mañana?", "¿Vienes mañana?"),
|
||||
("Llegó. Vienes?", "Vienes?", "¿Vienes?"),
|
||||
("Le preguntó: vienes?", "vienes?", "¿vienes?"),
|
||||
("Dijo «vienes?»", "vienes?", "¿vienes?"),
|
||||
("Qué bien!", "Qué bien!", "¡Qué bien!"),
|
||||
("¿Vienes? No sé qué hacer?", "No sé qué hacer?", "¿No sé qué hacer?"),
|
||||
("¿Viene el Sr. García? Llegó tarde!", "Llegó tarde!", "¡Llegó tarde!"),
|
||||
("¡¡Hola!! Qué bien!", "Qué bien!", "¡Qué bien!"),
|
||||
("¿Qué?! No sé qué decir!", "No sé qué decir!", "¡No sé qué decir!"),
|
||||
] {
|
||||
let found = missing(text);
|
||||
assert_eq!(found.len(), 1, "{text:?}: {found:?}");
|
||||
assert_eq!((found[0].0.as_str(), found[0].1.first().map(String::as_str)), (flagged, Some(offered)), "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_lookup_finds_the_range_a_byte_is_in() {
|
||||
let ranges = [(2, 5), (7, 8), (10, 20)];
|
||||
let inside = |j| address_at(&ranges, j);
|
||||
assert_eq!([0, 1].map(inside), [None, None], "before the first");
|
||||
assert_eq!([2, 3, 4].map(inside), [Some((2, 5)); 3], "the start is inside, the end is not");
|
||||
assert_eq!([5, 6].map(inside), [None, None], "between ranges");
|
||||
assert_eq!([7, 10, 19].map(inside), [Some((7, 8)), Some((10, 20)), Some((10, 20))]);
|
||||
assert_eq!([8, 9, 20, 99].map(inside), [None; 4], "after a range, and after the last");
|
||||
assert_eq!(address_at(&[], 3), None);
|
||||
}
|
||||
|
||||
/// Maintainer review on #3562: a paragraph of thousands of addresses followed by a question was
|
||||
/// checked in quadratic time (every character before the question scanned every address).
|
||||
/// About 99 KB (under the 100,000-byte paragraph cap) took hundreds of milliseconds; it is now
|
||||
/// O(n log m): about 1 to 5 ms in a debug build, against about 300 ms for the scan, so the
|
||||
/// bound below leaves room on a loaded machine and still catches a return to the quadratic
|
||||
/// lookup.
|
||||
#[test]
|
||||
fn many_addresses_before_a_question_are_checked_quickly() {
|
||||
for (unit, ending, whole_paragraph) in
|
||||
[("https://x/ ", "Vienes?", true), ("https://x/? ", "Vienes?", false), ("www.x.com ", "Qué bien!", true)]
|
||||
{
|
||||
// As many as fit in about 99 KB: 9,000 of the first, as in the review.
|
||||
let text = format!("{}{ending}", unit.repeat(99_000 / unit.len()));
|
||||
assert!(text.len() < 100_000, "{}", text.len());
|
||||
let started = std::time::Instant::now();
|
||||
let issues = check(&text, ProofLang::Es);
|
||||
let took = started.elapsed();
|
||||
assert!(took < std::time::Duration::from_millis(75), "{unit:?} took {took:?}");
|
||||
let missing: Vec<&Issue> = issues.iter().filter(|i| i.message.starts_with("Falta")).collect();
|
||||
assert_eq!(missing.len(), 1, "{unit:?}: {missing:?}");
|
||||
// The mark's sentence is the whole paragraph when the addresses don't end with a mark
|
||||
// of their own, and only the last question when they do.
|
||||
let want_start = if whole_paragraph { 0 } else { text.len() - ending.len() };
|
||||
assert_eq!((missing[0].start, missing[0].end), (want_start, text.len()), "{unit:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A word repeated once composed and once decomposed is still repeated.
|
||||
#[test]
|
||||
fn repeated_words_match_across_normalization_forms() {
|
||||
let text = "también tambie\u{301}n";
|
||||
let issues = check(text, ProofLang::Es);
|
||||
assert_eq!(issues.len(), 1, "{issues:?}");
|
||||
assert_eq!((issues[0].start, issues[0].end), (0, text.len()));
|
||||
assert!(issues[0].message.starts_with("Palabra repetida"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_space_after_an_opening_mark_is_flagged() {
|
||||
let text = "¿ Vienes? ¡ Hola!";
|
||||
let issues = check(text, ProofLang::EsUy);
|
||||
let spaced: Vec<&str> =
|
||||
issues.iter().filter(|i| i.message == "Espacio después de un signo de apertura").map(|i| &text[i.start..i.end]).collect();
|
||||
assert_eq!(spaced, ["¿ ", "¡ "], "{issues:?}");
|
||||
assert!(issues.iter().all(|i| !i.message.starts_with("Falta")), "{issues:?}");
|
||||
assert!(check("¿ ", ProofLang::Es).is_empty());
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn never_panics_and_ranges_are_valid(s in "\\PC{0,80}|[¿¡?!a-z .,(«»):]{0,40}") {
|
||||
for issue in check(&s, ProofLang::Es) {
|
||||
proptest::prop_assert!(issue.start <= issue.end && issue.end <= s.len());
|
||||
proptest::prop_assert!(s.is_char_boundary(issue.start) && s.is_char_boundary(issue.end));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Spanish proofing (#3360, #3553) using the installed Hunspell dictionaries (never bundled;
|
||||
//! LibreOffice ships them as its `dict-es` extension, Debian and Ubuntu as `hunspell-es`). Each
|
||||
//! variety reads its own dictionary: Spain ([`ProofLang::Es`]) `es_ES`, Mexico
|
||||
//! ([`ProofLang::EsMx`]) `es_MX`, Latin America ([`ProofLang::Es419`]) `es_419` and Uruguay
|
||||
//! ([`ProofLang::EsUy`]) `es_UY`, each falling back to a nearby or generic one. Uruguayan Spanish
|
||||
//! also accepts the `vos` forms of every verb in the dictionary ([`voseo`]: `tenés`, `vení`,
|
||||
//! `decime`). Suggestions use the dictionary's own MAP/REP/TRY/KEY tables (the regional
|
||||
//! dictionaries' REP tables carry their differences, such as `vosotros` forms in Mexico).
|
||||
//! Grammar is limited to generic repeated-word and spacing checks and the Spanish opening
|
||||
//! question and exclamation marks, with Spanish messages; hyphenation keeps the English fallback
|
||||
//! (no Spanish patterns are bundled).
|
||||
|
||||
pub mod grammar;
|
||||
mod suggest;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_dict;
|
||||
pub mod voseo;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::ProofLang;
|
||||
use crate::affix::AffixDict;
|
||||
|
||||
pub use suggest::suggest;
|
||||
|
||||
/// The dictionary of each variety (Spain, Mexico, Latin America, Uruguay): `None` until first
|
||||
/// use, then the installed one (or an empty one).
|
||||
static DICTS: [RwLock<Option<Arc<AffixDict>>>; 4] = [const { RwLock::new(None) }; 4];
|
||||
|
||||
/// File names of a Spanish Hunspell dictionary for Spain, most specific first (`es_ANY` is the
|
||||
/// generic one RLA-ES publishes).
|
||||
const SPAIN: &[&str] = &["es_ES", "es-ES", "es_ANY", "es"];
|
||||
/// For Mexico: its own, then a Latin American or generic one.
|
||||
const MEXICO: &[&str] = &["es_MX", "es-MX", "es_419", "es-419", "es_ANY", "es"];
|
||||
/// For Latin America: its own or a generic one, then the United States' and Mexico's, which
|
||||
/// LibreOffice ships (it has no `es_419`) and which use Latin American spelling, then
|
||||
/// Argentina's, so `es-AR` text is checked when that is the only one installed.
|
||||
const LATIN_AMERICA: &[&str] = &["es_419", "es-419", "es_ANY", "es", "es_US", "es_MX", "es_AR", "es-AR"];
|
||||
/// For Uruguay: its own, then Argentina's (the same Rioplatense Spanish), then a Latin American
|
||||
/// or generic one.
|
||||
const URUGUAY: &[&str] = &["es_UY", "es-UY", "es_AR", "es-AR", "es_419", "es-419", "es_ANY", "es"];
|
||||
|
||||
/// `lang`'s variety: its slot in [`DICTS`], its English name (for the log) and its dictionary's
|
||||
/// file names; Spain for any other language.
|
||||
fn variety(lang: ProofLang) -> (usize, &'static str, &'static [&'static str]) {
|
||||
match lang {
|
||||
ProofLang::EsMx => (1, "Spanish (Mexico)", MEXICO),
|
||||
ProofLang::Es419 => (2, "Spanish (Latin America)", LATIN_AMERICA),
|
||||
ProofLang::EsUy => (3, "Spanish (Uruguay)", URUGUAY),
|
||||
_ => (0, "Spanish (Spain)", SPAIN),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Hunspell name each variety's dictionary is known by (`es_ES`, `es_MX`, `es_419`,
|
||||
/// `es_UY`): the first of its file names, used for the status-bar hint and load errors.
|
||||
pub fn dictionary_name(lang: ProofLang) -> &'static str {
|
||||
variety(lang).2.first().copied().unwrap_or("es_ES")
|
||||
}
|
||||
|
||||
/// The Spanish dictionary of `lang`'s variety: the installed one (read on first use), one given
|
||||
/// with [`install_dictionary`], or an empty one when none is available (spelling is off then;
|
||||
/// see [`has_dictionary`]).
|
||||
pub fn dictionary(lang: ProofLang) -> Arc<AffixDict> {
|
||||
let Some(slot) = DICTS.get(variety(lang).0) else { return Arc::new(AffixDict::default()) };
|
||||
if let Some(d) = slot.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed(lang));
|
||||
let mut slot = slot.write().unwrap_or_else(|e| e.into_inner());
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
|
||||
fn load_installed(lang: ProofLang) -> AffixDict {
|
||||
let (_, name, files) = variety(lang);
|
||||
let key = dictionary_name(lang);
|
||||
let Some((aff, dic)) = crate::hunspell::find(files) else {
|
||||
crate::hunspell::clear_load_error(key);
|
||||
log::info!("{name} spelling: no Hunspell {key} dictionary installed");
|
||||
return AffixDict::default();
|
||||
};
|
||||
crate::hunspell::note_load(key, &dic, crate::hunspell::load(&aff, &dic)).unwrap_or_else(|e| {
|
||||
log::warn!("{name} spelling: {e}");
|
||||
AffixDict::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Use this dictionary for `lang`'s variety from now on (Hunspell `.aff` and `.dic` text,
|
||||
/// decoded to UTF-8), for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(lang: ProofLang, aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
if let Some(slot) = DICTS.get(variety(lang).0) {
|
||||
*slot.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget every variety's loaded dictionary, so the next check looks for an installed one again
|
||||
/// (after [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
for slot in &DICTS {
|
||||
*slot.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.dic` file of the Hunspell dictionary `lang`'s variety finds in the dictionaries folder
|
||||
/// or the system locations now (File › Options › Proofing shows it); `None` when there is none.
|
||||
/// A dictionary given with [`install_dictionary`] has no file and isn't reported.
|
||||
pub fn installed_path(lang: ProofLang) -> Option<std::path::PathBuf> {
|
||||
crate::hunspell::find(variety(lang).2).map(|(_, dic)| dic)
|
||||
}
|
||||
|
||||
/// Is a dictionary available for `lang`'s variety (spelling is checked)?
|
||||
pub fn has_dictionary(lang: ProofLang) -> bool {
|
||||
!dictionary(lang).is_empty()
|
||||
}
|
||||
|
||||
/// Is `c` a letter of the Latin script (the only one the Spanish dictionaries cover)?
|
||||
fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c))
|
||||
}
|
||||
|
||||
/// Is `word` spelled correctly in `lang`'s Spanish? Numbers, single letters, short words in
|
||||
/// capitals (acronyms: `ONU`, `CDMX`), words with digits and words in other scripts are accepted,
|
||||
/// and so is every word when no dictionary is installed for the variety. Hyphenated compounds
|
||||
/// are checked part by part (`teórico-práctico`, `franco-mexicano`). In Uruguayan Spanish the
|
||||
/// `vos` forms of the dictionary's verbs are correct too ([`voseo`]).
|
||||
pub fn is_correct(word: &str, lang: ProofLang) -> bool {
|
||||
is_correct_with(&dictionary(lang), word, lang)
|
||||
}
|
||||
|
||||
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
|
||||
/// short words in capitals are checked against the dictionary too, and with Ignore words that
|
||||
/// contain numbers off, so are words with digits.
|
||||
pub fn is_correct_with_options(word: &str, lang: ProofLang, opts: &crate::Options) -> bool {
|
||||
check(&dictionary(lang), word, lang, opts)
|
||||
}
|
||||
|
||||
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
|
||||
pub fn is_correct_with(d: &AffixDict, word: &str, lang: ProofLang) -> bool {
|
||||
check(d, word, lang, &crate::Options::default())
|
||||
}
|
||||
|
||||
/// [`is_correct_with_options`] against a given dictionary.
|
||||
fn check(d: &AffixDict, word: &str, lang: ProofLang, opts: &crate::Options) -> bool {
|
||||
if d.is_empty() {
|
||||
return true; // no Spanish dictionary installed: no spelling marks
|
||||
}
|
||||
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
|
||||
let w = w.trim_matches(['\'', '’']);
|
||||
let n = w.chars().count();
|
||||
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
|
||||
return true;
|
||||
}
|
||||
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
|
||||
return true;
|
||||
}
|
||||
if !w.chars().any(is_latin_letter) {
|
||||
return true; // other scripts: no dictionary
|
||||
}
|
||||
if w.contains('-') {
|
||||
return known(d, w, lang) || w.split('-').all(|p| p.chars().count() <= 1 || known(d, p, lang));
|
||||
}
|
||||
known(d, w, lang)
|
||||
}
|
||||
|
||||
/// Is one word in the dictionary or the user dictionary, or (in Uruguayan Spanish) the `vos`
|
||||
/// form of one of the dictionary's verbs?
|
||||
fn known(d: &AffixDict, w: &str, lang: ProofLang) -> bool {
|
||||
d.check(w) || crate::in_user_dictionary(&w.to_lowercase()) || (lang == ProofLang::EsUy && voseo::is_vos_form(w, |inf| d.check(inf)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VARIETIES: [ProofLang; 4] = [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy];
|
||||
|
||||
#[test]
|
||||
fn each_variety_looks_for_its_own_dictionary_first() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-es-aliases-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (es, mx, la, uy) = (ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy);
|
||||
// Alone in the folder, each file name is found by the varieties that list it.
|
||||
for (name, found_by) in [
|
||||
("es_ES", &[es][..]),
|
||||
("es-ES", &[es]),
|
||||
("es_MX", &[mx, la]),
|
||||
("es-MX", &[mx]),
|
||||
("es_419", &[mx, la, uy]),
|
||||
("es-419", &[mx, la, uy]),
|
||||
("es_US", &[la]),
|
||||
("es_UY", &[uy]),
|
||||
("es-UY", &[uy]),
|
||||
("es_AR", &[la, uy]),
|
||||
("es-AR", &[la, uy]),
|
||||
("es_ANY", &VARIETIES),
|
||||
("es", &VARIETIES),
|
||||
] {
|
||||
std::fs::write(dir.join(format!("{name}.aff")), test_dict::AFF).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap();
|
||||
for lang in VARIETIES {
|
||||
let found = crate::hunspell::find_in(&dir, variety(lang).2);
|
||||
assert_eq!(found.is_some(), found_by.contains(&lang), "{name}: {lang:?}");
|
||||
if let Some((aff, dic)) = found {
|
||||
assert!(is_correct_with(&crate::hunspell::load(&aff, &dic).unwrap(), "canción", lang));
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(dir.join(format!("{name}.aff"))).unwrap();
|
||||
std::fs::remove_file(dir.join(format!("{name}.dic"))).unwrap();
|
||||
}
|
||||
// With them all, each variety reads its own.
|
||||
for name in ["es_ES", "es_MX", "es_419", "es_UY", "es_AR", "es_ANY"] {
|
||||
std::fs::write(dir.join(format!("{name}.aff")), test_dict::AFF).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap();
|
||||
}
|
||||
for (lang, name) in [(es, "es_ES"), (mx, "es_MX"), (la, "es_419"), (uy, "es_UY")] {
|
||||
assert_eq!(crate::hunspell::find_in(&dir, variety(lang).2).map(|(_, dic)| dic), Some(dir.join(format!("{name}.dic"))));
|
||||
assert_eq!(dictionary_name(lang), name);
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_words_affixes_case_and_hyphens() {
|
||||
test_dict::install();
|
||||
let d = dictionary(ProofLang::Es);
|
||||
assert!(has_dictionary(ProofLang::Es));
|
||||
assert!(d.rule_count() > 0);
|
||||
for w in [
|
||||
"canción",
|
||||
"canciones",
|
||||
"prueba",
|
||||
"pruebas",
|
||||
"corrector",
|
||||
"correctores",
|
||||
"también",
|
||||
"México",
|
||||
"Canción",
|
||||
"CANCIÓN",
|
||||
"teórico-práctico",
|
||||
"franco-mexicano",
|
||||
"ONU",
|
||||
"Año",
|
||||
"niño",
|
||||
"pingüino",
|
||||
] {
|
||||
assert!(is_correct(w, ProofLang::Es), "{w}");
|
||||
}
|
||||
for w in ["prueva", "corector", "tambien", "mexico", "cancion", "teórico-prático", "cAnción", "nino"] {
|
||||
assert!(!is_correct(w, ProofLang::Es), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn varieties_keep_their_own_dictionaries() {
|
||||
test_dict::install();
|
||||
// The Americas' fixture lacks `vosotros` forms and has American words; Spain's is the other way round.
|
||||
assert!(is_correct("habéis", ProofLang::Es) && !is_correct("habéis", ProofLang::EsMx));
|
||||
assert!(is_correct("platicar", ProofLang::EsMx) && !is_correct("platicar", ProofLang::Es));
|
||||
assert!(is_correct("platicar", ProofLang::Es419) && is_correct("computadora", ProofLang::Es419));
|
||||
assert!(!is_correct("habéis", ProofLang::Es419) && !is_correct("habéis", ProofLang::EsUy));
|
||||
assert!(is_correct("ordenador", ProofLang::Es) && !is_correct("ordenador", ProofLang::EsMx));
|
||||
}
|
||||
|
||||
/// Uruguayan Spanish accepts the `vos` forms of the dictionary's verbs, which the other
|
||||
/// varieties flag.
|
||||
#[test]
|
||||
fn uruguayan_spanish_accepts_vos_forms() {
|
||||
test_dict::install();
|
||||
for w in ["tenés", "vení", "Vení", "decime", "decímelo", "andate", "mirá", "hablás", "querés", "venís", "fijate"] {
|
||||
assert!(is_correct(w, ProofLang::EsUy), "{w}");
|
||||
assert!(!is_correct(w, ProofLang::Es419) && !is_correct(w, ProofLang::Es), "{w}");
|
||||
}
|
||||
for w in ["tenéz", "venì", "decimelo", "decíme", "zqbailá"] {
|
||||
assert!(!is_correct(w, ProofLang::EsUy), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Spanish checks
|
||||
/// those words against the dictionary instead of accepting them (CodeRabbit review on #3562).
|
||||
#[test]
|
||||
fn proofing_options_reach_the_spanish_check() {
|
||||
test_dict::install();
|
||||
let defaults = crate::Options::default();
|
||||
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
|
||||
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
|
||||
for lang in VARIETIES {
|
||||
assert!(crate::is_correct_in_with("PRUEVA", lang, &defaults), "{lang:?}: short capitals are acronyms by default");
|
||||
assert!(!crate::is_correct_in_with("PRUEVA", lang, &check_caps), "{lang:?}");
|
||||
assert!(crate::is_correct_in_with("PRUEBA", lang, &check_caps), "{lang:?}");
|
||||
assert!(crate::is_correct_in_with("prueva2", lang, &defaults), "{lang:?}");
|
||||
assert!(!crate::is_correct_in_with("prueva2", lang, &check_digits), "{lang:?}");
|
||||
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "{lang:?}: numbers alone stay correct");
|
||||
}
|
||||
assert!(is_correct("PRUEVA", ProofLang::Es), "direct checks keep the defaults");
|
||||
}
|
||||
|
||||
/// Maintainer review on #3562: a word typed decomposed (NFD: `e` + U+0301) is checked like its
|
||||
/// composed form (NFC) and keeps its own byte range, accents and all.
|
||||
#[test]
|
||||
fn decomposed_accents_are_checked_like_composed_ones() {
|
||||
test_dict::install();
|
||||
for lang in VARIETIES {
|
||||
for text in ["esto también", "esto tambie\u{301}n", "una cancio\u{301}n y pingu\u{308}ino", "CANCIO\u{301}N", "nin\u{303}o"] {
|
||||
let issues = crate::check_text(text, &[(0..text.len(), lang)]);
|
||||
assert!(issues.is_empty(), "{lang:?} {text:?}: {issues:?}");
|
||||
}
|
||||
// A misspelling that ends in a decomposed accent is flagged whole, at its own bytes.
|
||||
let text = "esto es una prueva tambie\u{301}nn";
|
||||
let issues = crate::check_text(text, &[(0..text.len(), lang)]);
|
||||
let flagged: Vec<(&str, usize, usize)> = issues.iter().map(|i| (&text[i.start..i.end], i.start, i.end)).collect();
|
||||
assert_eq!(flagged, [("prueva", 12, 18), ("tambie\u{301}nn", 19, text.len())], "{lang:?}");
|
||||
assert!(crate::suggest_in("tambie\u{301}nn", 8, lang).contains(&"también".into()), "{lang:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_words_are_rejected_in_every_case() {
|
||||
test_dict::install();
|
||||
for w in ["zqprohibida", "Zqprohibida", "ZQPROHIBIDA"] {
|
||||
assert!(!is_correct(w, ProofLang::Es), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_dictionary_and_ignored_inputs_are_quiet() {
|
||||
let empty = AffixDict::default();
|
||||
assert!(is_correct_with(&empty, "prueva", ProofLang::Es));
|
||||
assert!(install_dictionary(ProofLang::Es, "FLAG long\n", "1\ncasa\n").is_err());
|
||||
let d = AffixDict::from_hunspell(test_dict::AFF, test_dict::DIC, &[]).unwrap();
|
||||
for w in ["", "a", "2026", "EE", "1.º", "日本語", "-", "--"] {
|
||||
assert!(is_correct_with(&d, w, ProofLang::EsUy), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_dictionary_applies() {
|
||||
test_dict::install();
|
||||
assert!(!is_correct("zqpalabrapropia", ProofLang::EsMx));
|
||||
crate::add_word("Zqpalabrapropia");
|
||||
assert!(is_correct("zqpalabrapropia", ProofLang::EsMx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_dispatch_messages_options_and_mixed_spans() {
|
||||
test_dict::install();
|
||||
for tag in ["es-ES", "es-MX", "es-419", "es-AR", "es-UY", "spa"] {
|
||||
assert!(crate::has_dictionary(Some(tag)), "{tag}");
|
||||
}
|
||||
assert!(crate::is_correct_for("canción", Some("es-MX")));
|
||||
assert!(crate::is_correct_for("tenés", Some("es-UY")) && !crate::is_correct_for("tenés", Some("es-MX")));
|
||||
// The sentence from #3553.
|
||||
let text = "esto es una prueva del corector";
|
||||
for lang in VARIETIES {
|
||||
let issues = crate::check_text(text, &[(0..text.len(), lang)]);
|
||||
let flagged: Vec<&str> = issues.iter().map(|i| &text[i.start..i.end]).collect();
|
||||
assert_eq!(flagged, ["prueva", "corector"], "{lang:?}: {issues:?}");
|
||||
assert!(issues.iter().all(|i| i.message == "Posible error ortográfico" && i.lang == lang), "{issues:?}");
|
||||
assert!(crate::suggest_in("prueva", 8, lang).contains(&"prueba".into()), "{lang:?}");
|
||||
assert!(crate::suggest_in("corector", 8, lang).contains(&"corrector".into()), "{lang:?}");
|
||||
}
|
||||
let text = "Canción this is is wrong";
|
||||
let issues = crate::check_text(text, &[(0..8, ProofLang::Es419), (9..text.len(), ProofLang::En)]);
|
||||
assert!(issues.iter().any(|i| i.lang == ProofLang::En && i.message.starts_with("Repeated word")));
|
||||
assert!(issues.iter().all(|i| i.start >= 9));
|
||||
let opts = crate::Options { ignore_uppercase: false, ..Default::default() };
|
||||
assert!(!crate::is_correct_in_with("ZQPROHIBIDA", ProofLang::Es, &opts));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Spanish suggestions. The ranking is the dictionary-driven one Czech uses
|
||||
//! ([`crate::cs::suggest::suggest_in`]: case, MAP — the Spanish dictionaries map each vowel to
|
||||
//! its accented forms and `n` to `ñ`, so `tambien` → `también` comes first —, REP, missing spaces,
|
||||
//! KEY, TRY edits and a stem scan); every table comes from the variety's installed dictionary.
|
||||
//! In Uruguayan Spanish a `vos` form typed without its accent is offered with it first
|
||||
//! (`veni` → `vení`, `tenes` → `tenés`; [`super::voseo`]).
|
||||
|
||||
use crate::ProofLang;
|
||||
use crate::affix::{AffixDict, Casing, capitalize, casing};
|
||||
use crate::cs::suggest::MAX_LETTERS;
|
||||
|
||||
/// Spelling suggestions for a word in `lang`'s Spanish, best first, at most `max`, in the word's
|
||||
/// case.
|
||||
pub fn suggest(word: &str, max: usize, lang: ProofLang) -> Vec<String> {
|
||||
suggest_in(&super::dictionary(lang), word, max, lang)
|
||||
}
|
||||
|
||||
/// [`suggest`] against a given dictionary.
|
||||
pub fn suggest_in(d: &AffixDict, word: &str, max: usize, lang: ProofLang) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let word = word.trim();
|
||||
if lang == ProofLang::EsUy
|
||||
&& !d.is_empty()
|
||||
&& word.chars().count() <= MAX_LETTERS
|
||||
&& let Some(v) = super::voseo::accented(&word.to_lowercase(), |inf| d.check(inf))
|
||||
{
|
||||
out.push(match casing(word) {
|
||||
Casing::Upper => v.to_uppercase(),
|
||||
Casing::Initial => capitalize(&v),
|
||||
_ => v,
|
||||
});
|
||||
}
|
||||
for s in crate::cs::suggest::suggest_in(d, word, max) {
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
out.truncate(max);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spanish_suggestions_use_runtime_tables_and_case() {
|
||||
crate::es::test_dict::install();
|
||||
for lang in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] {
|
||||
for (word, want) in [
|
||||
("tambien", "también"),
|
||||
("cancion", "canción"),
|
||||
("Cancion", "Canción"),
|
||||
("mexico", "México"),
|
||||
("nino", "niño"),
|
||||
("pinguino", "pingüino"),
|
||||
("prueva", "prueba"),
|
||||
("corector", "corrector"),
|
||||
("estoes", "esto es"),
|
||||
] {
|
||||
assert!(suggest(word, 8, lang).contains(&want.into()), "{lang:?} {word}: {:?}", suggest(word, 8, lang));
|
||||
}
|
||||
}
|
||||
// The first suggestion for a missing accent is the accented word.
|
||||
assert_eq!(suggest("tambien", 4, ProofLang::Es).first().map(String::as_str), Some("también"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uruguayan_vos_forms_get_their_accent_back_first() {
|
||||
crate::es::test_dict::install();
|
||||
for (word, want) in [("veni", "vení"), ("tenes", "tenés"), ("Veni", "Vení"), ("TENES", "TENÉS"), ("queres", "querés")] {
|
||||
assert_eq!(suggest(word, 4, ProofLang::EsUy).first().map(String::as_str), Some(want), "{word}");
|
||||
assert!(!suggest(word, 8, ProofLang::Es419).contains(&want.into()), "{word}: no vos forms outside Uruguay");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions_stay_bounded_and_skip_forbidden_words() {
|
||||
crate::es::test_dict::install();
|
||||
for lang in [ProofLang::Es, ProofLang::EsUy] {
|
||||
for w in ["", "dos palabras", &"a".repeat(41)] {
|
||||
assert!(suggest(w, 4, lang).is_empty(), "{w}");
|
||||
}
|
||||
assert!(suggest("prueva", 0, lang).is_empty());
|
||||
assert!(suggest("veni", 0, lang).is_empty());
|
||||
assert!(suggest_in(&AffixDict::default(), "veni", 4, lang).is_empty());
|
||||
assert!(suggest("prueba", 4, lang).len() <= 4);
|
||||
assert!(!suggest("prueba", 4, lang).contains(&"prueba".into()));
|
||||
assert!(!suggest("zqprohibda", 8, lang).contains(&"zqprohibida".into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nosuggest_stems_and_forms_are_never_offered() {
|
||||
crate::affix::tests::assert_nosuggest_is_honoured(|d, w, max| suggest_in(d, w, max, ProofLang::Es));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Hand-written synthetic Spanish test data. No words, rules or suggestion tables were copied
|
||||
//! from a system dictionary. This is a small fixture, not a redistributable Spanish dictionary.
|
||||
//! Spain's word list has `vosotros` forms and Spain's words; the Americas' list (Mexico, Latin
|
||||
//! America and Uruguay) has theirs instead, so the tests can tell the varieties' dictionaries apart.
|
||||
|
||||
use crate::ProofLang;
|
||||
|
||||
pub const AFF: &str = "SET UTF-8
|
||||
FLAG UTF-8
|
||||
FORBIDDENWORD F
|
||||
TRY aeosrnidlctumpbgvyqhfzjñáéíóúüxkw
|
||||
KEY qwertyuiop|asdfghjklñ|zxcvbnm
|
||||
MAP 6
|
||||
MAP aá
|
||||
MAP eé
|
||||
MAP ií
|
||||
MAP oó
|
||||
MAP uúü
|
||||
MAP nñ
|
||||
REP 2
|
||||
REP v b
|
||||
REP b v
|
||||
SFX S Y 2 # synthetic plurals
|
||||
SFX S 0 s [aeiou]
|
||||
SFX S ón ones ón
|
||||
SFX E Y 1
|
||||
SFX E 0 es [^aeiou]
|
||||
";
|
||||
|
||||
/// Spain.
|
||||
pub const DIC: &str = "32
|
||||
hablar
|
||||
tener
|
||||
venir
|
||||
decir
|
||||
andar
|
||||
mirar
|
||||
querer
|
||||
fijar
|
||||
poner
|
||||
canción/S
|
||||
prueba/S
|
||||
corrector/E
|
||||
también
|
||||
México
|
||||
esto
|
||||
es
|
||||
una
|
||||
del
|
||||
de
|
||||
la
|
||||
el
|
||||
y
|
||||
niño/S
|
||||
pingüino/S
|
||||
año/S
|
||||
teórico
|
||||
práctico
|
||||
mexicano
|
||||
franco
|
||||
habéis
|
||||
ordenador/E
|
||||
zqprohibida/F
|
||||
";
|
||||
|
||||
/// Mexico, Latin America and Uruguay.
|
||||
pub const DIC_AMERICAS: &str = "33
|
||||
hablar
|
||||
tener
|
||||
venir
|
||||
decir
|
||||
andar
|
||||
mirar
|
||||
querer
|
||||
fijar
|
||||
poner
|
||||
canción/S
|
||||
prueba/S
|
||||
corrector/E
|
||||
también
|
||||
México
|
||||
esto
|
||||
es
|
||||
una
|
||||
del
|
||||
de
|
||||
la
|
||||
el
|
||||
y
|
||||
niño/S
|
||||
pingüino/S
|
||||
año/S
|
||||
teórico
|
||||
práctico
|
||||
mexicano
|
||||
franco
|
||||
platicar
|
||||
computadora/S
|
||||
han
|
||||
zqprohibida/F
|
||||
";
|
||||
|
||||
/// Install the fixture for every variety: [`DIC`] for Spain, [`DIC_AMERICAS`] for Mexico, Latin
|
||||
/// America and Uruguay.
|
||||
pub fn install() {
|
||||
for (lang, dic) in [(ProofLang::Es, DIC), (ProofLang::EsMx, DIC_AMERICAS), (ProofLang::Es419, DIC_AMERICAS), (ProofLang::EsUy, DIC_AMERICAS)] {
|
||||
if let Err(e) = super::install_dictionary(lang, AFF, dic) {
|
||||
panic!("test dictionary: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! The `vos` forms of verbs in Uruguayan Spanish ([`crate::ProofLang::EsUy`]), which dictionaries
|
||||
//! built around `tú` may lack (#3360). Rioplatense `voseo` builds them regularly from the
|
||||
//! infinitive, stress on the ending, without the stem changes `tú` has:
|
||||
//!
|
||||
//! - the imperative drops the infinitive's `r` and accents the vowel: `hablar` → `hablá`,
|
||||
//! `tener` → `tené`, `venir` → `vení`, `decir` → `decí` (`ir` uses `andá`, from `andar`);
|
||||
//! - with one pronoun after it the accent goes (`decime`, `fijate`, `andate`, `poneme`), with two
|
||||
//! it comes back (`decímelo`, `mandámela`); `-ír` verbs keep theirs (`sonreíte`);
|
||||
//! - the present indicative ends in `-ás`, `-és`, `-ís`: `hablás`, `tenés`, `venís`.
|
||||
//!
|
||||
//! A form is accepted only when the dictionary has its verb's infinitive, so nothing but these
|
||||
//! endings is hard-coded. Other `vos` forms (`sos`) are ordinary dictionary words.
|
||||
|
||||
/// Pronouns that come first when two follow an imperative (`decímelo`, `dáselo`).
|
||||
const FIRST_PRONOUNS: [&str; 4] = ["me", "te", "se", "nos"];
|
||||
/// Pronouns that come second (or alone).
|
||||
const SECOND_PRONOUNS: [&str; 6] = ["lo", "la", "le", "los", "las", "les"];
|
||||
|
||||
/// Shortest infinitive considered (`dar` and `ir` have no `vos` imperative of their own here, and
|
||||
/// short stems would let nouns such as `bar` pass for verbs).
|
||||
const MIN_INFINITIVE: usize = 4;
|
||||
|
||||
/// Is `word` (one word, any case) a `vos` form of a verb whose infinitive `is_word` accepts?
|
||||
pub fn is_vos_form(word: &str, is_word: impl Fn(&str) -> bool) -> bool {
|
||||
let w = word.to_lowercase();
|
||||
if w.chars().count() > crate::affix::MAX_WORD_BYTES {
|
||||
return false;
|
||||
}
|
||||
infinitives(&w).iter().any(|inf| inf.chars().count() >= MIN_INFINITIVE && is_word(inf))
|
||||
}
|
||||
|
||||
/// The infinitives `w` (lower case) could be a `vos` form of, by its ending.
|
||||
fn infinitives(w: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
// Present indicative: `hablás`, `tenés`, `venís`.
|
||||
if let Some(stem) = w.strip_suffix('s') {
|
||||
imperative(stem, &mut out);
|
||||
}
|
||||
// Imperative: `hablá`, `tené`, `vení`.
|
||||
imperative(w, &mut out);
|
||||
// With one pronoun the accent goes: `decime`, `fijate`; `-ír` verbs keep it: `sonreíte`.
|
||||
for p in FIRST_PRONOUNS.iter().chain(SECOND_PRONOUNS.iter()) {
|
||||
if let Some(base) = w.strip_suffix(p)
|
||||
&& base.ends_with(['a', 'e', 'i', 'í'])
|
||||
{
|
||||
out.push(format!("{base}r"));
|
||||
}
|
||||
}
|
||||
// With two the accent comes back: `decímelo`, `mandámela`.
|
||||
for second in SECOND_PRONOUNS {
|
||||
let Some(rest) = w.strip_suffix(second) else { continue };
|
||||
for first in FIRST_PRONOUNS {
|
||||
if let Some(base) = rest.strip_suffix(first) {
|
||||
imperative(base, &mut out);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The infinitives of an imperative ending in an accented vowel: `hablá` → `hablar`, and for `í`
|
||||
/// both `vení` → `venir` and `sonreí` → `sonreír`.
|
||||
fn imperative(form: &str, out: &mut Vec<String>) {
|
||||
let mut chars = form.chars();
|
||||
let Some(last) = chars.next_back() else { return };
|
||||
let stem = chars.as_str();
|
||||
match last {
|
||||
'á' => out.push(format!("{stem}ar")),
|
||||
'é' => out.push(format!("{stem}er")),
|
||||
'í' => {
|
||||
out.push(format!("{stem}ir"));
|
||||
out.push(format!("{stem}ír"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `vos` form `word` (lower case, without its accent) was most likely meant as, when the
|
||||
/// writer left the accent out: `veni` → `vení`, `tenes` → `tenés`. `None` when it isn't one.
|
||||
pub fn accented(word: &str, is_word: impl Fn(&str) -> bool) -> Option<String> {
|
||||
let (body, s) = match word.strip_suffix('s') {
|
||||
Some(b) => (b, "s"),
|
||||
None => (word, ""),
|
||||
};
|
||||
let mut chars = body.chars();
|
||||
let accent = match chars.next_back()? {
|
||||
'a' => 'á',
|
||||
'e' => 'é',
|
||||
'i' => 'í',
|
||||
_ => return None,
|
||||
};
|
||||
let form = format!("{}{accent}{s}", chars.as_str());
|
||||
is_vos_form(&form, is_word).then_some(form)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn verbs(w: &str) -> bool {
|
||||
["hablar", "tener", "venir", "decir", "andar", "mirar", "sonreír", "fijar", "mandar", "poner", "bar", "querer"].contains(&w)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imperatives_present_and_pronouns_come_from_the_infinitive() {
|
||||
for w in [
|
||||
"hablá",
|
||||
"tené",
|
||||
"vení",
|
||||
"decí",
|
||||
"andá",
|
||||
"mirá",
|
||||
"poné",
|
||||
"hablás",
|
||||
"tenés",
|
||||
"venís",
|
||||
"querés",
|
||||
"decime",
|
||||
"fijate",
|
||||
"andate",
|
||||
"poneme",
|
||||
"sonreíte",
|
||||
"sonreí",
|
||||
"decímelo",
|
||||
"mandámela",
|
||||
"decinos",
|
||||
"Vení",
|
||||
"TENÉS",
|
||||
] {
|
||||
assert!(is_vos_form(w, verbs), "{w}");
|
||||
}
|
||||
// Wrong accents, verbs the dictionary lacks, and short stems (`bame` isn't `bar`).
|
||||
for w in ["habla", "decíme", "decínos", "decimelo", "tenéz", "cantá", "bá", "bame", "", "s", "á", "més", "lo", "melo"] {
|
||||
assert!(!is_vos_form(w, verbs), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_accents_are_restored() {
|
||||
assert_eq!(accented("veni", verbs).as_deref(), Some("vení"));
|
||||
assert_eq!(accented("tenes", verbs).as_deref(), Some("tenés"));
|
||||
assert_eq!(accented("habla", verbs).as_deref(), Some("hablá"));
|
||||
for w in ["canta", "casa", "", "s", "pan", "tenés"] {
|
||||
assert_eq!(accented(w, verbs), None, "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn never_panics(s in "\\PC{0,40}") {
|
||||
let _ = is_vos_form(&s, |_| true);
|
||||
let _ = accented(&s, |_| true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -852,7 +852,7 @@ mod tests {
|
||||
#[test]
|
||||
fn languages_without_patterns_keep_the_english_ones() {
|
||||
for w in ["hyphenation", "typography", "internationalization"] {
|
||||
for lang in [ProofLang::Other, ProofLang::Cs, ProofLang::Id] {
|
||||
for lang in [ProofLang::Other, ProofLang::Cs, ProofLang::Id, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] {
|
||||
assert_eq!(hyphen_points_in(w, &Limits::default(), lang), hyphen_points(w, &Limits::default()), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
+95
-17
@@ -49,6 +49,23 @@ pub enum ProofLang {
|
||||
/// `id_ID` dictionary, dictionary-driven suggestions and generic checks with Indonesian
|
||||
/// messages. Hyphenation keeps the fallback English patterns.
|
||||
Id,
|
||||
/// Spanish of Spain (`es`, `es-ES`, and every region not listed for [`ProofLang::EsMx`],
|
||||
/// [`ProofLang::Es419`] or [`ProofLang::EsUy`]; also ISO 639-2 `spa`): the installed
|
||||
/// Hunspell `es_ES` dictionary (or a generic Spanish one), dictionary-driven suggestions and
|
||||
/// generic checks with Spanish messages ([`crate::es`]). Hyphenation keeps the fallback
|
||||
/// English patterns.
|
||||
Es,
|
||||
/// Mexican Spanish (`es-MX`): [`ProofLang::Es`]'s tools with the installed `es_MX`
|
||||
/// dictionary, else a Latin American or generic Spanish one.
|
||||
EsMx,
|
||||
/// Latin American Spanish (`es-419`, and the Spanish of the Americas other than Mexico and
|
||||
/// Uruguay: `es-AR`, `es-CO`, `es-US`…): [`ProofLang::Es`]'s tools with the installed `es_419`
|
||||
/// dictionary, else a generic or another Latin American Spanish one.
|
||||
Es419,
|
||||
/// Uruguayan Spanish (`es-UY`): [`ProofLang::Es`]'s tools with the installed `es_UY`
|
||||
/// dictionary (else the neighbouring Rioplatense `es_AR`, a Latin American or a generic one),
|
||||
/// and the `vos` forms of verbs (`tenés`, `vení`, `decime`) accepted ([`crate::es::voseo`]).
|
||||
EsUy,
|
||||
/// German (`de`, `de-DE`, `de-AT`, `de-CH`…): hyphenation by the German patterns (reformed
|
||||
/// spelling). There is no German dictionary or grammar yet, so, as for [`ProofLang::Other`],
|
||||
/// no spelling or grammar marks.
|
||||
@@ -62,7 +79,7 @@ pub enum ProofLang {
|
||||
|
||||
impl ProofLang {
|
||||
/// Every language with a spelling dictionary, in display order.
|
||||
pub const PROOFED: [ProofLang; 11] = [
|
||||
pub const PROOFED: [ProofLang; 15] = [
|
||||
ProofLang::En,
|
||||
ProofLang::EnGb,
|
||||
ProofLang::EnCa,
|
||||
@@ -74,11 +91,16 @@ impl ProofLang {
|
||||
ProofLang::Ru,
|
||||
ProofLang::Cs,
|
||||
ProofLang::Id,
|
||||
ProofLang::Es,
|
||||
ProofLang::EsMx,
|
||||
ProofLang::Es419,
|
||||
ProofLang::EsUy,
|
||||
];
|
||||
|
||||
/// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). The primary subtag
|
||||
/// counts, and for English the region too ([`ProofLang::english`]). No tag (or an empty one)
|
||||
/// is English, WordCraft's default editing language.
|
||||
/// counts, and for English and Spanish the region too ([`ProofLang::english`],
|
||||
/// [`ProofLang::spanish`]). No tag (or an empty one) is English, WordCraft's default editing
|
||||
/// language.
|
||||
pub fn from_tag(tag: Option<&str>) -> ProofLang {
|
||||
let Some(tag) = tag.map(str::trim).filter(|t| !t.is_empty()) else { return ProofLang::En };
|
||||
let primary = tag.split(['-', '_']).next().unwrap_or("");
|
||||
@@ -102,6 +124,8 @@ impl ProofLang {
|
||||
ProofLang::Cs
|
||||
} else if primary.eq_ignore_ascii_case("id") || primary.eq_ignore_ascii_case("ind") || primary.eq_ignore_ascii_case("in") {
|
||||
ProofLang::Id
|
||||
} else if primary.eq_ignore_ascii_case("es") || primary.eq_ignore_ascii_case("spa") {
|
||||
ProofLang::spanish(tag)
|
||||
} else if ["de", "deu", "ger"].iter().any(|c| primary.eq_ignore_ascii_case(c)) {
|
||||
ProofLang::De
|
||||
} else {
|
||||
@@ -109,31 +133,41 @@ impl ProofLang {
|
||||
}
|
||||
}
|
||||
|
||||
/// The English of an English tag, by its region (the first two-letter or three-digit subtag
|
||||
/// after the language and script, before any singleton; a POSIX `.UTF-8` or `@…` tail is ignored): Canada is
|
||||
/// [`ProofLang::EnCa`], regions that spell the British way are [`ProofLang::EnGb`], and the
|
||||
/// United States, any other region and no region are [`ProofLang::En`].
|
||||
/// The English of an English tag, by its [`region`]: Canada is [`ProofLang::EnCa`], regions
|
||||
/// that spell the British way are [`ProofLang::EnGb`], and the United States, any other
|
||||
/// region and no region are [`ProofLang::En`].
|
||||
fn english(tag: &str) -> ProofLang {
|
||||
let tag = tag.split(['.', '@']).next().unwrap_or("");
|
||||
// Subtags after a singleton (`x-…` private use, `u-…` extensions) aren't regions.
|
||||
let region = tag
|
||||
.split(['-', '_'])
|
||||
.skip(1)
|
||||
.take_while(|p| p.len() != 1)
|
||||
.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())));
|
||||
match region {
|
||||
match region(tag) {
|
||||
Some(r) if r.eq_ignore_ascii_case("CA") => ProofLang::EnCa,
|
||||
Some(r) if BRITISH_SPELLING.iter().any(|b| b.eq_ignore_ascii_case(r)) => ProofLang::EnGb,
|
||||
_ => ProofLang::En,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Spanish of a Spanish tag, by its [`region`]: Mexico is [`ProofLang::EsMx`], Uruguay
|
||||
/// [`ProofLang::EsUy`], Latin America (`419`) and the other countries of the Americas are
|
||||
/// [`ProofLang::Es419`], and Spain, any other region and no region are [`ProofLang::Es`].
|
||||
fn spanish(tag: &str) -> ProofLang {
|
||||
match region(tag) {
|
||||
Some(r) if r.eq_ignore_ascii_case("MX") => ProofLang::EsMx,
|
||||
Some(r) if r.eq_ignore_ascii_case("UY") => ProofLang::EsUy,
|
||||
Some(r) if LATIN_AMERICA.iter().any(|l| l.eq_ignore_ascii_case(r)) => ProofLang::Es419,
|
||||
_ => ProofLang::Es,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this one of the Englishes ([`ProofLang::En`], [`ProofLang::EnGb`], [`ProofLang::EnCa`])?
|
||||
pub fn is_english(self) -> bool {
|
||||
matches!(self, ProofLang::En | ProofLang::EnGb | ProofLang::EnCa)
|
||||
}
|
||||
|
||||
/// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`, `cs`, `id`, `de`); empty for [`ProofLang::Other`].
|
||||
/// Is this one of the Spanishes ([`ProofLang::Es`], [`ProofLang::EsMx`], [`ProofLang::Es419`],
|
||||
/// [`ProofLang::EsUy`])?
|
||||
pub fn is_spanish(self) -> bool {
|
||||
matches!(self, ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy)
|
||||
}
|
||||
|
||||
/// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`, `cs`, `id`, `de`, `es`); empty for [`ProofLang::Other`].
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa => "en",
|
||||
@@ -146,6 +180,7 @@ impl ProofLang {
|
||||
ProofLang::Ru => "ru",
|
||||
ProofLang::Cs => "cs",
|
||||
ProofLang::Id => "id",
|
||||
ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => "es",
|
||||
ProofLang::De => "de",
|
||||
ProofLang::Other => "",
|
||||
}
|
||||
@@ -157,6 +192,24 @@ impl ProofLang {
|
||||
}
|
||||
}
|
||||
|
||||
/// The region of a tag: the first two-letter or three-digit subtag after the language and
|
||||
/// script, before any singleton; a POSIX `.UTF-8` or `@…` tail is ignored.
|
||||
fn region(tag: &str) -> Option<&str> {
|
||||
let tag = tag.split(['.', '@']).next().unwrap_or("");
|
||||
// Subtags after a singleton (`x-…` private use, `u-…` extensions) aren't regions.
|
||||
tag.split(['-', '_'])
|
||||
.skip(1)
|
||||
.take_while(|p| p.len() != 1)
|
||||
.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())))
|
||||
}
|
||||
|
||||
/// Regions whose Spanish is Latin American ([`ProofLang::Es419`]): Latin America and the
|
||||
/// Caribbean (`419`) and its parts (South America `005`, Central America `013`, the Caribbean
|
||||
/// `029`), the Spanish-speaking countries of the Americas other than Mexico and Uruguay, and the
|
||||
/// United States.
|
||||
const LATIN_AMERICA: [&str; 22] =
|
||||
["419", "005", "013", "029", "AR", "BO", "CL", "CO", "CR", "CU", "DO", "EC", "GT", "HN", "NI", "PA", "PE", "PR", "PY", "SV", "US", "VE"];
|
||||
|
||||
/// Regions whose English uses British spelling ([`ProofLang::EnGb`]): the United Kingdom (and
|
||||
/// the non-standard `UK`), Ireland, Australia, New Zealand, South Africa, India and other
|
||||
/// Commonwealth countries, the Crown dependencies and Europe (`150`).
|
||||
@@ -286,7 +339,7 @@ mod tests {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::De, "{t}");
|
||||
}
|
||||
assert!(!ProofLang::De.is_proofed());
|
||||
for t in ["fr-FR", "dex", "x-none", "ja-JP", "plx", "bgx", "trx", "csx", "idx", "inx", "e", "-"] {
|
||||
for t in ["fr-FR", "dex", "x-none", "ja-JP", "plx", "bgx", "trx", "csx", "idx", "inx", "esx", "spax", "e", "-"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}");
|
||||
}
|
||||
assert_eq!(ProofLang::from_tag(None), ProofLang::En);
|
||||
@@ -313,4 +366,29 @@ mod tests {
|
||||
assert_eq!(ProofLang::Tr.code(), "tr");
|
||||
assert!(ProofLang::PROOFED.contains(&ProofLang::Pt), "every proofed language is listed");
|
||||
}
|
||||
|
||||
/// #3360, #3553: Spanish is proofed, by region: Spain (and no region), Mexico, Uruguay, and
|
||||
/// Latin America with the other countries of the Americas.
|
||||
#[test]
|
||||
fn spanish_tags_resolve_by_region() {
|
||||
for t in ["es", "es-ES", "ES_es", "es_ES.UTF-8", " es-ES ", "spa", "es-Latn-ES", "es-GQ", "es-PH", "es-x-mx", "es_ES@euro"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Es, "{t}");
|
||||
}
|
||||
for t in ["es-MX", "es_MX", "ES-mx", "es_MX.UTF-8", "spa-MX", "es-Latn-MX"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::EsMx, "{t}");
|
||||
}
|
||||
for t in ["es-419", "es_419", "es-AR", "es-CO", "es_CL.UTF-8", "es-US", "ES-ve", "es-PR", "es-Latn-419", "es-005", "spa-PE"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Es419, "{t}");
|
||||
}
|
||||
for t in ["es-UY", "es_UY", "ES-uy", "es_UY.UTF-8", "spa-UY", "es-Latn-UY"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::EsUy, "{t}");
|
||||
}
|
||||
for l in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] {
|
||||
assert!(l.is_spanish() && l.is_proofed() && ProofLang::PROOFED.contains(&l), "{l:?}");
|
||||
assert!(!l.is_english());
|
||||
assert_eq!(l.code(), "es");
|
||||
}
|
||||
assert!(!ProofLang::En.is_spanish() && !ProofLang::Pt.is_spanish() && !ProofLang::Other.is_spanish());
|
||||
assert_eq!(normalize_tag("ES_mx").as_deref(), Some("es-MX"));
|
||||
}
|
||||
}
|
||||
+181
-27
@@ -34,6 +34,11 @@
|
||||
//! - **Indonesian** ([`ProofLang::Id`], module [`id`]): the installed Hunspell `id_ID`
|
||||
//! dictionary (not bundled), dictionary-driven suggestions and generic checks with Indonesian
|
||||
//! messages. Hyphenation uses the existing English fallback.
|
||||
//! - **Spanish** ([`ProofLang::Es`], [`ProofLang::EsMx`], [`ProofLang::Es419`], [`ProofLang::EsUy`],
|
||||
//! module [`es`]): each variety's installed Hunspell dictionary (`es_ES`, `es_MX`, `es_419`,
|
||||
//! `es_UY`, with fallbacks; not bundled), dictionary-driven suggestions, Uruguayan `vos` forms,
|
||||
//! and generic checks plus the opening `¿`/`¡` with Spanish messages. Hyphenation uses the
|
||||
//! existing English fallback.
|
||||
//! - **German** ([`ProofLang::De`]): hyphenation by `hyph-utf8`'s German patterns (reformed
|
||||
//! spelling, MIT); no spelling or grammar marks yet.
|
||||
//! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without
|
||||
@@ -49,6 +54,7 @@ pub mod bg;
|
||||
pub mod cs;
|
||||
pub mod dict;
|
||||
pub mod en_variants;
|
||||
pub mod es;
|
||||
pub mod hr;
|
||||
pub mod hunspell;
|
||||
pub mod hyphen;
|
||||
@@ -114,16 +120,24 @@ fn user_words() -> &'static RwLock<HashSet<String>> {
|
||||
/// Bumped whenever the user dictionary changes, so cached proofing results can be dropped.
|
||||
static DICTIONARY_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The key a word has in the user dictionary and the Ignore All list: lower case and composed
|
||||
/// (NFC), as spelling looks words up ([`composed`]). A word added, ignored, removed or loaded
|
||||
/// decomposed (`zqprue\u{301}ba`) is then the same entry as its composed form (`zqpruéba`), and
|
||||
/// the other way round (#3562 review).
|
||||
fn dictionary_key(w: &str) -> String {
|
||||
composed(&w.to_lowercase()).into_owned()
|
||||
}
|
||||
|
||||
/// Add a word to the user dictionary ("Add to Dictionary"). It applies to every language.
|
||||
pub fn add_word(w: &str) {
|
||||
if user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(w.to_lowercase()) {
|
||||
if user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(dictionary_key(w)) {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a word out of the user dictionary (File › Options › Proofing). Returns whether it was there.
|
||||
pub fn remove_word(w: &str) -> bool {
|
||||
let removed = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&w.to_lowercase());
|
||||
let removed = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&dictionary_key(w));
|
||||
if removed {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -138,7 +152,7 @@ fn ignored_words() -> &'static RwLock<HashSet<String>> {
|
||||
/// Ignore a word everywhere until the app closes ("Ignore All"). Unlike [`add_word`] it isn't
|
||||
/// part of the user dictionary: it is never listed, saved or loaded, as in Word.
|
||||
pub fn ignore_word(w: &str) {
|
||||
if ignored_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(w.to_lowercase()) {
|
||||
if ignored_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(dictionary_key(w)) {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +170,7 @@ pub fn set_dictionaries_folder(dir: Option<std::path::PathBuf>) {
|
||||
ru::reload_dictionary();
|
||||
cs::reload_dictionary();
|
||||
id::reload_dictionary();
|
||||
es::reload_dictionary();
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -171,6 +186,10 @@ pub fn installed_dictionaries() -> Vec<(ProofLang, Option<std::path::PathBuf>)>
|
||||
(ProofLang::Ru, ru::installed_path()),
|
||||
(ProofLang::Cs, cs::installed_path()),
|
||||
(ProofLang::Id, id::installed_path()),
|
||||
(ProofLang::Es, es::installed_path(ProofLang::Es)),
|
||||
(ProofLang::EsMx, es::installed_path(ProofLang::EsMx)),
|
||||
(ProofLang::Es419, es::installed_path(ProofLang::Es419)),
|
||||
(ProofLang::EsUy, es::installed_path(ProofLang::EsUy)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -185,6 +204,7 @@ pub fn dictionary_load_error(lang: ProofLang) -> Option<String> {
|
||||
ProofLang::Hr => "hr_HR",
|
||||
ProofLang::Tr => "tr_TR",
|
||||
ProofLang::Id => "id_ID",
|
||||
ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::dictionary_name(lang),
|
||||
_ => return None,
|
||||
};
|
||||
hunspell::load_error(key).map(|(_, reason)| reason)
|
||||
@@ -196,10 +216,12 @@ pub fn dictionary_generation() -> u64 {
|
||||
DICTIONARY_GENERATION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Is `lower` (a lower-case word) in the user dictionary or ignored?
|
||||
/// Is `lower` (a lower-case word) in the user dictionary or ignored? It is looked up composed, as
|
||||
/// the entries are kept ([`dictionary_key`]).
|
||||
pub(crate) fn in_user_dictionary(lower: &str) -> bool {
|
||||
user_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(lower)
|
||||
|| ignored_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(lower)
|
||||
let key = composed(lower);
|
||||
user_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&*key)
|
||||
|| ignored_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&*key)
|
||||
}
|
||||
|
||||
/// Words in the user dictionary.
|
||||
@@ -228,7 +250,7 @@ pub fn custom_word(w: &str) -> Option<String> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(w.to_lowercase())
|
||||
Some(dictionary_key(w))
|
||||
}
|
||||
|
||||
/// The valid words of the custom-dictionary file at `path`, reading at most
|
||||
@@ -275,7 +297,7 @@ pub fn load_user_dictionary(path: &std::path::Path) -> Vec<String> {
|
||||
/// out. The file is replaced atomically: written beside it, then renamed over it. Errors (a
|
||||
/// read-only folder, a full disk) go back to the caller; the in-memory words are kept.
|
||||
pub fn save_user_dictionary(path: &std::path::Path, removed: &[String]) -> std::io::Result<()> {
|
||||
let removed: HashSet<String> = removed.iter().map(|w| w.to_lowercase()).collect();
|
||||
let removed: HashSet<String> = removed.iter().map(|w| dictionary_key(w)).collect();
|
||||
let on_disk = read_dictionary_file(path);
|
||||
{
|
||||
let mut set = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -334,9 +356,7 @@ fn known_core(w: &str) -> bool {
|
||||
if base_known(d, w) || COMMON.split(' ').any(|c| c == w) {
|
||||
return true;
|
||||
}
|
||||
if user_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(w)
|
||||
|| ignored_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(w)
|
||||
{
|
||||
if in_user_dictionary(w) {
|
||||
return true;
|
||||
}
|
||||
// Inflections.
|
||||
@@ -427,11 +447,13 @@ fn dictionary_loaded(lang: ProofLang) -> Option<bool> {
|
||||
ProofLang::Ru => ru::has_dictionary(),
|
||||
ProofLang::Cs => cs::has_dictionary(),
|
||||
ProofLang::Id => id::has_dictionary(),
|
||||
l if l.is_spanish() => es::has_dictionary(l),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The Hunspell name `lang` needs when `installed` is false (the first name its module looks for).
|
||||
/// The Hunspell name `lang` needs when `installed` is false (the first name its module looks for;
|
||||
/// a Spanish variety's is its own: `es_ES`, `es_MX`, `es_419`, `es_UY`).
|
||||
fn dictionary_needed(lang: ProofLang, installed: bool) -> Option<&'static str> {
|
||||
match lang {
|
||||
_ if installed => None,
|
||||
@@ -443,6 +465,7 @@ fn dictionary_needed(lang: ProofLang, installed: bool) -> Option<&'static str> {
|
||||
ProofLang::Ru => Some("ru_RU"),
|
||||
ProofLang::Cs => Some("cs_CZ"),
|
||||
ProofLang::Id => Some("id_ID"),
|
||||
l if l.is_spanish() => Some(es::dictionary_name(l)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -623,11 +646,12 @@ fn damerau(a: &[char], b: &str) -> usize {
|
||||
prev.get(m).copied().unwrap_or(9)
|
||||
}
|
||||
|
||||
/// Words in `text`: (byte start, byte end).
|
||||
/// Words in `text`: (byte start, byte end). Combining marks belong to the word they follow, so a
|
||||
/// word typed decomposed (`tambie\u{301}n`, NFD) is one word, as its precomposed form is.
|
||||
pub fn words(text: &str) -> Vec<(usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
let is_w = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
|
||||
let is_w = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-' || unicode_normalization::char::is_combining_mark(c);
|
||||
for (i, c) in text.char_indices() {
|
||||
match (start, is_w(c)) {
|
||||
(None, true) => start = Some(i),
|
||||
@@ -693,10 +717,17 @@ fn spelling_issues(text: &str, lang: impl Fn(usize) -> ProofLang, opts: &Options
|
||||
/// The whitespace-delimited token enclosing the word at `a..b` of `text`: its byte range, and
|
||||
/// whether it looks like a web or e-mail address.
|
||||
fn enclosing_token(text: &str, a: usize, b: usize) -> (usize, usize, bool) {
|
||||
let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0);
|
||||
// The token starts after the last white-space character, whatever its width (a no-break
|
||||
// space is two bytes and an ideographic space is three).
|
||||
let ts = text.get(..a).and_then(|t| t.char_indices().rev().find(|(_, c)| c.is_whitespace())).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
|
||||
let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len());
|
||||
let token = text.get(ts..te).unwrap_or("");
|
||||
(ts, te, token.contains("://") || token.contains('@') || token.starts_with("www."))
|
||||
(ts, te, text.get(ts..te).is_some_and(is_address))
|
||||
}
|
||||
|
||||
/// Does a whitespace-delimited token look like a web or e-mail address (`https://…`, `www.…`,
|
||||
/// `a@b.org`)? Spelling skips its words, and Spanish grammar its punctuation.
|
||||
pub(crate) fn is_address(token: &str) -> bool {
|
||||
token.contains("://") || token.contains('@') || token.starts_with("www.")
|
||||
}
|
||||
|
||||
/// The spelling issue of the word at `a..b` of `text`, if it is misspelled in `lang`. Words in
|
||||
@@ -717,6 +748,7 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option
|
||||
ProofLang::Ru => "Возможна орфографическая ошибка",
|
||||
ProofLang::Cs => "Možná pravopisná chyba",
|
||||
ProofLang::Id => "Kemungkinan salah eja",
|
||||
ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => "Posible error ortográfico",
|
||||
_ => "Possible spelling mistake",
|
||||
};
|
||||
Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang })
|
||||
@@ -727,8 +759,21 @@ pub fn is_correct_in(word: &str, lang: ProofLang) -> bool {
|
||||
is_correct_in_with(word, lang, &Options::default())
|
||||
}
|
||||
|
||||
/// [`is_correct_in`] with the File › Options › Proofing choices.
|
||||
/// `word` composed (NFC), as the dictionaries spell their words: `tambie\u{301}n` is looked up as
|
||||
/// `también`. Words already composed (all ASCII ones among them) are borrowed as they are.
|
||||
fn composed(word: &str) -> std::borrow::Cow<'_, str> {
|
||||
use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
|
||||
if word.is_ascii() || is_nfc_quick(word.chars()) == IsNormalized::Yes {
|
||||
std::borrow::Cow::Borrowed(word)
|
||||
} else {
|
||||
std::borrow::Cow::Owned(word.nfc().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// [`is_correct_in`] with the File › Options › Proofing choices. A word with combining marks is
|
||||
/// checked as its composed form ([`composed`]).
|
||||
pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool {
|
||||
let word = &*composed(word);
|
||||
match lang {
|
||||
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa => is_correct_english(word, lang, opts),
|
||||
ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct_with_options(word, opts),
|
||||
@@ -737,14 +782,19 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool {
|
||||
ProofLang::It => accepted_by_options(word, opts) || it::is_correct_with_options(word, opts),
|
||||
ProofLang::Tr => accepted_by_options(word, opts) || tr::is_correct(word),
|
||||
ProofLang::Ru => accepted_by_options(word, opts) || ru::is_correct(word),
|
||||
ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => {
|
||||
accepted_by_options(word, opts) || es::is_correct_with_options(word, lang, opts)
|
||||
}
|
||||
ProofLang::Cs => accepted_by_options(word, opts) || cs::is_correct_with_options(word, opts),
|
||||
ProofLang::Id => accepted_by_options(word, opts) || id::is_correct_with_options(word, opts),
|
||||
ProofLang::Bg | ProofLang::De | ProofLang::Other => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spelling suggestions for a word misspelled in `lang` (best first, at most `max`).
|
||||
/// Spelling suggestions for a word misspelled in `lang` (best first, at most `max`), composed
|
||||
/// ([`composed`]) like the dictionaries' words.
|
||||
pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec<String> {
|
||||
let word = &*composed(word);
|
||||
match lang {
|
||||
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa => suggest_english(word, max, lang),
|
||||
ProofLang::Pl => pl::suggest(word, max),
|
||||
@@ -755,13 +805,15 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec<String> {
|
||||
ProofLang::Ru => ru::suggest(word, max),
|
||||
ProofLang::Cs => cs::suggest(word, max),
|
||||
ProofLang::Id => id::suggest(word, max),
|
||||
ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::suggest(word, max, lang),
|
||||
ProofLang::Bg | ProofLang::De | ProofLang::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones
|
||||
/// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), the Italian ones ([`it::grammar`]),
|
||||
/// the Turkish ones ([`tr::grammar`]), Czech and Indonesian generic checks ([`cs::grammar`], [`id::grammar`]), or none for a language WordCraft doesn't proof.
|
||||
/// the Turkish ones ([`tr::grammar`]), Czech and Indonesian generic checks ([`cs::grammar`], [`id::grammar`]), the Spanish ones
|
||||
/// ([`es::grammar`]), or none for a language WordCraft doesn't proof.
|
||||
pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec<Issue> {
|
||||
grammar_in(text, lang, true)
|
||||
}
|
||||
@@ -781,6 +833,8 @@ fn grammar_in(text: &str, lang: ProofLang, capital_first: bool) -> Vec<Issue> {
|
||||
// Czech has no sentence-capital rule, so a list item needs no exception.
|
||||
ProofLang::Cs => cs::grammar::check(text),
|
||||
ProofLang::Id => id::grammar::check(text),
|
||||
// Spanish has no sentence-capital rule either.
|
||||
ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::grammar::check(text, lang),
|
||||
ProofLang::Bg | ProofLang::De | ProofLang::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -978,6 +1032,11 @@ mod tests {
|
||||
(ProofLang::Ru, "ru_RU"),
|
||||
(ProofLang::Cs, "cs_CZ"),
|
||||
(ProofLang::Id, "id_ID"),
|
||||
// #3360: each Spanish variety names its own.
|
||||
(ProofLang::Es, "es_ES"),
|
||||
(ProofLang::EsMx, "es_MX"),
|
||||
(ProofLang::Es419, "es_419"),
|
||||
(ProofLang::EsUy, "es_UY"),
|
||||
] {
|
||||
assert_eq!(dictionary_needed(l, false), Some(name), "{l:?}");
|
||||
assert_eq!(dictionary_needed(l, true), None, "{l:?}");
|
||||
@@ -993,19 +1052,24 @@ mod tests {
|
||||
// Installed dictionaries and the built-in English list: no hint.
|
||||
pl::test_dict::install();
|
||||
pt::test_dict::install();
|
||||
for tag in [Some("pl-PL"), Some("pt-BR"), Some("en-US"), Some("fr-FR"), None] {
|
||||
es::test_dict::install();
|
||||
for tag in [Some("pl-PL"), Some("pt-BR"), Some("es-ES"), Some("es-MX"), Some("es-419"), Some("es-UY"), Some("en-US"), Some("fr-FR"), None] {
|
||||
assert_eq!(missing_dictionary(tag), None, "{tag:?}");
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Held by tests that swap or rely on the shared Spanish dictionary, so a folder reset can't land mid-test.
|
||||
static DICTIONARY_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// #1063: the dictionaries folder is searched before the system locations; the dictionaries
|
||||
/// in it are reported and used, and cached checks are dropped. The folder holds the same test
|
||||
/// dictionaries the other tests install, so tests running meanwhile see the same words; it
|
||||
/// stays set for the rest of the run for that reason.
|
||||
#[test]
|
||||
fn dictionaries_folder_is_searched_first() {
|
||||
let _guard = DICTIONARY_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let dir = std::env::temp_dir().join(format!("wc-proof-dictionaries-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name, aff, dic) in [
|
||||
@@ -1016,6 +1080,10 @@ mod tests {
|
||||
("ru_RU", ru::test_dict::AFF, ru::test_dict::DIC),
|
||||
("cs_CZ", cs::test_dict::AFF, cs::test_dict::DIC),
|
||||
("id_ID", id::test_dict::AFF, id::test_dict::DIC),
|
||||
("es_ES", es::test_dict::AFF, es::test_dict::DIC),
|
||||
("es_MX", es::test_dict::AFF, es::test_dict::DIC_AMERICAS),
|
||||
("es_419", es::test_dict::AFF, es::test_dict::DIC_AMERICAS),
|
||||
("es_UY", es::test_dict::AFF, es::test_dict::DIC_AMERICAS),
|
||||
] {
|
||||
std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap();
|
||||
@@ -1028,10 +1096,19 @@ mod tests {
|
||||
let before = dictionary_generation();
|
||||
set_dictionaries_folder(Some(dir.clone()));
|
||||
assert!(dictionary_generation() > before, "cached checks are dropped");
|
||||
// Turkish has no test dictionary to put in the folder; Indonesian is listed after it.
|
||||
// Turkish has no test dictionary to put in the folder; Russian, Czech, Indonesian and the Spanishes follow it.
|
||||
let found = installed_dictionaries();
|
||||
assert_eq!(found.get(4).map(|(lang, _)| *lang), Some(ProofLang::Tr));
|
||||
assert_eq!(found.last(), Some(&(ProofLang::Id, Some(dir.join("id_ID.dic")))));
|
||||
assert_eq!(
|
||||
found[7..],
|
||||
[
|
||||
(ProofLang::Id, Some(dir.join("id_ID.dic"))),
|
||||
(ProofLang::Es, Some(dir.join("es_ES.dic"))),
|
||||
(ProofLang::EsMx, Some(dir.join("es_MX.dic"))),
|
||||
(ProofLang::Es419, Some(dir.join("es_419.dic"))),
|
||||
(ProofLang::EsUy, Some(dir.join("es_UY.dic"))),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
found[..4],
|
||||
[
|
||||
@@ -1050,6 +1127,7 @@ mod tests {
|
||||
// would race them for no reason this test needs to care about.
|
||||
assert_eq!(found[5..7].iter().map(|(lang, _)| *lang).collect::<Vec<_>>(), [ProofLang::Ru, ProofLang::Cs], "{found:?}");
|
||||
assert!(is_correct_in("chrząszcz", ProofLang::Pl) && !is_correct_in("chrzonszcz", ProofLang::Pl));
|
||||
assert!(is_correct_in("tenés", ProofLang::EsUy) && !is_correct_in("prueva", ProofLang::EsUy));
|
||||
assert!(is_correct_in("дом", ProofLang::Ru) && !is_correct_in("дум", ProofLang::Ru));
|
||||
}
|
||||
|
||||
@@ -1431,6 +1509,18 @@ mod tests {
|
||||
assert_eq!(bad.last(), Some(&"wugwug"), "{bad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_after_a_wide_space_is_an_internet_token() {
|
||||
// A web or e-mail address after a no-break space or an ideographic space is skipped,
|
||||
// as it is after a plain space.
|
||||
let t = "see\u{a0}https://xyzq.example/blorp and\u{3000}www.qwirpy.example or\u{a0}zxq@mail.example wugwug";
|
||||
let bad: Vec<&str> = check_spelling(t).iter().map(|i| &t[i.start..i.end]).collect();
|
||||
assert_eq!(bad, vec!["wugwug"]);
|
||||
let opts = Options { ignore_internet: false, ..Options::default() };
|
||||
let bad: Vec<&str> = check_spelling_with(t, &opts).iter().map(|i| &t[i.start..i.end]).collect();
|
||||
assert!(["xyzq", "blorp", "qwirpy", "zxq", "wugwug"].iter().all(|w| bad.contains(w)), "{bad:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spelling_scales_over_one_long_token() {
|
||||
// #1335: thousands of punctuation-separated words inside a single
|
||||
@@ -1593,7 +1683,7 @@ mod tests {
|
||||
for l in [None, Some(""), Some(" "), Some("en"), Some("en-US"), Some("en-GB"), Some("EN-us"), Some("en_US")] {
|
||||
assert!(has_dictionary(l), "{l:?}");
|
||||
}
|
||||
for l in [Some("lt-LT"), Some("fr-FR"), Some("de"), Some("uk"), Some("es"), Some("-US"), Some("e")] {
|
||||
for l in [Some("lt-LT"), Some("fr-FR"), Some("de"), Some("uk"), Some("-US"), Some("e")] {
|
||||
assert!(!has_dictionary(l), "{l:?}");
|
||||
}
|
||||
// The gate follows the dictionaries, not English: every proofed language is proofed.
|
||||
@@ -1603,6 +1693,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Combining marks stay in their word (#3562 review): a decomposed word is one word, with the
|
||||
/// byte range of what was typed, and it is checked as its composed form in every language.
|
||||
#[test]
|
||||
fn decomposed_words_stay_whole() {
|
||||
let text = "tambie\u{301}n, cafe\u{301} \u{301}x";
|
||||
let ws: Vec<&str> = words(text).into_iter().map(|(a, b)| &text[a..b]).collect();
|
||||
assert_eq!(ws, ["tambie\u{301}n", "cafe\u{301}", "\u{301}x"]);
|
||||
assert_eq!(words("tambie\u{301}n"), [(0, 9)]);
|
||||
assert_eq!(&*composed("tambie\u{301}n"), "también");
|
||||
assert!(matches!(composed("también"), std::borrow::Cow::Borrowed(_)) && matches!(composed("plain"), std::borrow::Cow::Borrowed(_)));
|
||||
pt::test_dict::install();
|
||||
assert!(is_correct_in("corac\u{327}a\u{303}o", ProofLang::Pt), "Portuguese gets the same composition");
|
||||
}
|
||||
|
||||
/// #3562 review: Add to Dictionary, Ignore All, removal and the dictionary file use the same
|
||||
/// composed key as spelling, so a word added or ignored decomposed is accepted in both forms,
|
||||
/// an NFD entry in a saved file loads (and saves) composed, and either form removes it.
|
||||
#[test]
|
||||
fn user_dictionary_and_ignore_all_match_both_normalization_forms() {
|
||||
let _guard = DICTIONARY_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
es::test_dict::install();
|
||||
let es = ProofLang::Es;
|
||||
let (nfd, nfc) = ("zqprue\u{301}ba", "zqpruéba");
|
||||
let text = format!("esto {nfd}");
|
||||
let issues = check_text(&text, &[(0..text.len(), es)]);
|
||||
assert_eq!(issues.iter().map(|i| (i.start, i.end)).collect::<Vec<_>>(), [(5, text.len())], "flagged at the typed bytes");
|
||||
assert!(!is_correct_in(nfc, es));
|
||||
add_word(nfd);
|
||||
assert!(is_correct_in(nfd, es) && is_correct_in(nfc, es));
|
||||
assert!(check_text(&text, &[(0..text.len(), es)]).is_empty());
|
||||
assert!(user_dictionary().contains(&nfc.to_string()) && !user_dictionary().contains(&nfd.to_string()));
|
||||
assert!(remove_word(nfc), "the composed form removes the entry added decomposed");
|
||||
assert!(!is_correct_in(nfd, es) && !is_correct_in(nfc, es));
|
||||
// Ignore All, either way round.
|
||||
ignore_word("zqigno\u{301}ro");
|
||||
assert!(is_correct_in("zqignóro", es) && is_correct_in("zqigno\u{301}ro", es));
|
||||
ignore_word("zqcallé");
|
||||
assert!(is_correct_in("zqcalle\u{301}", es));
|
||||
// A dictionary file with a decomposed entry loads composed and is saved composed.
|
||||
let path = std::env::temp_dir().join(format!("wc-nfd-dictionary-{}.txt", std::process::id()));
|
||||
std::fs::write(&path, "zqguarda\u{301}do\nzqotra\n").unwrap();
|
||||
let added = load_user_dictionary(&path);
|
||||
assert!(added.contains(&"zqguardádo".to_string()) && !added.iter().any(|w| w.contains('\u{301}')), "{added:?}");
|
||||
assert!(is_correct_in("zqguardádo", es) && is_correct_in("zqguarda\u{301}do", es));
|
||||
save_user_dictionary(&path, &["zqotra".to_string()]).unwrap();
|
||||
let saved = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(saved.lines().any(|l| l == "zqguardádo") && !saved.contains('\u{301}') && !saved.lines().any(|l| l == "zqotra"), "{saved}");
|
||||
// Removing the decomposed form takes out the composed entry, and the file follows.
|
||||
assert!(remove_word("zqguarda\u{301}do"));
|
||||
save_user_dictionary(&path, &["zqguarda\u{301}do".to_string()]).unwrap();
|
||||
assert!(!std::fs::read_to_string(&path).unwrap().lines().any(|l| l == "zqguardádo"));
|
||||
assert!(!is_correct_in("zqguardádo", es));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// #100: runs in a language without a dictionary are never flagged; English still is.
|
||||
#[test]
|
||||
fn non_english_words_are_not_flagged() {
|
||||
@@ -1714,7 +1859,7 @@ mod tests {
|
||||
let _ = has_dictionary(Some(&s));
|
||||
let _ = is_correct_for(&s, Some(&s));
|
||||
let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default());
|
||||
for lang in [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::De, ProofLang::Other] {
|
||||
for lang in [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::De, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy, ProofLang::Other] {
|
||||
let _ = check_spelling_in(&s, lang);
|
||||
let _ = check_grammar_in(&s, lang);
|
||||
let _ = is_correct_in(&s, lang);
|
||||
@@ -1758,8 +1903,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..11), 0..6)) {
|
||||
let langs = [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Other];
|
||||
fn spanish_suggestions_never_panic(s in "[a-zA-ZáéíóúüñÁÉÍÓÚÜÑ¿¡'’ -]{0,14}") {
|
||||
es::test_dict::install();
|
||||
for lang in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] {
|
||||
let _ = suggest_in(&s, 4, lang);
|
||||
let _ = is_correct_in(&s, lang);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..15), 0..6)) {
|
||||
let langs = [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy, ProofLang::Other];
|
||||
let spans: Vec<(Range<usize>, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % langs.len()])).collect();
|
||||
for i in check_text(&s, &spans) {
|
||||
proptest::prop_assert!(i.start <= i.end && i.end <= s.len());
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "WordCraft rasteriser: pages and thumbnails from the layout displa
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
|
||||
@@ -17,6 +17,8 @@ fn media_cache_only_hits_the_same_allocation() {
|
||||
c.insert("pic", &a, 7, 3);
|
||||
assert_eq!(c.get("pic", &a), Some(7));
|
||||
assert_eq!(c.get("pic", &b), None, "equal bytes in another allocation are another picture");
|
||||
drop(a);
|
||||
assert_eq!(c.get("pic", &Arc::new(vec![1u8, 2, 3])), None, "a freed source's address isn't reused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -34,17 +36,18 @@ fn media_cache_is_bounded_by_bytes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_cache_counts_the_source_bytes_it_holds() {
|
||||
fn media_cache_counts_what_it_keeps_and_lets_sources_go() {
|
||||
let mut c: MediaCache<Option<u32>> = MediaCache { map: HashMap::new(), bytes: 0 };
|
||||
let src = Arc::new(vec![0u8; 1000]);
|
||||
c.insert("broken", &src, None, 0);
|
||||
assert_eq!(c.bytes, 1000, "an entry that decoded to nothing still holds its source");
|
||||
c.insert("pic", &src, Some(1), 24);
|
||||
assert_eq!(c.bytes, 2024);
|
||||
// A source over the whole budget is not kept, whatever it decoded to (calloc'd, so cheap).
|
||||
assert_eq!(c.bytes, 24, "an entry counts what it decoded to");
|
||||
assert_eq!(Arc::strong_count(&src), 1, "and doesn't keep its source alive");
|
||||
// A source over the whole budget is cached by what it decoded to (calloc'd, so cheap), so a
|
||||
// broken one isn't decoded again on every render either.
|
||||
let huge = Arc::new(vec![0u8; CACHE_BYTES + 1]);
|
||||
c.insert("huge", &huge, None, 0);
|
||||
assert!(!c.map.contains_key("huge"));
|
||||
assert_eq!(c.get("huge", &huge), Some(None));
|
||||
assert_eq!(CACHE_BYTES * 2, 256 * 1024 * 1024, "the image and vector caches share about 256 MB");
|
||||
}
|
||||
|
||||
|
||||
+239
-22
@@ -2,7 +2,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use vello_cpu::kurbo::{self, Affine, BezPath, Shape};
|
||||
use vello_cpu::peniko;
|
||||
@@ -140,16 +140,16 @@ const CACHE_BYTES: usize = 256 * 1024 * 1024 / 2;
|
||||
/// Upper bound on cached entries per cache.
|
||||
const CACHE_ENTRIES: usize = 256;
|
||||
|
||||
/// Decoded media by media key, shared across renders, with an approximate byte budget. An entry holds
|
||||
/// the source bytes it was decoded from, so a hit needs the same allocation (`Arc::ptr_eq`), and that
|
||||
/// allocation cannot be freed and its address reused while the entry lives.
|
||||
/// Decoded media by media key, shared across renders, with an approximate byte budget. An entry knows
|
||||
/// its source allocation weakly: a hit needs the same allocation, whose address can't be reused while
|
||||
/// the entry lives, and the source bytes are still freed with the document.
|
||||
struct MediaCache<T> {
|
||||
map: HashMap<String, Entry<T>>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
struct Entry<T> {
|
||||
src: Arc<Vec<u8>>,
|
||||
src: Weak<Vec<u8>>,
|
||||
val: T,
|
||||
size: usize,
|
||||
}
|
||||
@@ -157,14 +157,12 @@ struct Entry<T> {
|
||||
impl<T: Clone> MediaCache<T> {
|
||||
fn get(&self, key: &str, src: &Arc<Vec<u8>>) -> Option<T> {
|
||||
let e = self.map.get(key)?;
|
||||
Arc::ptr_eq(&e.src, src).then(|| e.val.clone())
|
||||
std::ptr::eq(e.src.as_ptr(), Arc::as_ptr(src)).then(|| e.val.clone())
|
||||
}
|
||||
|
||||
/// Stores `val` (about `size` bytes) with its source, whose bytes the entry keeps alive and so counts
|
||||
/// too. Clears the cache first when the budget or entry cap is hit; an entry larger than the whole
|
||||
/// budget is not kept.
|
||||
/// Stores `val` (about `size` bytes) for `src`. Clears the cache first when the budget or entry
|
||||
/// cap is hit; an entry larger than the whole budget is not kept.
|
||||
fn insert(&mut self, key: &str, src: &Arc<Vec<u8>>, val: T, size: usize) {
|
||||
let size = size.saturating_add(src.len());
|
||||
if size > CACHE_BYTES {
|
||||
return;
|
||||
}
|
||||
@@ -175,7 +173,7 @@ impl<T: Clone> MediaCache<T> {
|
||||
self.map.clear();
|
||||
self.bytes = 0;
|
||||
}
|
||||
self.map.insert(key.to_string(), Entry { src: src.clone(), val, size });
|
||||
self.map.insert(key.to_string(), Entry { src: Arc::downgrade(src), val, size });
|
||||
self.bytes = self.bytes.saturating_add(size);
|
||||
}
|
||||
}
|
||||
@@ -202,6 +200,10 @@ const MAX_DECODE_SIDE: u32 = u16::MAX as u32;
|
||||
/// Decode an encoded image to a premultiplied pixmap. The dimension limit above and the default
|
||||
/// allocation cap (512 MB, what `load_from_memory` applied) bound the decode during paint.
|
||||
pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
|
||||
// The shared probe also charges what the decoder holds beside its output (#3644).
|
||||
if !wordcraft_geom::picture::probe(bytes).is_ok_and(|p| p.fits(wordcraft_geom::MAX_DECODED_BYTES)) {
|
||||
return None;
|
||||
}
|
||||
decode_pixmap_within(bytes, wordcraft_geom::MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
@@ -410,10 +412,10 @@ const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024;
|
||||
fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view: Affine) {
|
||||
let Some(pic) = &doc.settings.page_picture else { return };
|
||||
let Some(bytes) = doc.media.get(&pic.media) else { return };
|
||||
match image_size(bytes) {
|
||||
Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => {}
|
||||
let (nw, nh) = match image_size(bytes) {
|
||||
Some((w, h)) if w > 0 && h > 0 && (w as u64) * (h as u64) <= MAX_BACKGROUND_PIXELS => (w as f64, h as f64),
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
let Some(pm) = pixmap_for(doc, &pic.media) else { return };
|
||||
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
|
||||
let (w, h) = (page.w as f64, (page.h as f64).min(1e6));
|
||||
@@ -421,7 +423,8 @@ fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &Page, view:
|
||||
return;
|
||||
}
|
||||
let tile = pic.fill_type.eq_ignore_ascii_case("tile");
|
||||
let (sx, sy) = if tile { (0.75, 0.75) } else { (w / pw, h / ph) };
|
||||
// Tiles at the picture's natural size, whatever size its cached pixmap is kept at.
|
||||
let (sx, sy) = if tile { (0.75 * nw / pw, 0.75 * nh / ph) } else { (w / pw, h / ph) };
|
||||
let extend = if tile { peniko::Extend::Repeat } else { peniko::Extend::Pad };
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
@@ -438,12 +441,60 @@ fn pixmap_for(doc: &Document, key: &str) -> Option<Arc<Pixmap>> {
|
||||
if let Some(v) = lock(images()).get(key, bytes) {
|
||||
return v;
|
||||
}
|
||||
let pm = decode_pixmap(bytes).map(Arc::new);
|
||||
let pm = decode_pixmap(bytes).map(|p| Arc::new(cacheable(p)));
|
||||
let size = pm.as_ref().map_or(0, |p| p.width() as usize * p.height() as usize * 4);
|
||||
lock(images()).insert(key, bytes, pm.clone(), size);
|
||||
pm
|
||||
}
|
||||
|
||||
/// A decoded picture at a size the media cache keeps. One too large for the whole cache is
|
||||
/// box-averaged down to half of it, so it is decoded once instead of on every render (#3665); pages
|
||||
/// draw it scaled to its frame either way.
|
||||
fn cacheable(pm: Pixmap) -> Pixmap {
|
||||
let bytes = pm.width() as usize * pm.height() as usize * 4;
|
||||
if bytes <= CACHE_BYTES {
|
||||
return pm;
|
||||
}
|
||||
shrink(&pm, (CACHE_BYTES / 2 / 4) as u64).unwrap_or(pm)
|
||||
}
|
||||
|
||||
/// `pm` averaged over square blocks, the smallest whole number of pixels a side that brings it to
|
||||
/// at most `max_pixels`; None when it already fits or none are allowed. Premultiplied pixels
|
||||
/// average without colour fringes.
|
||||
fn shrink(pm: &Pixmap, max_pixels: u64) -> Option<Pixmap> {
|
||||
let (w, h) = (usize::from(pm.width()), usize::from(pm.height()));
|
||||
if (w * h) as u64 <= max_pixels || max_pixels == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut k = (((w * h) as f64 / max_pixels as f64).sqrt().ceil() as usize).max(2);
|
||||
while (w.div_ceil(k) * h.div_ceil(k)) as u64 > max_pixels {
|
||||
k += 1;
|
||||
}
|
||||
let (nw, nh) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let mut out = Vec::with_capacity(nw * nh);
|
||||
let mut sums = vec![[0u64; 4]; nw];
|
||||
for (oy, band) in pm.data().chunks(w * k).enumerate() {
|
||||
sums.fill([0; 4]);
|
||||
for row in band.chunks(w) {
|
||||
for (sum, block) in sums.iter_mut().zip(row.chunks(k)) {
|
||||
for p in block {
|
||||
sum[0] += u64::from(p.r);
|
||||
sum[1] += u64::from(p.g);
|
||||
sum[2] += u64::from(p.b);
|
||||
sum[3] += u64::from(p.a);
|
||||
}
|
||||
}
|
||||
}
|
||||
let rows = (h - oy * k).min(k);
|
||||
for (ox, sum) in sums.iter().enumerate() {
|
||||
let n = (((w - ox * k).min(k)) * rows).max(1) as u64;
|
||||
let [r, g, b, a] = sum.map(|s| (s / n) as u8);
|
||||
out.push(vello_cpu::color::PremulRgba8 { r, g, b, a });
|
||||
}
|
||||
}
|
||||
Some(Pixmap::from_parts(out, nw as u16, nh as u16))
|
||||
}
|
||||
|
||||
/// Options for a page raster.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RenderOptions {
|
||||
@@ -460,12 +511,24 @@ pub struct RenderOptions {
|
||||
pub dark: bool,
|
||||
/// The grey that black becomes on a dark page (white paper turns this, not pure black).
|
||||
pub dark_paper: u8,
|
||||
/// Read Mode's Page Color: paper drawn instead of the document's page color and page
|
||||
/// picture. `None` shows the document's page background. Screen only.
|
||||
pub read_paper: Option<Rgb>,
|
||||
}
|
||||
|
||||
/// Default grey for a dark page's paper.
|
||||
pub const DARK_PAPER: u8 = 0x33;
|
||||
|
||||
/// Read Mode's Sepia paper: Word's RGB (112, 66, 20) at 80% transparency over white.
|
||||
pub const SEPIA_PAPER: Rgb = Rgb(0xE2, 0xD9, 0xD0);
|
||||
|
||||
impl RenderOptions {
|
||||
/// The on-screen color of the paper under `doc`'s pages: [`RenderOptions::read_paper`],
|
||||
/// else the document's page color, else [`RenderOptions::paper`].
|
||||
pub fn paper_of(&self, doc: &Document) -> Rgb {
|
||||
self.ink(self.read_paper.or(doc.settings.page_color).unwrap_or(self.paper))
|
||||
}
|
||||
|
||||
/// The on-screen colour for a document colour (see [`RenderOptions::dark`]).
|
||||
pub fn ink(&self, c: Rgb) -> Rgb {
|
||||
if !self.dark {
|
||||
@@ -541,6 +604,7 @@ impl Default for RenderOptions {
|
||||
text_darkening: false,
|
||||
dark: false,
|
||||
dark_paper: DARK_PAPER,
|
||||
read_paper: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,9 +631,11 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
|
||||
let (w16, h16) = (cw as u16, ch as u16);
|
||||
let mut ctx = context_pool::CONTEXTS.take(w16, h16);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0));
|
||||
ctx.set_paint(color(opts.paper_of(doc), 1.0));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
|
||||
draw_page_picture(&mut ctx, doc, page, view);
|
||||
if opts.read_paper.is_none() {
|
||||
draw_page_picture(&mut ctx, doc, page, view);
|
||||
}
|
||||
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
|
||||
if let Some(wm) = &doc.settings.watermark {
|
||||
draw_watermark(&mut ctx, view, page, wm, opts);
|
||||
@@ -602,7 +668,7 @@ fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affin
|
||||
/// pictures and turned or rotated content are left out. The caret takes its colour from this
|
||||
/// so it shows against whatever lies behind it (#299).
|
||||
pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, y: f32) -> Rgb {
|
||||
let paper = opts.ink(doc.settings.page_color.unwrap_or(opts.paper));
|
||||
let paper = opts.paper_of(doc);
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return paper;
|
||||
}
|
||||
@@ -789,6 +855,10 @@ fn wave_path(x0: f64, y0: f64, x1: f64, y1: f64, amp: f64) -> BezPath {
|
||||
p
|
||||
}
|
||||
|
||||
/// Rasterize one display item with `view` mapping its current point coordinates to pixels.
|
||||
/// `visible` is a culling area in those point coordinates; nested clips intersect it and turns
|
||||
/// transform it into the child's coordinates. Shape and path strokes apply their stored caps,
|
||||
/// while ink uses its tool's round ends and joins.
|
||||
fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visible: &kurbo::Rect, opts: &RenderOptions) {
|
||||
match it {
|
||||
Draw::Figure { draws, .. } => {
|
||||
@@ -978,7 +1048,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
Draw::Path { segs, fill, stroke, stroke_width, dash } => {
|
||||
Draw::Path { segs, fill, stroke, stroke_width, dash, cap } => {
|
||||
let path = wordcraft_layout::display::seg_path(segs);
|
||||
if path.elements().is_empty() {
|
||||
return;
|
||||
@@ -992,7 +1062,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
// Width 0 (or none) is a hairline.
|
||||
let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 };
|
||||
ctx.set_paint(color(opts.ink(*s), 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_dashes(0.0, dash.iter().map(|v| f64::from(*v))));
|
||||
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, dash.iter().map(|v| f64::from(*v))));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
@@ -1026,7 +1096,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner, dash } => {
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner, dash, cap } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
let path = shape_path(*kind, r, *corner);
|
||||
ctx.set_transform(view);
|
||||
@@ -1082,6 +1152,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.set_paint(color(opts.ink(*s), 1.0));
|
||||
ctx.set_stroke(
|
||||
kurbo::Stroke::new(if stroke_width.is_nan() { 0.25 } else { stroke_width.clamp(0.25, 200.0) } as f64)
|
||||
.with_caps(screen_cap(*cap))
|
||||
.with_dashes(0.0, dash.iter().map(|v| f64::from(*v))),
|
||||
);
|
||||
ctx.stroke_path(&path);
|
||||
@@ -1139,6 +1210,15 @@ fn ink_path(pts: &[(f32, f32)]) -> Option<BezPath> {
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Map the document cap to the raster backend without changing ink-tool stroke styles.
|
||||
fn screen_cap(cap: wordcraft_doc::connector::LineCap) -> kurbo::Cap {
|
||||
match cap {
|
||||
wordcraft_doc::connector::LineCap::Flat => kurbo::Cap::Butt,
|
||||
wordcraft_doc::connector::LineCap::Square => kurbo::Cap::Square,
|
||||
wordcraft_doc::connector::LineCap::Round => kurbo::Cap::Round,
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline of a basic shape in a rectangle; `corner` is a rounded rectangle's
|
||||
/// ([`wordcraft_doc::connector::ShapeExtra::corner`]).
|
||||
pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option<u32>) -> BezPath {
|
||||
@@ -1354,6 +1434,7 @@ mod tests {
|
||||
effects: Default::default(),
|
||||
corner: None,
|
||||
dash: Vec::new(),
|
||||
cap: Default::default(),
|
||||
};
|
||||
let at = |x: f32, y: f32| {
|
||||
let mut bg = [255.0; 3];
|
||||
@@ -1566,6 +1647,44 @@ mod tests {
|
||||
assert_eq!(&img.pixels[0..3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
/// Read Mode's Page Color: the reading paper replaces the document's page color and page
|
||||
/// picture on screen, in the raster and under the caret; the text keeps its color on Sepia.
|
||||
#[test]
|
||||
fn read_paper_replaces_the_page_background() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 200, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut d = Document::from_text("Hello WordCraft");
|
||||
d.media.insert("bg.png".into(), Arc::new(png));
|
||||
d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40));
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let page = &l.pages[0];
|
||||
let at = |img: &Rendered, x: u32, y: u32| {
|
||||
let i = ((y * img.width + x) * 4) as usize;
|
||||
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]]
|
||||
};
|
||||
let sepia = RenderOptions { read_paper: Some(SEPIA_PAPER), ..Default::default() };
|
||||
let white = RenderOptions { read_paper: Some(Rgb::WHITE), ..Default::default() };
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 5, 5), [0x10, 0x10, 0x40], "the page color");
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 5, 5), [0xE2, 0xD9, 0xD0]);
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &white), 5, 5), [255, 255, 255]);
|
||||
assert_eq!(background_at(&d, page, &sepia, 5.0, 5.0), SEPIA_PAPER);
|
||||
assert_eq!(background_at(&d, page, &white, 5.0, 5.0), Rgb::WHITE);
|
||||
let inverse = RenderOptions { read_paper: Some(Rgb::WHITE), dark: true, ..Default::default() };
|
||||
assert_eq!(background_at(&d, page, &inverse, 5.0, 5.0), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER));
|
||||
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "tile".into(), title: String::new() });
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &RenderOptions::default()), 300, 300), [10, 200, 30], "the page picture");
|
||||
assert_eq!(at(&render_page(&d, page, 1.0, &sepia), 300, 300), [0xE2, 0xD9, 0xD0]);
|
||||
// The text is drawn in its own color on sepia paper: its darkest pixel is within 8 levels of its value on white.
|
||||
d.settings.page_picture = None;
|
||||
d.settings.page_color = None;
|
||||
let darkest = |img: &Rendered| img.pixels.chunks(4).map(|p| p[0].min(p[1]).min(p[2])).min().unwrap_or(255);
|
||||
let plain = render_page(&d, page, 2.0, &RenderOptions::default());
|
||||
let on_sepia = render_page(&d, page, 2.0, &sepia);
|
||||
assert!(darkest(&plain) < 0x40 && darkest(&on_sepia) <= darkest(&plain) + 8, "{} {}", darkest(&plain), darkest(&on_sepia));
|
||||
}
|
||||
|
||||
/// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so
|
||||
/// the header parses), with no real pixel data.
|
||||
fn ihdr_only_png(w: u32, h: u32) -> Vec<u8> {
|
||||
@@ -1630,6 +1749,67 @@ mod tests {
|
||||
assert!(decode_pixmap(b"not an image").is_none());
|
||||
}
|
||||
|
||||
/// A JPEG whose headers say progressive (SOF2, a DC-only first scan) and `w` × `h`, its three
|
||||
/// components unsubsampled, with no pixel data that size.
|
||||
fn progressive_jpeg_header(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
image::RgbImage::new(16, 16).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
|
||||
let sof = jpeg.windows(2).position(|m| m == [0xFF, 0xC0]).unwrap();
|
||||
jpeg[sof + 1] = 0xC2;
|
||||
jpeg[sof + 5..sof + 7].copy_from_slice(&h.to_be_bytes());
|
||||
jpeg[sof + 7..sof + 9].copy_from_slice(&w.to_be_bytes());
|
||||
for c in 0..3 {
|
||||
jpeg[sof + 11 + 3 * c] = 0x11;
|
||||
}
|
||||
let sos = jpeg.windows(2).position(|m| m == [0xFF, 0xDA]).unwrap();
|
||||
let n = usize::from(jpeg[sos + 4]);
|
||||
jpeg[sos + 6 + 2 * n] = 0;
|
||||
jpeg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_jpeg_coefficients_count_toward_the_decode_budget() {
|
||||
// Two RGBA buffers of 7900 × 7900 fit 512 MB, but RGB output beside three full-size
|
||||
// coefficient planes (9 bytes a pixel) doesn't (#3644).
|
||||
let jpeg = progressive_jpeg_header(7900, 7900);
|
||||
assert!(wordcraft_geom::decode_fits(7900, 7900, wordcraft_geom::MAX_DECODED_BYTES));
|
||||
assert!(decode_pixmap(&jpeg).is_none());
|
||||
let mut baseline = Vec::new();
|
||||
image::RgbImage::new(64, 64).write_to(&mut std::io::Cursor::new(&mut baseline), image::ImageFormat::Jpeg).unwrap();
|
||||
assert!(decode_pixmap(&baseline).is_some(), "other JPEGs still draw");
|
||||
}
|
||||
|
||||
/// #3665: a picture too large for the whole cache is kept at half of it, so it is cached
|
||||
/// instead of decoded again on every render.
|
||||
#[test]
|
||||
fn pictures_too_large_for_the_cache_are_kept_smaller() {
|
||||
let px = vello_cpu::color::PremulRgba8 { r: 40, g: 80, b: 120, a: 200 };
|
||||
// 6000 × 6000 is 144 MB of RGBA, past the 128 MB cache.
|
||||
let big = Pixmap::from_parts(vec![px; 6000 * 6000], 6000, 6000);
|
||||
let small = cacheable(big);
|
||||
let bytes = small.width() as usize * small.height() as usize * 4;
|
||||
assert!(bytes <= CACHE_BYTES / 2, "{} x {}", small.width(), small.height());
|
||||
assert!(small.width() >= 3000 && small.width() == small.height(), "aspect kept");
|
||||
assert!(small.data().iter().all(|p| (p.r, p.g, p.b, p.a) == (40, 80, 120, 200)), "flat stays flat");
|
||||
let mut cache = MediaCache { map: HashMap::new(), bytes: 0 };
|
||||
// A source file larger than the whole cache (an uncompressed BMP, say) doesn't count against it.
|
||||
let src = Arc::new(vec![0u8; CACHE_BYTES + 1]);
|
||||
cache.insert("big", &src, Some(Arc::new(small)), bytes);
|
||||
assert!(cache.get("big", &src).is_some(), "cached");
|
||||
// Pictures the cache holds as they are keep their size.
|
||||
assert_eq!(cacheable(Pixmap::from_parts(vec![px; 100 * 50], 100, 50)).width(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_averages_premultiplied_pixels() {
|
||||
let p = |v: u8| vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: 255 };
|
||||
let pm = Pixmap::from_parts(vec![p(0), p(100), p(200), p(40)], 4, 1);
|
||||
let out = shrink(&pm, 2).unwrap();
|
||||
assert_eq!((out.width(), out.height()), (2, 1));
|
||||
assert_eq!(out.data().iter().map(|q| q.r).collect::<Vec<_>>(), [50, 120]);
|
||||
assert!(shrink(&pm, 4).is_none() && shrink(&pm, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimmed_header_image_renders() {
|
||||
let mut d = Document::from_text("body");
|
||||
@@ -2239,6 +2419,43 @@ mod tests {
|
||||
assert!(dashed.pixel(406, 400)[1] < 80);
|
||||
}
|
||||
|
||||
/// Distinguish flat, square and round endpoints for both preset lines and connector paths.
|
||||
#[test]
|
||||
fn shape_line_caps_reach_the_screen() {
|
||||
use wordcraft_doc::{
|
||||
connector::{LineCap, ShapeExtra},
|
||||
para::{Anchor, Float, InlineObject, Wrap},
|
||||
};
|
||||
// A preset line uses Draw::Shape; a straight connector uses Draw::Path.
|
||||
for kind in [ShapeKind::Line, ShapeKind::StraightConnector] {
|
||||
for cap in [LineCap::Flat, LineCap::Square, LineCap::Round] {
|
||||
let shape = InlineObject::Shape {
|
||||
kind,
|
||||
w: 40.0,
|
||||
h: 0.0,
|
||||
fill: None,
|
||||
stroke: Some(Rgb::BLACK),
|
||||
stroke_width: 10.0,
|
||||
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() },
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: ShapeExtra { cap, ..Default::default() },
|
||||
};
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.insert_object(0, shape, &Default::default()).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let image = render_page(&d, &l.pages[0], 4.0, &RenderOptions::default());
|
||||
let painted = |x, y| image.pixel(x, y)[0] < 80;
|
||||
assert!(painted(404, 400), "line interior: {kind:?} {cap:?}");
|
||||
assert_eq!(painted(388, 400), cap != LineCap::Flat, "past the endpoint: {kind:?} {cap:?}");
|
||||
assert_eq!(painted(384, 384), cap == LineCap::Square, "square corner: {kind:?} {cap:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_connector_dashes() {
|
||||
use wordcraft_doc::{
|
||||
|
||||
@@ -12,9 +12,10 @@ workspace = true
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n
|
||||
# catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in
|
||||
# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247).
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" }
|
||||
wordcraft-geom = { workspace = true }
|
||||
# `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing
|
||||
# (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values.
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
@@ -38,6 +39,14 @@ sys-locale = { version = "0.3", features = ["js"] }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = "=5.19.0"
|
||||
|
||||
# Comment times in the user's own time zone (`local_time.rs`); the web app asks the browser.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["clock"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32).
|
||||
js-sys = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
cfb = { workspace = true }
|
||||
# Producer tests simulate a globally exhausted IME revision allocator through the engine's
|
||||
|
||||
@@ -137,7 +137,7 @@ impl AutoScroll {
|
||||
vec2(0.0, scroll_delta(displacement, dt))
|
||||
}
|
||||
|
||||
/// Draw a marker at the anchor, above the pages, and the up-down cursor. Run after the
|
||||
/// Draw a marker at the anchor, above the pages, and the cursor. Run after the
|
||||
/// canvas has set its own cursor.
|
||||
pub(crate) fn paint(&self, ui: &egui::Ui, viewport: egui::Rect) {
|
||||
let Some(anchor) = self.anchor else { return };
|
||||
@@ -152,7 +152,8 @@ impl AutoScroll {
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical);
|
||||
let displacement = ui.ctx().input(|i| i.pointer.hover_pos()).map_or(0.0, |p| p.y - anchor.y);
|
||||
ui.ctx().set_cursor_icon(cursor_icon(displacement));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +171,18 @@ fn scroll_delta(displacement: f32, dt: f32) -> f32 {
|
||||
-displacement.signum() * speed * dt.clamp(0.0, 0.05)
|
||||
}
|
||||
|
||||
/// The cursor for a pointer `displacement` points (positive = below) from the anchor: the
|
||||
/// up-down arrow while it rests in the dead zone, otherwise an arrow towards the scroll direction.
|
||||
fn cursor_icon(displacement: f32) -> CursorIcon {
|
||||
if !displacement.is_finite() || displacement.abs() <= DEAD_ZONE {
|
||||
CursorIcon::ResizeVertical
|
||||
} else if displacement < 0.0 {
|
||||
CursorIcon::ResizeNorth
|
||||
} else {
|
||||
CursorIcon::ResizeSouth
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -192,4 +205,16 @@ mod tests {
|
||||
assert_eq!(scroll_delta(1000.0, invalid), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_follows_the_scroll_direction_outside_the_dead_zone() {
|
||||
for y in [-15.0, -1.0, 0.0, 1.0, 15.0] {
|
||||
assert_eq!(cursor_icon(y), CursorIcon::ResizeVertical);
|
||||
}
|
||||
assert_eq!(cursor_icon(-16.0), CursorIcon::ResizeNorth);
|
||||
assert_eq!(cursor_icon(16.0), CursorIcon::ResizeSouth);
|
||||
for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert_eq!(cursor_icon(invalid), CursorIcon::ResizeVertical);
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
-25
@@ -799,6 +799,7 @@ fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let v = &app.session.view;
|
||||
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
|
||||
(v.read_mode, v.read_page_color).hash(&mut h);
|
||||
app.session.prefs.markup.hash(&mut h);
|
||||
dim_body.hash(&mut h);
|
||||
format!("{:?}{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.page_picture, app.session.doc.settings.watermark)
|
||||
@@ -1078,7 +1079,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0));
|
||||
}
|
||||
// View › Switch Modes: dark paper, inverted document colours.
|
||||
let dark_page = app.session.view.dark_mode;
|
||||
// In Read Mode the Page color setting is used instead: Inverse draws the dark page, Sepia
|
||||
// draws sepia paper and None draws white paper.
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
let read_mode = app.session.view.read_mode;
|
||||
let dark_page = if read_mode { app.session.view.read_page_color == ReadPageColor::Inverse } else { app.session.view.dark_mode };
|
||||
let sepia_page = read_mode && app.session.view.read_page_color == ReadPageColor::Sepia;
|
||||
let dark_paper = wordcraft_render::DARK_PAPER;
|
||||
let show_ruler = app.session.view.ruler
|
||||
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
|
||||
@@ -1379,7 +1385,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
};
|
||||
// Shadow and paper.
|
||||
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
|
||||
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
|
||||
let paper = if dark_page {
|
||||
Color32::from_gray(dark_paper)
|
||||
} else if sepia_page {
|
||||
let s = wordcraft_render::SEPIA_PAPER;
|
||||
Color32::from_rgb(s.0, s.1, s.2)
|
||||
} else {
|
||||
Color32::WHITE
|
||||
};
|
||||
painter.rect_filled(visual_sr, 0.0, paper);
|
||||
if tiled {
|
||||
// Render before painting to keep the frame sharp immediately after an edit or
|
||||
// zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling.
|
||||
@@ -1959,7 +1973,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
{
|
||||
let opts = page_render_options(app, dim_body);
|
||||
let bg = if on_visible_page(&rects, &geo.bands, p) {
|
||||
opts.ink(app.session.doc.settings.page_color.unwrap_or(opts.paper))
|
||||
opts.paper_of(&app.session.doc)
|
||||
} else {
|
||||
let c = Tokens::get(ui.ctx()).canvas;
|
||||
wordcraft_doc::props::Rgb(c.r(), c.g(), c.b())
|
||||
@@ -2145,22 +2159,26 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
||||
// Over the page the card goes under the commented line, not on it.
|
||||
let top = (anchor.y + if over_page { 4.0 } else { -12.0 }).max(next_y);
|
||||
let active = app.canvas.balloon == Some(*id);
|
||||
// Lay out a read-only balloon's text: the comment, then each reply under a rule.
|
||||
let mut lines: Vec<(craft_ui::text::AlignedGalley, f32)> = Vec::new();
|
||||
// Lay out a read-only balloon's text: the comment, then each reply under a rule,
|
||||
// each with who wrote it and when. (galley, gap above, a rule above)
|
||||
let mut lines: Vec<(craft_ui::text::AlignedGalley, f32, bool)> = Vec::new();
|
||||
let card = if active {
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0)))
|
||||
} else {
|
||||
let dim = |r: bool| if r { t.text_dim } else { t.text };
|
||||
lines.push((balloon_text(painter, author, semibold(fs), t.text, w - 16.0), 0.0));
|
||||
let body = app.session.doc.plain_text(StoryRef::Part(c.part));
|
||||
lines.push((balloon_text(painter, body.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0));
|
||||
for rid in &thread {
|
||||
let Some(r) = app.session.doc.comments.get(rid) else { continue };
|
||||
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), 9.0));
|
||||
let small = regular((fs * 0.9).max(8.0));
|
||||
for (i, cid) in std::iter::once(*id).chain(thread.iter().copied()).enumerate() {
|
||||
let Some(r) = app.session.doc.comments.get(&cid) else { continue };
|
||||
let gap = if i == 0 { 0.0 } else { 9.0 };
|
||||
lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), gap, i > 0));
|
||||
let when = crate::local_time::comment_time(r);
|
||||
if !when.is_empty() {
|
||||
lines.push((balloon_text(painter, when, small.clone(), t.text_dim, w - 16.0), 1.0, false));
|
||||
}
|
||||
let text = app.session.doc.plain_text(StoryRef::Part(r.part));
|
||||
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0));
|
||||
lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0, false));
|
||||
}
|
||||
let h = lines.iter().map(|(g, gap)| g.height() + gap).sum::<f32>() + 12.0;
|
||||
let h = lines.iter().map(|(g, gap, _)| g.height() + gap).sum::<f32>() + 12.0;
|
||||
Rect::from_min_size(pos2(x0, top), vec2(w, h))
|
||||
};
|
||||
if !card.intersects(clip) {
|
||||
@@ -2184,9 +2202,9 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
||||
card_shapes(painter, &t, card, color, selected, None);
|
||||
let text_painter = painter.with_clip_rect(card.intersect(clip));
|
||||
let mut y = card.min.y + 6.0;
|
||||
for (i, (g, gap)) in lines.into_iter().enumerate() {
|
||||
for (g, gap, rule) in lines {
|
||||
y += gap;
|
||||
if i >= 2 && i % 2 == 0 {
|
||||
if rule {
|
||||
// A reply starts: a hairline between it and what it answers.
|
||||
painter.hline(card.min.x + 10.0..=card.max.x - 8.0, y - gap / 2.0, Stroke::new(1.0, t.border));
|
||||
}
|
||||
@@ -2255,8 +2273,9 @@ fn balloon_text(painter: &egui::Painter, text: String, font: egui::FontId, color
|
||||
craft_ui::text::align_galley_rows(painter.layout_job(job), width, |row| if row.rtl_paragraph { egui::Align::Max } else { egui::Align::Min })
|
||||
}
|
||||
|
||||
/// The selected balloon: the comment and its replies as editable fields (each edit is one undo
|
||||
/// step via `review.editComment`), an optional reply field, and Reply / Resolve / Delete.
|
||||
/// The selected balloon: the comment and its replies, each with who wrote it and when, as
|
||||
/// editable fields (each edit is one undo step via `review.editComment`), an optional reply
|
||||
/// field, and Reply / Resolve / Delete.
|
||||
/// `card` carries last frame's size; returns this frame's.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u32, thread: &[u32], card: Rect, fs: f32, color: Color32) -> Rect {
|
||||
@@ -2270,6 +2289,13 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u
|
||||
let (font, bold, small) = (regular(fs), semibold(fs), regular((fs * 0.9).max(8.0)));
|
||||
let focus = std::mem::take(&mut app.canvas.balloon_focus);
|
||||
let name = |app: &WordApp, cid: u32| app.session.doc.comments.get(&cid).map(|c| author_name(&c.author)).unwrap_or_default();
|
||||
// When it was written, in local time, under the author.
|
||||
let when = |app: &WordApp, ui: &mut Ui, cid: u32| {
|
||||
let when = app.session.doc.comments.get(&cid).map(crate::local_time::comment_time).unwrap_or_default();
|
||||
if !when.is_empty() {
|
||||
ui.label(egui::RichText::new(when).font(small.clone()).color(t.text_dim));
|
||||
}
|
||||
};
|
||||
let resolved = app.session.doc.comments.get(&id).is_some_and(|c| c.resolved);
|
||||
child.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(name(app, id)).font(bold.clone()).color(t.text));
|
||||
@@ -2277,10 +2303,12 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u
|
||||
ui.label(egui::RichText::new(tl!("Resolved")).font(small.clone()).color(t.resolved_text));
|
||||
}
|
||||
});
|
||||
when(app, &mut child, id);
|
||||
crate::panes::comment_editor(app, &mut child, id, Some(font.clone()), focus && !app.canvas.balloon_reply);
|
||||
for rid in thread {
|
||||
child.add_space(4.0);
|
||||
child.label(egui::RichText::new(name(app, *rid)).font(bold.clone()).color(t.text));
|
||||
when(app, &mut child, *rid);
|
||||
crate::panes::comment_editor(app, &mut child, *rid, Some(font.clone()), false);
|
||||
}
|
||||
if app.canvas.balloon_reply && crate::panes::reply_editor(app, &mut child, id, Some(font.clone()), focus) {
|
||||
@@ -3502,6 +3530,13 @@ fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_render::Rende
|
||||
opts.display.revisions = app.session.prefs.markup.clone();
|
||||
opts.dark = app.session.view.dark_mode;
|
||||
opts.dark_paper = wordcraft_render::DARK_PAPER;
|
||||
// In Read Mode the Page color sets the paper and the dark page, and Switch Modes is ignored.
|
||||
if app.session.view.read_mode {
|
||||
use wordcraft_engine::cmd::view::ReadPageColor;
|
||||
let color = app.session.view.read_page_color;
|
||||
opts.read_paper = Some(if color == ReadPageColor::Sepia { wordcraft_render::SEPIA_PAPER } else { wordcraft_doc::props::Rgb::WHITE });
|
||||
opts.dark = color == ReadPageColor::Inverse;
|
||||
}
|
||||
opts.display.dim_header = !dim_body;
|
||||
opts.display.dim_body = dim_body;
|
||||
opts
|
||||
@@ -3983,6 +4018,46 @@ mod tests {
|
||||
assert_eq!(at(&mut a, 2), Color32::WHITE, "again, from the cache");
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color: the reading columns are drawn on white, sepia or a dark
|
||||
/// page, in place of the document's page color; other views keep the page color.
|
||||
#[test]
|
||||
fn read_mode_page_color_sets_the_reading_paper() {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello WordCraft")), Default::default());
|
||||
a.run("design.pageColor", json!({"color": "#102040"})).unwrap();
|
||||
let paper = |a: &WordApp| page_render_options(a, false).paper_of(&a.session.doc);
|
||||
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "Print Layout shows the page color");
|
||||
let print_key = view_key(&a, false, None);
|
||||
a.run("view.readMode", json!({})).unwrap();
|
||||
assert_eq!(paper(&a), Rgb::WHITE, "None: white, the page color ignored");
|
||||
let none_key = view_key(&a, false, None);
|
||||
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
|
||||
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
|
||||
assert!(!page_render_options(&a, false).dark, "Sepia keeps the text's colors");
|
||||
let sepia_key = view_key(&a, false, None);
|
||||
a.run("view.readPageColor", json!({"value": "inverse"})).unwrap();
|
||||
assert!(page_render_options(&a, false).dark);
|
||||
let d = wordcraft_render::DARK_PAPER;
|
||||
assert_eq!(paper(&a), Rgb(d, d, d));
|
||||
let inverse_key = view_key(&a, false, None);
|
||||
let keys = [print_key, none_key, sepia_key, inverse_key];
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
assert!(!keys[..i].contains(k), "each paper has its own view key");
|
||||
}
|
||||
a.run("view.printLayout", json!({})).unwrap();
|
||||
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "leaving Read Mode brings the page color back");
|
||||
assert!(!page_render_options(&a, false).dark);
|
||||
// With Switch Modes on, Read Mode's paper still follows the Page color: None is white and Sepia is sepia.
|
||||
a.session.view.dark_mode = true;
|
||||
a.run("view.readMode", json!({})).unwrap();
|
||||
assert!(!page_render_options(&a, false).dark);
|
||||
assert_eq!(paper(&a), Rgb::WHITE);
|
||||
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
|
||||
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
|
||||
a.run("view.printLayout", json!({})).unwrap();
|
||||
assert!(page_render_options(&a, false).dark, "Print Layout keeps Switch Modes");
|
||||
}
|
||||
|
||||
/// #299: the caret's colour is worked out only when the caret is drawn: with text selected
|
||||
/// there is no caret, so the page's draw list isn't built for it.
|
||||
#[test]
|
||||
@@ -5029,9 +5104,35 @@ mod tests {
|
||||
assert_eq!(ctx.native_pixels_per_point(), Some(1.5), "the real app receives the fixture's monitor scale");
|
||||
assert_eq!(ctx.pixels_per_point(), 1.5, "Interface Auto retains the fixture's physical scale");
|
||||
assert_eq!(app.canvas.balloon_rects.len(), 1, "the reply stays in its parent's actual hit card");
|
||||
// Current main paints each comment's author, its local timestamp, then its body;
|
||||
// a reply repeats that under a 9 pt rule. Model that sequence from the
|
||||
// independently shaped originals, not from production's painted Y.
|
||||
let comment_when = crate::local_time::comment_time(&app.session.doc.comments[&id]);
|
||||
let reply_when = crate::local_time::comment_time(&app.session.doc.comments[&reply_id]);
|
||||
for when in [&comment_when, &reply_when] {
|
||||
assert_eq!(when.len(), 16, "WordCraft comments carry a local YYYY-MM-DD HH:MM timestamp: {when:?}");
|
||||
let b = when.as_bytes();
|
||||
assert_eq!(
|
||||
(b.get(4), b.get(7), b.get(10), b.get(13)),
|
||||
(Some(&b'-'), Some(&b'-'), Some(&b' '), Some(&b':')),
|
||||
"timestamp separators: {when:?}"
|
||||
);
|
||||
assert!(
|
||||
b.iter().enumerate().all(|(i, c)| matches!(i, 4 | 7 | 10 | 13) || c.is_ascii_digit()),
|
||||
"timestamp fields are digits: {when:?}"
|
||||
);
|
||||
}
|
||||
let expected_lines = [
|
||||
("Balloon author", 0.0),
|
||||
(comment_when.as_str(), 1.0),
|
||||
(body, 4.0),
|
||||
("Reply author", 9.0),
|
||||
(reply_when.as_str(), 1.0),
|
||||
(reply.as_str(), 4.0),
|
||||
];
|
||||
let mut y = card.min.y + 6.0;
|
||||
let mut line_geometry = Vec::new();
|
||||
for (text, gap) in [("Balloon author", 0.0), (body, 4.0), ("Reply author", 9.0), (reply.as_str(), 4.0)] {
|
||||
for (line_index, &(text, gap)) in expected_lines.iter().enumerate() {
|
||||
y += gap;
|
||||
let shapes: Vec<_> = output
|
||||
.shapes
|
||||
@@ -5041,8 +5142,14 @@ mod tests {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shapes.len(), 1, "the actual read-only line must be painted: {text}");
|
||||
let (clip, shape) = shapes[0];
|
||||
let occurrence = expected_lines[..line_index].iter().filter(|entry| entry.0 == text).count();
|
||||
let expected_occurrences = expected_lines.iter().filter(|entry| entry.0 == text).count();
|
||||
assert_eq!(
|
||||
shapes.len(),
|
||||
expected_occurrences,
|
||||
"the actual read-only line must be painted once per occurrence: text={text:?} line={line_index}"
|
||||
);
|
||||
let (clip, shape) = shapes[occurrence];
|
||||
assert_eq!(shape.galley.pixels_per_point, 1.5, "the actual card galley uses the fixture's physical scale: {text}");
|
||||
// Recreate the original shaping job independently of the wrapper.
|
||||
// Complete normalized bounds may exceed its preserved reading height.
|
||||
@@ -5053,8 +5160,12 @@ mod tests {
|
||||
}
|
||||
let reading_height = original.rect.height();
|
||||
line_geometry.push((text.to_string(), reading_height, shape.galley.rect.height(), shape.pos, y));
|
||||
assert!(card.contains_rect(clip) && clip.is_positive(), "body and reply keep the actual card clip");
|
||||
assert!((shape.pos.y - y).abs() * 1.5 <= 0.501, "same reading-origin Y and vertical gaps");
|
||||
assert!(card.contains_rect(clip) && clip.is_positive(), "bodies, timestamps and replies keep the actual card clip");
|
||||
assert!(
|
||||
(shape.pos.y - y).abs() * 1.5 <= 0.501,
|
||||
"same reading-origin Y and vertical gaps: text={text:?} line={line_index} expected={y} actual={} gap={gap}",
|
||||
shape.pos.y
|
||||
);
|
||||
assert!(shape.galley.rows.iter().any(|row| !row.visuals.mesh.vertices.is_empty()), "real glyph ink is painted");
|
||||
assert!(
|
||||
ctx.fonts_mut(|fonts| text
|
||||
@@ -5082,7 +5193,7 @@ mod tests {
|
||||
}
|
||||
assert!(
|
||||
(y + 6.0 - card.bottom()).abs() < 1e-3,
|
||||
"card height still sums original text heights and gaps: family={} expected_bottom={} card={card:?} delta={} lines={line_geometry:?}",
|
||||
"card height still sums original author, timestamp, body and reply heights and gaps: family={} expected_bottom={} card={card:?} delta={} lines={line_geometry:?}",
|
||||
fixture.family,
|
||||
y + 6.0,
|
||||
y + 6.0 - card.bottom()
|
||||
@@ -5091,13 +5202,13 @@ mod tests {
|
||||
}
|
||||
output.drop_without_applying_deltas();
|
||||
}
|
||||
assert!(checked, "actual body and reply shapes must be observed");
|
||||
assert!(checked, "actual author, timestamp, body and reply shapes must be observed");
|
||||
assert_eq!(app.session.rev(), rev, "painting never edits the document");
|
||||
assert_eq!(app.session.doc.plain_text(StoryRef::Part(app.session.doc.comments[&id].part)), body);
|
||||
assert_eq!(app.session.doc.plain_text(StoryRef::Part(app.session.doc.comments[&reply_id].part)), reply);
|
||||
}
|
||||
assert_eq!(rtl_cases, fixtures.len() - 1);
|
||||
eprintln!("actual balloon card coverage: {} faces, {rtl_cases} RTL body/reply cases", fixtures.len());
|
||||
eprintln!("actual balloon card coverage: {} faces, {rtl_cases} RTL author/timestamp/body/reply cases", fixtures.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -289,7 +289,7 @@ fn source(app: &WordApp, ctx: &egui::Context, media: &str) -> Option<egui::Textu
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
|
||||
return t;
|
||||
}
|
||||
let tex = image::load_from_memory(bytes).ok().map(|img| {
|
||||
let tex = wordcraft_geom::picture::decode(bytes, wordcraft_geom::MAX_DECODED_BYTES).ok().map(|img| {
|
||||
let img = if img.width().max(img.height()) > SOURCE_MAX_PX { img.thumbnail(SOURCE_MAX_PX, SOURCE_MAX_PX) } else { img };
|
||||
let rgba = img.to_rgba8();
|
||||
let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
|
||||
|
||||
@@ -82,16 +82,17 @@ impl ChartDataForm {
|
||||
Ok(json!({"data": {"categories": self.categories, "series": series}}))
|
||||
}
|
||||
|
||||
// Default names become document text (saved and exported), so no isolate marks.
|
||||
fn add_row(&mut self) {
|
||||
if self.categories.len() < MAX_POINTS {
|
||||
self.categories.push(crate::i18n::fmt(tl!("Category {n}"), &[("n", &(self.categories.len() + 1).to_string())]));
|
||||
self.categories.push(crate::i18n::fmt_plain(tl!("Category {n}"), &[("n", &(self.categories.len() + 1).to_string())]));
|
||||
self.cells.push(vec![String::new(); self.names.len()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_series(&mut self) {
|
||||
if self.names.len() < MAX_SERIES {
|
||||
self.names.push(crate::i18n::fmt(tl!("Series {n}"), &[("n", &(self.names.len() + 1).to_string())]));
|
||||
self.names.push(crate::i18n::fmt_plain(tl!("Series {n}"), &[("n", &(self.names.len() + 1).to_string())]));
|
||||
for r in &mut self.cells {
|
||||
r.push(String::new());
|
||||
}
|
||||
@@ -386,4 +387,19 @@ mod tests {
|
||||
assert!(pos.y < 20.0, "RIGHT_TOP leaves the number on the first line, not centred (pos={pos:?})");
|
||||
assert!(pos.x > 40.0, "the number is right-aligned (pos={pos:?})");
|
||||
}
|
||||
|
||||
/// Default names are saved into the document, so an RTL interface must not add isolate marks.
|
||||
#[test]
|
||||
fn default_names_in_an_rtl_interface_have_no_isolates() {
|
||||
crate::i18n::set_current(crate::i18n::fixture_lang("ar", "\tCategory {n}\tالفئة {n}\n\tSeries {n}\tالسلسلة {n}\n"));
|
||||
let mut f = ChartDataForm::of(&ChartSpec::sample(ChartType::Column));
|
||||
f.add_row();
|
||||
f.add_series();
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let (category, series) = (f.categories.last().unwrap(), f.names.last().unwrap());
|
||||
assert!(category.starts_with("الفئة") && series.starts_with("السلسلة"), "{category:?} {series:?}");
|
||||
for name in [category, series] {
|
||||
assert!(!name.chars().any(|c| ('\u{2066}'..='\u{2069}').contains(&c)), "{name:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,7 +407,7 @@ fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> bool {
|
||||
// Language
|
||||
|
||||
/// Proofing languages offered, by BCP 47 tag, with their English names (translated for display).
|
||||
pub const LANGUAGES: [(&str, &str); 36] = [
|
||||
pub const LANGUAGES: [(&str, &str); 38] = [
|
||||
("en-US", "English (United States)"),
|
||||
("en-GB", "English (United Kingdom)"),
|
||||
("en-AU", "English (Australia)"),
|
||||
@@ -420,6 +420,8 @@ pub const LANGUAGES: [(&str, &str); 36] = [
|
||||
("fr-FR", "French (France)"),
|
||||
("es-ES", "Spanish (Spain)"),
|
||||
("es-MX", "Spanish (Mexico)"),
|
||||
("es-419", "Spanish (Latin America)"),
|
||||
("es-UY", "Spanish (Uruguay)"),
|
||||
("it-IT", "Italian"),
|
||||
("pt-BR", "Portuguese (Brazil)"),
|
||||
("pt-PT", "Portuguese (Portugal)"),
|
||||
|
||||
@@ -353,6 +353,8 @@ impl AutoCorrectForm {
|
||||
"bullets": o.bullets,
|
||||
"numbering": o.numbering,
|
||||
"borderLines": o.border_lines,
|
||||
"tabIndent": o.tab_indent,
|
||||
"headingStyles": o.heading_styles,
|
||||
"autoAddFirstLetter": o.auto_add_first_letter,
|
||||
"autoAddInitialCaps": o.auto_add_initial_caps,
|
||||
})
|
||||
@@ -401,6 +403,9 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo
|
||||
ui.checkbox(&mut o.bullets, tl!("Automatic bulleted lists"));
|
||||
ui.checkbox(&mut o.numbering, tl!("Automatic numbered lists"));
|
||||
ui.checkbox(&mut o.border_lines, tl!("Border lines"));
|
||||
ui.checkbox(&mut o.heading_styles, tl!("Built-in Heading styles"));
|
||||
heading(ui, "Automatically as you type");
|
||||
ui.checkbox(&mut o.tab_indent, tl!("Set left- and first-indent with tabs and backspaces"));
|
||||
}
|
||||
_ => math_tab(ui, f),
|
||||
}
|
||||
|
||||
@@ -32,13 +32,14 @@
|
||||
//! - [`get`]: one context's translation only, never the plain row (`spacing`'s "Open").
|
||||
//! - [`location`]: a ribbon location such as `Home › Font`, segment by segment.
|
||||
//! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them.
|
||||
//! - [`fmt_plain`]: [`fmt`] without bidi isolate marks, for text that goes into the document.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
|
||||
use craft_i18n::I18n;
|
||||
|
||||
pub use craft_i18n::fmt;
|
||||
pub use craft_i18n::{fmt, fmt_plain};
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs"));
|
||||
|
||||
@@ -79,6 +80,8 @@ pub static LANGUAGES: [LangInfo; 27] = [
|
||||
LangInfo { code: "ko", prefer_hans: false, editing_lang: None },
|
||||
LangInfo { code: "uk", prefer_hans: false, editing_lang: None },
|
||||
// Spanish, neutral across Spain and Latin America; `es-ES`, `es-MX`, `es-419` … resolve here.
|
||||
// No fixed editing language: new documents are written in the region's Spanish
|
||||
// (`Session::region_spanish`), like English.
|
||||
LangInfo { code: "es", prefer_hans: false, editing_lang: None },
|
||||
// Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no
|
||||
// catalog yet (the European vocabulary differs), so they stay in English ([`lang_from_tag`]).
|
||||
|
||||
@@ -160,6 +160,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
||||
pen.line(&[(5.0, 12.0), (3.5, 15.0), (8.0, 15.0)]);
|
||||
pen.frect(2.0, 16.5, 18.0, 19.0, yellow);
|
||||
}
|
||||
"highlightNoColor" => {
|
||||
pen.fill(&[(5.0, 12.0), (11.0, 4.0), (14.0, 7.0), (8.0, 15.0)], c);
|
||||
pen.line(&[(5.0, 12.0), (3.5, 15.0), (8.0, 15.0)]);
|
||||
}
|
||||
"fontcolor" => {
|
||||
pen.text(10.0, 9.0, 14.0, "A", c, false);
|
||||
pen.frect(2.0, 16.5, 18.0, 19.0, red);
|
||||
@@ -1617,3 +1621,26 @@ pub fn library(p: &Painter, r: Rect, icon: &wordcraft_render::icon_lib::Icon, c:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn has_default_highlight_bar(name: &str) -> bool {
|
||||
let ctx = egui::Context::default();
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, egui::vec2(20.0, 20.0)), name, Color32::BLACK, Color32::BLACK);
|
||||
});
|
||||
let yellow = Color32::from_rgb(0xF2, 0xC8, 0x11);
|
||||
let found = output.shapes.iter().any(|shape| matches!(&shape.shape, Shape::Rect(rect) if rect.fill == yellow));
|
||||
output.drop_without_applying_deltas();
|
||||
found
|
||||
}
|
||||
|
||||
/// #4087: No Color uses the pen-only icon, not the ordinary icon's yellow fallback bar.
|
||||
#[test]
|
||||
fn no_colour_highlight_icon_has_no_yellow_bar() {
|
||||
assert!(has_default_highlight_bar("highlight"));
|
||||
assert!(!has_default_highlight_bar("highlightNoColor"));
|
||||
}
|
||||
}
|
||||
+170
-8
@@ -63,6 +63,7 @@ pub mod icons;
|
||||
pub mod ink;
|
||||
pub mod keys;
|
||||
pub mod keytips;
|
||||
mod local_time;
|
||||
pub mod mini_toolbar;
|
||||
pub mod objects;
|
||||
pub mod options;
|
||||
@@ -127,6 +128,15 @@ pub struct Services {
|
||||
/// #1315). The hook starts a ticket for such a save and reports how it ended; until then the
|
||||
/// document's save isn't confirmed, and a New/Open held back by the Save Changes prompt waits.
|
||||
pub async_saves: Option<async_saves::AsyncSaves>,
|
||||
/// Web, framed by a page that keeps the files (`?host=parent`): set alongside `download`,
|
||||
/// Save, Save As and Export hand the bytes to that page instead, with whether it was Save As
|
||||
/// ([`HostSaveFn`]: true for a `file.saveAs` in a format that keeps everything, which File ›
|
||||
/// Export to such a format runs too, and for a Save under a name the page never sent; false
|
||||
/// for Save and for copies: the PDF and PNG exports and Save As in a format that doesn't keep
|
||||
/// everything). Save then writes the opened file
|
||||
/// under its own name and format, as on the desktop, and a Save or Save As that keeps
|
||||
/// everything makes that name the document's file, so the next Save goes to it.
|
||||
pub host_save: Option<HostSaveFn>,
|
||||
/// Web: told whether the document has unsaved changes after each pass, for the browser's
|
||||
/// leave-page guard (`beforeunload` runs between frames and can't ask the app).
|
||||
pub on_dirty: Option<Box<dyn Fn(bool)>>,
|
||||
@@ -223,6 +233,10 @@ pub enum DefaultApp {
|
||||
/// Files delivered asynchronously.
|
||||
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
|
||||
/// the file, and the save failed.
|
||||
pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
|
||||
|
||||
/// Whether a pasted picture belongs in the document: the page has keyboard focus, and no
|
||||
/// Backstage or dialog is open (#1349). A focused text field (comment, Find box) means the page
|
||||
/// does not have focus, so `canvas_focused` already covers it; egui's own "wants keyboard input"
|
||||
@@ -1036,7 +1050,7 @@ impl WordApp {
|
||||
}
|
||||
// Web: saving and exporting become downloads.
|
||||
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
|
||||
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.web_save_name(id));
|
||||
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
|
||||
// A save with a password encrypts this and later saves (`file.save`'s `password`).
|
||||
if matches!(id, "file.save" | "file.saveAs")
|
||||
@@ -1061,19 +1075,31 @@ impl WordApp {
|
||||
if let Some(saves) = &saves {
|
||||
let _ = saves.take_started();
|
||||
}
|
||||
if let Some(d) = &self.services.download
|
||||
&& let Err(e) = d(&name, &bytes)
|
||||
{
|
||||
// An export is a copy, and so is a save in a format that doesn't keep everything (a
|
||||
// page image, plain text): the document itself still has unsaved changes.
|
||||
let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
|
||||
// Save As tells the host page the document is now this file, which it only is when the
|
||||
// save marks it saved. A Save under a name the page never sent (an untitled document,
|
||||
// an imported .doc saved as .docx) is a new file too, never an overwrite.
|
||||
let opened_name = self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
let new_file = id == "file.saveAs" || opened_name.as_deref() != Some(name.as_str());
|
||||
let delivered = match (&self.services.host_save, &self.services.download) {
|
||||
(Some(host), _) => host(&name, &bytes, marked_saved && new_file),
|
||||
(None, Some(download)) => download(&name, &bytes),
|
||||
(None, None) => Ok(()),
|
||||
};
|
||||
if let Err(e) = delivered {
|
||||
let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &name), ("error", &e)]);
|
||||
log::error!("download of {name} failed: {e}");
|
||||
self.status(msg.clone());
|
||||
return Err(msg);
|
||||
}
|
||||
// An export is a copy, and so is a save in a format that doesn't keep everything (a
|
||||
// page image, plain text): the document itself still has unsaved changes.
|
||||
let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
|
||||
if marked_saved {
|
||||
self.session.dirty = false;
|
||||
// The host page keeps the file under this name now, as a desktop save would.
|
||||
if self.services.host_save.is_some() {
|
||||
self.session.path = Some(std::path::PathBuf::from(&name));
|
||||
}
|
||||
}
|
||||
// The browser's picker writes the file later (#1315): until it says how that went,
|
||||
// the document's save isn't confirmed.
|
||||
@@ -2011,6 +2037,26 @@ impl WordApp {
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("doc") || e.eq_ignore_ascii_case("dot"))
|
||||
}
|
||||
|
||||
/// The name a browser Save or Export without a name uses. An export is named after the
|
||||
/// document (`report.pdf`, not `report.docx.pdf`). A host page that keeps the files
|
||||
/// ([`Services::host_save`]) gets Save back under the opened file's own name and format, as
|
||||
/// the desktop app writes it, except an import-only Word 97–2003 file (#2445).
|
||||
fn web_save_name(&self, id: &str) -> String {
|
||||
match id {
|
||||
"file.exportPdf" => return format!("{}.pdf", self.title_stem()),
|
||||
"file.exportPng" => return format!("{}.png", self.title_stem()),
|
||||
_ => {}
|
||||
}
|
||||
if id == "file.save"
|
||||
&& self.services.host_save.is_some()
|
||||
&& !self.legacy_word_path()
|
||||
&& let Some(name) = self.session.path.as_ref().and_then(|p| p.file_name())
|
||||
{
|
||||
return name.to_string_lossy().to_string();
|
||||
}
|
||||
self.default_save_name()
|
||||
}
|
||||
|
||||
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
|
||||
/// otherwise .docx.
|
||||
fn default_save_name(&self) -> String {
|
||||
@@ -2200,10 +2246,12 @@ impl WordApp {
|
||||
// New documents are written in the interface's language when WordCraft can proof it
|
||||
// (a Polish interface: Polish spelling, grammar and hyphenation from the first word).
|
||||
// An English interface writes the region's English (English (UK) on a British system,
|
||||
// #1045).
|
||||
// #1045), a Spanish one the region's Spanish (Spanish (Uruguay) on a Uruguayan system,
|
||||
// Spain's elsewhere, #3360).
|
||||
let editing = match lang.editing_lang() {
|
||||
Some(t) => Some(t.to_string()),
|
||||
None if lang.code() == "en" => self.session.region_english(),
|
||||
None if lang.code() == "es" => Some(self.session.region_spanish()),
|
||||
None => None,
|
||||
};
|
||||
if self.applied_editing.as_ref().is_none_or(|(l, e)| *l != lang || *e != editing) {
|
||||
@@ -6990,6 +7038,120 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Web inside a page that keeps the files: the app with `host_save` recording each file it
|
||||
/// is handed (name, Save As, size).
|
||||
fn host_app(fail: bool) -> (WordApp, std::rc::Rc<std::cell::RefCell<Vec<(String, bool, usize)>>>) {
|
||||
let mut a = typed();
|
||||
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let seen = got.clone();
|
||||
a.services.download = Some(Box::new(|_, _| Err("downloads go to the host page".into())));
|
||||
a.services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
|
||||
if fail {
|
||||
return Err("the page is gone".into());
|
||||
}
|
||||
seen.borrow_mut().push((name.to_string(), save_as, bytes.len()));
|
||||
Ok(())
|
||||
}));
|
||||
(a, got)
|
||||
}
|
||||
|
||||
/// Open `name` as the host page sends it (bytes of the typed document in that format).
|
||||
fn host_open(a: &mut WordApp, name: &str) {
|
||||
let bytes = wordcraft_engine::io::save_bytes_with(name, &typed().session.doc, None, &Default::default()).unwrap();
|
||||
a.execute("file.open", json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})).unwrap();
|
||||
a.run("text.insert", json!({"text": "Edited "})).unwrap();
|
||||
assert!(a.session.dirty);
|
||||
}
|
||||
|
||||
/// Save hands the opened file back under its own name and format; Save As says so and
|
||||
/// becomes the file the next Save writes.
|
||||
#[test]
|
||||
fn host_saves_go_back_to_the_opened_file() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "report.odt");
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert!(!a.session.dirty);
|
||||
a.run("text.insert", json!({"text": "More "})).unwrap();
|
||||
a.execute("file.saveAs", json!({"path": "report copy.docx"})).unwrap();
|
||||
assert!(!a.session.dirty);
|
||||
a.run("text.insert", json!({"text": "Again "})).unwrap();
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
let got = got.borrow();
|
||||
let calls: Vec<(&str, bool)> = got.iter().map(|(n, s, _)| (n.as_str(), *s)).collect();
|
||||
assert_eq!(calls, [("report.odt", false), ("report copy.docx", true), ("report copy.docx", false)]);
|
||||
assert!(got.iter().all(|(_, _, len)| *len > 0));
|
||||
}
|
||||
|
||||
/// A Markdown file goes back as Markdown, which doesn't keep everything: the document stays
|
||||
/// unsaved, as on the desktop.
|
||||
#[test]
|
||||
fn host_save_of_markdown_keeps_its_format_and_stays_unsaved() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "notes.md");
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert_eq!(got.borrow().first().map(|(n, s, _)| (n.as_str(), *s)), Some(("notes.md", false)));
|
||||
assert!(a.session.dirty);
|
||||
assert_eq!(a.session.path.as_deref(), Some(std::path::Path::new("notes.md")));
|
||||
}
|
||||
|
||||
/// An import-only Word 97–2003 file is saved as a .docx, never back over itself (#2445). The
|
||||
/// host page never had that name, so it's a new file: Save As.
|
||||
#[test]
|
||||
fn host_save_of_a_legacy_word_file_writes_a_docx() {
|
||||
let (mut a, got) = host_app(false);
|
||||
a.session.path = Some("old.doc".into());
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
assert_eq!(got.borrow().first().map(|(n, s, _)| (n.as_str(), *s)), Some(("old.docx", true)));
|
||||
}
|
||||
|
||||
/// Saving a document the host page never sent is Save As, so the page doesn't overwrite a
|
||||
/// file of the same name; the next Save goes back to it.
|
||||
#[test]
|
||||
fn host_save_of_an_untitled_document_is_save_as() {
|
||||
let (mut a, got) = host_app(false);
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
a.run("text.insert", json!({"text": "More "})).unwrap();
|
||||
a.execute("file.save", json!({})).unwrap();
|
||||
let got = got.borrow();
|
||||
let calls: Vec<bool> = got.iter().map(|(_, s, _)| *s).collect();
|
||||
assert_eq!(calls, [true, false], "{got:?}");
|
||||
assert_eq!(got.first().map(|(n, ..)| n), got.get(1).map(|(n, ..)| n));
|
||||
}
|
||||
|
||||
/// Exports without a name are named after the document, not after its file.
|
||||
#[test]
|
||||
fn web_exports_are_named_after_the_document() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "report.docx");
|
||||
a.execute("file.exportPdf", json!({})).unwrap();
|
||||
a.execute("file.exportPng", json!({})).unwrap();
|
||||
let names: Vec<(String, bool)> = got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect();
|
||||
assert_eq!(names, [("report.pdf".to_string(), false), ("report.png".to_string(), false)]);
|
||||
assert!(a.session.dirty, "an export is a copy");
|
||||
}
|
||||
|
||||
/// Save As in a format that doesn't keep everything (File › Export to PDF or Markdown) is a
|
||||
/// copy: the host page isn't told the document is now that file.
|
||||
#[test]
|
||||
fn host_save_as_a_copy_format_is_not_save_as() {
|
||||
let (mut a, got) = host_app(false);
|
||||
host_open(&mut a, "report.docx");
|
||||
a.execute("file.saveAs", json!({"path": "report.pdf"})).unwrap();
|
||||
a.execute("file.saveAs", json!({"path": "report.md"})).unwrap();
|
||||
let calls: Vec<(String, bool)> = got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect();
|
||||
assert_eq!(calls, [("report.pdf".to_string(), false), ("report.md".to_string(), false)]);
|
||||
assert_eq!(a.session.path.as_deref(), Some(std::path::Path::new("report.docx")));
|
||||
}
|
||||
|
||||
/// The page not getting the file is a failed save: the document keeps its changes.
|
||||
#[test]
|
||||
fn a_host_save_that_fails_leaves_the_document_unsaved() {
|
||||
let (mut a, _) = host_app(true);
|
||||
host_open(&mut a, "report.docx");
|
||||
assert!(a.execute("file.save", json!({})).is_err());
|
||||
assert!(a.session.dirty);
|
||||
}
|
||||
|
||||
/// Macro-enabled documents and templates keep everything (#172), so once saved in this
|
||||
/// session AutoSave covers them like a .docx.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Comment times in the user's own time zone. A document keeps a comment's time as written, and
|
||||
//! its UTC time beside it when it knows it (`w16cex:dateUtc`, which WordCraft writes for the
|
||||
//! comments it stamps). The Comments pane and the balloons show it in local time, as Word does.
|
||||
|
||||
/// Minutes to add to UTC for local time at Unix time `secs` (east of Greenwich is positive).
|
||||
fn offset_minutes(secs: i64) -> i32 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let minutes = {
|
||||
use chrono::{Local, Offset, TimeZone};
|
||||
Local.timestamp_opt(secs, 0).earliest().map_or(0, |t| t.offset().fix().local_minus_utc() / 60)
|
||||
};
|
||||
// The browser knows the zone: `getTimezoneOffset` is UTC minus local time, in minutes.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let minutes = {
|
||||
let d = js_sys::Date::new_0();
|
||||
let offset = if d.set_time(secs as f64 * 1000.0).is_finite() { d.get_timezone_offset() } else { 0.0 };
|
||||
if offset.is_finite() { -(offset as i32) } else { 0 }
|
||||
};
|
||||
// Real zones are within a day of UTC.
|
||||
minutes.clamp(-24 * 60, 24 * 60)
|
||||
}
|
||||
|
||||
/// A comment's time for the pane and the balloons: `YYYY-MM-DD HH:MM` in local time. The UTC
|
||||
/// time (`date_utc`, Word's `w16cex:dateUtc`) wins, else `date`, converted when it names an
|
||||
/// offset (`±HH:MM`) and shown as written when it doesn't. A `Z` on `date` alone doesn't count:
|
||||
/// Word writes local time there with a `Z` it doesn't mean, and files from before `dateUtc` have
|
||||
/// nothing else. Empty when neither is a date.
|
||||
pub(crate) fn comment_time(c: &wordcraft_doc::Comment) -> String {
|
||||
stamp(&c.date, c.date_utc.as_deref(), offset_minutes)
|
||||
}
|
||||
|
||||
fn stamp(date: &str, date_utc: Option<&str>, offset: impl Fn(i64) -> i32) -> String {
|
||||
let utc = date_utc.and_then(parse).filter(|t| t.timed).map(|t| Time { zone: Some(t.zone.unwrap_or(0)), ..t });
|
||||
let local_date = || parse(date).map(|t| if date.trim_end().ends_with(['Z', 'z']) { Time { zone: None, ..t } } else { t });
|
||||
let Some(t) = utc.or_else(local_date) else { return String::new() };
|
||||
// A date without a time of day stays the date it says.
|
||||
let local = match t.zone.filter(|_| t.timed) {
|
||||
Some(zone) => {
|
||||
let utc = t.secs - i64::from(zone) * 60;
|
||||
utc + i64::from(offset(utc)) * 60
|
||||
}
|
||||
None => t.secs,
|
||||
};
|
||||
let (y, m, d) = wordcraft_engine::cmd::civil_from_days(local.div_euclid(86_400));
|
||||
if !t.timed {
|
||||
return format!("{y:04}-{m:02}-{d:02}");
|
||||
}
|
||||
let s = local.rem_euclid(86_400);
|
||||
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", s / 3600, s / 60 % 60)
|
||||
}
|
||||
|
||||
/// A date and time as a comment stores it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Time {
|
||||
/// The wall time as if it were UTC, in seconds since the Unix epoch.
|
||||
secs: i64,
|
||||
/// The zone's offset from UTC in minutes, when the text names one.
|
||||
zone: Option<i32>,
|
||||
/// It has a time of day, not just a date.
|
||||
timed: bool,
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM]`, else None.
|
||||
fn parse(iso: &str) -> Option<Time> {
|
||||
let iso = iso.trim();
|
||||
let num = |a: usize, b: usize| -> Option<u32> {
|
||||
let s = iso.get(a..b)?;
|
||||
if s.bytes().all(|c| c.is_ascii_digit()) { s.parse().ok() } else { None }
|
||||
};
|
||||
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
|
||||
if iso.get(4..5) != Some("-") || iso.get(7..8) != Some("-") || !(1..=12).contains(&mo) || d == 0 || d > days_in_month(y, mo) || y == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut secs = days_from_civil(i64::from(y), mo, d) * 86_400;
|
||||
let mut rest = iso.get(10..)?;
|
||||
let mut zone = None;
|
||||
let mut timed = false;
|
||||
if let Some(time) = rest.strip_prefix('T').or_else(|| rest.strip_prefix(' ')) {
|
||||
timed = true;
|
||||
let part = |a: usize, b: usize| time.get(a..b).filter(|s| s.bytes().all(|c| c.is_ascii_digit())).and_then(|s| s.parse::<u32>().ok());
|
||||
let (h, mi) = (part(0, 2)?, part(3, 5)?);
|
||||
if time.get(2..3) != Some(":") || h > 23 || mi > 59 {
|
||||
return None;
|
||||
}
|
||||
let mut end = 5;
|
||||
let mut s = 0;
|
||||
if time.get(5..6) == Some(":") {
|
||||
s = part(6, 8).filter(|s| *s <= 59)?;
|
||||
end = 8;
|
||||
// Fractions of a second.
|
||||
if time.get(8..9) == Some(".") {
|
||||
end = 9 + time.get(9..)?.bytes().take_while(u8::is_ascii_digit).count();
|
||||
}
|
||||
}
|
||||
secs += i64::from(h * 3600 + mi * 60 + s);
|
||||
rest = time.get(end..)?;
|
||||
}
|
||||
match rest {
|
||||
"" => {}
|
||||
"Z" | "z" => zone = Some(0),
|
||||
_ => {
|
||||
let sign = match rest.get(0..1) {
|
||||
Some("+") => 1,
|
||||
Some("-") => -1,
|
||||
_ => return None,
|
||||
};
|
||||
let digits: String = rest.get(1..)?.chars().filter(|c| *c != ':').collect();
|
||||
if digits.len() != 4 || !digits.bytes().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let (h, m) = (digits.get(0..2)?.parse::<i32>().ok()?, digits.get(2..4)?.parse::<i32>().ok()?);
|
||||
if h > 18 || m > 59 {
|
||||
return None;
|
||||
}
|
||||
zone = Some(sign * (h * 60 + m));
|
||||
}
|
||||
}
|
||||
Some(Time { secs, zone, timed })
|
||||
}
|
||||
|
||||
/// Days in month `m` (1–12) of year `y`, proleptic Gregorian.
|
||||
fn days_in_month(y: u32, m: u32) -> u32 {
|
||||
match m {
|
||||
2 if y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400)) => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 of a proleptic Gregorian date (the inverse of
|
||||
/// [`wordcraft_engine::cmd::civil_from_days`]).
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = i64::from((m + 9) % 12);
|
||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn days_round_trip() {
|
||||
for days in [-800_000, -1, 0, 1, 19_000, 20_376, 2_932_896] {
|
||||
let (y, m, d) = wordcraft_engine::cmd::civil_from_days(days);
|
||||
assert_eq!(days_from_civil(y, m, d), days);
|
||||
}
|
||||
assert_eq!(days_from_civil(2026, 10, 12), 20_738);
|
||||
}
|
||||
|
||||
/// WordCraft's own comments carry their UTC time: shown in the user's zone, across a date
|
||||
/// line too.
|
||||
#[test]
|
||||
fn utc_times_show_in_local_time() {
|
||||
let toronto = |_: i64| -4 * 60;
|
||||
let tokyo = |_: i64| 9 * 60;
|
||||
let ours = |d: &str, offset: fn(i64) -> i32| stamp(d, Some(d), offset);
|
||||
assert_eq!(ours("2026-10-12T04:44:31Z", toronto), "2026-10-12 00:44");
|
||||
assert_eq!(ours("2026-10-12T02:05:00Z", toronto), "2026-10-11 22:05");
|
||||
assert_eq!(ours("2026-10-12T20:30:00Z", tokyo), "2026-10-13 05:30");
|
||||
assert_eq!(ours("2026-10-12T04:44:31Z", |_| 0), "2026-10-12 04:44");
|
||||
// A zone in the text is honoured, and fractions of a second are fine.
|
||||
assert_eq!(stamp("2026-10-12T09:00:00+05:30", None, |_| 0), "2026-10-12 03:30");
|
||||
assert_eq!(stamp("2026-10-12T09:00:00.125-0200", None, |_| 0), "2026-10-12 11:00");
|
||||
assert_eq!(ours("2026-10-12T04:44Z", toronto), "2026-10-12 00:44");
|
||||
}
|
||||
|
||||
/// A Word file's `date` is local time (often with a Z it doesn't mean) and its `dateUtc` the real
|
||||
/// time: the real time wins. A time without a zone shows as written.
|
||||
#[test]
|
||||
fn word_times_prefer_the_utc_one() {
|
||||
let berlin = |_: i64| 2 * 60;
|
||||
assert_eq!(stamp("2026-10-12T10:00:00Z", Some("2026-10-12T08:00:00Z"), berlin), "2026-10-12 10:00");
|
||||
// Without `dateUtc` (older files), the local time shows as written, `Z` or not.
|
||||
assert_eq!(stamp("2026-10-12T10:00:00Z", None, berlin), "2026-10-12 10:00");
|
||||
assert_eq!(stamp("2026-10-12T10:00:00", None, berlin), "2026-10-12 10:00");
|
||||
assert_eq!(stamp("2026-10-12T10:00:00", Some("not a date"), berlin), "2026-10-12 10:00");
|
||||
// A date alone stays a date.
|
||||
assert_eq!(stamp("2026-10-12", None, berlin), "2026-10-12");
|
||||
assert_eq!(stamp("2026-10-12Z", Some("2026-10-12"), |_| -4 * 60), "2026-10-12");
|
||||
}
|
||||
|
||||
/// Whatever a file holds, a stamp is a date or nothing, never a panic.
|
||||
#[test]
|
||||
fn hostile_times_show_nothing() {
|
||||
for bad in [
|
||||
"",
|
||||
"yesterday",
|
||||
"2026-13-01T00:00:00Z",
|
||||
"2026-10-12T25:00:00Z",
|
||||
"2026-10-12T10:61Z",
|
||||
"2026-10-12T10:00:00+99:00",
|
||||
"2026-10-12T10:00:00+1",
|
||||
"2026-1O-12",
|
||||
"0000-01-01",
|
||||
"2026-10-12",
|
||||
"2026-10-12T10:00:00ZZ",
|
||||
"2026-10-12T1",
|
||||
"+2026-10-12",
|
||||
"2026-02-30T10:00:00Z",
|
||||
"2026-02-29",
|
||||
"2026-10-12T10:00:99Z",
|
||||
] {
|
||||
assert_eq!(stamp(bad, None, |_| 0), "", "{bad:?}");
|
||||
}
|
||||
assert_eq!(stamp("2024-02-29", None, |_| 0), "2024-02-29");
|
||||
assert_eq!(stamp("9999-12-31T23:59:59Z", Some("9999-12-31T23:59:59Z"), |_| 24 * 60), "10000-01-01 23:59");
|
||||
assert_eq!(stamp("0001-01-01T00:00:00Z", Some("0001-01-01T00:00:00Z"), |_| -24 * 60), "0000-12-31 00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_offset_is_a_real_zone() {
|
||||
let m = offset_minutes(1_760_000_000);
|
||||
assert!((-24 * 60..=24 * 60).contains(&m), "{m}");
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
sep(ui, t);
|
||||
|
||||
let hl = app.canvas.last_highlight.clone();
|
||||
widgets::split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| {
|
||||
let (icon, sw, color) = highlight_button_appearance(&hl);
|
||||
widgets::split(ui, app, icon, "Text Highlight Color", "format.highlight", json!({"color": color}), false, sw, |ui, app| {
|
||||
highlight_menu(ui, app);
|
||||
});
|
||||
let fc = app.canvas.last_font_color.clone();
|
||||
@@ -303,6 +304,13 @@ fn sep(ui: &mut egui::Ui, t: &Tokens) {
|
||||
p.vline(r.center().x, r.y_range().shrink(4.0), egui::Stroke::new(1.0, t.border));
|
||||
}
|
||||
|
||||
/// The split-button icon and colour bar for the remembered highlight choice.
|
||||
pub(crate) fn highlight_button_appearance(name: &str) -> (&'static str, Option<egui::Color32>, &'static str) {
|
||||
let highlight = wordcraft_doc::props::Highlight::from_ooxml(name);
|
||||
let icon = if highlight == wordcraft_doc::props::Highlight::None { "highlightNoColor" } else { "highlight" };
|
||||
(icon, highlight.rgb().map(crate::theme::c32), highlight.ooxml())
|
||||
}
|
||||
|
||||
/// Highlight colour grid and No Color, shared by the ribbon and the mini toolbar. The pick,
|
||||
/// No Color too, becomes the split button's colour, as in Word: after No Color a click on the
|
||||
/// button removes highlighting (#2105).
|
||||
@@ -348,6 +356,13 @@ fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn highlight_button_appearance_follows_the_picked_colour() {
|
||||
assert_eq!(highlight_button_appearance("cyan"), ("highlight", Some(egui::Color32::from_rgb(0, 255, 255)), "cyan"));
|
||||
assert_eq!(highlight_button_appearance("none"), ("highlightNoColor", None, "none"));
|
||||
assert_eq!(highlight_button_appearance("unknown"), ("highlightNoColor", None, "none"));
|
||||
}
|
||||
|
||||
fn vp() -> Rect {
|
||||
Rect::from_min_size(pos2(100.0, 50.0), Vec2::new(1200.0, 700.0))
|
||||
}
|
||||
@@ -859,6 +874,14 @@ mod tests {
|
||||
let arrow = highlight_arrow(&h);
|
||||
click_at(&mut h, pos2(arrow.min.x - 12.0, arrow.center().y));
|
||||
assert_eq!(format_state(&mut h).get("highlight"), Some(&Value::Null), "the button applies No Color, not yellow again");
|
||||
|
||||
// A stale or corrupted remembered value looks like No Color and must act like it too.
|
||||
h.state_mut().canvas.last_highlight = "unknown".into();
|
||||
h.state_mut().run("format.highlight", json!({"color": "yellow"})).unwrap();
|
||||
steps(&mut h);
|
||||
let arrow = highlight_arrow(&h);
|
||||
click_at(&mut h, pos2(arrow.min.x - 12.0, arrow.center().y));
|
||||
assert_eq!(format_state(&mut h).get("highlight"), Some(&Value::Null), "an unknown remembered colour is normalized to No Color");
|
||||
}
|
||||
|
||||
/// #3039: picking Automatic from the bar's font-colour menu hands the keyboard back to the
|
||||
|
||||
@@ -122,12 +122,22 @@ fn active_story(app: &WordApp, layout: &DocLayout) -> StoryRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected
|
||||
/// and deleted, not moved or resized (they aren't written back on save yet), so they get no
|
||||
/// handles, no move cursor and no drag.
|
||||
/// Whether the object under `hit` keeps its place and size: a chart or diagram WordCraft has no
|
||||
/// model for can be selected and deleted, not moved or resized, so it gets no handles, no move
|
||||
/// cursor and no drag.
|
||||
fn fixed(app: &WordApp, hit: &ObjectHit) -> bool {
|
||||
let pos = hit.pos();
|
||||
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. }))
|
||||
matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. })) && !size_only(app, hit)
|
||||
}
|
||||
|
||||
/// Whether the object under `hit` is a chart or SmartArt that WordCraft can edit: it takes
|
||||
/// resize handles and is laid out again at the new size, but it can't be moved or turned.
|
||||
fn size_only(app: &WordApp, hit: &ObjectHit) -> bool {
|
||||
let pos = hit.pos();
|
||||
matches!(
|
||||
app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)),
|
||||
Some(InlineObject::Graphic { graphic, .. }) if graphic.chart.is_some() || graphic.smart_art.is_some()
|
||||
)
|
||||
}
|
||||
|
||||
/// The story of the selected text box.
|
||||
@@ -294,7 +304,8 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
None => return true,
|
||||
};
|
||||
// Objects in table cells can be selected and resized, not moved; charts neither.
|
||||
if !fixed(app, &object) && (grab != Grab::Move || movable(app, &object)) {
|
||||
let size_only = size_only(app, &object);
|
||||
if !fixed(app, &object) && (grab != Grab::Move || (movable(app, &object) && !size_only)) {
|
||||
let copy = grab == Grab::Move && ui.input(|i| i.modifiers.command);
|
||||
app.canvas.obj_drag = Some(ObjectDrag {
|
||||
drag: Drag::new(grab, object.page, object.rect, object.spin.deg, at),
|
||||
@@ -432,7 +443,7 @@ fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Po
|
||||
let story = active_story(app, layout);
|
||||
let handle = active(app, layout).filter(|(o, _)| o.story == story && !fixed(app, o)).and_then(|(o, _)| {
|
||||
let f = screen(pages, scale, o.page, o.rect)?;
|
||||
if frame::rotate_handle_at(f, o.spin.deg, at) {
|
||||
if !size_only(app, &o) && frame::rotate_handle_at(f, o.spin.deg, at) {
|
||||
return Some((o, Grab::Rotate));
|
||||
}
|
||||
frame::handle_at(f, o.spin.deg, at).map(|h| (o, Grab::Resize(h)))
|
||||
@@ -522,6 +533,11 @@ fn drop(app: &mut WordApp, d: &ObjectDrag) {
|
||||
return;
|
||||
}
|
||||
let r = d.drag.rect;
|
||||
// A chart or SmartArt takes a new size, never a new place.
|
||||
if matches!(d.drag.grab, Grab::Resize(_)) && size_only(app, &d.object) {
|
||||
let _ = app.run("picture.size", json!({"width": r.w, "height": r.h, "lockAspect": false}));
|
||||
return;
|
||||
}
|
||||
let params = match d.drag.grab {
|
||||
Grab::Rotate => {
|
||||
let _ = app.run("arrange.rotation", json!({"degrees": d.drag.deg}));
|
||||
@@ -624,7 +640,7 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
|
||||
// Handles where it can be resized and turned (body and header/footer objects,
|
||||
// not charts): else a plain frame.
|
||||
let handles = selectable_story(app, o.story) && !fixed(app, &o);
|
||||
frame::paint(painter, t, f, o.spin.deg, editing, handles, handles);
|
||||
frame::paint(painter, t, f, o.spin.deg, editing, handles, handles && !size_only(app, &o));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,7 +709,13 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
|
||||
if connector_end_at(app, layout, pages, scale, at).is_some() {
|
||||
return Some(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
grab_at(app, layout, pages, scale, at).map(|(o, g)| if fixed(app, &o) { egui::CursorIcon::Default } else { g.cursor() })
|
||||
grab_at(app, layout, pages, scale, at).map(|(o, g)| hover_cursor(app, &o, g))
|
||||
}
|
||||
|
||||
/// The cursor over part `g` of object `o`: none of a drag for an object that won't move there (a
|
||||
/// fixed graphic, or the body of a chart or SmartArt, which only resizes).
|
||||
fn hover_cursor(app: &WordApp, o: &ObjectHit, g: Grab) -> egui::CursorIcon {
|
||||
if fixed(app, o) || (g == Grab::Move && size_only(app, o)) { egui::CursorIcon::Default } else { g.cursor() }
|
||||
}
|
||||
|
||||
/// Keys for a selected object: arrows nudge a floating one; typing or Enter goes into a text box.
|
||||
@@ -767,6 +789,23 @@ mod tests {
|
||||
let (app, hit) = app_with(shape);
|
||||
assert!(!fixed(&app, &hit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_smart_art_takes_resize_handles_but_not_a_move() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default());
|
||||
app.run("insert.smartArt", json!({"layout": "pyramid", "items": ["a", "b"]})).unwrap();
|
||||
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
|
||||
assert!(!fixed(&app, &hit));
|
||||
assert!(size_only(&app, &hit));
|
||||
// Its body shows no move cursor; its handles show theirs.
|
||||
assert_eq!(hover_cursor(&app, &hit, Grab::Move), egui::CursorIcon::Default);
|
||||
assert_ne!(hover_cursor(&app, &hit, Grab::Resize(frame::Handle { x: 1, y: 0 })), egui::CursorIcon::Default);
|
||||
let (w0, h0) = (hit.rect.w, hit.rect.h);
|
||||
assert!(app.run("arrange.bounds", json!({"width": w0 / 2.0, "height": h0 / 2.0})).is_err());
|
||||
app.run("picture.size", json!({"width": w0 / 2.0, "height": h0 / 2.0, "lockAspect": false})).unwrap();
|
||||
let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
|
||||
assert!((hit.rect.w - w0 / 2.0).abs() < 1.0);
|
||||
}
|
||||
/// Alignment guides: dragging a floating shape to within a few points of the page's centre
|
||||
/// snaps its centre exactly onto it and shows the guide while dragging; the drop lands there,
|
||||
/// and the guides go. Holding Alt drags freely.
|
||||
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user