mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 05:47:00 -06:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2dc9e99c | ||
|
|
81bf9187f2 | ||
|
|
9bfdbee0b7 | ||
|
|
1800007463 | ||
|
|
4b450f1b00 | ||
|
|
29e978d6d8 |
Generated
+159
@@ -121,6 +121,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -488,6 +499,15 @@ version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -672,6 +692,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -681,6 +711,12 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -736,6 +772,12 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -785,6 +827,21 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -834,6 +891,24 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -864,6 +939,18 @@ dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -1406,9 +1493,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1739,6 +1828,24 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
@@ -1888,6 +1995,15 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
@@ -3418,6 +3534,28 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3885,6 +4023,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -4927,13 +5071,21 @@ dependencies = [
|
||||
name = "wordcraft-docx"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"cfb",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"image",
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.38.4",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -4959,6 +5111,7 @@ dependencies = [
|
||||
"wordcraft-pdf",
|
||||
"wordcraft-proof",
|
||||
"wordcraft-render",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -5392,6 +5545,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -52,6 +52,14 @@ krilla = { version = "0.8", features = ["pdf"] }
|
||||
quick-xml = "0.38"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
cfb = "0.15"
|
||||
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
|
||||
aes = "0.9"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
hmac = "0.13"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
|
||||
@@ -234,6 +234,12 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
**If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a
|
||||
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's
|
||||
software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL
|
||||
instead, without setting anything. The log file records the choice (lines starting with
|
||||
`graphics:`). `WGPU_BACKEND` still overrides it, e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
|
||||
+6
-2
@@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **384 / 430 (89.3%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **390 / 431 (90.5%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
|
||||
@@ -130,7 +130,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176); v0.4.0 released |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176); System theme keeps following the OS and Dark page leaves the interface alone (#311, #312); v0.4.0 released |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316); v0.4.0 released |
|
||||
| 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) |
|
||||
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
|
||||
@@ -141,6 +142,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
|
||||
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |
|
||||
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
|
||||
| 2026-10-10 | minor | Merged main: catalog 384/430 (89.3%), 431 commands; Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info, Serbian landed; mainstream 54→55% |
|
||||
| 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h |
|
||||
| 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the standard's discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha |
|
||||
|
||||
@@ -1,33 +1,122 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, DeviceType},
|
||||
};
|
||||
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
/// What the start-up probe learned about one DirectX 12 adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Dx12Adapter {
|
||||
/// `Cpu` is WARP (Microsoft's software rasteriser): it works, but every frame is drawn on the
|
||||
/// processor, which makes the app lag on older machines (#316).
|
||||
pub device_type: DeviceType,
|
||||
/// Whether the adapter offers the limits eframe will ask for when it creates the device.
|
||||
pub meets_limits: bool,
|
||||
}
|
||||
|
||||
/// The backends to initialize on Windows.
|
||||
///
|
||||
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
|
||||
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
|
||||
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
|
||||
/// driver has no DirectX 12 support (Intel 4th generation on Windows 11, #316) still have an
|
||||
/// OpenGL driver, and GL can't reach the AMD driver crash described in [`configure_backends`],
|
||||
/// because an AMD GPU with a driver shows up as a DirectX 12 adapter and keeps us on DirectX 12.
|
||||
/// When DirectX 12 offers only WARP, it stays enabled next to GL as a last resort for machines
|
||||
/// without an OpenGL driver either (virtual machines): wgpu prefers any GPU over a software
|
||||
/// adapter, so it's only picked when GL has nothing better.
|
||||
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
|
||||
if let Some(backends) = backend_override {
|
||||
return backends;
|
||||
}
|
||||
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
|
||||
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
|
||||
Backends::DX12
|
||||
} else if !dx12_adapters.is_empty() && !dx12_adapters.iter().any(|a| hardware(&a)) {
|
||||
Backends::DX12 | Backends::GL
|
||||
} else {
|
||||
// No DirectX 12 adapter at all, or only GPUs that can't run the app: GL alone, so a
|
||||
// DirectX 12 GPU that would fail device creation isn't picked over the OpenGL one.
|
||||
Backends::GL
|
||||
}
|
||||
}
|
||||
|
||||
/// Restricts the wgpu instance eframe creates to `backends`.
|
||||
pub fn configure_backends(options: &mut NativeOptions, backends: Backends) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
create.instance_descriptor.backends = backends;
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses the backends ([`choose_backends`]) and applies them to `options`. Without a
|
||||
/// `backend_override` this first probes DirectX 12 with a short-lived instance of its own, which
|
||||
/// is dropped before eframe creates the real one.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
let backends = match backend_override {
|
||||
Some(backends) => {
|
||||
log::info!("graphics: WGPU_BACKEND override, backends {backends:?}");
|
||||
backends
|
||||
}
|
||||
None => {
|
||||
let adapters = probe_dx12(options);
|
||||
let backends = choose_backends(None, &adapters);
|
||||
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
|
||||
backends
|
||||
}
|
||||
};
|
||||
configure_backends(options, backends);
|
||||
}
|
||||
|
||||
/// Lists the DirectX 12 adapters, checking each against the limits eframe's device descriptor
|
||||
/// will require. Empty when this build has no DirectX 12 backend.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
|
||||
use eframe::wgpu::{Instance, InstanceDescriptor};
|
||||
// `Instance::new` panics when the build has no backend for the platform.
|
||||
if !Instance::enabled_backend_features().contains(Backends::DX12) {
|
||||
return Vec::new();
|
||||
}
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
return Vec::new();
|
||||
};
|
||||
let instance = Instance::new(InstanceDescriptor { backends: Backends::DX12, ..InstanceDescriptor::new_without_display_handle() });
|
||||
pollster::block_on(instance.enumerate_adapters(Backends::DX12))
|
||||
.iter()
|
||||
.map(|adapter| {
|
||||
let info = adapter.get_info();
|
||||
let required = (create.device_descriptor)(adapter).required_limits;
|
||||
let meets_limits = required.check_limits(&adapter.limits());
|
||||
log::info!("graphics: DirectX 12 adapter {:?} ({:?}, driver {:?}), limits ok: {meets_limits}", info.name, info.device_type, info.driver);
|
||||
Dx12Adapter { device_type: info.device_type, meets_limits }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
configure_backends(&mut options, backends);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.backends
|
||||
}
|
||||
|
||||
const fn adapter(device_type: DeviceType, meets_limits: bool) -> Dx12Adapter {
|
||||
Dx12Adapter { device_type, meets_limits }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_initializes_only_dx12() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
@@ -35,13 +124,32 @@ mod tests {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
let warp_only = [adapter(DeviceType::Cpu, true)];
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
assert_eq!(configured_backends(Some(backends)), backends);
|
||||
// Whatever the probe would have found, the override wins (the probe doesn't run then).
|
||||
assert_eq!(choose_backends(Some(backends), &[]), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
|
||||
assert_eq!(configured_backends(backends), backends);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gl_when_dx12_has_no_usable_gpu() {
|
||||
// #316: no DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
|
||||
let warp_only = [adapter(DeviceType::Cpu, true)];
|
||||
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &[]), Backends::GL);
|
||||
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
|
||||
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
|
||||
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
|
||||
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
|
||||
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
|
||||
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,8 @@ fn main() -> eframe::Result {
|
||||
if let Some(icon) = app_icon() {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
|
||||
// DirectX 12 has no GPU driver (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
let restored = saved_window();
|
||||
|
||||
+78
-6
@@ -74,12 +74,83 @@ fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
|
||||
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
|
||||
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
|
||||
let is = |s: &Option<String>| s.as_deref() == Some(id);
|
||||
match b {
|
||||
Block::Para(p) => is(&p.props.style) || is(&p.mark.style) || p.runs.iter().any(|r| is(&r.props.style)),
|
||||
Block::Table(t) if depth < 16 => {
|
||||
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).any(|cb| uses_style(cb, id, depth + 1))
|
||||
}
|
||||
Block::Table(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point every paragraph and run in `b` that uses style `from` at `to`; returns how many
|
||||
/// paragraphs changed.
|
||||
fn restyle_block(b: &mut Block, from: &str, to: &Option<String>, depth: usize) -> usize {
|
||||
let fix = |s: &mut Option<String>| {
|
||||
if s.as_deref() == Some(from) {
|
||||
s.clone_from(to);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
match b {
|
||||
Block::Para(p) => {
|
||||
let mut changed = fix(&mut p.props.style);
|
||||
changed |= fix(&mut p.mark.style);
|
||||
for r in &mut p.runs {
|
||||
changed |= fix(&mut r.props.style);
|
||||
}
|
||||
if changed {
|
||||
p.normalize();
|
||||
p.touch();
|
||||
}
|
||||
usize::from(changed)
|
||||
}
|
||||
Block::Table(t) => {
|
||||
let mut n = 0;
|
||||
if depth < 16 {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
for cb in &mut cell.blocks {
|
||||
if uses_style(cb, from, depth + 1) {
|
||||
n += restyle_block(Arc::make_mut(cb), from, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text box story ids of a paragraph's shapes.
|
||||
fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
|
||||
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story: Some(id), .. } = o { Some(*id) } else { None })
|
||||
p.objects.iter().flat_map(InlineObject::text_boxes)
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Point every paragraph and run that uses paragraph or character style `from` at `to`
|
||||
/// (`None`: the default style), in every story, tables included. Returns how many paragraphs
|
||||
/// changed. (Tables' own table styles are retargeted by the engine's table style deletion.)
|
||||
pub fn restyle(&mut self, from: &str, to: Option<String>) -> usize {
|
||||
let stories: Vec<crate::StoryRef> =
|
||||
std::iter::once(crate::StoryRef::Body).chain(self.parts.keys().map(|k| crate::StoryRef::Part(*k))).collect();
|
||||
let mut n = 0;
|
||||
for s in stories {
|
||||
let Ok(bl) = self.story_mut(s) else { continue };
|
||||
for b in bl.iter_mut() {
|
||||
if uses_style(b, from, 0) {
|
||||
n += restyle_block(Arc::make_mut(b), from, &to, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Insert plain text at `pos` (`\n` inside `s` is a manual line break; use
|
||||
/// [`Document::split_paragraph`] for paragraph breaks). Returns the position after it.
|
||||
pub fn insert_text(&mut self, pos: &Pos, s: &str, props: &CharProps) -> Result<Pos> {
|
||||
@@ -320,11 +391,11 @@ impl Document {
|
||||
each_para_mut(b, 0, &mut |p| {
|
||||
let mut changed = false;
|
||||
for o in p.objects.iter_mut() {
|
||||
if let InlineObject::Shape { story, .. } = o
|
||||
&& let Some(old) = *story
|
||||
{
|
||||
*story = self.adopt_text_box(old, parts, depth, budget);
|
||||
changed = true;
|
||||
for story in o.text_box_slots() {
|
||||
if let Some(old) = *story {
|
||||
*story = self.adopt_text_box(old, parts, depth, budget);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
@@ -501,6 +572,7 @@ mod tests {
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Shape effects (DrawingML `a:effectLst`, ECMA-376 Part 1 §20.1.8): an outer shadow, a glow and
|
||||
//! soft edges. Lengths are points, angles degrees, transparencies percent (0 = opaque).
|
||||
//!
|
||||
//! Renderers approximate the blurs with [`bands`]: a few copies of the shape's silhouette, grown
|
||||
//! or shrunk by steps and overlaid with partial opacity, so the screen and PDF output (which has
|
||||
//! no blur filter) match.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Largest blur radius, points.
|
||||
pub const MAX_BLUR: f32 = 100.0;
|
||||
/// Largest shadow distance, points.
|
||||
pub const MAX_DISTANCE: f32 = 200.0;
|
||||
/// Largest glow size, points.
|
||||
pub const MAX_GLOW: f32 = 150.0;
|
||||
/// Largest soft-edge radius, points.
|
||||
pub const MAX_SOFT_EDGE: f32 = 100.0;
|
||||
|
||||
/// The effects on a shape. Empty (the default) draws the shape plainly.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShapeEffects {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shadow: Option<Shadow>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glow: Option<Glow>,
|
||||
/// Soft-edge radius, points: the shape fades out over this distance inside its outline.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub soft_edge: Option<f32>,
|
||||
}
|
||||
|
||||
/// An outer shadow: a blurred copy of the shape's silhouette cast `distance` points away in
|
||||
/// direction `angle` (degrees clockwise from the positive x axis, y pointing down).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Shadow {
|
||||
pub color: Rgb,
|
||||
pub transparency: f32,
|
||||
pub blur: f32,
|
||||
pub distance: f32,
|
||||
pub angle: f32,
|
||||
}
|
||||
|
||||
impl Default for Shadow {
|
||||
/// "Offset: Bottom Right".
|
||||
fn default() -> Self {
|
||||
Shadow { color: Rgb::BLACK, transparency: 60.0, blur: 4.0, distance: 3.0, angle: 45.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A glow: a soft band of colour `size` points wide around the shape's outline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Glow {
|
||||
pub color: Rgb,
|
||||
pub size: f32,
|
||||
pub transparency: f32,
|
||||
}
|
||||
|
||||
impl Default for Glow {
|
||||
fn default() -> Self {
|
||||
Glow { color: Rgb(0x15, 0x60, 0x82), size: 5.0, transparency: 60.0 }
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { dflt }
|
||||
}
|
||||
|
||||
/// The outer-shadow presets (no perspective shadows): id, label.
|
||||
pub const SHADOW_PRESETS: [(&str, &str); 9] = [
|
||||
("offsetBottomRight", "Offset: Bottom Right"),
|
||||
("offsetBottom", "Offset: Bottom"),
|
||||
("offsetBottomLeft", "Offset: Bottom Left"),
|
||||
("offsetRight", "Offset: Right"),
|
||||
("offsetCenter", "Offset: Center"),
|
||||
("offsetLeft", "Offset: Left"),
|
||||
("offsetTopRight", "Offset: Top Right"),
|
||||
("offsetTop", "Offset: Top"),
|
||||
("offsetTopLeft", "Offset: Top Left"),
|
||||
];
|
||||
|
||||
impl Shadow {
|
||||
/// A preset by id (see [`SHADOW_PRESETS`]).
|
||||
pub fn preset(id: &str) -> Option<Shadow> {
|
||||
let d = Shadow::default();
|
||||
let angle = match id {
|
||||
"offsetBottomRight" => 45.0,
|
||||
"offsetBottom" => 90.0,
|
||||
"offsetBottomLeft" => 135.0,
|
||||
"offsetLeft" => 180.0,
|
||||
"offsetTopLeft" => 225.0,
|
||||
"offsetTop" => 270.0,
|
||||
"offsetTopRight" => 315.0,
|
||||
"offsetRight" => 0.0,
|
||||
"offsetCenter" => return Some(Shadow { distance: 0.0, blur: 5.0, ..d }),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Shadow { angle, ..d })
|
||||
}
|
||||
/// Finite, in range.
|
||||
pub fn sanitized(self) -> Shadow {
|
||||
let angle = clamp(self.angle, -1e6, 1e6, 45.0).rem_euclid(360.0);
|
||||
Shadow {
|
||||
color: self.color,
|
||||
transparency: clamp(self.transparency, 0.0, 100.0, 60.0),
|
||||
blur: clamp(self.blur, 0.0, MAX_BLUR, 0.0),
|
||||
distance: clamp(self.distance, 0.0, MAX_DISTANCE, 0.0),
|
||||
angle: if angle.is_finite() && angle < 360.0 { angle } else { 0.0 },
|
||||
}
|
||||
}
|
||||
/// The shadow's offset from the shape, points (x right, y down).
|
||||
pub fn offset(&self) -> (f32, f32) {
|
||||
let s = self.sanitized();
|
||||
let a = s.angle.to_radians();
|
||||
(s.distance * a.cos(), s.distance * a.sin())
|
||||
}
|
||||
/// Opacity 0..1.
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - self.sanitized().transparency / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Glow {
|
||||
/// Finite, in range.
|
||||
pub fn sanitized(self) -> Glow {
|
||||
Glow { color: self.color, size: clamp(self.size, 0.0, MAX_GLOW, 0.0), transparency: clamp(self.transparency, 0.0, 100.0, 60.0) }
|
||||
}
|
||||
/// Opacity 0..1.
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - self.sanitized().transparency / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ShapeEffects {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.shadow.is_none() && self.glow.is_none() && self.soft_edge.is_none()
|
||||
}
|
||||
/// Finite, in range; zero-size glows and soft edges dropped.
|
||||
pub fn sanitized(self) -> ShapeEffects {
|
||||
ShapeEffects {
|
||||
shadow: self.shadow.map(Shadow::sanitized),
|
||||
glow: self.glow.map(Glow::sanitized).filter(|g| g.size > 0.0),
|
||||
soft_edge: self.soft_edge.map(|r| clamp(r, 0.0, MAX_SOFT_EDGE, 0.0)).filter(|r| *r > 0.0),
|
||||
}
|
||||
}
|
||||
/// How far the effects reach beyond the shape's frame on each side (left, top, right,
|
||||
/// bottom), points — what `wp:effectExtent` records.
|
||||
pub fn extent(&self) -> [f32; 4] {
|
||||
let e = self.sanitized();
|
||||
let mut out = [0.0f32; 4];
|
||||
if let Some(g) = e.glow {
|
||||
out = [g.size; 4];
|
||||
}
|
||||
if let Some(s) = e.shadow {
|
||||
let (dx, dy) = s.offset();
|
||||
let r = s.blur / 2.0;
|
||||
let reach = [r - dx, r - dy, r + dx, r + dy];
|
||||
for (o, v) in out.iter_mut().zip(reach) {
|
||||
*o = o.max(v.max(0.0));
|
||||
}
|
||||
}
|
||||
out.map(|v| (v * 100.0).round() / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bands approximating a blurred edge: `(grow, alpha)` pairs. Each is a copy of the silhouette
|
||||
/// grown outward by `grow` points (negative shrinks it), filled with opacity `alpha`; overlaid in
|
||||
/// any order, in one colour, they ramp linearly from `opacity` at `from` to nothing at `to`.
|
||||
pub fn bands(from: f32, to: f32, opacity: f32) -> Vec<(f32, f32)> {
|
||||
let (from, to) = (clamp(from, -1000.0, 1000.0, 0.0), clamp(to, -1000.0, 1000.0, 0.0));
|
||||
let opacity = clamp(opacity, 0.0, 1.0, 0.0);
|
||||
if opacity <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let span = (to - from).max(0.0);
|
||||
if span < 0.25 {
|
||||
return vec![((from + to) / 2.0, opacity)];
|
||||
}
|
||||
let n = ((span / 1.5).ceil() as usize).clamp(3, 12);
|
||||
let nf = n as f32;
|
||||
(1..=n)
|
||||
.map(|m| {
|
||||
// The m-th band from the outside: after it, coverage is opacity·m/n.
|
||||
let grow = to - span * (m as f32 - 0.5) / nf;
|
||||
let before = 1.0 - opacity * (m as f32 - 1.0) / nf;
|
||||
let after = 1.0 - opacity * m as f32 / nf;
|
||||
let alpha = if before > 0.0 { 1.0 - after / before } else { 1.0 };
|
||||
(grow, alpha.clamp(0.0, 1.0))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bands_ramp_to_the_requested_opacity() {
|
||||
for (from, to, o) in [(-2.0, 2.0, 0.4), (-5.0, 0.0, 1.0), (0.0, 8.0, 0.6)] {
|
||||
let b = bands(from, to, o);
|
||||
assert!(b.iter().all(|(g, a)| *g > from && *g < to && (0.0..=1.0).contains(a)));
|
||||
let covered = 1.0 - b.iter().map(|(_, a)| 1.0 - a).product::<f32>();
|
||||
assert!((covered - o).abs() < 1e-4, "{covered} vs {o}");
|
||||
}
|
||||
assert_eq!(bands(0.0, 0.0, 0.5), vec![(0.0, 0.5)]);
|
||||
assert!(bands(f32::NAN, f32::INFINITY, f32::NAN).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_are_clamped() {
|
||||
let s = Shadow { color: Rgb::BLACK, transparency: f32::NAN, blur: 1e9, distance: -4.0, angle: -90.0 }.sanitized();
|
||||
assert_eq!((s.transparency, s.blur, s.distance, s.angle), (60.0, MAX_BLUR, 0.0, 270.0));
|
||||
let e = ShapeEffects { shadow: None, glow: Some(Glow { size: 1e9, ..Glow::default() }), soft_edge: Some(-1.0) }.sanitized();
|
||||
assert_eq!(e.glow.map(|g| g.size), Some(MAX_GLOW));
|
||||
assert_eq!(e.soft_edge, None);
|
||||
let e = ShapeEffects { glow: Some(Glow { size: f32::INFINITY, ..Glow::default() }), ..Default::default() }.sanitized();
|
||||
assert!(e.is_empty(), "a non-finite size is no glow");
|
||||
let (dx, dy) = Shadow::preset("offsetBottomRight").unwrap().offset();
|
||||
assert!(dx > 2.0 && dy > 2.0);
|
||||
}
|
||||
}
|
||||
+21
-17
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod bidi;
|
||||
pub mod edit;
|
||||
pub mod effects;
|
||||
pub mod encoding;
|
||||
pub mod fields;
|
||||
pub mod graphic;
|
||||
@@ -330,6 +331,9 @@ pub struct Settings {
|
||||
/// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch.
|
||||
pub grid_h: f32,
|
||||
pub grid_v: f32,
|
||||
/// Equation options (`m:mathPr`), when the document has them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub math: Option<math::MathProps>,
|
||||
}
|
||||
|
||||
/// Word's compatibility mode for documents that don't state one.
|
||||
@@ -393,6 +397,7 @@ impl Default for Settings {
|
||||
compat_mode: COMPAT_MODE_CURRENT,
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
math: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -596,7 +601,7 @@ impl Document {
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if matches!(p.object_at(off), Some(InlineObject::Shape { story: Some(id), .. }) if *id == part) {
|
||||
if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) {
|
||||
return Some(Pos { story, path, off: off + para::OBJ.len_utf8() });
|
||||
}
|
||||
}
|
||||
@@ -700,9 +705,8 @@ impl Document {
|
||||
/// document order. Headers, footers, comments and stories nothing points at aren't included.
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
self.reachable(vec![StoryRef::Body], &|o| match o {
|
||||
InlineObject::Shape { story: Some(id), .. } => Some((*id, &[PartKind::TextBox])),
|
||||
InlineObject::NoteRef { id, .. } => Some((*id, &[PartKind::Footnote, PartKind::Endnote])),
|
||||
_ => None,
|
||||
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
|
||||
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -716,10 +720,7 @@ impl Document {
|
||||
let roots = std::iter::once(StoryRef::Body)
|
||||
.chain(self.parts.iter().filter(|(_, p)| p.kind != PartKind::TextBox).map(|(id, _)| StoryRef::Part(*id)))
|
||||
.collect();
|
||||
let live = self.reachable(roots, &|o| match o {
|
||||
InlineObject::Shape { story: Some(id), .. } => Some((*id, &[PartKind::TextBox])),
|
||||
_ => None,
|
||||
});
|
||||
let live = self.reachable(roots, &|o| o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect());
|
||||
let before = self.parts.len();
|
||||
self.parts.retain(|id, p| p.kind != PartKind::TextBox || live.contains(&StoryRef::Part(*id)));
|
||||
before - self.parts.len()
|
||||
@@ -765,13 +766,16 @@ impl Document {
|
||||
for b in blocks {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
if f(p, k)
|
||||
&& let InlineObject::Shape { story: Some(id), .. } = o
|
||||
&& let Some(part) = self.parts.get(id).filter(|p| p.kind == PartKind::TextBox)
|
||||
&& budget.enter(*id)
|
||||
{
|
||||
self.walk_objects(&part.blocks, budget, f);
|
||||
budget.leave();
|
||||
if !f(p, k) {
|
||||
continue;
|
||||
}
|
||||
for id in o.text_boxes() {
|
||||
if let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox)
|
||||
&& budget.enter(id)
|
||||
{
|
||||
self.walk_objects(&part.blocks, budget, f);
|
||||
budget.leave();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -780,7 +784,7 @@ impl Document {
|
||||
|
||||
/// `roots`, then every story their objects lead to (`follow`: an object's story id and the
|
||||
/// part kinds that count), transitively, in the order found.
|
||||
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Option<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
|
||||
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
|
||||
let mut out = roots;
|
||||
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
|
||||
let mut i = 0;
|
||||
@@ -789,7 +793,7 @@ impl Document {
|
||||
let mut found = Vec::new();
|
||||
for b in self.story(s).into_iter().flatten() {
|
||||
edit::each_para(b, 0, &mut |p| {
|
||||
found.extend(p.objects.iter().filter_map(follow));
|
||||
found.extend(p.objects.iter().flat_map(follow));
|
||||
});
|
||||
}
|
||||
for (id, kinds) in found {
|
||||
|
||||
@@ -54,6 +54,55 @@ pub enum MathJc {
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Where a display equation too wide for its line breaks at a binary operator (`m:brkBin`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BrkBin {
|
||||
/// The operator starts the next line.
|
||||
#[default]
|
||||
Before,
|
||||
/// The operator ends the line.
|
||||
After,
|
||||
/// The operator ends the line and starts the next one again.
|
||||
Repeat,
|
||||
}
|
||||
|
||||
/// How a minus repeats across a break when [`BrkBin::Repeat`] (`m:brkBinSub`): the operator
|
||||
/// ending the line, then the one starting the next.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BrkBinSub {
|
||||
/// `--`
|
||||
#[default]
|
||||
MinusMinus,
|
||||
/// `-+`
|
||||
MinusPlus,
|
||||
/// `+-`
|
||||
PlusMinus,
|
||||
}
|
||||
|
||||
/// Document-wide equation options (`m:mathPr` in the settings part) that layout uses: how long
|
||||
/// display equations wrap.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MathProps {
|
||||
pub brk_bin: BrkBin,
|
||||
pub brk_bin_sub: BrkBinSub,
|
||||
/// Indent of a wrapped equation's continuation lines, points (`m:wrapIndent`).
|
||||
pub wrap_indent: f32,
|
||||
/// Continuation lines align right instead (`m:wrapRight`).
|
||||
pub wrap_right: bool,
|
||||
}
|
||||
|
||||
/// `m:wrapIndent`'s default: 1440 twips, one inch.
|
||||
pub const DEFAULT_WRAP_INDENT: f32 = 72.0;
|
||||
|
||||
impl Default for MathProps {
|
||||
fn default() -> Self {
|
||||
MathProps { brk_bin: BrkBin::Before, brk_bin_sub: BrkBinSub::MinusMinus, wrap_indent: DEFAULT_WRAP_INDENT, wrap_right: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Math run style (`m:sty`): plain, bold, italic, bold italic.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -100,6 +149,10 @@ pub struct MRun {
|
||||
/// Font for normal text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font: Option<String>,
|
||||
/// A manual line break before this run in a display equation (`m:brk`): `Some(0)` wraps as
|
||||
/// usual, `Some(n)` aligns the new line at the `n`th operator of the first line (`m:alnAt`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub brk: Option<u8>,
|
||||
}
|
||||
|
||||
impl MRun {
|
||||
@@ -662,6 +715,7 @@ pub fn merge_runs(a: Arg) -> Arg {
|
||||
&& prev.size == r.size
|
||||
&& prev.color == r.color
|
||||
&& prev.font == r.font
|
||||
&& r.brk.is_none()
|
||||
{
|
||||
prev.text.push_str(&r.text);
|
||||
continue;
|
||||
|
||||
+115
-1
@@ -223,6 +223,23 @@ pub enum InlineObject {
|
||||
/// Text box content: `Document::parts` id.
|
||||
#[serde(default)]
|
||||
story: Option<u32>,
|
||||
/// Shadow, glow and soft edges.
|
||||
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
|
||||
effects: crate::effects::ShapeEffects,
|
||||
},
|
||||
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
|
||||
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
|
||||
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
|
||||
/// `a:chExt` and `a:ext`), so resizing the group scales them.
|
||||
Group {
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Size of the members' coordinate space (their offsets and sizes are in it).
|
||||
ch_w: f32,
|
||||
ch_h: f32,
|
||||
children: Vec<GroupChild>,
|
||||
},
|
||||
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
|
||||
/// `result` the cached display text.
|
||||
@@ -283,7 +300,101 @@ pub enum InlineObject {
|
||||
},
|
||||
}
|
||||
|
||||
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
|
||||
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupChild {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub obj: InlineObject,
|
||||
}
|
||||
|
||||
/// Most members a group keeps (hostile files).
|
||||
pub const MAX_GROUP_CHILDREN: usize = 1000;
|
||||
|
||||
impl InlineObject {
|
||||
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
|
||||
pub fn is_drawing(&self) -> bool {
|
||||
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
|
||||
}
|
||||
/// A drawing's size and placement.
|
||||
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
|
||||
match self {
|
||||
InlineObject::Image { w, h, float, .. }
|
||||
| InlineObject::Graphic { w, h, float, .. }
|
||||
| InlineObject::Shape { w, h, float, .. }
|
||||
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A drawing's placement, to change.
|
||||
pub fn float_mut(&mut self) -> Option<&mut Float> {
|
||||
match self {
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => Some(float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Resize a drawing (a group's members scale with it).
|
||||
pub fn set_size(&mut self, nw: f32, nh: f32) {
|
||||
if let InlineObject::Image { w, h, .. }
|
||||
| InlineObject::Graphic { w, h, .. }
|
||||
| InlineObject::Shape { w, h, .. }
|
||||
| InlineObject::Group { w, h, .. } = self
|
||||
{
|
||||
*w = nw;
|
||||
*h = nh;
|
||||
}
|
||||
}
|
||||
/// The text box story this object shows, if it is a text box.
|
||||
pub fn text_box(&self) -> Option<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => *story,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The text box stories this object shows: a text box's, or its group members'.
|
||||
pub fn text_boxes(&self) -> Vec<u32> {
|
||||
match self {
|
||||
InlineObject::Shape { story: Some(id), .. } => vec![*id],
|
||||
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The text box story slots of this object and its group members, to repoint.
|
||||
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
|
||||
match self {
|
||||
InlineObject::Shape { story, .. } => vec![story],
|
||||
InlineObject::Group { children, .. } => children
|
||||
.iter_mut()
|
||||
.filter_map(|c| match &mut c.obj {
|
||||
InlineObject::Shape { story, .. } => Some(story),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
|
||||
/// (x, y, w, h) and the member. Empty for anything else.
|
||||
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
|
||||
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Zero-width markers don't take part in layout.
|
||||
pub fn is_marker(&self) -> bool {
|
||||
matches!(
|
||||
@@ -298,7 +409,10 @@ impl InlineObject {
|
||||
}
|
||||
pub fn is_floating(&self) -> bool {
|
||||
match self {
|
||||
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
|
||||
InlineObject::Image { float, .. }
|
||||
| InlineObject::Graphic { float, .. }
|
||||
| InlineObject::Shape { float, .. }
|
||||
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -622,13 +622,34 @@ pub struct ParaProps {
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
|
||||
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
|
||||
/// Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kinsoku: Option<bool>,
|
||||
/// `w:wordWrap`: Latin words wrap whole; `false` lets them break at any character (Word's
|
||||
/// "Allow Latin text to wrap in the middle of a word"). Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub word_wrap: Option<bool>,
|
||||
/// `w:overflowPunct`: punctuation may hang past the line end. Unset = on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overflow_punct: Option<bool>,
|
||||
/// `w:topLinePunct`: compress punctuation at the start of a line. Unset = off.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_line_punct: Option<bool>,
|
||||
/// `w:autoSpaceDE`: automatic space between Asian and Latin text. Unset = on.
|
||||
#[serde(rename = "autoSpaceDE", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_de: Option<bool>,
|
||||
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
|
||||
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
|
||||
pub auto_space_dn: Option<bool>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
pub fn overlay(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing,
|
||||
contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap);
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
|
||||
@@ -141,6 +141,14 @@ pub struct ResolvedPara {
|
||||
pub bidi: bool,
|
||||
pub drop_cap: u8,
|
||||
pub suppress_hyphens: bool,
|
||||
/// Asian typography (see `ParaProps`): East Asian line-breaking rules.
|
||||
pub kinsoku: bool,
|
||||
/// Latin words wrap whole (`false`: they may break at any character).
|
||||
pub word_wrap: bool,
|
||||
pub overflow_punct: bool,
|
||||
pub top_line_punct: bool,
|
||||
pub auto_space_de: bool,
|
||||
pub auto_space_dn: bool,
|
||||
}
|
||||
|
||||
const MAX_INDENT: f32 = 1584.0; // 22"
|
||||
@@ -186,6 +194,12 @@ impl ResolvedPara {
|
||||
bidi: p.bidi.unwrap_or(false),
|
||||
drop_cap: p.drop_cap.unwrap_or(0).min(10),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
kinsoku: p.kinsoku.unwrap_or(true),
|
||||
word_wrap: p.word_wrap.unwrap_or(true),
|
||||
overflow_punct: p.overflow_punct.unwrap_or(true),
|
||||
top_line_punct: p.top_line_punct.unwrap_or(false),
|
||||
auto_space_de: p.auto_space_de.unwrap_or(true),
|
||||
auto_space_dn: p.auto_space_dn.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,21 @@ pub struct TableStyleParts {
|
||||
pub first_col_chr: CharProps,
|
||||
pub total_chr: CharProps,
|
||||
pub total_border_top: Option<Border>,
|
||||
/// Shading and cell borders of the first column (`firstCol` region).
|
||||
pub first_col_fill: Option<Rgb>,
|
||||
pub first_col_borders: Option<Borders>,
|
||||
/// The last column (`lastCol` region).
|
||||
pub last_col_fill: Option<Rgb>,
|
||||
pub last_col_chr: CharProps,
|
||||
pub last_col_borders: Option<Borders>,
|
||||
/// Shading and cell borders of the total (last) row (`lastRow` region); a top edge left
|
||||
/// unset falls back to `total_border_top`.
|
||||
pub total_fill: Option<Rgb>,
|
||||
pub total_borders: Option<Borders>,
|
||||
/// The odd column bands (`band1Vert` region).
|
||||
pub col_band_fill: Option<Rgb>,
|
||||
pub col_band_chr: CharProps,
|
||||
pub col_band_borders: Option<Borders>,
|
||||
}
|
||||
|
||||
impl TableStyleParts {
|
||||
@@ -91,6 +106,16 @@ impl TableStyleParts {
|
||||
self.first_col_chr.overlay(&patch.first_col_chr);
|
||||
self.total_chr.overlay(&patch.total_chr);
|
||||
self.total_border_top = patch.total_border_top.or(self.total_border_top);
|
||||
self.first_col_fill = patch.first_col_fill.or(self.first_col_fill);
|
||||
self.first_col_borders = merge(self.first_col_borders, patch.first_col_borders);
|
||||
self.last_col_fill = patch.last_col_fill.or(self.last_col_fill);
|
||||
self.last_col_chr.overlay(&patch.last_col_chr);
|
||||
self.last_col_borders = merge(self.last_col_borders, patch.last_col_borders);
|
||||
self.total_fill = patch.total_fill.or(self.total_fill);
|
||||
self.total_borders = merge(self.total_borders, patch.total_borders);
|
||||
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
|
||||
self.col_band_chr.overlay(&patch.col_band_chr);
|
||||
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +462,25 @@ impl StyleSheet {
|
||||
None => self.styles.push(st),
|
||||
}
|
||||
}
|
||||
/// Remove style `id` and return it. Styles based on it are rebased onto its own base, and
|
||||
/// `next` / `linked` references to it are dropped. Text that uses it is the document's to
|
||||
/// retarget ([`crate::Document::restyle`]).
|
||||
pub fn remove(&mut self, id: &str) -> Option<Style> {
|
||||
let i = self.styles.iter().position(|s| s.id == id)?;
|
||||
let gone = self.styles.remove(i);
|
||||
for s in &mut self.styles {
|
||||
if s.based_on.as_deref() == Some(id) {
|
||||
s.based_on = gone.based_on.clone().filter(|b| *b != s.id);
|
||||
}
|
||||
if s.next.as_deref() == Some(id) {
|
||||
s.next = None;
|
||||
}
|
||||
if s.linked.as_deref() == Some(id) {
|
||||
s.linked = None;
|
||||
}
|
||||
}
|
||||
Some(gone)
|
||||
}
|
||||
/// An id not used yet, derived from a display name.
|
||||
pub fn new_id(&self, name: &str) -> String {
|
||||
let base: String = name.chars().filter(|c| c.is_alphanumeric()).collect();
|
||||
|
||||
@@ -88,6 +88,7 @@ fn text_box_anchor_finds_the_owning_shape() {
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
effects: Default::default(),
|
||||
};
|
||||
// A plain shape before it doesn't count.
|
||||
d.insert_object(&Pos::body(0, 0), shape(None), &CharProps::default()).unwrap();
|
||||
@@ -108,6 +109,7 @@ fn word_count_including_notes_counts_used_boxes_and_notes_only() {
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
|
||||
let boxed = d.add_part(PartKind::TextBox, para_of("three four"));
|
||||
@@ -146,6 +148,7 @@ fn counted_stories_survive_self_nested_boxes() {
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap();
|
||||
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, shape, &CharProps::default()).unwrap();
|
||||
@@ -163,6 +166,7 @@ fn prune_text_boxes_drops_only_unshown_ones() {
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
|
||||
let mut d = Document::from_text("body");
|
||||
@@ -195,6 +199,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() };
|
||||
let mut d = Document::new();
|
||||
|
||||
@@ -15,6 +15,18 @@ quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
getrandom = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
|
||||
# The browser's `crypto.getRandomValues` for the salts and keys of encrypted saves.
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
//! Password-protected Word documents: ECMA-376 document encryption with the *agile* method
|
||||
//! (Word 2010 and later), written from Microsoft's public Open Specification [MS-OFFCRYPTO]
|
||||
//! §2.3.4.4 and §2.3.4.10–§2.3.4.15, with the data-spaces streams of §2.1 / §2.3.4.1–§2.3.4.3.
|
||||
//!
|
||||
//! An encrypted package is not a zip but an OLE compound file ([MS-CFB]) holding
|
||||
//! `EncryptionInfo` (how the key is derived from the password, as XML) and `EncryptedPackage`
|
||||
//! (the original zip, AES-CBC encrypted in 4096-byte segments). A random *intermediate key*
|
||||
//! encrypts the package; the password, hashed `spinCount` times with a salt, encrypts that key and
|
||||
//! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it
|
||||
//! hasn't been damaged or tampered with.
|
||||
//!
|
||||
//! Files are hostile: every stream read is capped, every length is checked before slicing, and the
|
||||
//! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory
|
||||
//! when dropped ([`Zeroizing`]) and never logged.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use aes::cipher::{BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
use base64::Engine as _;
|
||||
use hmac::Mac;
|
||||
use sha2::Digest;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::DocxError;
|
||||
use crate::xml;
|
||||
|
||||
/// The spin count Word uses, and WordCraft writes.
|
||||
pub const DEFAULT_SPIN_COUNT: u32 = 100_000;
|
||||
/// The largest spin count the specification allows (§2.3.4.10 `ST_SpinCount`): it bounds the
|
||||
/// work a hostile file can ask for.
|
||||
const MAX_SPIN_COUNT: u32 = 10_000_000;
|
||||
/// Word's limit on password length, in characters.
|
||||
pub const MAX_PASSWORD_CHARS: usize = 255;
|
||||
/// `EncryptedPackage` is encrypted in segments of this many bytes (§2.3.4.15).
|
||||
const SEGMENT: usize = 4096;
|
||||
/// Largest `EncryptionInfo` stream read (the XML is a few kilobytes).
|
||||
const MAX_INFO: u64 = 1 << 20;
|
||||
/// AES's block size.
|
||||
const BLOCK: usize = 16;
|
||||
/// Salt length written.
|
||||
const SALT: usize = 16;
|
||||
|
||||
/// Block keys (§2.3.4.13, §2.3.4.14).
|
||||
const BK_VERIFIER_INPUT: [u8; 8] = [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79];
|
||||
const BK_VERIFIER_VALUE: [u8; 8] = [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e];
|
||||
const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6];
|
||||
const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6];
|
||||
const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33];
|
||||
|
||||
/// An OLE compound file starts with these bytes ([MS-CFB] §2.2).
|
||||
const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
const NS_ENCRYPTION: &str = "http://schemas.microsoft.com/office/2006/encryption";
|
||||
const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password";
|
||||
|
||||
/// Is this an encrypted Office package (a compound file with `EncryptionInfo` and
|
||||
/// `EncryptedPackage` streams) rather than a zip?
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
if bytes.get(..8) != Some(&CFB_MAGIC[..]) {
|
||||
return false;
|
||||
}
|
||||
match cfb::CompoundFile::open(Cursor::new(bytes)) {
|
||||
Ok(comp) => comp.is_stream("/EncryptionInfo") && comp.is_stream("/EncryptedPackage"),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zip package inside an encrypted Office file. Without a password (`None`) this says
|
||||
/// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that
|
||||
/// doesn't fit gives [`DocxError::WrongPassword`].
|
||||
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
|
||||
let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?;
|
||||
let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?;
|
||||
let info = parse_info(&info)?;
|
||||
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
|
||||
let pe = &info.password;
|
||||
let hn = iterate(pe.hash, &pe.salt, password, pe.spin_count);
|
||||
// The verifier (§2.3.4.13): a random value and its hash, both encrypted with keys derived
|
||||
// from the password. They match only for the right password.
|
||||
let key_input = derive_key(pe.hash, &hn, &BK_VERIFIER_INPUT, pe.key_bytes);
|
||||
let key_value = derive_key(pe.hash, &hn, &BK_VERIFIER_VALUE, pe.key_bytes);
|
||||
let iv = make_iv(pe.hash, &pe.salt, None, pe.block_size);
|
||||
let input = Zeroizing::new(cbc_decrypt(&key_input, &iv, &pe.verifier_input)?);
|
||||
let input = input.get(..pe.salt_size).ok_or_else(|| bad("the password verifier is too short"))?;
|
||||
let value = Zeroizing::new(cbc_decrypt(&key_value, &iv, &pe.verifier_hash)?);
|
||||
let value = value.get(..pe.hash.size()).ok_or_else(|| bad("the password verifier hash is too short"))?;
|
||||
if !same(&pe.hash.hash(&[input]), value) {
|
||||
return Err(DocxError::WrongPassword);
|
||||
}
|
||||
// The intermediate key that encrypts the package.
|
||||
let key_key = derive_key(pe.hash, &hn, &BK_KEY_VALUE, pe.key_bytes);
|
||||
let key = Zeroizing::new(cbc_decrypt(&key_key, &iv, &pe.key_value)?);
|
||||
let key = Zeroizing::new(key.get(..info.key_bytes).ok_or_else(|| bad("the encrypted key is too short"))?.to_vec());
|
||||
// Data integrity (§2.3.4.14): an HMAC of the whole EncryptedPackage stream.
|
||||
if let Some((enc_key, enc_value)) = &info.integrity {
|
||||
let hmac_key = Zeroizing::new(cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_KEY), info.block_size), enc_key)?);
|
||||
// The key is the salt, zero-padded to the block size; HMAC pads short keys with zeros
|
||||
// anyway, so the padded key gives the same HMAC.
|
||||
let hmac_key = hmac_key.get(..hmac_key.len().min(info.hash.size())).unwrap_or(&[]);
|
||||
let expected = cbc_decrypt(&key, &make_iv(info.hash, &info.salt, Some(&BK_HMAC_VALUE), info.block_size), enc_value)?;
|
||||
let expected = expected.get(..info.hash.size()).ok_or_else(|| bad("the integrity check value is too short"))?;
|
||||
if !same(&info.hash.hmac(hmac_key, &package)?, expected) {
|
||||
return Err(bad("the document is damaged (its integrity check failed)"));
|
||||
}
|
||||
}
|
||||
// The package itself (§2.3.4.15): StreamSize, then 4096-byte segments, each with its own IV.
|
||||
let size = package
|
||||
.get(..8)
|
||||
.and_then(|b| <[u8; 8]>::try_from(b).ok())
|
||||
.map(u64::from_le_bytes)
|
||||
.ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let data = package.get(8..).unwrap_or(&[]);
|
||||
let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK);
|
||||
let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let mut out = Vec::with_capacity(needed);
|
||||
for (i, seg) in data.chunks(SEGMENT).enumerate() {
|
||||
let i = u32::try_from(i).map_err(|_| bad("the encrypted package is too large"))?;
|
||||
let iv = make_iv(info.hash, &info.salt, Some(&i.to_le_bytes()), info.block_size);
|
||||
out.extend_from_slice(&cbc_decrypt(&key, &iv, seg)?);
|
||||
}
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin
|
||||
/// count), as Word's "Encrypt with Password" does.
|
||||
pub fn encrypt(package: &[u8], password: &str) -> Result<Vec<u8>, DocxError> {
|
||||
encrypt_with_spin_count(package, password, DEFAULT_SPIN_COUNT)
|
||||
}
|
||||
|
||||
/// [`encrypt`] with a chosen spin count (tests use a small one; files should use the default).
|
||||
pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32) -> Result<Vec<u8>, DocxError> {
|
||||
check_password(password)?;
|
||||
let spin_count = spin_count.min(MAX_SPIN_COUNT);
|
||||
let hash = Hash::Sha512;
|
||||
let key_bytes = 32;
|
||||
let key_data_salt = random(SALT)?;
|
||||
let password_salt = random(SALT)?;
|
||||
let key = random(key_bytes)?;
|
||||
let verifier = random(SALT)?;
|
||||
// The HMAC key is a salt as long as the hash (MS-OFFCRYPTO §2.3.4.14).
|
||||
let hmac_salt = random(hash.size())?;
|
||||
|
||||
// The package, segment by segment; the last segment is zero-padded to the block size.
|
||||
let mut stream = Vec::with_capacity(package.len().saturating_add(8 + BLOCK));
|
||||
stream.extend_from_slice(&(package.len() as u64).to_le_bytes());
|
||||
for (i, seg) in package.chunks(SEGMENT).enumerate() {
|
||||
let i = u32::try_from(i).map_err(|_| bad("the document is too large to encrypt"))?;
|
||||
let iv = make_iv(hash, &key_data_salt, Some(&i.to_le_bytes()), BLOCK);
|
||||
stream.extend_from_slice(&cbc_encrypt(&key, &iv, &padded(seg))?);
|
||||
}
|
||||
|
||||
// The password key encryptor (§2.3.4.13).
|
||||
let hn = iterate(hash, &password_salt, password, spin_count);
|
||||
let iv = make_iv(hash, &password_salt, None, BLOCK);
|
||||
let enc_verifier_input = cbc_encrypt(&derive_key(hash, &hn, &BK_VERIFIER_INPUT, key_bytes), &iv, &padded(&verifier))?;
|
||||
let enc_verifier_hash = cbc_encrypt(&derive_key(hash, &hn, &BK_VERIFIER_VALUE, key_bytes), &iv, &padded(&hash.hash(&[&verifier])))?;
|
||||
let enc_key_value = cbc_encrypt(&derive_key(hash, &hn, &BK_KEY_VALUE, key_bytes), &iv, &padded(&key))?;
|
||||
|
||||
// Data integrity (§2.3.4.14).
|
||||
let enc_hmac_key = cbc_encrypt(&key, &make_iv(hash, &key_data_salt, Some(&BK_HMAC_KEY), BLOCK), &padded(&hmac_salt))?;
|
||||
let hmac = hash.hmac(&hmac_salt, &stream)?;
|
||||
let enc_hmac_value = cbc_encrypt(&key, &make_iv(hash, &key_data_salt, Some(&BK_HMAC_VALUE), BLOCK), &padded(&hmac))?;
|
||||
|
||||
let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
|
||||
let params = format!(
|
||||
r#"saltSize="{SALT}" blockSize="{BLOCK}" keyBits="{}" hashSize="{}" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="{}""#,
|
||||
key_bytes * 8,
|
||||
hash.size(),
|
||||
hash.name(),
|
||||
);
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n\
|
||||
<encryption xmlns=\"{NS_ENCRYPTION}\" xmlns:p=\"{NS_PASSWORD}\">\
|
||||
<keyData {params} saltValue=\"{}\"/>\
|
||||
<dataIntegrity encryptedHmacKey=\"{}\" encryptedHmacValue=\"{}\"/>\
|
||||
<keyEncryptors><keyEncryptor uri=\"{NS_PASSWORD}\">\
|
||||
<p:encryptedKey spinCount=\"{spin_count}\" {params} saltValue=\"{}\" encryptedVerifierHashInput=\"{}\" encryptedVerifierHashValue=\"{}\" encryptedKeyValue=\"{}\"/>\
|
||||
</keyEncryptor></keyEncryptors></encryption>",
|
||||
b64(&key_data_salt),
|
||||
b64(&enc_hmac_key),
|
||||
b64(&enc_hmac_value),
|
||||
b64(&password_salt),
|
||||
b64(&enc_verifier_input),
|
||||
b64(&enc_verifier_hash),
|
||||
b64(&enc_key_value),
|
||||
);
|
||||
// EncryptionInfo (§2.3.4.10): version 4.4, reserved 0x40, the XML.
|
||||
let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0];
|
||||
info.extend_from_slice(xml.as_bytes());
|
||||
|
||||
let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})"));
|
||||
let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?;
|
||||
for (path, data) in [
|
||||
("/\u{6}DataSpaces/Version", data_space_version()),
|
||||
("/\u{6}DataSpaces/DataSpaceMap", data_space_map()),
|
||||
("/\u{6}DataSpaces/DataSpaceInfo/StrongEncryptionDataSpace", data_space_definition()),
|
||||
("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary", primary_transform()),
|
||||
("/EncryptionInfo", info),
|
||||
("/EncryptedPackage", stream),
|
||||
] {
|
||||
if let Some((parent, _)) = path.rsplit_once('/')
|
||||
&& !parent.is_empty()
|
||||
{
|
||||
comp.create_storage_all(parent).map_err(io)?;
|
||||
}
|
||||
comp.create_stream(path).map_err(io)?.write_all(&data).map_err(io)?;
|
||||
}
|
||||
comp.flush().map_err(io)?;
|
||||
Ok(comp.into_inner().into_inner())
|
||||
}
|
||||
|
||||
/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`].
|
||||
pub fn check_password(password: &str) -> Result<(), DocxError> {
|
||||
if password.is_empty() {
|
||||
return Err(bad("the password is empty"));
|
||||
}
|
||||
if password.chars().count() > MAX_PASSWORD_CHARS {
|
||||
return Err(bad(format!("passwords are at most {MAX_PASSWORD_CHARS} characters")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bad(msg: impl Into<String>) -> DocxError {
|
||||
DocxError::Encryption(msg.into())
|
||||
}
|
||||
|
||||
/// A root-level stream, at most `cap` bytes.
|
||||
fn stream<F: Read + std::io::Seek>(comp: &mut cfb::CompoundFile<F>, path: &str, cap: u64) -> Result<Vec<u8>, DocxError> {
|
||||
let s = comp.open_stream(path).map_err(|e| bad(format!("can't read {} ({e})", path.trim_start_matches('/'))))?;
|
||||
let mut v = Vec::new();
|
||||
s.take(cap.saturating_add(1)).read_to_end(&mut v).map_err(|e| bad(format!("can't read {} ({e})", path.trim_start_matches('/'))))?;
|
||||
if v.len() as u64 > cap {
|
||||
return Err(DocxError::Limit(format!("{} is too large", path.trim_start_matches('/'))));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The hash algorithms agile encryption names that WordCraft supports.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Hash {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha384,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
impl Hash {
|
||||
fn parse(s: &str) -> Option<Hash> {
|
||||
match s.to_ascii_uppercase().replace('-', "").as_str() {
|
||||
"SHA1" => Some(Hash::Sha1),
|
||||
"SHA256" => Some(Hash::Sha256),
|
||||
"SHA384" => Some(Hash::Sha384),
|
||||
"SHA512" => Some(Hash::Sha512),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Hash::Sha1 => "SHA-1",
|
||||
Hash::Sha256 => "SHA256",
|
||||
Hash::Sha384 => "SHA384",
|
||||
Hash::Sha512 => "SHA512",
|
||||
}
|
||||
}
|
||||
|
||||
fn size(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha1 => 20,
|
||||
Hash::Sha256 => 32,
|
||||
Hash::Sha384 => 48,
|
||||
Hash::Sha512 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// The hash of the concatenated `parts`.
|
||||
fn hash(self, parts: &[&[u8]]) -> Vec<u8> {
|
||||
fn go<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut d = D::new();
|
||||
for p in parts {
|
||||
d.update(p);
|
||||
}
|
||||
d.finalize().to_vec()
|
||||
}
|
||||
match self {
|
||||
Hash::Sha1 => go::<sha1::Sha1>(parts),
|
||||
Hash::Sha256 => go::<sha2::Sha256>(parts),
|
||||
Hash::Sha384 => go::<sha2::Sha384>(parts),
|
||||
Hash::Sha512 => go::<sha2::Sha512>(parts),
|
||||
}
|
||||
}
|
||||
|
||||
fn hmac(self, key: &[u8], msg: &[u8]) -> Result<Vec<u8>, DocxError> {
|
||||
fn go<M: Mac + KeyInit>(key: &[u8], msg: &[u8]) -> Result<Vec<u8>, DocxError> {
|
||||
let mut m = <M as KeyInit>::new_from_slice(key).map_err(|_| bad("bad integrity key"))?;
|
||||
m.update(msg);
|
||||
Ok(m.finalize().into_bytes().to_vec())
|
||||
}
|
||||
match self {
|
||||
Hash::Sha1 => go::<hmac::Hmac<sha1::Sha1>>(key, msg),
|
||||
Hash::Sha256 => go::<hmac::Hmac<sha2::Sha256>>(key, msg),
|
||||
Hash::Sha384 => go::<hmac::Hmac<sha2::Sha384>>(key, msg),
|
||||
Hash::Sha512 => go::<hmac::Hmac<sha2::Sha512>>(key, msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The password hash after `spin_count` iterations (§2.3.4.11): H0 = H(salt + password as
|
||||
/// UTF-16LE), Hn = H(iterator + Hn-1).
|
||||
fn iterate(hash: Hash, salt: &[u8], password: &str, spin_count: u32) -> Zeroizing<Vec<u8>> {
|
||||
let pw: Zeroizing<Vec<u8>> = Zeroizing::new(password.encode_utf16().flat_map(u16::to_le_bytes).collect());
|
||||
let mut h = Zeroizing::new(hash.hash(&[salt, &pw]));
|
||||
for i in 0..spin_count.min(MAX_SPIN_COUNT) {
|
||||
*h = hash.hash(&[&i.to_le_bytes(), &h]);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// A key from the iterated password hash and a block key (§2.3.4.11): H(Hn + blockKey),
|
||||
/// truncated to `len`, or padded with 0x36.
|
||||
fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing<Vec<u8>> {
|
||||
let mut k = Zeroizing::new(hash.hash(&[hn, block_key]));
|
||||
k.resize(len, 0x36);
|
||||
k
|
||||
}
|
||||
|
||||
/// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to
|
||||
/// the block size or padded with 0x36.
|
||||
fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec<u8> {
|
||||
let mut iv = match block_key {
|
||||
Some(b) => hash.hash(&[salt, b]),
|
||||
None => salt.to_vec(),
|
||||
};
|
||||
iv.resize(block_size, 0x36);
|
||||
iv
|
||||
}
|
||||
|
||||
/// `data` zero-padded to a whole number of blocks.
|
||||
fn padded(data: &[u8]) -> Zeroizing<Vec<u8>> {
|
||||
let mut v = Zeroizing::new(data.to_vec());
|
||||
v.resize(data.len().div_ceil(BLOCK).saturating_mul(BLOCK), 0);
|
||||
v
|
||||
}
|
||||
|
||||
fn random(n: usize) -> Result<Zeroizing<Vec<u8>>, DocxError> {
|
||||
let mut v = Zeroizing::new(vec![0u8; n]);
|
||||
getrandom::fill(&mut v).map_err(|e| bad(format!("no secure random numbers available ({e})")))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Compare without stopping at the first difference.
|
||||
fn same(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// AES with a 128-, 192- or 256-bit key.
|
||||
enum Aes {
|
||||
A128(aes::Aes128),
|
||||
A192(aes::Aes192),
|
||||
A256(aes::Aes256),
|
||||
}
|
||||
|
||||
impl Aes {
|
||||
fn new(key: &[u8]) -> Result<Aes, DocxError> {
|
||||
let err = |_| bad("bad AES key");
|
||||
Ok(match key.len() {
|
||||
16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(err)?),
|
||||
24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(err)?),
|
||||
32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(err)?),
|
||||
n => return Err(bad(format!("unsupported AES key length {} bits", n * 8))),
|
||||
})
|
||||
}
|
||||
|
||||
fn encrypt(&self, b: &mut aes::Block) {
|
||||
match self {
|
||||
Aes::A128(c) => c.encrypt_block(b),
|
||||
Aes::A192(c) => c.encrypt_block(b),
|
||||
Aes::A256(c) => c.encrypt_block(b),
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypt(&self, b: &mut aes::Block) {
|
||||
match self {
|
||||
Aes::A128(c) => c.decrypt_block(b),
|
||||
Aes::A192(c) => c.decrypt_block(b),
|
||||
Aes::A256(c) => c.decrypt_block(b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-CBC without padding: `data` must be whole blocks.
|
||||
fn cbc_encrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>, DocxError> {
|
||||
let aes = Aes::new(key)?;
|
||||
let (blocks, rest) = data.as_chunks::<BLOCK>();
|
||||
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| bad("bad initialization vector"))?;
|
||||
if !rest.is_empty() {
|
||||
return Err(bad("encrypted data isn't a whole number of blocks"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for b in blocks {
|
||||
let mut x = aes::Block::default();
|
||||
for ((o, p), v) in x.iter_mut().zip(b).zip(&prev) {
|
||||
*o = p ^ v;
|
||||
}
|
||||
aes.encrypt(&mut x);
|
||||
prev.copy_from_slice(&x);
|
||||
out.extend_from_slice(&x);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// AES-CBC decryption without padding.
|
||||
fn cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>, DocxError> {
|
||||
let aes = Aes::new(key)?;
|
||||
let (blocks, rest) = data.as_chunks::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(bad("encrypted data isn't a whole number of blocks"));
|
||||
}
|
||||
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| bad("bad initialization vector"))?;
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for b in blocks {
|
||||
let mut x = aes::Block::default();
|
||||
x.copy_from_slice(b);
|
||||
aes.decrypt(&mut x);
|
||||
for (o, v) in x.iter_mut().zip(&prev) {
|
||||
*o ^= v;
|
||||
}
|
||||
prev = *b;
|
||||
out.extend_from_slice(&x);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What `EncryptionInfo` says.
|
||||
struct Info {
|
||||
hash: Hash,
|
||||
salt: Vec<u8>,
|
||||
block_size: usize,
|
||||
key_bytes: usize,
|
||||
/// encryptedHmacKey, encryptedHmacValue.
|
||||
integrity: Option<(Vec<u8>, Vec<u8>)>,
|
||||
password: PasswordEncryptor,
|
||||
}
|
||||
|
||||
struct PasswordEncryptor {
|
||||
hash: Hash,
|
||||
salt: Vec<u8>,
|
||||
salt_size: usize,
|
||||
block_size: usize,
|
||||
key_bytes: usize,
|
||||
spin_count: u32,
|
||||
verifier_input: Vec<u8>,
|
||||
verifier_hash: Vec<u8>,
|
||||
key_value: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Cipher parameters shared by `keyData` and `encryptedKey`.
|
||||
struct Params {
|
||||
hash: Hash,
|
||||
salt: Vec<u8>,
|
||||
salt_size: usize,
|
||||
block_size: usize,
|
||||
key_bytes: usize,
|
||||
}
|
||||
|
||||
fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
|
||||
let (major, minor) = match info.get(..4) {
|
||||
Some([a, b, c, d]) => (u16::from_le_bytes([*a, *b]), u16::from_le_bytes([*c, *d])),
|
||||
_ => return Err(bad("EncryptionInfo is truncated")),
|
||||
};
|
||||
match (major, minor) {
|
||||
(4, 4) => {}
|
||||
(2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")),
|
||||
(3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")),
|
||||
_ => return Err(bad(format!("unknown encryption version {major}.{minor}"))),
|
||||
}
|
||||
let root = xml::parse(info.get(8..).unwrap_or(&[]))?;
|
||||
if root.local() != "encryption" {
|
||||
return Err(bad("EncryptionInfo has no <encryption> element"));
|
||||
}
|
||||
let key_data = root.els().find(|e| e.local() == "keyData").ok_or_else(|| bad("EncryptionInfo has no keyData"))?;
|
||||
let kd = params(key_data)?;
|
||||
let integrity = match root.els().find(|e| e.local() == "dataIntegrity") {
|
||||
Some(di) => Some((b64_attr(di, "encryptedHmacKey")?, b64_attr(di, "encryptedHmacValue")?)),
|
||||
None => None,
|
||||
};
|
||||
// The password key encryptor is the `encryptedKey` with a spin count (a certificate key
|
||||
// encryptor has none).
|
||||
let encryptors = root.els().find(|e| e.local() == "keyEncryptors").ok_or_else(|| bad("EncryptionInfo has no keyEncryptors"))?;
|
||||
let ek = encryptors
|
||||
.els()
|
||||
.filter(|e| e.local() == "keyEncryptor")
|
||||
.flat_map(|e| e.els())
|
||||
.find(|e| e.local() == "encryptedKey" && e.attr("spinCount").is_some())
|
||||
.ok_or_else(|| bad("this file isn't protected with a password (only certificate encryption is listed)"))?;
|
||||
let pe = params(ek)?;
|
||||
let spin_count: u32 = ek.attr("spinCount").and_then(|s| s.trim().parse().ok()).ok_or_else(|| bad("bad spinCount"))?;
|
||||
if spin_count > MAX_SPIN_COUNT {
|
||||
return Err(bad(format!("spinCount {spin_count} is over the allowed maximum")));
|
||||
}
|
||||
Ok(Info {
|
||||
hash: kd.hash,
|
||||
salt: kd.salt,
|
||||
block_size: kd.block_size,
|
||||
key_bytes: kd.key_bytes,
|
||||
integrity,
|
||||
password: PasswordEncryptor {
|
||||
hash: pe.hash,
|
||||
salt: pe.salt,
|
||||
salt_size: pe.salt_size,
|
||||
block_size: pe.block_size,
|
||||
key_bytes: pe.key_bytes,
|
||||
spin_count,
|
||||
verifier_input: b64_attr(ek, "encryptedVerifierHashInput")?,
|
||||
verifier_hash: b64_attr(ek, "encryptedVerifierHashValue")?,
|
||||
key_value: b64_attr(ek, "encryptedKeyValue")?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn params(e: &xml::El) -> Result<Params, DocxError> {
|
||||
let num = |k: &str| -> Result<usize, DocxError> { e.attr(k).and_then(|s| s.trim().parse::<usize>().ok()).ok_or_else(|| bad(format!("bad {k}"))) };
|
||||
let cipher = e.attr("cipherAlgorithm").unwrap_or("");
|
||||
if !cipher.eq_ignore_ascii_case("AES") {
|
||||
return Err(bad(format!("the {cipher:?} cipher isn't supported")));
|
||||
}
|
||||
let chaining = e.attr("cipherChaining").unwrap_or("");
|
||||
if chaining != "ChainingModeCBC" {
|
||||
return Err(bad(format!("the {chaining:?} chaining mode isn't supported")));
|
||||
}
|
||||
let name = e.attr("hashAlgorithm").unwrap_or("");
|
||||
let hash = Hash::parse(name).ok_or_else(|| bad(format!("the {name:?} hash algorithm isn't supported")))?;
|
||||
if num("hashSize")? != hash.size() {
|
||||
return Err(bad("hashSize doesn't match the hash algorithm"));
|
||||
}
|
||||
let block_size = num("blockSize")?;
|
||||
if block_size != BLOCK {
|
||||
return Err(bad(format!("AES needs a block size of {BLOCK}, not {block_size}")));
|
||||
}
|
||||
let key_bits = num("keyBits")?;
|
||||
if !matches!(key_bits, 128 | 192 | 256) {
|
||||
return Err(bad(format!("unsupported AES key size {key_bits}")));
|
||||
}
|
||||
let salt = b64_attr(e, "saltValue")?;
|
||||
let salt_size = num("saltSize")?;
|
||||
if salt.is_empty() || salt.len() != salt_size {
|
||||
return Err(bad("the salt doesn't match saltSize"));
|
||||
}
|
||||
Ok(Params { hash, salt, salt_size, block_size, key_bytes: key_bits / 8 })
|
||||
}
|
||||
|
||||
fn b64_attr(e: &xml::El, k: &str) -> Result<Vec<u8>, DocxError> {
|
||||
let s = e.attr(k).ok_or_else(|| bad(format!("{k} is missing")))?;
|
||||
let s: String = s.chars().filter(|c| !c.is_ascii_whitespace()).collect();
|
||||
base64::engine::general_purpose::STANDARD.decode(s).map_err(|_| bad(format!("{k} isn't valid base64")))
|
||||
}
|
||||
|
||||
/// A UNICODE-LP-P4 string (§2.1.2): byte length, UTF-16LE, zero-padded to 4 bytes.
|
||||
fn lp_p4(out: &mut Vec<u8>, s: &str) {
|
||||
let units: Vec<u8> = s.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
out.extend_from_slice(&(units.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&units);
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `\x06DataSpaces\Version` (§2.1.5).
|
||||
fn data_space_version() -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
lp_p4(&mut v, "Microsoft.Container.DataSpaces");
|
||||
for _ in 0..3 {
|
||||
v.extend_from_slice(&[1, 0, 0, 0]);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// `\x06DataSpaces\DataSpaceMap` (§2.1.6, §2.3.4.1): EncryptedPackage → StrongEncryptionDataSpace.
|
||||
fn data_space_map() -> Vec<u8> {
|
||||
let mut entry = Vec::new();
|
||||
entry.extend_from_slice(&1u32.to_le_bytes());
|
||||
entry.extend_from_slice(&0u32.to_le_bytes());
|
||||
lp_p4(&mut entry, "EncryptedPackage");
|
||||
lp_p4(&mut entry, "StrongEncryptionDataSpace");
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&8u32.to_le_bytes());
|
||||
v.extend_from_slice(&1u32.to_le_bytes());
|
||||
v.extend_from_slice(&((entry.len() + 4) as u32).to_le_bytes());
|
||||
v.extend_from_slice(&entry);
|
||||
v
|
||||
}
|
||||
|
||||
/// `\x06DataSpaces\DataSpaceInfo\StrongEncryptionDataSpace` (§2.1.7, §2.3.4.2).
|
||||
fn data_space_definition() -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&8u32.to_le_bytes());
|
||||
v.extend_from_slice(&1u32.to_le_bytes());
|
||||
lp_p4(&mut v, "StrongEncryptionTransform");
|
||||
v
|
||||
}
|
||||
|
||||
/// `\x06DataSpaces\TransformInfo\StrongEncryptionTransform\x06Primary` (§2.1.8, §2.1.9,
|
||||
/// §2.3.4.3): the encryption transform, with a null EncryptionName as agile encryption requires.
|
||||
fn primary_transform() -> Vec<u8> {
|
||||
let mut id = Vec::new();
|
||||
lp_p4(&mut id, "{FF9A3F03-56EF-4613-BDD5-5A41C1D07246}");
|
||||
let mut v = Vec::new();
|
||||
// TransformLength: the bytes before TransformName.
|
||||
v.extend_from_slice(&((8 + id.len()) as u32).to_le_bytes());
|
||||
v.extend_from_slice(&1u32.to_le_bytes());
|
||||
v.extend_from_slice(&id);
|
||||
lp_p4(&mut v, "Microsoft.Container.EncryptionTransform");
|
||||
for _ in 0..3 {
|
||||
v.extend_from_slice(&[1, 0, 0, 0]);
|
||||
}
|
||||
// EncryptionTransformInfo: null name, block size, cipher mode 0, reserved 4.
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v.extend_from_slice(&(BLOCK as u32).to_le_bytes());
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v.extend_from_slice(&4u32.to_le_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn zip_package() -> Vec<u8> {
|
||||
let mut doc = wordcraft_doc::Document::from_text("Top secret plans\nSecond paragraph");
|
||||
doc.core.title = "Secret".into();
|
||||
crate::write(&doc).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_with_a_password() {
|
||||
let pkg = zip_package();
|
||||
let enc = encrypt_with_spin_count(&pkg, "pässwörd 🔑", 16).unwrap();
|
||||
assert!(is_encrypted(&enc));
|
||||
assert!(!is_encrypted(&pkg));
|
||||
// The compound file carries the data spaces Word expects.
|
||||
let comp = cfb::CompoundFile::open(Cursor::new(&enc)).unwrap();
|
||||
assert!(comp.is_stream("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary"));
|
||||
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")).unwrap(), pkg);
|
||||
let doc = crate::read_with_password(&enc, Some("pässwörd 🔑")).unwrap();
|
||||
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
|
||||
assert_eq!(doc.core.title, "Secret");
|
||||
// Without a password, or with the wrong one, it says so instead of opening blank.
|
||||
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(crate::read_with_password(&enc, None), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
|
||||
// A package that is a whole number of segments, and an empty one.
|
||||
for n in [0usize, SEGMENT, SEGMENT * 2 + 1] {
|
||||
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
|
||||
let enc = encrypt_with_spin_count(&data, "x", 1).unwrap();
|
||||
assert_eq!(decrypt(&enc, Some("x")).unwrap(), data);
|
||||
}
|
||||
assert!(encrypt(&pkg, "").is_err());
|
||||
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_package_fails_the_integrity_check() {
|
||||
let pkg = zip_package();
|
||||
let enc = encrypt_with_spin_count(&pkg, "pw", 4).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
|
||||
let mut data = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut data).unwrap();
|
||||
let last = data.len() - 1;
|
||||
data[last] ^= 1;
|
||||
comp.create_stream("/EncryptedPackage").unwrap().write_all(&data).unwrap();
|
||||
comp.flush().unwrap();
|
||||
let enc = comp.into_inner().into_inner();
|
||||
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("integrity")));
|
||||
}
|
||||
|
||||
/// A compound file with the given EncryptionInfo and EncryptedPackage streams.
|
||||
fn cfb_with(info: &[u8], package: &[u8]) -> Vec<u8> {
|
||||
let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
|
||||
comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap();
|
||||
comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap();
|
||||
comp.flush().unwrap();
|
||||
comp.into_inner().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_encryption_info_errors_without_panicking() {
|
||||
let good = encrypt_with_spin_count(&zip_package(), "pw", 2).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(&good)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let mut package = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
|
||||
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
|
||||
// (EncryptionInfo, EncryptedPackage, must fail). Truncations only must not panic: the
|
||||
// XML reader is lenient, so cutting the closing tags still reads.
|
||||
let mut cases: Vec<(Vec<u8>, Vec<u8>, bool)> = Vec::new();
|
||||
for n in (0..info.len()).step_by(7) {
|
||||
cases.push((info[..n].to_vec(), package.clone(), n < info.len() / 2));
|
||||
}
|
||||
for n in [0, 4, 8, 9, 100, package.len() - 1] {
|
||||
cases.push((info.clone(), package[..n].to_vec(), true));
|
||||
}
|
||||
// Standard and unknown versions, absurd numbers, bad base64, a huge StreamSize.
|
||||
let with = |from: &str, to: &str| {
|
||||
let mut v = info[..8].to_vec();
|
||||
v.extend_from_slice(xml.replacen(from, to, 2).as_bytes());
|
||||
v
|
||||
};
|
||||
cases.push(([3u8, 0, 2, 0, 0x24, 0, 0, 0].to_vec(), package.clone(), true));
|
||||
cases.push(([9u8, 0, 9, 0].to_vec(), package.clone(), true));
|
||||
cases.push((with("spinCount=\"2\"", "spinCount=\"4294967295\""), package.clone(), true));
|
||||
cases.push((with("keyBits=\"256\"", "keyBits=\"8\""), package.clone(), true));
|
||||
cases.push((with("blockSize=\"16\"", "blockSize=\"4096\""), package.clone(), true));
|
||||
cases.push((with("hashSize=\"64\"", "hashSize=\"0\""), package.clone(), true));
|
||||
cases.push((with("saltSize=\"16\"", "saltSize=\"99999999999999999999\""), package.clone(), true));
|
||||
cases.push((with("SHA512", "MD5"), package.clone(), true));
|
||||
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
|
||||
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
|
||||
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
|
||||
// Data integrity is optional; without it a good file still opens.
|
||||
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), false));
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
|
||||
for (i, p, must_fail) in cases {
|
||||
let file = cfb_with(&i, &p);
|
||||
let r = decrypt(&file, Some("pw"));
|
||||
assert!(!must_fail || r.is_err());
|
||||
let _ = crate::read_with_password(&file, Some("pw"));
|
||||
assert!(crate::read(&file).is_err());
|
||||
}
|
||||
// A file that only looks like a compound file.
|
||||
let mut fake = CFB_MAGIC.to_vec();
|
||||
fake.extend_from_slice(&[0u8; 600]);
|
||||
assert!(!is_encrypted(&fake));
|
||||
assert!(decrypt(&fake, Some("pw")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! error or a best-effort document, never a panic.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod crypt;
|
||||
mod custom;
|
||||
mod package;
|
||||
mod read;
|
||||
@@ -14,9 +15,26 @@ mod units;
|
||||
mod write;
|
||||
mod xml;
|
||||
|
||||
pub use crypt::{DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, check_password, decrypt, encrypt, encrypt_with_spin_count, is_encrypted};
|
||||
pub use read::read;
|
||||
pub use write::{write, write_as};
|
||||
|
||||
/// What [`DocxError::PasswordRequired`] says.
|
||||
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
|
||||
/// What [`DocxError::WrongPassword`] says.
|
||||
pub const WRONG_PASSWORD: &str = "the password is incorrect";
|
||||
|
||||
/// Read a package that may be password-protected ([`is_encrypted`]): it is decrypted with
|
||||
/// `password` first. An encrypted package without a password gives
|
||||
/// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`].
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
if is_encrypted(bytes) {
|
||||
let package = zeroize::Zeroizing::new(decrypt(bytes, password)?);
|
||||
return read::read_package(&package);
|
||||
}
|
||||
read(bytes)
|
||||
}
|
||||
|
||||
/// Which kind of WordprocessingML package to write. The main part's content type differs per
|
||||
/// kind, and Word refuses a file whose content type doesn't match its extension.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -78,4 +96,14 @@ pub enum DocxError {
|
||||
/// The package is readable but isn't a WordprocessingML document.
|
||||
#[error("not a Word document: {0}")]
|
||||
NotWord(String),
|
||||
/// The package is encrypted and no password was given.
|
||||
#[error("{}", PASSWORD_REQUIRED)]
|
||||
PasswordRequired,
|
||||
/// The password doesn't open the package.
|
||||
#[error("{}", WRONG_PASSWORD)]
|
||||
WrongPassword,
|
||||
/// The package is encrypted in a way WordCraft can't read, or its encryption data is damaged
|
||||
/// (or encrypting failed).
|
||||
#[error("encrypted document: {0}")]
|
||||
Encryption(String),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Office Math (OMML, ECMA-376 Part 1 §22.1) → [`wordcraft_doc::math`] tree.
|
||||
|
||||
use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, merge_runs};
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, merge_runs,
|
||||
};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
use crate::xml::El;
|
||||
@@ -20,6 +22,27 @@ pub fn para_jc(para: &El) -> MathJc {
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings part's `m:mathPr`: how display equations wrap.
|
||||
pub fn read_math_pr(e: &El) -> MathProps {
|
||||
let mut m = MathProps::default();
|
||||
let pr = Some(e);
|
||||
m.brk_bin = match val(pr, "m:brkBin") {
|
||||
Some("after") => BrkBin::After,
|
||||
Some("repeat") => BrkBin::Repeat,
|
||||
_ => BrkBin::Before,
|
||||
};
|
||||
m.brk_bin_sub = match val(pr, "m:brkBinSub") {
|
||||
Some("-+") => BrkBinSub::MinusPlus,
|
||||
Some("+-") => BrkBinSub::PlusMinus,
|
||||
_ => BrkBinSub::MinusMinus,
|
||||
};
|
||||
if let Some(v) = e.child("m:wrapIndent").and_then(|c| crate::units::tw(c, "m:val")).filter(|v| v.is_finite() && *v >= 0.0) {
|
||||
m.wrap_indent = v.min(1584.0);
|
||||
}
|
||||
m.wrap_right = prop(pr, "m:wrapRight", false);
|
||||
m
|
||||
}
|
||||
|
||||
/// OMML on/off property: absent `m:val` = on.
|
||||
fn on(e: &El) -> bool {
|
||||
match e.attr("m:val") {
|
||||
@@ -251,6 +274,10 @@ fn run(r: &El) -> Option<MRun> {
|
||||
};
|
||||
out.nor = pr.child("m:nor").is_some_and(on);
|
||||
out.lit = pr.child("m:lit").is_some_and(on);
|
||||
if let Some(b) = pr.child("m:brk") {
|
||||
let aln = b.attr("m:alnAt").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
|
||||
out.brk = Some(aln.min(255) as u8);
|
||||
}
|
||||
}
|
||||
wrpr(r.child("w:rPr"), &mut out);
|
||||
Some(out)
|
||||
|
||||
@@ -63,6 +63,16 @@ fn part_of(rels: &Rels, id: &str, kind: &str) -> Option<String> {
|
||||
|
||||
/// Read a `.docx` package.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
// A password-protected document is a compound file, not a zip: say so rather than failing
|
||||
// as a broken zip (`read_with_password` opens it).
|
||||
if crate::is_encrypted(bytes) {
|
||||
return crate::read_with_password(bytes, None);
|
||||
}
|
||||
read_package(bytes)
|
||||
}
|
||||
|
||||
/// [`read`] for bytes known not to be an encrypted package.
|
||||
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
let pkg = Package::open(bytes)?;
|
||||
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());
|
||||
@@ -505,10 +515,28 @@ impl Reader<'_> {
|
||||
t.band_chr = chr;
|
||||
t.band_borders = cell_borders;
|
||||
}
|
||||
Some("firstCol") => t.first_col_chr = chr,
|
||||
Some("firstCol") => {
|
||||
t.first_col_chr = chr;
|
||||
t.first_col_fill = fill;
|
||||
t.first_col_borders = cell_borders;
|
||||
}
|
||||
Some("lastCol") => {
|
||||
t.last_col_chr = chr;
|
||||
t.last_col_fill = fill;
|
||||
t.last_col_borders = cell_borders;
|
||||
}
|
||||
Some("lastRow") => {
|
||||
t.total_chr = chr;
|
||||
t.total_border_top = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).and_then(|b| b.child("w:top")).map(props::border);
|
||||
t.total_fill = fill;
|
||||
// The top rule is kept on its own (as built-in styles have it); the other edges
|
||||
// are the region's borders.
|
||||
t.total_border_top = cell_borders.and_then(|b| b.top);
|
||||
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
|
||||
}
|
||||
Some("band1Vert") => {
|
||||
t.col_band_fill = fill;
|
||||
t.col_band_chr = chr;
|
||||
t.col_band_borders = cell_borders;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -633,6 +661,7 @@ impl Reader<'_> {
|
||||
if k.name == "w:drawingGridHorizontalSpacing" { s.grid_h = v } else { s.grid_v = v }
|
||||
}
|
||||
}
|
||||
"m:mathPr" => s.math = Some(math::read_math_pr(k)),
|
||||
"w:mirrorMargins" => s.mirror_margins = on_off(k),
|
||||
"w:autoHyphenation" => s.auto_hyphenation = on_off(k),
|
||||
"w:footnotePr" => {
|
||||
|
||||
@@ -142,6 +142,12 @@ impl PropCtx {
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
|
||||
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
|
||||
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
|
||||
"w:topLinePunct" => p.top_line_punct = Some(on_off(k)),
|
||||
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
|
||||
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
|
||||
"w:framePr" => {
|
||||
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
|
||||
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
|
||||
|
||||
+181
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind};
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{CharProps, Rgb};
|
||||
@@ -125,7 +126,7 @@ fn clean_text(s: &str) -> String {
|
||||
|
||||
fn children_of_choice(ac: &El) -> Option<&El> {
|
||||
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
|
||||
const KNOWN: &[&str] = &["wps", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
|
||||
const KNOWN: &[&str] = &["wps", "wpg", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"];
|
||||
let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r));
|
||||
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
|
||||
}
|
||||
@@ -663,19 +664,11 @@ impl Reader<'_> {
|
||||
let graphic = self.graphic(kind, gd, rels, w, h);
|
||||
return Some(InlineObject::Graphic { w, h, alt, float, graphic });
|
||||
}
|
||||
if let Some(blip) = gd.find("a:blip") {
|
||||
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
|
||||
let mut crop = [0.0f32; 4];
|
||||
if let Some(sr) = gd.find("a:srcRect") {
|
||||
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
|
||||
if let Some(v) = sr.attr(n).and_then(int)
|
||||
&& let Some(slot) = crop.get_mut(i)
|
||||
{
|
||||
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some(InlineObject::Image { media, w, h, alt, float, crop });
|
||||
if let Some(g) = gd.child("wpg:wgp") {
|
||||
return self.read_group(sc, g, rels, w, h, float);
|
||||
}
|
||||
if gd.find("a:blip").is_some() {
|
||||
return self.read_pic(gd, rels, w, h, alt, float);
|
||||
}
|
||||
if let Some(wsp) = gd.find("wps:wsp") {
|
||||
return Some(self.read_wsp(sc, wsp, rels, w, h, float));
|
||||
@@ -715,6 +708,99 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture: the first `a:blip` in `pic` and its crop.
|
||||
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, float: Float) -> Option<InlineObject> {
|
||||
let blip = pic.find("a:blip")?;
|
||||
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
|
||||
let mut crop = [0.0f32; 4];
|
||||
if let Some(sr) = pic.find("a:srcRect") {
|
||||
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
|
||||
if let Some(v) = sr.attr(n).and_then(int)
|
||||
&& let Some(slot) = crop.get_mut(i)
|
||||
{
|
||||
*slot = (v.clamp(0, 100_000) as f32 / 100_000.0).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Image { media, w, h, alt, float, crop })
|
||||
}
|
||||
|
||||
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
|
||||
/// flattened into it. `None` when it has no member we can show.
|
||||
fn read_group(&mut self, sc: &mut StoryCtx, g: &El, rels: &Rels, w: f32, h: f32, float: Float) -> Option<InlineObject> {
|
||||
let x = group_xfrm(g.child("wpg:grpSpPr"));
|
||||
// The members' space; without `a:chExt` it is the group's own size.
|
||||
let (ch_w, ch_h) = match x.ch_ext {
|
||||
Some((cw, ch)) if cw > 0.0 && ch > 0.0 => (cw, ch),
|
||||
_ => (w.max(1.0), h.max(1.0)),
|
||||
};
|
||||
let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0));
|
||||
let mut children = Vec::new();
|
||||
self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(InlineObject::Group { w, h, float, ch_w, ch_h, children })
|
||||
}
|
||||
|
||||
/// The members of group `g` into `out`, mapped into the outermost group's space by `t`
|
||||
/// (x0, y0, sx, sy: outer = x0 + inner × sx).
|
||||
fn group_members(
|
||||
&mut self,
|
||||
sc: &mut StoryCtx,
|
||||
g: &El,
|
||||
rels: &Rels,
|
||||
t: (f32, f32, f32, f32),
|
||||
depth: usize,
|
||||
out: &mut Vec<wordcraft_doc::para::GroupChild>,
|
||||
) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
for e in g.els() {
|
||||
if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN {
|
||||
return;
|
||||
}
|
||||
let e = match e.name.as_str() {
|
||||
"mc:AlternateContent" => match children_of_choice(e).and_then(|c| c.els().next()) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
},
|
||||
_ => e,
|
||||
};
|
||||
let sppr = match e.name.as_str() {
|
||||
"wps:wsp" => e.child("wps:spPr"),
|
||||
"pic:pic" => e.child("pic:spPr"),
|
||||
"wpg:grpSp" => e.child("wpg:grpSpPr"),
|
||||
_ => continue,
|
||||
};
|
||||
let x = group_xfrm(sppr);
|
||||
let ((ox, oy), (ew, eh)) = (x.off, x.ext);
|
||||
if e.name == "wpg:grpSp" {
|
||||
if depth >= 8 {
|
||||
continue;
|
||||
}
|
||||
// Its members' space maps onto its own box.
|
||||
let (cox, coy) = x.ch_off.unwrap_or((0.0, 0.0));
|
||||
let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
|
||||
let (kx, ky) = (ew / cw, eh / ch);
|
||||
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
|
||||
self.group_members(sc, e, rels, nt, depth + 1, out);
|
||||
continue;
|
||||
}
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
|
||||
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
|
||||
let obj = if e.name == "pic:pic" {
|
||||
let alt = e.find("pic:cNvPr").and_then(|p| p.attr("descr")).unwrap_or("").to_string();
|
||||
self.read_pic(e, rels, w, h, alt, Float::default())
|
||||
} else {
|
||||
Some(self.read_wsp(sc, e, rels, w, h, Float::default()))
|
||||
};
|
||||
if let Some(obj) = obj {
|
||||
out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + ox * sx, -max), y: fit(y0 + oy * sy, -max), obj });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, float: Float) -> InlineObject {
|
||||
let sppr = wsp.child("wps:spPr");
|
||||
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
|
||||
@@ -741,11 +827,12 @@ impl Reader<'_> {
|
||||
let ln = sppr.and_then(|s| s.child("a:ln"));
|
||||
let stroke = solid(ln);
|
||||
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0);
|
||||
let effects = sppr.and_then(|s| s.child("a:effectLst")).map(effect_list).unwrap_or_default();
|
||||
let story = match txbx {
|
||||
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
|
||||
_ => None,
|
||||
};
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story }
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, effects }
|
||||
}
|
||||
|
||||
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
|
||||
@@ -775,7 +862,17 @@ impl Reader<'_> {
|
||||
let story = Some(self.read_textbox(sc, t, rels));
|
||||
let fill = shape.attr("fillcolor").and_then(Rgb::parse);
|
||||
let stroke = shape.attr("strokecolor").and_then(Rgb::parse);
|
||||
return Some(InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill, stroke, stroke_width: 0.75, float, story });
|
||||
return Some(InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: 0.75,
|
||||
float,
|
||||
story,
|
||||
effects: Default::default(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -965,3 +1062,71 @@ fn vml_float(shape: &El, style: &str) -> Float {
|
||||
fn graphic_work(items: &[GraphicItem]) -> usize {
|
||||
items.iter().map(|it| if let GraphicItem::Path { segs, .. } = it { segs.len() + 1 } else { 1 }).sum()
|
||||
}
|
||||
|
||||
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26): its outer shadow, glow and soft edges.
|
||||
/// Other effects are dropped.
|
||||
fn effect_list(l: &El) -> ShapeEffects {
|
||||
let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0);
|
||||
let shadow = l.child("a:outerShdw").map(|e| {
|
||||
let (color, transparency) = effect_color(e);
|
||||
// `dir`: 60000ths of a degree, clockwise.
|
||||
let angle = e.attr("dir").and_then(int).map(|v| (v.rem_euclid(21_600_000) as f32) / 60_000.0).unwrap_or(0.0);
|
||||
Shadow { color: color.unwrap_or(Rgb::BLACK), transparency, blur: pt(e, "blurRad"), distance: pt(e, "dist"), angle }
|
||||
});
|
||||
let glow = l.child("a:glow").map(|e| {
|
||||
let (color, transparency) = effect_color(e);
|
||||
Glow { color: color.unwrap_or(Glow::default().color), size: pt(e, "rad"), transparency }
|
||||
});
|
||||
let soft_edge = l.child("a:softEdge").map(|e| pt(e, "rad"));
|
||||
ShapeEffects { shadow, glow, soft_edge }.sanitized()
|
||||
}
|
||||
|
||||
/// The colour inside an effect (`a:srgbClr`, `a:prstClr`, `a:sysClr`; theme colours aren't
|
||||
/// resolved here) and its transparency, percent (from `a:alpha`, opaque when absent).
|
||||
fn effect_color(e: &El) -> (Option<Rgb>, f32) {
|
||||
let Some(c) = e.els().find(|c| matches!(c.name.as_str(), "a:srgbClr" | "a:prstClr" | "a:sysClr" | "a:schemeClr" | "a:scrgbClr" | "a:hslClr"))
|
||||
else {
|
||||
return (None, 0.0);
|
||||
};
|
||||
let rgb = match c.name.as_str() {
|
||||
"a:srgbClr" => c.attr("val").and_then(Rgb::parse),
|
||||
"a:sysClr" => c.attr("lastClr").and_then(Rgb::parse),
|
||||
"a:prstClr" => match c.attr("val").unwrap_or("") {
|
||||
"black" => Some(Rgb::BLACK),
|
||||
"white" => Some(Rgb::WHITE),
|
||||
"gray" | "grey" => Some(Rgb(0x80, 0x80, 0x80)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
// `a:alpha`: opacity in 1000ths of a percent.
|
||||
let opacity = c.child("a:alpha").and_then(|a| a.attr("val")).and_then(int).map(|v| v.clamp(0, 100_000) as f32 / 1000.0).unwrap_or(100.0);
|
||||
(rgb, 100.0 - opacity)
|
||||
}
|
||||
|
||||
/// A DrawingML `a:xfrm` (ECMA-376 §20.1.7.5/6) in points: offset, extent and, for a group, its
|
||||
/// members' offset and extent.
|
||||
struct Xfrm {
|
||||
off: (f32, f32),
|
||||
ext: (f32, f32),
|
||||
ch_off: Option<(f32, f32)>,
|
||||
ch_ext: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
/// The `a:xfrm` in shape properties `sppr` (zeros when missing).
|
||||
fn group_xfrm(sppr: Option<&El>) -> Xfrm {
|
||||
let x = sppr.and_then(|s| s.child("a:xfrm"));
|
||||
let pair = |name: &str, a: &str, b: &str, lim: f32| {
|
||||
let e = x.and_then(|x| x.child(name))?;
|
||||
let v = |n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).map(|v| v.clamp(-lim, lim)).unwrap_or(0.0);
|
||||
Some((v(a), v(b)))
|
||||
};
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let pos = |p: Option<(f32, f32)>| p.map(|(a, b)| (a.max(0.0), b.max(0.0)));
|
||||
Xfrm {
|
||||
off: pair("a:off", "x", "y", max).unwrap_or((0.0, 0.0)),
|
||||
ext: pos(pair("a:ext", "cx", "cy", max)).unwrap_or((0.0, 0.0)),
|
||||
ch_off: pair("a:chOff", "x", "y", max),
|
||||
ch_ext: pos(pair("a:chExt", "cx", "cy", max)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
|
||||
|
||||
use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, parse_linear};
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
|
||||
};
|
||||
|
||||
use crate::xml::W;
|
||||
|
||||
@@ -86,6 +88,30 @@ fn wrap_ns(xml: &str) -> String {
|
||||
format!("<w:x{decl}>{xml}</w:x>")
|
||||
}
|
||||
|
||||
/// The settings part's `m:mathPr` (CT_MathPr order; `m:wrapIndent` and `m:wrapRight` are a choice).
|
||||
pub fn math_pr(w: &mut W, m: &MathProps) {
|
||||
w.open("m:mathPr", &[]);
|
||||
let bin = match m.brk_bin {
|
||||
BrkBin::Before => "before",
|
||||
BrkBin::After => "after",
|
||||
BrkBin::Repeat => "repeat",
|
||||
};
|
||||
val(w, "m:brkBin", bin);
|
||||
let sub = match m.brk_bin_sub {
|
||||
BrkBinSub::MinusMinus => "--",
|
||||
BrkBinSub::MinusPlus => "-+",
|
||||
BrkBinSub::PlusMinus => "+-",
|
||||
};
|
||||
val(w, "m:brkBinSub", sub);
|
||||
if m.wrap_right {
|
||||
w.empty("m:wrapRight", &[]);
|
||||
} else {
|
||||
let indent = if m.wrap_indent.is_finite() { m.wrap_indent.clamp(0.0, 1584.0) } else { wordcraft_doc::math::DEFAULT_WRAP_INDENT };
|
||||
val(w, "m:wrapIndent", &crate::units::twips(indent));
|
||||
}
|
||||
w.close("m:mathPr");
|
||||
}
|
||||
|
||||
fn val(w: &mut W, name: &str, v: &str) {
|
||||
w.empty(name, &[("m:val", v)]);
|
||||
}
|
||||
@@ -382,7 +408,7 @@ fn run(w: &mut W, r: &MRun) {
|
||||
MScr::SansSerif => Some("sans-serif"),
|
||||
MScr::Monospace => Some("monospace"),
|
||||
};
|
||||
if sty.is_some() || scr.is_some() || r.nor || r.lit {
|
||||
if sty.is_some() || scr.is_some() || r.nor || r.lit || r.brk.is_some() {
|
||||
w.open("m:rPr", &[]);
|
||||
if r.lit {
|
||||
w.empty("m:lit", &[]);
|
||||
@@ -396,6 +422,11 @@ fn run(w: &mut W, r: &MRun) {
|
||||
if let Some(s) = sty {
|
||||
val(w, "m:sty", s);
|
||||
}
|
||||
match r.brk {
|
||||
Some(0) => w.empty("m:brk", &[]),
|
||||
Some(n) => w.empty("m:brk", &[("m:alnAt", &n.to_string())]),
|
||||
None => {}
|
||||
}
|
||||
w.close("m:rPr");
|
||||
}
|
||||
w.open("w:rPr", &[]);
|
||||
|
||||
@@ -665,9 +665,18 @@ fn style_xml(w: &mut W, st: &Style) {
|
||||
};
|
||||
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
|
||||
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
|
||||
let total_top = t.total_border_top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() });
|
||||
cond(w, "lastRow", &t.total_chr, None, total_top);
|
||||
cond(w, "firstCol", &t.first_col_chr, None, None);
|
||||
// The total row's borders, its top edge falling back to the built-in top rule.
|
||||
let total_borders = match (t.total_borders, t.total_border_top) {
|
||||
(Some(mut b), top) => {
|
||||
b.top = b.top.or(top);
|
||||
Some(b)
|
||||
}
|
||||
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
|
||||
};
|
||||
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
|
||||
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
|
||||
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
|
||||
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
|
||||
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
|
||||
}
|
||||
w.close("w:style");
|
||||
@@ -793,6 +802,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
|
||||
let mode = doc.settings.compat_mode.clamp(11, 15).to_string();
|
||||
w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]);
|
||||
w.close("w:compat");
|
||||
if let Some(m) = &s.math {
|
||||
math::math_pr(&mut w, m);
|
||||
}
|
||||
w.close("w:settings");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
@@ -230,6 +230,12 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool) {
|
||||
w.close("w:tabs");
|
||||
}
|
||||
toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens);
|
||||
toggle(w, "w:kinsoku", p.kinsoku);
|
||||
toggle(w, "w:wordWrap", p.word_wrap);
|
||||
toggle(w, "w:overflowPunct", p.overflow_punct);
|
||||
toggle(w, "w:topLinePunct", p.top_line_punct);
|
||||
toggle(w, "w:autoSpaceDE", p.auto_space_de);
|
||||
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
|
||||
toggle(w, "w:bidi", p.bidi);
|
||||
if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() {
|
||||
let before = p.space_before.map(|v| twips(v.max(0.0)));
|
||||
|
||||
+204
-89
@@ -1,7 +1,8 @@
|
||||
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
|
||||
|
||||
use wordcraft_doc::effects::ShapeEffects;
|
||||
use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::props::{CharProps, Rgb};
|
||||
use wordcraft_doc::section::{LineNumberRestart, SectionProps, SectionStart};
|
||||
use wordcraft_doc::table::Table;
|
||||
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind};
|
||||
@@ -371,10 +372,8 @@ impl Writer<'_> {
|
||||
}
|
||||
// Charts and diagrams aren't written back yet.
|
||||
InlineObject::Graphic { .. } => {}
|
||||
InlineObject::Image { media, w: iw, h: ih, alt, float, crop } => {
|
||||
InlineObject::Image { media, w: iw, h: ih, alt, float, .. } => {
|
||||
let Some(file) = self.media_files.get(media).cloned() else { return };
|
||||
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
self.used_media.insert(media.clone());
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
@@ -385,30 +384,7 @@ impl Writer<'_> {
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
w.empty("pic:cNvPr", &[("id", "0"), ("name", &file), ("descr", alt)]);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
w.close("pic:nvPicPr");
|
||||
w.open("pic:blipFill", &[]);
|
||||
w.empty("a:blip", &[("r:embed", &rid)]);
|
||||
if crop.iter().any(|c| *c > 0.0) {
|
||||
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
|
||||
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
|
||||
}
|
||||
w.open("a:stretch", &[]);
|
||||
w.empty("a:fillRect", &[]);
|
||||
w.close("a:stretch");
|
||||
w.close("pic:blipFill");
|
||||
w.open("pic:spPr", &[("bwMode", "auto")]);
|
||||
xfrm(w, *iw, *ih);
|
||||
w.open("a:prstGeom", &[("prst", "rect")]);
|
||||
w.empty("a:avLst", &[]);
|
||||
w.close("a:prstGeom");
|
||||
w.close("pic:spPr");
|
||||
w.close("pic:pic");
|
||||
self.pic(w, o, &file, (0.0, 0.0), rels);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
@@ -416,77 +392,70 @@ impl Writer<'_> {
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story } => {
|
||||
InlineObject::Shape { w: sw, h: sh, float, effects, .. } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Shape {docpr}");
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
let mut float = *float;
|
||||
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
|
||||
*e = wordcraft_geom::finite(*e).max(fx);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, float, *sw, *sh, &docpr, &name, "");
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
|
||||
w.open("wps:wsp", &[]);
|
||||
if *kind == ShapeKind::TextBox {
|
||||
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
|
||||
} else {
|
||||
w.empty("wps:cNvSpPr", &[]);
|
||||
}
|
||||
w.open("wps:spPr", &[]);
|
||||
xfrm(w, *sw, *sh);
|
||||
let prst = match kind {
|
||||
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
|
||||
ShapeKind::RoundedRectangle => "roundRect",
|
||||
ShapeKind::Ellipse => "ellipse",
|
||||
ShapeKind::Triangle => "triangle",
|
||||
ShapeKind::Diamond => "diamond",
|
||||
ShapeKind::Line => "line",
|
||||
ShapeKind::Arrow => "rightArrow",
|
||||
ShapeKind::Star => "star5",
|
||||
ShapeKind::Heart => "heart",
|
||||
};
|
||||
w.open("a:prstGeom", &[("prst", prst)]);
|
||||
w.empty("a:avLst", &[]);
|
||||
w.close("a:prstGeom");
|
||||
match fill {
|
||||
Some(c) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
None => w.empty("a:noFill", &[]),
|
||||
}
|
||||
match stroke {
|
||||
Some(c) => {
|
||||
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
w.close("a:ln");
|
||||
}
|
||||
None => {
|
||||
w.open("a:ln", &[]);
|
||||
w.empty("a:noFill", &[]);
|
||||
w.close("a:ln");
|
||||
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Group {docpr}");
|
||||
self.drawing_open(w, float, *gw, *gh, &docpr, &name, "");
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
|
||||
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
|
||||
// maps onto the group's `a:off`/`a:ext`.
|
||||
w.open("wpg:wgp", &[]);
|
||||
w.empty("wpg:cNvGrpSpPr", &[]);
|
||||
w.open("wpg:grpSpPr", &[]);
|
||||
w.open("a:xfrm", &[]);
|
||||
w.empty("a:off", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
|
||||
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
|
||||
w.close("a:xfrm");
|
||||
w.close("wpg:grpSpPr");
|
||||
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
|
||||
let at = (c.x, c.y);
|
||||
match &c.obj {
|
||||
InlineObject::Image { media, .. } => {
|
||||
if let Some(file) = self.media_files.get(media).cloned() {
|
||||
self.pic(w, &c.obj, &file, at, rels);
|
||||
}
|
||||
}
|
||||
InlineObject::Shape { .. } => {
|
||||
let id = self.next_docpr();
|
||||
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
w.close("wps:spPr");
|
||||
// Its text, within the same bounds layout shows boxes inside boxes with.
|
||||
if let Some(id) = *story
|
||||
&& let Some(part) = self.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::TextBox)
|
||||
&& self.boxes.enter(id)
|
||||
{
|
||||
w.open("wps:txbx", &[]);
|
||||
w.open("w:txbxContent", &[]);
|
||||
let blocks = part.blocks.clone();
|
||||
self.blocks(w, &blocks, rels, false, depth + 1);
|
||||
w.close("w:txbxContent");
|
||||
w.close("wps:txbx");
|
||||
self.boxes.leave();
|
||||
}
|
||||
w.empty("wps:bodyPr", &[]);
|
||||
w.close("wps:wsp");
|
||||
w.close("wpg:wgp");
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
@@ -576,6 +545,108 @@ impl Writer<'_> {
|
||||
run(w, &|w| w.empty("w:fldChar", &[("w:fldCharType", "separate")]));
|
||||
}
|
||||
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, .. } = o else { return };
|
||||
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
w.empty("pic:cNvPr", &[("id", "0"), ("name", file), ("descr", alt)]);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
w.close("pic:nvPicPr");
|
||||
w.open("pic:blipFill", &[]);
|
||||
w.empty("a:blip", &[("r:embed", &rid)]);
|
||||
if crop.iter().any(|c| *c > 0.0) {
|
||||
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
|
||||
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
|
||||
}
|
||||
w.open("a:stretch", &[]);
|
||||
w.empty("a:fillRect", &[]);
|
||||
w.close("a:stretch");
|
||||
w.close("pic:blipFill");
|
||||
w.open("pic:spPr", &[("bwMode", "auto")]);
|
||||
xfrm(w, off, *iw, *ih);
|
||||
w.open("a:prstGeom", &[("prst", "rect")]);
|
||||
w.empty("a:avLst", &[]);
|
||||
w.close("a:prstGeom");
|
||||
w.close("pic:spPr");
|
||||
w.close("pic:pic");
|
||||
}
|
||||
|
||||
/// A shape's or text box's `wps:wsp`, at `off` in its group (or 0, 0). In a group it carries
|
||||
/// its own `wps:cNvPr` with drawing id `id`.
|
||||
fn wsp(&mut self, w: &mut W, o: &InlineObject, id: Option<&str>, off: (f32, f32), rels: &mut PartRels, depth: usize) {
|
||||
let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, .. } = o else { return };
|
||||
w.open("wps:wsp", &[]);
|
||||
if let Some(id) = id {
|
||||
let name = format!("Shape {id}");
|
||||
w.empty("wps:cNvPr", &[("id", id), ("name", &name)]);
|
||||
}
|
||||
if *kind == ShapeKind::TextBox {
|
||||
w.empty("wps:cNvSpPr", &[("txBox", "1")]);
|
||||
} else {
|
||||
w.empty("wps:cNvSpPr", &[]);
|
||||
}
|
||||
w.open("wps:spPr", &[]);
|
||||
xfrm(w, off, *sw, *sh);
|
||||
let prst = match kind {
|
||||
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
|
||||
ShapeKind::RoundedRectangle => "roundRect",
|
||||
ShapeKind::Ellipse => "ellipse",
|
||||
ShapeKind::Triangle => "triangle",
|
||||
ShapeKind::Diamond => "diamond",
|
||||
ShapeKind::Line => "line",
|
||||
ShapeKind::Arrow => "rightArrow",
|
||||
ShapeKind::Star => "star5",
|
||||
ShapeKind::Heart => "heart",
|
||||
};
|
||||
w.open("a:prstGeom", &[("prst", prst)]);
|
||||
w.empty("a:avLst", &[]);
|
||||
w.close("a:prstGeom");
|
||||
match fill {
|
||||
Some(c) => {
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
}
|
||||
None => w.empty("a:noFill", &[]),
|
||||
}
|
||||
match stroke {
|
||||
Some(c) => {
|
||||
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
|
||||
w.open("a:solidFill", &[]);
|
||||
w.empty("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.close("a:solidFill");
|
||||
w.close("a:ln");
|
||||
}
|
||||
None => {
|
||||
w.open("a:ln", &[]);
|
||||
w.empty("a:noFill", &[]);
|
||||
w.close("a:ln");
|
||||
}
|
||||
}
|
||||
effect_list(w, effects);
|
||||
w.close("wps:spPr");
|
||||
// Its text, within the same bounds layout shows boxes inside boxes with.
|
||||
if let Some(id) = *story
|
||||
&& let Some(part) = self.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::TextBox)
|
||||
&& self.boxes.enter(id)
|
||||
{
|
||||
w.open("wps:txbx", &[]);
|
||||
w.open("w:txbxContent", &[]);
|
||||
let blocks = part.blocks.clone();
|
||||
self.blocks(w, &blocks, rels, false, depth + 1);
|
||||
w.close("w:txbxContent");
|
||||
w.close("wps:txbx");
|
||||
self.boxes.leave();
|
||||
}
|
||||
w.empty("wps:bodyPr", &[]);
|
||||
w.close("wps:wsp");
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) {
|
||||
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
|
||||
@@ -776,9 +847,53 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn xfrm(w: &mut W, cw: f32, ch: f32) {
|
||||
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order.
|
||||
fn effect_list(w: &mut W, effects: &ShapeEffects) {
|
||||
let fx = effects.sanitized();
|
||||
if fx.is_empty() {
|
||||
return;
|
||||
}
|
||||
let color = |w: &mut W, c: Rgb, transparency: f32| {
|
||||
let alpha = n(((100.0 - transparency.clamp(0.0, 100.0)) * 1000.0).round() as i64);
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &alpha)]);
|
||||
w.close("a:srgbClr");
|
||||
};
|
||||
w.open("a:effectLst", &[]);
|
||||
if let Some(g) = fx.glow {
|
||||
w.open("a:glow", &[("rad", &emu(g.size))]);
|
||||
color(w, g.color, g.transparency);
|
||||
w.close("a:glow");
|
||||
}
|
||||
if let Some(s) = fx.shadow {
|
||||
let dir = n((s.angle * 60_000.0).round() as i64);
|
||||
// Anchor scaling at the corner the shadow falls away from (no scaling is written, so
|
||||
// this only matters to editors that resize it).
|
||||
let (dx, dy) = s.offset();
|
||||
let algn = match (dy > 0.01, dy < -0.01, dx > 0.01, dx < -0.01) {
|
||||
(true, _, true, _) => "tl",
|
||||
(true, _, _, true) => "tr",
|
||||
(true, _, _, _) => "t",
|
||||
(_, true, true, _) => "bl",
|
||||
(_, true, _, true) => "br",
|
||||
(_, true, _, _) => "b",
|
||||
(_, _, true, _) => "l",
|
||||
(_, _, _, true) => "r",
|
||||
_ => "ctr",
|
||||
};
|
||||
w.open("a:outerShdw", &[("blurRad", &emu(s.blur)), ("dist", &emu(s.distance)), ("dir", &dir), ("algn", algn), ("rotWithShape", "0")]);
|
||||
color(w, s.color, s.transparency);
|
||||
w.close("a:outerShdw");
|
||||
}
|
||||
if let Some(r) = fx.soft_edge {
|
||||
w.empty("a:softEdge", &[("rad", &emu(r))]);
|
||||
}
|
||||
w.close("a:effectLst");
|
||||
}
|
||||
|
||||
fn xfrm(w: &mut W, (x, y): (f32, f32), cw: f32, ch: f32) {
|
||||
w.open("a:xfrm", &[]);
|
||||
w.empty("a:off", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:off", &[("x", &emu(x)), ("y", &emu(y))]);
|
||||
w.empty("a:ext", &[("cx", &emu(cw.max(0.0))), ("cy", &emu(ch.max(0.0)))]);
|
||||
w.close("a:xfrm");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph};
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||||
|
||||
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
@@ -699,3 +699,28 @@ fn hostile_vml_style_values_stay_finite() {
|
||||
assert!([*w, *h, float.x, float.y, float.dist, float.dist_top].iter().all(|v| v.is_finite()), "{w} {h} {float:?}");
|
||||
assert_eq!((float.h_align, float.dist, float.dist_top), (None, 0.0, 1584.0));
|
||||
}
|
||||
|
||||
/// A group as Word saves one (in `mc:AlternateContent`, needing `wpg`): its members' space
|
||||
/// starts at `a:chOff`, and a nested group is flattened into it through its own transform.
|
||||
#[test]
|
||||
fn word_group_with_nested_group() {
|
||||
let body = r#"<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor behindDoc="0" distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="2540000" cy="1270000"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapSquare wrapText="bothSides"/><wp:docPr id="1" name="Group 1"/><wp:cNvGraphicFramePr/>
|
||||
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2540000" cy="1270000"/><a:chOff x="127000" y="127000"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
|
||||
<wps:wsp><wps:cNvPr id="2" name="Rectangle 2"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="127000" y="127000"/><a:ext cx="1270000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
|
||||
<wpg:grpSp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="1397000" y="762000"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
|
||||
<wps:wsp><wps:cNvPr id="3" name="Oval 3"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="254000" y="254000"/><a:ext cx="508000" cy="254000"/></a:xfrm><a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
|
||||
</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict/></mc:Fallback></mc:AlternateContent></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let p = paras(&d);
|
||||
let InlineObject::Group { w, h, float, ch_w, ch_h, children } = &p[0].objects[0] else { panic!("{:?}", p[0].objects) };
|
||||
assert_eq!((*w, *h, *ch_w, *ch_h), (200.0, 100.0, 200.0, 100.0));
|
||||
assert_eq!(float.wrap, Wrap::Square);
|
||||
let got: Vec<(f32, f32, ShapeKind, f32, f32)> = children
|
||||
.iter()
|
||||
.map(|c| match &c.obj {
|
||||
InlineObject::Shape { kind, w, h, .. } => (c.x, c.y, *kind, *w, *h),
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, [(0.0, 0.0, ShapeKind::Rectangle, 100.0, 50.0), (110.0, 60.0, ShapeKind::Ellipse, 20.0, 10.0)]);
|
||||
}
|
||||
|
||||
@@ -136,11 +136,18 @@ fn every_para_prop_round_trips() {
|
||||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
drop_cap: None,
|
||||
kinsoku: Some(false),
|
||||
word_wrap: Some(false),
|
||||
overflow_punct: Some(false),
|
||||
top_line_punct: Some(true),
|
||||
auto_space_de: Some(false),
|
||||
auto_space_dn: Some(true),
|
||||
};
|
||||
let variants = [
|
||||
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
|
||||
ParaProps { line_spacing: Some(LineSpacing::Exactly(20.0)), align: Some(Align::Right), ..Default::default() },
|
||||
ParaProps { align: Some(Align::Distribute), ..Default::default() },
|
||||
ParaProps { kinsoku: Some(true), word_wrap: Some(true), auto_space_dn: Some(false), ..Default::default() },
|
||||
ParaProps::default(),
|
||||
];
|
||||
let mut ps = vec![Paragraph::with_text("main", CharProps::default())];
|
||||
@@ -678,6 +685,7 @@ fn toc_field_skips_nested_stories() {
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
}
|
||||
};
|
||||
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
|
||||
@@ -799,6 +807,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let star = InlineObject::Shape {
|
||||
kind: ShapeKind::Star,
|
||||
@@ -809,6 +818,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() };
|
||||
let mut p = Paragraph::with_text("shapes ", CharProps::default());
|
||||
@@ -824,7 +834,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
let got = paras(&r);
|
||||
assert_eq!(got[0].objects.len(), 3);
|
||||
match &got[0].objects[0] {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story } => {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => {
|
||||
assert_eq!(
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width),
|
||||
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0)
|
||||
@@ -848,6 +858,82 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
assert_eq!(got[1].props, dc.props);
|
||||
}
|
||||
|
||||
/// A group of a picture, a shape and a text box (`wpg:wgp`) comes back as it was saved: its
|
||||
/// size, placement and members' space, and each member's offset, size and content.
|
||||
#[test]
|
||||
fn groups_round_trip() {
|
||||
use wordcraft_doc::para::GroupChild;
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
|
||||
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4] };
|
||||
let oval = InlineObject::Shape {
|
||||
kind: ShapeKind::Ellipse,
|
||||
w: 50.0,
|
||||
h: 30.0,
|
||||
fill: Some(Rgb(200, 0, 0)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
// A member's shape effects (#275) come back too.
|
||||
effects: wordcraft_doc::effects::ShapeEffects {
|
||||
shadow: Some(wordcraft_doc::effects::Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
|
||||
glow: None,
|
||||
soft_edge: Some(2.5),
|
||||
},
|
||||
};
|
||||
let tb = InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w: 100.0,
|
||||
h: 40.0,
|
||||
fill: Some(Rgb(255, 255, 255)),
|
||||
stroke: Some(Rgb(0, 0, 0)),
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let float = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 20.0, y: 10.0, dist: 9.0, ..Default::default() };
|
||||
let group = InlineObject::Group {
|
||||
w: 240.0,
|
||||
h: 120.0,
|
||||
float,
|
||||
ch_w: 200.0,
|
||||
ch_h: 100.0,
|
||||
children: vec![
|
||||
GroupChild { x: 0.0, y: 0.0, obj: pic },
|
||||
GroupChild { x: 150.0, y: 10.0, obj: oval.clone() },
|
||||
GroupChild { x: 50.0, y: 60.0, obj: tb },
|
||||
],
|
||||
};
|
||||
let mut p = Paragraph::with_text("grouped ", CharProps::default());
|
||||
let end = p.len();
|
||||
p.insert_object(end, group, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let r = rt(&d);
|
||||
let got = paras(&r);
|
||||
let InlineObject::Group { w, h, float: f, ch_w, ch_h, children } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
|
||||
assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0));
|
||||
assert_eq!(*f, float);
|
||||
assert_eq!(children.iter().map(|c| (c.x, c.y)).collect::<Vec<_>>(), [(0.0, 0.0), (150.0, 10.0), (50.0, 60.0)]);
|
||||
match &children[0].obj {
|
||||
InlineObject::Image { media, w, h, alt, .. } => {
|
||||
assert_eq!((*w, *h, alt.as_str()), (60.0, 40.0, "A picture"));
|
||||
assert_eq!(r.media.get(media).map(|m| m.as_slice()), Some(tiny_png().as_slice()));
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
assert_eq!(children[1].obj, oval);
|
||||
match &children[2].obj {
|
||||
InlineObject::Shape { kind, w, h, story, .. } => {
|
||||
assert_eq!((*kind, *w, *h), (ShapeKind::TextBox, 100.0, 40.0));
|
||||
assert_eq!(part_text(&r, *story), "In the group");
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_mode_round_trips() {
|
||||
// New documents are Word 2013+ documents; an older file keeps its mode, so saving it doesn't
|
||||
@@ -876,6 +962,12 @@ fn settings_core_theme_round_trip() {
|
||||
d.settings.protection = Some("readOnly".into());
|
||||
d.settings.grid_h = 5.5;
|
||||
d.settings.grid_v = 18.0;
|
||||
d.settings.math = Some(wordcraft_doc::math::MathProps {
|
||||
brk_bin: wordcraft_doc::math::BrkBin::Repeat,
|
||||
brk_bin_sub: wordcraft_doc::math::BrkBinSub::PlusMinus,
|
||||
wrap_indent: 36.0,
|
||||
wrap_right: false,
|
||||
});
|
||||
d.core.title = "Title & <stuff>".into();
|
||||
d.core.subject = "Subj".into();
|
||||
d.core.creator = "Me".into();
|
||||
@@ -1027,6 +1119,7 @@ fn self_showing_text_box_saves_bounded() {
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
for _ in 0..30 {
|
||||
d.insert_object(
|
||||
@@ -1107,3 +1200,71 @@ fn list_level_overrides_round_trip() {
|
||||
assert_eq!(c.next_label(&back.numbering, restart, 0).unwrap().0, "1.");
|
||||
assert_eq!(c.next_label(&back.numbering, restart, 1).unwrap().0, "1.01");
|
||||
}
|
||||
|
||||
/// Shape effects (#275): `a:effectLst` with an outer shadow, a glow and soft edges round-trips,
|
||||
/// and the effect extent leaves room for them.
|
||||
#[test]
|
||||
fn shape_effects_round_trip() {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
let effects = ShapeEffects {
|
||||
shadow: Some(Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
|
||||
glow: Some(Glow { color: Rgb(0xC0, 0x50, 0x10), size: 8.0, transparency: 40.0 }),
|
||||
soft_edge: Some(2.5),
|
||||
};
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::RoundedRectangle,
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
fill: Some(Rgb(0x15, 0x60, 0x82)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects,
|
||||
};
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.insert_object(1, shape, &CharProps::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(
|
||||
xml.contains(
|
||||
r#"<a:outerShdw blurRad="50800" dist="38100" dir="8100000" algn="tr" rotWithShape="0"><a:srgbClr val="203040"><a:alpha val="40000"/>"#
|
||||
),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains(r#"<a:glow rad="101600"><a:srgbClr val="C05010"><a:alpha val="60000"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:softEdge rad="31750"/>"#), "{xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
match paras(&r)[0].objects.first() {
|
||||
Some(InlineObject::Shape { effects: got, float, .. }) => {
|
||||
assert_eq!(*got, effects);
|
||||
assert_eq!(float.effect_extent(), effects.extent());
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equation_manual_breaks_round_trip() {
|
||||
use wordcraft_doc::math::{MNode, MRun, Math};
|
||||
let nodes = vec![
|
||||
MNode::Run(MRun::new("a=b")),
|
||||
MNode::Run(MRun { brk: Some(1), ..MRun::new("+c") }),
|
||||
MNode::Run(MRun { brk: Some(0), ..MRun::new("+d") }),
|
||||
];
|
||||
let mut d = Document::new();
|
||||
let math = Math { nodes: nodes.clone(), ..Default::default() };
|
||||
let mut p = Paragraph::with_text("", CharProps::default());
|
||||
p.insert_object(0, InlineObject::Equation { linear: "a=b+c+d".into(), display: true, math }, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let r = rt(&d);
|
||||
let got = paras(&r).iter().flat_map(|p| p.objects.iter()).find_map(|o| match o {
|
||||
InlineObject::Equation { math, .. } => Some(math.nodes.clone()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(got, Some(nodes));
|
||||
// Word's own settings round-trip; a document without them gets none.
|
||||
assert_eq!(r.settings.math, None);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
regex-automata = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32).
|
||||
|
||||
@@ -329,6 +329,7 @@ Table Design|Table Style Options|Table Style Options|table.look
|
||||
Table Design|Table Styles|Table Styles|table.style
|
||||
Table Design|Table Styles|New Table Style|table.newStyle
|
||||
Table Design|Table Styles|Modify Table Style|table.modifyStyle
|
||||
Table Design|Table Styles|Delete Table Style|table.deleteStyle
|
||||
Table Design|Table Styles|Shading|table.shading
|
||||
Table Design|Borders|Borders|table.borders
|
||||
Table Design|Borders|Border Painter|table.borderPainter
|
||||
@@ -502,6 +503,6 @@ mod tests {
|
||||
let p = parity(®);
|
||||
let pct = p["percent"].as_f64().unwrap();
|
||||
// The floor only ever rises.
|
||||
assert!(pct >= 89.0, "parity {pct}%");
|
||||
assert!(pct >= 90.4, "parity {pct}%");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"text"?: string}"#)
|
||||
.key("Mod+Shift+Alt+V"),
|
||||
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
|
||||
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
|
||||
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
|
||||
s.view.clipboard_pane = on;
|
||||
Ok(json!({"value": on, "count": s.clip_history.len()}))
|
||||
})
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.clipboardItems", "Clipboard Items", "Home › Clipboard › Clipboard Pane", |s, _| Ok(s.clip_history.describe())).pure(),
|
||||
CommandSpec::new("edit.pasteClipboardItem", "Paste Clipboard Item", "Home › Clipboard › Clipboard Pane", paste_clip_item)
|
||||
.params(r#"{"index": n (0 = newest)}"#)
|
||||
.when(has_clips),
|
||||
CommandSpec::new("edit.pasteAllClipboard", "Paste All", "Home › Clipboard › Clipboard Pane", paste_all_clips).when(has_clips),
|
||||
CommandSpec::new("edit.deleteClipboardItem", "Delete Clipboard Item", "Home › Clipboard › Clipboard Pane", |s, v| {
|
||||
let i = clip_index(s, v)?;
|
||||
s.clip_history.items.remove(i);
|
||||
Ok(s.clip_history.describe())
|
||||
})
|
||||
.params(r#"{"index": n (0 = newest)}"#)
|
||||
.when(has_clips)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.clearClipboard", "Clear All", "Home › Clipboard › Clipboard Pane", |s, _| {
|
||||
s.clip_history.items.clear();
|
||||
Ok(s.clip_history.describe())
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("edit.find", "Find", "Home › Editing", find)
|
||||
.key("Mod+F")
|
||||
.params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
|
||||
@@ -66,6 +91,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
s.clipboard_text = f.plain_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
}
|
||||
@@ -88,6 +114,140 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Most items the Clipboard pane keeps.
|
||||
pub const CLIP_MAX_ITEMS: usize = 24;
|
||||
/// A copy bigger than this (estimated) is not collected (it still reaches the clipboard).
|
||||
const CLIP_ITEM_BYTES: usize = 4 << 20;
|
||||
/// All collected items together stay under this; the oldest go first.
|
||||
const CLIP_TOTAL_BYTES: usize = 24 << 20;
|
||||
/// Characters of preview per item.
|
||||
const CLIP_PREVIEW_CHARS: usize = 120;
|
||||
|
||||
/// One item collected by Copy or Cut.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClipItem {
|
||||
pub fragment: Fragment,
|
||||
/// Its plain text.
|
||||
pub text: String,
|
||||
/// Rough memory use.
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl ClipItem {
|
||||
/// One line of the item's text for a list: whitespace runs become one space, objects drop out.
|
||||
pub fn preview(&self) -> String {
|
||||
let words = self.text.split(|c: char| c.is_whitespace() || c == wordcraft_doc::para::OBJ).filter(|w| !w.is_empty());
|
||||
let mut out = String::new();
|
||||
let mut n = 0;
|
||||
for w in words {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
n += 1;
|
||||
}
|
||||
for c in w.chars() {
|
||||
if n >= CLIP_PREVIEW_CHARS {
|
||||
out.push('…');
|
||||
return out;
|
||||
}
|
||||
out.push(c);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Items collected by Copy and Cut during the session, newest first: the Clipboard pane
|
||||
/// (Home › Clipboard). At most [`CLIP_MAX_ITEMS`]; oversized copies are skipped.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ClipHistory {
|
||||
items: Vec<ClipItem>,
|
||||
}
|
||||
|
||||
impl ClipHistory {
|
||||
pub fn items(&self) -> &[ClipItem] {
|
||||
&self.items
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
fn push(&mut self, f: &Fragment, text: &str) {
|
||||
// Text plus per-paragraph overhead; pictures are references into the document.
|
||||
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
|
||||
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
|
||||
if bytes > CLIP_ITEM_BYTES || f.blocks.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Copying the same thing again doesn't add a second item.
|
||||
if self.items.first().is_some_and(|i| i.text == text && i.fragment == *f) {
|
||||
return;
|
||||
}
|
||||
self.items.insert(0, ClipItem { fragment: f.clone(), text: text.to_string(), bytes });
|
||||
self.items.truncate(CLIP_MAX_ITEMS);
|
||||
while self.items.len() > 1 && self.items.iter().map(|i| i.bytes).sum::<usize>() > CLIP_TOTAL_BYTES {
|
||||
self.items.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The items for agents and the pane, newest first.
|
||||
fn describe(&self) -> Value {
|
||||
Value::Array(
|
||||
self.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, it)| {
|
||||
json!({
|
||||
"index": i,
|
||||
"preview": it.preview(),
|
||||
"chars": it.text.chars().count(),
|
||||
"objects": it.text.matches(wordcraft_doc::para::OBJ).count(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_clips(s: &Session) -> Option<&'static str> {
|
||||
if s.clip_history.is_empty() { Some("the Clipboard pane is empty") } else { None }
|
||||
}
|
||||
|
||||
fn clip_index(s: &Session, v: &Value) -> Result<usize, CmdError> {
|
||||
let i = p::u64(v, "index").ok_or_else(|| CmdError::Params("`index` (number, 0 = newest) is required".into()))?;
|
||||
usize::try_from(i)
|
||||
.ok()
|
||||
.filter(|i| *i < s.clip_history.len())
|
||||
.ok_or_else(|| CmdError::Params(format!("no clipboard item {i} (there are {})", s.clip_history.len())))
|
||||
}
|
||||
|
||||
/// Paste a collected item through the normal paste path. The clipboard itself stays as it was.
|
||||
fn paste_clip(s: &mut Session, f: Fragment) -> CmdResult {
|
||||
let saved = s.clipboard.replace(f);
|
||||
let r = paste(s, &json!({}));
|
||||
s.clipboard = saved;
|
||||
r
|
||||
}
|
||||
|
||||
fn paste_clip_item(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let i = clip_index(s, v)?;
|
||||
let f = s.clip_history.items.get(i).map(|x| x.fragment.clone()).ok_or_else(|| CmdError::Params("no such clipboard item".into()))?;
|
||||
paste_clip(s, f)
|
||||
}
|
||||
|
||||
/// Every collected item, in the order they were copied (one undo step).
|
||||
fn paste_all_clips(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let all: Vec<Fragment> = s.clip_history.items.iter().rev().map(|x| x.fragment.clone()).collect();
|
||||
let mut r = sel_result(s)?;
|
||||
for f in all {
|
||||
r = paste_clip(s, f)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let text = match p::str(v, "text") {
|
||||
Some(t) => t.to_string(),
|
||||
|
||||
@@ -29,9 +29,20 @@ fn set_author(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
|
||||
CommandSpec::new("file.open", "Open", "File", open).key("Mod+O").params(r#"{"path": string}"#).pure(),
|
||||
CommandSpec::new("file.save", "Save", "File", save).key("Mod+S").params(r#"{"path"?: string}"#).pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string}"#).pure(),
|
||||
CommandSpec::new("file.open", "Open", "File", open)
|
||||
.key("Mod+O")
|
||||
.params(r#"{"path": string, "data"?: base64 (the file's bytes; `path` then only names it), "password"?: string (for a password-protected Word document)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.save", "Save", "File", save)
|
||||
.key("Mod+S")
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string}"#).pure(),
|
||||
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
|
||||
// to undo; it marks the document as changed itself.
|
||||
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
|
||||
.params(r#"{"password": string (Word documents are saved encrypted with it) | null or "" (saved unencrypted)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
|
||||
@@ -112,15 +123,39 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.ui_requests.push(json!({"open": "openFile"}));
|
||||
return sel_result(s);
|
||||
};
|
||||
let doc = if let Some(data) = p::str(v, "data") {
|
||||
let password = p::str(v, "password");
|
||||
let (doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
crate::io::open_bytes(path, &bytes).map_err(CmdError::Failed)?
|
||||
crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?
|
||||
crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)?
|
||||
};
|
||||
s.set_document(doc);
|
||||
s.path = Some(path.into());
|
||||
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count()}))
|
||||
// Like Word, a document opened with a password is saved with it again.
|
||||
if encrypted {
|
||||
s.password = password.map(crate::io::Password::new);
|
||||
}
|
||||
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted}))
|
||||
}
|
||||
|
||||
/// File › Info › Protect Document › Encrypt with Password: the password Word documents are saved
|
||||
/// with from now on, or none.
|
||||
fn encrypt(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let password = match v.get("password") {
|
||||
Some(Value::Null) => None,
|
||||
Some(Value::String(pw)) if pw.is_empty() => None,
|
||||
Some(Value::String(pw)) => {
|
||||
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
Some(crate::io::Password::new(pw))
|
||||
}
|
||||
_ => return Err(CmdError::Params("`password` must be a string, or null to remove the password".into())),
|
||||
};
|
||||
if password != s.password {
|
||||
s.password = password;
|
||||
s.dirty = true;
|
||||
}
|
||||
Ok(json!({"encrypted": s.password.is_some()}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -131,14 +166,26 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
};
|
||||
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
|
||||
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
|
||||
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
|
||||
};
|
||||
if !crate::io::can_encrypt(&path.to_string_lossy()) {
|
||||
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
|
||||
}
|
||||
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
s.password = Some(crate::io::Password::new(pw));
|
||||
}
|
||||
s.stamp_save();
|
||||
crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?;
|
||||
let password = s.password.as_ref().map(crate::io::Password::as_str);
|
||||
crate::io::save_path_with(&path, &s.doc, password).map_err(CmdError::Failed)?;
|
||||
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
}
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy()}))
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -216,6 +263,7 @@ fn info(s: &mut Session, _: &Value) -> CmdResult {
|
||||
"trackChanges": s.doc.settings.track_changes,
|
||||
"comments": s.doc.comments.len(),
|
||||
"sections": s.doc.sections().len(),
|
||||
"encrypted": s.password.is_some(),
|
||||
"layoutMs": l.ms,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -252,8 +252,17 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let obj =
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None };
|
||||
let obj = InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::Inline, ..Default::default() },
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
sel_result(s)
|
||||
@@ -275,6 +284,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
s.doc.insert_object(&at, obj, &props)?;
|
||||
// Like Word, type straight into the new box.
|
||||
|
||||
@@ -23,7 +23,7 @@ fn has_object(s: &Session) -> Option<&'static str> {
|
||||
fn has_floating(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
|
||||
Some((_, InlineObject::Image { float, .. } | InlineObject::Shape { float, .. })) if float.wrap != Wrap::Inline => None,
|
||||
Some((_, o)) if o.is_floating() => None,
|
||||
_ => Some("select a floating picture or shape first"),
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,15 @@ fn has_movable(s: &Session) -> Option<&'static str> {
|
||||
_ => has_object(s),
|
||||
}
|
||||
}
|
||||
fn has_group(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Group { .. })) => None,
|
||||
_ => Some("select a group first"),
|
||||
}
|
||||
}
|
||||
fn can_group(s: &Session) -> Option<&'static str> {
|
||||
if picked_objects(s).len() >= 2 { None } else { Some("select two or more pictures or shapes first (Shift+click)") }
|
||||
}
|
||||
fn has_shape(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Shape { .. })) => None,
|
||||
@@ -271,6 +280,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"dx"?: pt, "dy"?: pt}"#)
|
||||
.when(has_floating),
|
||||
CommandSpec::new("arrange.group", "Group", "Layout › Arrange", group)
|
||||
.params(r#"{} (groups the selected pictures, shapes and text boxes: the selected one plus those added with select.addObject)"#)
|
||||
.when(can_group),
|
||||
CommandSpec::new("arrange.ungroup", "Ungroup", "Layout › Arrange", ungroup).when(has_group),
|
||||
CommandSpec::new("select.addObject", "Add Object to Selection", "Home › Editing › Select", add_object)
|
||||
.params(r#"{"pos"?: Pos, "index"?: n, "objects"?: [Pos | n]} (n: its Selection Pane index; with no object selected yet, the first one becomes the selection)"#)
|
||||
.pure(),
|
||||
CommandSpec::new("select.objects", "Select Objects", "Home › Editing › Select", |s, v| {
|
||||
let n = p::u64(v, "index").unwrap_or(0) as usize;
|
||||
let list = all_objects(s);
|
||||
@@ -305,6 +321,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#)
|
||||
.when(has_shape),
|
||||
CommandSpec::new("shape.effects", "Shape Effects", "Shape Format › Shape Styles", shape_effects)
|
||||
.params(
|
||||
r#"{"shadow"?: preset|{"preset"?, "color"?: "RRGGBB", "transparency"?: %, "blur"?: pt, "distance"?: pt, "angle"?: deg}|null, "glow"?: pt|{"color"?: "RRGGBB", "size"?: pt, "transparency"?: %}|null, "softEdge"?: pt|null} (shadow presets: offsetBottomRight, offsetBottom, offsetBottomLeft, offsetRight, offsetCenter, offsetLeft, offsetTopRight, offsetTop, offsetTopLeft; an omitted key is left as it is; applies to every shape in the selection)"#,
|
||||
)
|
||||
.when(has_shape),
|
||||
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
|
||||
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
with_obj(s, |o| {
|
||||
@@ -326,12 +347,20 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
|
||||
return None;
|
||||
}
|
||||
let o = s.doc.para_at(&a)?.object_at(a.off)?;
|
||||
matches!(o, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. }).then_some((a, o))
|
||||
o.is_drawing().then_some((a, o))
|
||||
}
|
||||
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
selected_all(s, 1).into_iter().next()
|
||||
}
|
||||
|
||||
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
|
||||
/// caret.
|
||||
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
|
||||
let mut out = Vec::new();
|
||||
let (a, b) = s.sel.ordered();
|
||||
let max = if a == b { 1 } else { max };
|
||||
let story = a.story;
|
||||
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
|
||||
for path in paths {
|
||||
@@ -345,12 +374,104 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
if !inside {
|
||||
continue;
|
||||
}
|
||||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||||
return Some((Pos { story, path: path.clone(), off }, o.clone()));
|
||||
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
|
||||
out.push((Pos { story, path: path.clone(), off }, o.clone()));
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
out
|
||||
}
|
||||
|
||||
/// Most shapes one `shape.effects` changes.
|
||||
const MAX_EFFECT_SHAPES: usize = 10_000;
|
||||
|
||||
/// `shape.effects`: set or clear the shadow, glow and soft edges of the selected shapes.
|
||||
fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
let color = |o: &Value| p::str(o, "color").and_then(Rgb::parse);
|
||||
// Each change is `None` (leave as it is) or `Some(new value)`.
|
||||
let shadow: Option<Option<Shadow>> = match v.get("shadow") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Bool(b)) => Some(b.then(Shadow::default)),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(Value::String(id)) => Some(Some(Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?)),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let mut sh = match p::str(o, "preset") {
|
||||
Some(id) => Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?,
|
||||
None => Shadow::default(),
|
||||
};
|
||||
sh.color = color(o).unwrap_or(sh.color);
|
||||
sh.transparency = p::f32(o, "transparency").unwrap_or(sh.transparency);
|
||||
sh.blur = p::f32(o, "blur").unwrap_or(sh.blur);
|
||||
sh.distance = p::f32(o, "distance").unwrap_or(sh.distance);
|
||||
sh.angle = p::f32(o, "angle").unwrap_or(sh.angle);
|
||||
Some(Some(sh.sanitized()))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`shadow`: a preset name, an object or null".into())),
|
||||
};
|
||||
let glow: Option<Option<Glow>> = match v.get("glow") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..Glow::default() }.sanitized())),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let d = Glow::default();
|
||||
Some(Some(
|
||||
Glow {
|
||||
color: color(o).unwrap_or(d.color),
|
||||
size: p::f32(o, "size").unwrap_or(d.size),
|
||||
transparency: p::f32(o, "transparency").unwrap_or(d.transparency),
|
||||
}
|
||||
.sanitized(),
|
||||
))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`glow`: a size in points, an object or null".into())),
|
||||
};
|
||||
let soft: Option<Option<f32>> = match v.get("softEdge") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(n @ Value::Number(_)) => Some(n.as_f64().map(|x| x as f32)),
|
||||
Some(_) => return Err(CmdError::Params("`softEdge`: a radius in points or null".into())),
|
||||
};
|
||||
// The shapes in the selection and those added to it (Shift+click).
|
||||
let mut targets: Vec<Pos> =
|
||||
selected_all(s, MAX_EFFECT_SHAPES).into_iter().filter(|(_, o)| matches!(o, InlineObject::Shape { .. })).map(|(pos, _)| pos).collect();
|
||||
for pos in picked_objects(s) {
|
||||
if !targets.contains(&pos) && matches!(s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)), Some(InlineObject::Shape { .. })) {
|
||||
targets.push(pos);
|
||||
}
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Err(CmdError::Disabled("no shape selected".into()));
|
||||
}
|
||||
let mut last = ShapeEffects::default();
|
||||
for pos in &targets {
|
||||
let o = edit_obj(s, pos, |o| {
|
||||
if let InlineObject::Shape { effects, float, .. } = o {
|
||||
let mut e = *effects;
|
||||
if let Some(x) = shadow {
|
||||
e.shadow = x;
|
||||
}
|
||||
if let Some(x) = glow {
|
||||
e.glow = x;
|
||||
}
|
||||
if let Some(x) = soft {
|
||||
e.soft_edge = x;
|
||||
}
|
||||
*effects = e.sanitized();
|
||||
// Room around the shape for its shadow and glow, as Word's effect extent.
|
||||
float.effect = effects.extent();
|
||||
}
|
||||
})?;
|
||||
if let InlineObject::Shape { effects, .. } = o {
|
||||
last = effects;
|
||||
}
|
||||
}
|
||||
Ok(json!({"shapes": targets.len(), "effects": serde_json::to_value(last).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// Smallest width/height an object can be resized to, points (a text box keeps room for a line).
|
||||
@@ -374,14 +495,7 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET);
|
||||
let h = p::f32(v, "height").unwrap_or(h0).clamp(min, MAX_OFFSET);
|
||||
if (w, h) != (w0, h0) {
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Graphic { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } =
|
||||
o
|
||||
{
|
||||
*ow = w;
|
||||
*oh = h;
|
||||
}
|
||||
})?;
|
||||
with_obj(s, |o| o.set_size(w, h))?;
|
||||
}
|
||||
let pos = match to {
|
||||
Some((x, y)) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
|
||||
@@ -461,7 +575,7 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
|
||||
fn unalign(s: &mut Session) -> Result<(), CmdError> {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
|
||||
let (InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. }) = &obj else { return Ok(()) };
|
||||
let Some((_, _, float)) = obj.frame() else { return Ok(()) };
|
||||
if float.h_align.is_none() && float.v_align.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -510,10 +624,7 @@ fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Op
|
||||
}
|
||||
|
||||
fn obj_size(o: &InlineObject) -> (f32, f32) {
|
||||
match o {
|
||||
InlineObject::Image { w, h, .. } | InlineObject::Graphic { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h),
|
||||
_ => (0.0, 0.0),
|
||||
}
|
||||
o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h))
|
||||
}
|
||||
|
||||
fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
|
||||
@@ -534,15 +645,15 @@ fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result
|
||||
|
||||
fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result<InlineObject, CmdError> {
|
||||
edit_obj(s, pos, |o| {
|
||||
if let InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } = o {
|
||||
if let Some(float) = o.float_mut() {
|
||||
f(float);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
|
||||
with_obj(s, |o| match o {
|
||||
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => {
|
||||
with_obj(s, |o| {
|
||||
if let Some(float) = o.float_mut() {
|
||||
let was_inline = float.wrap == Wrap::Inline;
|
||||
f(float);
|
||||
// Word's distance from text for a newly wrapped object: 0.125" at the sides.
|
||||
@@ -550,7 +661,6 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
|
||||
float.dist = 9.0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -750,12 +860,191 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
|
||||
/// those added to it ([`Session::also_selected`]), each once, in the body.
|
||||
pub fn picked_objects(s: &Session) -> Vec<Pos> {
|
||||
let Some((first, _)) = object_selection(s) else { return Vec::new() };
|
||||
let mut out = vec![first];
|
||||
for p in &s.also_selected {
|
||||
if !out.contains(p) && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing) {
|
||||
out.push(p.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The object a `select.addObject` item names: a position, or a Selection Pane index.
|
||||
fn object_ref(s: &Session, v: &Value) -> Result<Pos, CmdError> {
|
||||
let pos = match v.as_u64() {
|
||||
Some(n) => all_objects(s).into_iter().nth(usize::try_from(n).unwrap_or(usize::MAX)).map(|(p, _)| p),
|
||||
None => super::parse_pos(v),
|
||||
};
|
||||
pos.filter(|p| p.story == StoryRef::Body && s.doc.para_at(p).and_then(|q| q.object_at(p.off)).is_some_and(InlineObject::is_drawing))
|
||||
.ok_or_else(|| CmdError::Params("no picture or shape there".into()))
|
||||
}
|
||||
|
||||
/// Add objects to the selection (Shift+click): the first one is selected if no object is yet;
|
||||
/// one already selected is taken out again.
|
||||
fn add_object(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mut items: Vec<Value> = match v.get("objects").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().take(1000).cloned().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
if let Some(p) = v.get("pos") {
|
||||
items.push(p.clone());
|
||||
}
|
||||
if let Some(i) = v.get("index") {
|
||||
items.push(i.clone());
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(CmdError::Params("`pos`, `index` or `objects` required".into()));
|
||||
}
|
||||
for it in &items {
|
||||
let pos = object_ref(s, it)?;
|
||||
match object_selection(s).map(|(p, _)| p) {
|
||||
None => {
|
||||
s.also_selected.clear();
|
||||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos } };
|
||||
}
|
||||
Some(first) if first == pos => {}
|
||||
Some(_) => {
|
||||
if let Some(i) = s.also_selected.iter().position(|p| *p == pos) {
|
||||
s.also_selected.remove(i);
|
||||
} else {
|
||||
s.also_selected.push(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"selected": picked_objects(s).iter().map(super::pos_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Group the selected objects (Layout › Arrange › Group): one floating group object, anchored
|
||||
/// where the first of them was, in the box around them all; they keep their places and sizes.
|
||||
/// A group among them is merged in (its members join the new group).
|
||||
fn group(s: &mut Session, _: &Value) -> CmdResult {
|
||||
use wordcraft_doc::para::GroupChild;
|
||||
let mut picks = picked_objects(s);
|
||||
if picks.len() < 2 {
|
||||
return Err(CmdError::Disabled("select two or more pictures or shapes first".into()));
|
||||
}
|
||||
picks.sort();
|
||||
let layout = s.layout();
|
||||
let mut found = Vec::new();
|
||||
for pos in &picks {
|
||||
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
|
||||
return Err(CmdError::Disabled("only objects in the body text (not in tables) can be grouped".into()));
|
||||
}
|
||||
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
if matches!(obj, InlineObject::Graphic { .. }) {
|
||||
return Err(CmdError::Disabled(FROZEN.into()));
|
||||
}
|
||||
if !obj.is_floating() {
|
||||
return Err(CmdError::Disabled("objects in line with text can't be grouped: choose a text wrapping for them first".into()));
|
||||
}
|
||||
let hit = layout.object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
|
||||
found.push((obj, hit));
|
||||
}
|
||||
let page = found.first().map(|(_, h)| h.page).unwrap_or(0);
|
||||
if found.iter().any(|(_, h)| h.page != page) {
|
||||
return Err(CmdError::Disabled("objects on different pages can't be grouped".into()));
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for (_, h) in &found {
|
||||
(x0, y0) = (x0.min(h.rect.x), y0.min(h.rect.y));
|
||||
(x1, y1) = (x1.max(h.rect.right()), y1.max(h.rect.bottom()));
|
||||
}
|
||||
let (w, h) = ((x1 - x0).clamp(1.0, MAX_OFFSET), (y1 - y0).clamp(1.0, MAX_OFFSET));
|
||||
let mut children = Vec::new();
|
||||
let member = |obj: &InlineObject, r: [f32; 4]| {
|
||||
let mut obj = obj.clone();
|
||||
obj.set_size(r[2], r[3]);
|
||||
if let Some(f) = obj.float_mut() {
|
||||
*f = Float::default();
|
||||
}
|
||||
GroupChild { x: r[0] - x0, y: r[1] - y0, obj }
|
||||
};
|
||||
for (obj, hit) in &found {
|
||||
let r = hit.rect;
|
||||
match obj {
|
||||
InlineObject::Group { .. } => children.extend(obj.group_rects(r.x, r.y, r.w, r.h).into_iter().map(|(cr, c)| member(c, cr))),
|
||||
_ => children.push(member(obj, [r.x, r.y, r.w, r.h])),
|
||||
}
|
||||
}
|
||||
let float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| *f).unwrap_or_default();
|
||||
let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children };
|
||||
// Take the members out, last first so the earlier positions hold, and put the group where
|
||||
// the first one was.
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
for pos in picks.iter().rev() {
|
||||
s.doc.delete_range(pos, &Pos { off: pos.off + len, ..pos.clone() })?;
|
||||
}
|
||||
let first = picks.first().cloned().ok_or_else(|| CmdError::Failed("nothing to group".into()))?;
|
||||
let at = s.doc.clamp(&first);
|
||||
s.doc.insert_object(&at, grouped, &wordcraft_doc::CharProps::default())?;
|
||||
s.touch();
|
||||
let pos = move_object(s, at, Some(page as u64), x0, y0, (w, h))?;
|
||||
s.also_selected.clear();
|
||||
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + len, ..pos.clone() } };
|
||||
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
|
||||
}
|
||||
|
||||
/// Split the selected group back into its pictures and shapes (Layout › Arrange › Ungroup), in
|
||||
/// the places and sizes the group shows them, anchored where the group was. They stay
|
||||
/// selected, so Group puts them back together.
|
||||
fn ungroup(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a group first".into()))?;
|
||||
let InlineObject::Group { w, h, float, .. } = &obj else { return Err(CmdError::Disabled("select a group first".into())) };
|
||||
// Its offsets, explicit: the members are placed from the same anchor.
|
||||
let pos = if float.wrap == Wrap::Inline {
|
||||
if pos.story != StoryRef::Body || pos.path.depth() > 0 {
|
||||
return Err(CmdError::Disabled("only groups in the body text can be ungrouped in line with text".into()));
|
||||
}
|
||||
let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?;
|
||||
move_object(s, pos, Some(hit.page as u64), hit.rect.x, hit.rect.y, (*w, *h))?
|
||||
} else {
|
||||
unalign(s)?;
|
||||
pos
|
||||
};
|
||||
let obj = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
|
||||
let Some((w, h, float)) = obj.frame() else { return Err(CmdError::Failed("object vanished".into())) };
|
||||
let float = *float;
|
||||
let members: Vec<InlineObject> = obj
|
||||
.group_rects(0.0, 0.0, w, h)
|
||||
.into_iter()
|
||||
.map(|([x, y, cw, ch], c)| {
|
||||
let mut c = c.clone();
|
||||
c.set_size(cw.max(min_size(&c)), ch.max(min_size(&c)));
|
||||
if let Some(f) = c.float_mut() {
|
||||
*f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float };
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
return Err(CmdError::Failed("the group is empty".into()));
|
||||
}
|
||||
let len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
|
||||
let mut at = s.doc.clamp(&pos);
|
||||
let mut placed = Vec::new();
|
||||
for m in members {
|
||||
placed.push(at.clone());
|
||||
at = s.doc.insert_object(&at, m, &wordcraft_doc::CharProps::default())?;
|
||||
}
|
||||
let first = placed.first().cloned().unwrap_or(pos);
|
||||
s.sel = Selection { anchor: first.clone(), focus: Pos { off: first.off + len, ..first } };
|
||||
s.also_selected = placed.into_iter().skip(1).collect();
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
|
||||
let mut v = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||||
if let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) {
|
||||
v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone()));
|
||||
}
|
||||
}
|
||||
@@ -773,6 +1062,7 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
|
||||
InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }),
|
||||
InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)),
|
||||
InlineObject::Graphic { graphic, .. } => ("graphic", format!("{:?} {}", graphic.kind, i + 1)),
|
||||
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
|
||||
_ => ("object", format!("Object {}", i + 1)),
|
||||
};
|
||||
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
|
||||
@@ -876,6 +1166,66 @@ mod tests {
|
||||
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
|
||||
}
|
||||
|
||||
/// The page rectangle of the object at `pos`.
|
||||
fn rect_of(s: &mut Session, pos: &Pos) -> [f32; 4] {
|
||||
let r = s.layout().object(pos, 0).unwrap().rect;
|
||||
[r.x, r.y, r.w, r.h]
|
||||
}
|
||||
|
||||
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5)
|
||||
}
|
||||
|
||||
/// Two floating shapes, Shift+clicked together and grouped: one object that moves as one with
|
||||
/// its members in place; ungrouping puts them back where the group showed them, still
|
||||
/// selected, so Group regroups them.
|
||||
#[test]
|
||||
fn group_moves_as_one_and_ungroups() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "one\ntwo\nthree\nfour"})).unwrap();
|
||||
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 50})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 150})).unwrap();
|
||||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": 300, "y": 200})).unwrap();
|
||||
// One shape selected: nothing to group yet.
|
||||
assert!(s.run("arrange.group", &json!({})).is_err());
|
||||
let r = s.run("select.addObject", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(r["selected"].as_array().unwrap().len(), 2);
|
||||
let g = s.run("arrange.group", &json!({})).unwrap();
|
||||
let pos: Pos = serde_json::from_value(g["pos"].clone()).unwrap();
|
||||
let list = s.run("arrange.selectionPane", &json!({})).unwrap();
|
||||
assert_eq!(list.as_array().unwrap().len(), 1, "{list}");
|
||||
assert_eq!(list[0]["kind"], "group");
|
||||
assert!(s.also_selected.is_empty());
|
||||
assert!(near(rect_of(&mut s, &pos), [100.0, 150.0, 260.0, 90.0]), "{:?}", rect_of(&mut s, &pos));
|
||||
|
||||
// Moving the group moves its members with it.
|
||||
s.run("arrange.nudge", &json!({"dx": 20, "dy": 10})).unwrap();
|
||||
let (pos, obj) = selected(&s).unwrap();
|
||||
let [x, y, w, h] = rect_of(&mut s, &pos);
|
||||
let members: Vec<[f32; 4]> = obj.group_rects(x, y, w, h).into_iter().map(|(r, _)| r).collect();
|
||||
assert!(near(members[0], [120.0, 160.0, 100.0, 50.0]) && near(members[1], [320.0, 210.0, 60.0, 40.0]), "{members:?}");
|
||||
// Drawn there too: the page shows both shapes inside the group's frame.
|
||||
let shapes = s.layout().pages[0].items.iter().filter(|i| matches!(i, wordcraft_layout::Placed::Shape { .. })).count();
|
||||
assert_eq!(shapes, 2);
|
||||
|
||||
// Ungroup: two shapes again, where the group showed them, both still selected.
|
||||
s.run("arrange.ungroup", &json!({})).unwrap();
|
||||
let list = all_objects(&s);
|
||||
assert_eq!(list.len(), 2);
|
||||
let rects: Vec<[f32; 4]> = list.iter().map(|(p, _)| rect_of(&mut s, p)).collect();
|
||||
assert!(rects.iter().any(|r| near(*r, [120.0, 160.0, 100.0, 50.0])), "{rects:?}");
|
||||
assert!(rects.iter().any(|r| near(*r, [320.0, 210.0, 60.0, 40.0])), "{rects:?}");
|
||||
assert_eq!(picked_objects(&s).len(), 2);
|
||||
s.run("arrange.group", &json!({})).unwrap();
|
||||
assert_eq!(all_objects(&s).len(), 1);
|
||||
// Undo goes back step by step.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(all_objects(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_alt_and_crop_round_trip() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -129,6 +129,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("para.widowControl", "Widow/Orphan Control", "Home › Paragraph › Line and Page Breaks", |s, v| {
|
||||
tog(s, v, |p| p.widow_control, |p, b| p.widow_control = Some(b))
|
||||
}),
|
||||
CommandSpec::new("para.asianTypography", "Asian Typography", "Home › Paragraph › Asian Typography", asian_typography).params(
|
||||
r#"{"kinsoku"?: bool (East Asian line-breaking rules), "wordWrap"?: bool (false: Latin words may break at any character), "overflowPunct"?: bool (hanging punctuation), "topLinePunct"?: bool (compress punctuation at the start of a line), "autoSpaceDE"?: bool (space between Asian and Latin text), "autoSpaceDN"?: bool (space between Asian text and numbers)} — flags left out stay as they are; returns the flags at the caret"#,
|
||||
),
|
||||
CommandSpec::new("para.tabs", "Tabs", "Home › Paragraph › Paragraph", |s, v| {
|
||||
let tabs: Vec<TabStop> = serde_json::from_value(v.get("tabs").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
if tabs.len() > 64 {
|
||||
@@ -154,14 +157,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("styles.create", "Create a Style", "Home › Styles", create_style).params(r#"{"name": string, "basedOn"?: string, "fromSelection"?: bool}"#),
|
||||
CommandSpec::new("styles.modify", "Modify Style", "Home › Styles", modify_style).params(r#"{"style": string, "chr"?: CharProps, "para"?: ParaProps, "name"?: string, "next"?: string}"#),
|
||||
CommandSpec::new("styles.updateToMatch", "Update Style to Match Selection", "Home › Styles", update_to_match).params(r#"{"style"?: string}"#),
|
||||
CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", |s, v| {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
if s.doc.styles.get(&id).is_some_and(|st| st.builtin) {
|
||||
return Err(CmdError::Failed("built-in styles can't be deleted".into()));
|
||||
CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", delete_style).params(r#"{"style": string (name or id; custom styles only)}"#),
|
||||
CommandSpec::new("styles.manage", "Manage Styles", "Home › Styles", |s, v| {
|
||||
let sort = p::str(v, "sort").unwrap_or("recommended");
|
||||
if !matches!(sort, "recommended" | "alphabetical") {
|
||||
return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)")));
|
||||
}
|
||||
s.doc.styles.styles.retain(|st| st.id != id);
|
||||
sel_result(s)
|
||||
}),
|
||||
let list = manage_list(s, sort == "alphabetical");
|
||||
s.ui_requests.push(json!({"open": "manageStyles"}));
|
||||
Ok(list)
|
||||
})
|
||||
.params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#)
|
||||
.pure(),
|
||||
CommandSpec::new("styles.setVisibility", "Show or Hide Style", "Home › Styles", set_visibility)
|
||||
.params(r#"{"style": string, "gallery"?: bool (Styles gallery, w:qFormat), "hidden"?: bool (hidden from style lists, w:semiHidden)}"#),
|
||||
CommandSpec::new("styles.list", "Styles", "Home › Styles", |s, _| {
|
||||
Ok(Value::Array(
|
||||
s.doc.styles.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "kind": st.kind, "quick": st.quick, "basedOn": st.based_on})).collect(),
|
||||
@@ -240,6 +249,42 @@ fn tog(s: &mut Session, v: &Value, get: fn(&ParaProps) -> Option<bool>, set: fn(
|
||||
fmt(s, &|p| set(p, on))
|
||||
}
|
||||
|
||||
/// Asian Typography (the Paragraph dialog's tab): sets the given flags on every selected
|
||||
/// paragraph and returns the resolved flags at the caret.
|
||||
fn asian_typography(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let flag = |k: &str| p::bool(v, k);
|
||||
let (kinsoku, word_wrap, overflow, top_line, de, dn) =
|
||||
(flag("kinsoku"), flag("wordWrap"), flag("overflowPunct"), flag("topLinePunct"), flag("autoSpaceDE"), flag("autoSpaceDN"));
|
||||
let mut out = if [kinsoku, word_wrap, overflow, top_line, de, dn].iter().any(Option::is_some) {
|
||||
fmt(s, &|p| {
|
||||
let set = |field: &mut Option<bool>, val: Option<bool>| {
|
||||
if val.is_some() {
|
||||
*field = val;
|
||||
}
|
||||
};
|
||||
set(&mut p.kinsoku, kinsoku);
|
||||
set(&mut p.word_wrap, word_wrap);
|
||||
set(&mut p.overflow_punct, overflow);
|
||||
set(&mut p.top_line_punct, top_line);
|
||||
set(&mut p.auto_space_de, de);
|
||||
set(&mut p.auto_space_dn, dn);
|
||||
})?
|
||||
} else {
|
||||
sel_result(s)?
|
||||
};
|
||||
let r = cur(s);
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert(
|
||||
"asianTypography".into(),
|
||||
json!({
|
||||
"kinsoku": r.kinsoku, "wordWrap": r.word_wrap, "overflowPunct": r.overflow_punct,
|
||||
"topLinePunct": r.top_line_punct, "autoSpaceDE": r.auto_space_de, "autoSpaceDN": r.auto_space_dn,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.paths_between(&a, &b);
|
||||
@@ -584,6 +629,155 @@ fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Delete a custom style. Text that uses it falls back to the style it was based on (or the
|
||||
/// default style), styles based on it are rebased, and a custom character style linked to it
|
||||
/// goes with it. Built-in styles are refused.
|
||||
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let Some(st) = s.doc.styles.get(&id).cloned() else { return Err(CmdError::Params(format!("no style `{id}`"))) };
|
||||
// Table styles: their tables take Table Grid and styles based on them keep their look.
|
||||
if st.kind == StyleKind::Table {
|
||||
let mut out = super::table_style::delete(s, &id)?;
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
let tables = o.get("tables").cloned().unwrap_or(json!(0));
|
||||
o.insert("deleted".into(), json!([id]));
|
||||
o.insert("restyled".into(), tables);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
if st.builtin {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut gone = vec![st.clone()];
|
||||
if let Some(partner) = st.linked.as_deref().and_then(|l| s.doc.styles.get(l))
|
||||
&& !partner.builtin
|
||||
&& partner.linked.as_deref() == Some(id.as_str())
|
||||
{
|
||||
gone.push(partner.clone());
|
||||
}
|
||||
let mut changed = 0;
|
||||
for g in &gone {
|
||||
s.doc.styles.remove(&g.id);
|
||||
// Fall back to the base only while it is a style of the same kind that still exists.
|
||||
let base = g.based_on.clone().filter(|b| s.doc.styles.get(b).is_some_and(|x| x.kind == g.kind));
|
||||
changed += s.doc.restyle(&g.id, base);
|
||||
}
|
||||
touch_all(s);
|
||||
Ok(json!({"deleted": gone.iter().map(|g| g.id.clone()).collect::<Vec<_>>(), "restyled": changed}))
|
||||
}
|
||||
|
||||
/// Styles gallery and style list visibility (`w:qFormat` / `w:semiHidden`).
|
||||
fn set_visibility(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let (gallery, hidden) = (p::bool(v, "gallery"), p::bool(v, "hidden"));
|
||||
if gallery.is_none() && hidden.is_none() {
|
||||
return Err(CmdError::Params("give `gallery` and/or `hidden`".into()));
|
||||
}
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
|
||||
if let Some(h) = hidden {
|
||||
st.hidden = h;
|
||||
}
|
||||
if let Some(g) = gallery {
|
||||
st.quick = g;
|
||||
// Showing a style in the gallery unhides it.
|
||||
if g {
|
||||
st.hidden = false;
|
||||
}
|
||||
}
|
||||
let out = json!({"style": st.id, "inGallery": st.quick && !st.hidden && st.kind != StyleKind::Table, "hidden": st.hidden});
|
||||
touch_all(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Manage Styles' list: paragraph, character, linked and table styles (a linked pair once, as
|
||||
/// its paragraph half), sorted as recommended (priority, then name) or alphabetically.
|
||||
pub fn manage_list(s: &Session, alphabetical: bool) -> Value {
|
||||
let sheet = &s.doc.styles;
|
||||
let links_back = |a: &Style, b: &str| sheet.get(b).is_some_and(|x| x.linked.as_deref() == Some(a.id.as_str()));
|
||||
let mut list: Vec<&Style> = sheet
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|st| match st.kind {
|
||||
StyleKind::Numbering => false,
|
||||
// The character half of a linked pair is listed as the linked style.
|
||||
StyleKind::Character => {
|
||||
!st.linked.as_deref().is_some_and(|l| sheet.get(l).is_some_and(|x| x.kind == StyleKind::Paragraph) && links_back(st, l))
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
if alphabetical {
|
||||
list.sort_by_cached_key(|st| st.name.to_lowercase());
|
||||
} else {
|
||||
list.sort_by_cached_key(|st| (st.priority.unwrap_or(99), st.name.to_lowercase()));
|
||||
}
|
||||
Value::Array(
|
||||
list.into_iter()
|
||||
.map(|st| {
|
||||
let ty = match st.kind {
|
||||
// Linked: its character half links back (or isn't in the document yet).
|
||||
StyleKind::Paragraph
|
||||
if st.linked.as_deref().is_some_and(|l| sheet.get(l).is_none_or(|x| x.kind == StyleKind::Character && links_back(st, l))) =>
|
||||
{
|
||||
"linked"
|
||||
}
|
||||
StyleKind::Paragraph => "paragraph",
|
||||
StyleKind::Character => "character",
|
||||
_ => "table",
|
||||
};
|
||||
json!({
|
||||
"id": st.id,
|
||||
"name": st.name,
|
||||
"type": ty,
|
||||
"builtIn": st.builtin || (st.kind == StyleKind::Table && super::table_style::is_builtin_table_style(st)),
|
||||
"inGallery": st.quick && !st.hidden && st.kind != StyleKind::Table,
|
||||
"hidden": st.hidden,
|
||||
"basedOn": st.based_on.as_deref().and_then(|b| sheet.get(b)).map(|b| b.name.clone()),
|
||||
"priority": st.priority,
|
||||
"format": own_format(st),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The formatting a style sets itself (not what it inherits), for its description.
|
||||
fn own_format(st: &Style) -> Value {
|
||||
let mut f = serde_json::Map::new();
|
||||
let c = &st.chr;
|
||||
if let Some(x) = &c.font {
|
||||
f.insert("font".into(), json!(x));
|
||||
}
|
||||
if let Some(x) = c.size {
|
||||
f.insert("size".into(), json!(x));
|
||||
}
|
||||
for (k, v) in [("bold", c.bold), ("italic", c.italic), ("caps", c.caps), ("smallCaps", c.small_caps)] {
|
||||
if let Some(b) = v {
|
||||
f.insert(k.into(), json!(b));
|
||||
}
|
||||
}
|
||||
if let Some(wordcraft_doc::TextColor::Rgb(rgb)) = c.color {
|
||||
f.insert("color".into(), json!(rgb.hex()));
|
||||
}
|
||||
let pp = &st.para;
|
||||
if let Some(a) = pp.align {
|
||||
f.insert("align".into(), json!(a));
|
||||
}
|
||||
if let Some(x) = pp.space_before {
|
||||
f.insert("spaceBefore".into(), json!(x));
|
||||
}
|
||||
if let Some(x) = pp.space_after {
|
||||
f.insert("spaceAfter".into(), json!(x));
|
||||
}
|
||||
if let Some(x) = pp.indent_left {
|
||||
f.insert("indentLeft".into(), json!(x));
|
||||
}
|
||||
if let Some(LineSpacing::Multiple(m)) = pp.line_spacing {
|
||||
f.insert("lineSpacing".into(), json!(m));
|
||||
}
|
||||
Value::Object(f)
|
||||
}
|
||||
|
||||
/// Style edits re-lay out every paragraph: the layout cache is keyed on the style sheet, so
|
||||
/// nothing needs touching here (kept as the one place to hook style-change side effects).
|
||||
pub fn touch_all(s: &mut Session) {
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
//! Table Design › Table Styles: create and modify table styles (whole table, header row and
|
||||
//! banded rows), stored as table styles in the document's style sheet (ECMA-376 §17.7.6).
|
||||
//! Table Design › Table Styles: create, modify and delete table styles (whole table, header and
|
||||
//! total rows, first and last columns, banded rows and columns), stored as table styles in the
|
||||
//! document's style sheet (ECMA-376 §17.7.6).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Table};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`,
|
||||
/// `bandedRows`) takes the same shortcuts.
|
||||
/// The regions a table style formats, by param name: the table style options they follow.
|
||||
pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"];
|
||||
|
||||
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow`
|
||||
/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same
|
||||
/// shortcuts.
|
||||
macro_rules! style_params {
|
||||
($head:literal) => {
|
||||
concat!(
|
||||
"{",
|
||||
$head,
|
||||
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "bandedRows"?: Region, "bandSize"?: n, "para"?: ParaProps, "apply"?: bool}"#,
|
||||
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#,
|
||||
r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"#
|
||||
)
|
||||
};
|
||||
@@ -25,6 +33,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)),
|
||||
CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style)
|
||||
.params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)),
|
||||
CommandSpec::new("table.deleteStyle", "Delete Table Style", "Table Design › Table Styles", delete_style)
|
||||
.params(r#"{"style"?: string (default: the current table's)}"#),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +53,8 @@ fn current_table_style(s: &Session) -> Option<String> {
|
||||
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
|
||||
}
|
||||
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// The style `table.newStyle` would add for `v`, validated.
|
||||
fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
let name = p::req_str(v, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(CmdError::Params("name is empty".into()));
|
||||
@@ -56,8 +67,14 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")),
|
||||
};
|
||||
let id = s.doc.styles.new_id(&name);
|
||||
let mut st = Style { id: id.clone(), name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_new(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(true) {
|
||||
@@ -66,11 +83,28 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// The table style `v` names (`style`), else the current table's.
|
||||
fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
|
||||
match p::str(v, "style") {
|
||||
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`"))),
|
||||
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = match p::str(v, "style") {
|
||||
Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`")))?,
|
||||
None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into()))?,
|
||||
};
|
||||
let st = build_modified(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// The style `table.modifyStyle` would store for `v`, validated.
|
||||
fn build_modified(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
let id = target(s, v)?;
|
||||
let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &rename
|
||||
&& s.doc.styles.find(n).is_some_and(|x| x.id != id)
|
||||
@@ -96,12 +130,119 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
st.based_on = Some(b);
|
||||
}
|
||||
edit(&mut st, v)?;
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
/// How table style `v["style"]` (or, without it, a new style) would look with the formatting
|
||||
/// params of `table.modifyStyle` / `table.newStyle` applied, for a dialog's live preview. Names
|
||||
/// aren't checked; `None` when the params don't validate.
|
||||
pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
|
||||
if !v.is_object() {
|
||||
return None;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
let mut v = v.clone();
|
||||
let st = if p::str(&v, "style").is_some() {
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("name");
|
||||
}
|
||||
build_modified(s, &v).ok()?
|
||||
} else {
|
||||
// A name no style has: the look doesn't depend on it.
|
||||
v["name"] = json!("\u{1}preview");
|
||||
build_new(s, &v).ok()?
|
||||
};
|
||||
let id = st.id.clone();
|
||||
let mut sheet = s.doc.styles.clone();
|
||||
sheet.upsert(st);
|
||||
sheet.table_style(&id)
|
||||
}
|
||||
|
||||
/// Delete a custom table style. Styles based on it are re-based on its own base with its
|
||||
/// formatting folded in, so they look the same; tables using it take Table Grid (the style new
|
||||
/// tables get), or no style when the document has no Table Grid.
|
||||
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = target(s, v)?;
|
||||
delete(s, &id)
|
||||
}
|
||||
|
||||
/// Delete table style `id` (also what `styles.delete` runs for a table style): see
|
||||
/// [`delete_style`].
|
||||
pub fn delete(s: &mut Session, id: &str) -> CmdResult {
|
||||
let id = id.to_string();
|
||||
let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if st.kind != StyleKind::Table {
|
||||
return Err(CmdError::Params(format!("`{}` isn't a table style", st.name)));
|
||||
}
|
||||
if is_builtin_table_style(&st) {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut rebased = Vec::new();
|
||||
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
let mut para = st.para.clone();
|
||||
para.overlay(&child.para);
|
||||
child.para = para;
|
||||
let mut chr = st.chr.clone();
|
||||
chr.overlay(&child.chr);
|
||||
child.chr = chr;
|
||||
if child.kind == StyleKind::Table {
|
||||
let mut parts = st.table.clone().unwrap_or_default();
|
||||
if let Some(t) = &child.table {
|
||||
parts.overlay(t);
|
||||
}
|
||||
child.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
}
|
||||
child.based_on = st.based_on.clone();
|
||||
rebased.push(child.id.clone());
|
||||
}
|
||||
s.doc.styles.styles.retain(|x| x.id != id);
|
||||
let fallback = table_style_id(s, "TableGrid");
|
||||
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
|
||||
for part in s.doc.parts.values_mut() {
|
||||
tables += restyle(&mut part.blocks, &id, fallback.as_deref(), 0);
|
||||
}
|
||||
s.dirty = true;
|
||||
Ok(json!({"deleted": id, "tables": tables, "rebased": rebased}))
|
||||
}
|
||||
|
||||
/// Whether `st` is one of Word's built-in table styles. A .docx can mark any style custom
|
||||
/// (`w:customStyle`), so Table Grid and Normal Table are recognised by id and name too: deleting
|
||||
/// them would leave new tables (and the fallback for deleted styles) without a style to take.
|
||||
pub fn is_builtin_table_style(st: &Style) -> bool {
|
||||
const IDS: [&str; 2] = ["TableGrid", "TableNormal"];
|
||||
const NAMES: [&str; 3] = ["Table Grid", "Normal Table", "Table Normal"];
|
||||
st.builtin || IDS.iter().any(|x| st.id.eq_ignore_ascii_case(x)) || NAMES.iter().any(|x| st.name.eq_ignore_ascii_case(x))
|
||||
}
|
||||
|
||||
/// Tables nest at most this deep for restyling.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// Whether table `t`, or one nested in it, uses style `id`.
|
||||
fn uses_style(t: &Table, id: &str, depth: usize) -> bool {
|
||||
t.props.style.as_deref() == Some(id)
|
||||
|| (depth < MAX_DEPTH
|
||||
&& t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).any(|b| b.as_table().is_some_and(|t| uses_style(t, id, depth + 1))))
|
||||
}
|
||||
|
||||
/// Give every table in `blocks` that uses style `from` style `to`; the number restyled.
|
||||
fn restyle(blocks: &mut [Arc<Block>], from: &str, to: Option<&str>, depth: usize) -> usize {
|
||||
let mut n = 0;
|
||||
for b in blocks {
|
||||
if !b.as_table().is_some_and(|t| uses_style(t, from, depth)) {
|
||||
continue;
|
||||
}
|
||||
if let Block::Table(t) = Arc::make_mut(b) {
|
||||
if t.props.style.as_deref() == Some(from) {
|
||||
t.props.style = to.map(str::to_string);
|
||||
n += 1;
|
||||
}
|
||||
if depth < MAX_DEPTH {
|
||||
for c in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
|
||||
n += restyle(&mut c.blocks, from, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Apply style `id` to the table at the caret, if any.
|
||||
@@ -123,6 +264,18 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
|
||||
if let Some(r) = v.get("bandedRows") {
|
||||
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastRow") {
|
||||
region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("firstColumn") {
|
||||
region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastColumn") {
|
||||
region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("bandedColumns") {
|
||||
region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?;
|
||||
}
|
||||
if let Some(n) = v.get("bandSize") {
|
||||
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
|
||||
}
|
||||
|
||||
@@ -173,11 +173,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", |s, _| {
|
||||
s.status = "Password encryption isn't available yet; use Restrict Editing to prevent changes.".into();
|
||||
Err(CmdError::Failed("password encryption isn't available yet".into()))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", quick_parts)
|
||||
.params(r#"{"save"?: name (from the selection), "insert"?: name, "delete"?: name} → list"#),
|
||||
CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts).params(r#"{"save"?: name, "insert"?: name}"#),
|
||||
|
||||
+82
-8
@@ -36,9 +36,63 @@ pub fn is_word_package(ext: &str) -> bool {
|
||||
wordcraft_docx::Flavor::from_ext(ext).is_some()
|
||||
}
|
||||
|
||||
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
|
||||
#[derive(Clone, Default, PartialEq, Eq)]
|
||||
pub struct Password(zeroize::Zeroizing<String>);
|
||||
|
||||
impl Password {
|
||||
pub fn new(s: &str) -> Password {
|
||||
Password(zeroize::Zeroizing::new(s.to_string()))
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
/// The text, for a password box to edit in place.
|
||||
pub fn as_mut_string(&mut self) -> &mut String {
|
||||
&mut self.0
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Password(***)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this open error mean the file is password-protected and no password was given?
|
||||
pub fn needs_password(err: &str) -> bool {
|
||||
err.contains(wordcraft_docx::PASSWORD_REQUIRED)
|
||||
}
|
||||
|
||||
/// Does this open error mean the password given was wrong?
|
||||
pub fn wrong_password(err: &str) -> bool {
|
||||
err.contains(wordcraft_docx::WRONG_PASSWORD)
|
||||
}
|
||||
|
||||
/// Can files with this extension be saved with a password (Word's packages)?
|
||||
pub fn can_encrypt(name: &str) -> bool {
|
||||
is_word_package(&ext_of(name))
|
||||
}
|
||||
|
||||
/// Parse a document from bytes; `name` gives the format by extension.
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
open_bytes_with(name, bytes, None).map(|(doc, _)| doc)
|
||||
}
|
||||
|
||||
/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a
|
||||
/// password ([MS-OFFCRYPTO] agile encryption) is decrypted with `password` first. Also says
|
||||
/// whether the file was encrypted. Without the password needed the error satisfies
|
||||
/// [`needs_password`]; with a wrong one, [`wrong_password`].
|
||||
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
let ext = ext_of(name);
|
||||
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
|
||||
let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?;
|
||||
doc.ensure_nonempty();
|
||||
return Ok((doc, true));
|
||||
}
|
||||
let mut doc = match ext.as_str() {
|
||||
"txt" | "text" | "" => Document::from_text(&decode_text(bytes)),
|
||||
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
|
||||
@@ -48,7 +102,7 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
},
|
||||
};
|
||||
doc.ensure_nonempty();
|
||||
Ok(doc)
|
||||
Ok((doc, false))
|
||||
}
|
||||
|
||||
/// Serialise a document; `name` gives the format by extension.
|
||||
@@ -64,8 +118,23 @@ pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// [`save_bytes`], encrypted with `password` when there is one and the format is a Word package
|
||||
/// (other formats are copies and are written as they are).
|
||||
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>) -> Result<Vec<u8>, String> {
|
||||
let bytes = save_bytes(name, doc)?;
|
||||
match password {
|
||||
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
|
||||
_ => Ok(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
|
||||
open_path_with(path, None).map(|(doc, _)| doc)
|
||||
}
|
||||
|
||||
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if meta.len() > 2 << 30 {
|
||||
return Err(format!("{}: file is larger than 2 GB", path.display()));
|
||||
@@ -77,9 +146,9 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
|
||||
let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new("")));
|
||||
let mut doc = wordcraft_formats::html::import_with(&wordcraft_formats::html::decode(&bytes), &|src| images.load(src));
|
||||
doc.ensure_nonempty();
|
||||
return Ok(doc);
|
||||
return Ok((doc, false));
|
||||
}
|
||||
open_bytes(&name, &bytes)
|
||||
open_bytes_with(&name, &bytes, password)
|
||||
}
|
||||
|
||||
/// The pictures an HTML file names by a path relative to its folder.
|
||||
@@ -186,13 +255,18 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
|
||||
pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
Err(format!("{}: files are opened through the browser on the web", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
|
||||
let bytes = save_bytes(&path.to_string_lossy(), doc)?;
|
||||
save_path_with(path, doc, None)
|
||||
}
|
||||
|
||||
/// [`save_path`], encrypted with `password` as [`save_bytes_with`] does.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>) -> Result<(), String> {
|
||||
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password)?;
|
||||
// Write atomically: temp file next to the target, then rename.
|
||||
let tmp = path.with_extension(format!("{}.tmp", ext_of(&path.to_string_lossy())));
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
|
||||
@@ -200,7 +274,7 @@ pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save_path(path: &std::path::Path, _doc: &Document) -> Result<(), String> {
|
||||
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>) -> Result<(), String> {
|
||||
Err(format!("{}: files are saved through the browser on the web", path.display()))
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub use io::Password;
|
||||
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
|
||||
pub use wordcraft_doc as doc;
|
||||
pub use wordcraft_layout as layout;
|
||||
|
||||
@@ -65,6 +65,9 @@ pub struct ViewState {
|
||||
#[serde(default)]
|
||||
pub style_inspector: bool,
|
||||
pub comments_pane: bool,
|
||||
/// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut.
|
||||
#[serde(default)]
|
||||
pub clipboard_pane: bool,
|
||||
pub multi_page: bool,
|
||||
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
|
||||
pub fit: String,
|
||||
@@ -95,6 +98,7 @@ impl Default for ViewState {
|
||||
styles_pane: false,
|
||||
style_inspector: false,
|
||||
comments_pane: false,
|
||||
clipboard_pane: false,
|
||||
multi_page: false,
|
||||
fit: String::new(),
|
||||
web_width: 800.0,
|
||||
@@ -183,6 +187,8 @@ pub struct Session {
|
||||
pub clipboard: Option<Fragment>,
|
||||
/// Plain text mirror of the clipboard (for the system clipboard).
|
||||
pub clipboard_text: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||||
pub find: FindState,
|
||||
/// Page x the caret tries to keep on Up/Down.
|
||||
pub goal_x: Option<f32>,
|
||||
@@ -241,6 +247,13 @@ pub struct Session {
|
||||
pub math_latex: bool,
|
||||
/// Text typed into equations is normal (non-math) text.
|
||||
pub math_normal_text: bool,
|
||||
/// The password Word documents are saved with (File › Info › Protect Document › Encrypt with
|
||||
/// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected
|
||||
/// file that was opened, cleared when the document is replaced.
|
||||
pub password: Option<crate::io::Password>,
|
||||
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
|
||||
/// for Group. Cleared by any edit or selection change other than adding to it.
|
||||
pub also_selected: Vec<Pos>,
|
||||
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
|
||||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
@@ -285,6 +298,7 @@ impl Session {
|
||||
dirty: false,
|
||||
clipboard: None,
|
||||
clipboard_text: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find: FindState::default(),
|
||||
goal_x: None,
|
||||
page_hint: 0,
|
||||
@@ -316,8 +330,10 @@ impl Session {
|
||||
math: None,
|
||||
math_latex: false,
|
||||
math_normal_text: false,
|
||||
also_selected: Vec::new(),
|
||||
prefs: Prefs::default(),
|
||||
read_aloud: Default::default(),
|
||||
password: None,
|
||||
column: None,
|
||||
column_mode: false,
|
||||
}
|
||||
@@ -480,8 +496,10 @@ impl Session {
|
||||
self.document_id = self.document_id.wrapping_add(1);
|
||||
self.doc = doc;
|
||||
self.doc.ensure_nonempty();
|
||||
self.password = None;
|
||||
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
|
||||
self.pending = None;
|
||||
self.also_selected.clear();
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.reset_history();
|
||||
@@ -516,6 +534,7 @@ impl Session {
|
||||
dirty,
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
page_hint: _,
|
||||
@@ -550,6 +569,9 @@ impl Session {
|
||||
math: _,
|
||||
math_latex: _,
|
||||
math_normal_text: _,
|
||||
// The save password, like the file path: not an edit to the document.
|
||||
password: _,
|
||||
also_selected: _,
|
||||
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
|
||||
column: _,
|
||||
column_mode: _,
|
||||
@@ -581,6 +603,7 @@ impl Session {
|
||||
self.undo_evicted = evicted;
|
||||
self.doc = doc;
|
||||
self.sel = sel;
|
||||
self.also_selected.clear();
|
||||
self.history.truncate(history_len);
|
||||
self.redo = redo;
|
||||
self.typing_open = typing_open;
|
||||
@@ -679,6 +702,7 @@ impl Session {
|
||||
} else if spec.id.starts_with("caret.") {
|
||||
self.typing_open = false;
|
||||
}
|
||||
let sel_before = self.sel.clone();
|
||||
let run = spec.run;
|
||||
let mutates = spec.mutates;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
@@ -697,6 +721,10 @@ impl Session {
|
||||
self.doc.prune_text_boxes();
|
||||
}
|
||||
self.clamp_selection();
|
||||
// Extra selected objects last until something else is edited or selected.
|
||||
if !matches!(id, "select.addObject" | "arrange.ungroup") && (spec.mutates || self.sel != sel_before) {
|
||||
self.also_selected.clear();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some((d, s, h, r, t, ev, c)) = before_doc {
|
||||
|
||||
@@ -601,6 +601,73 @@ fn paste_special_respects_track_changes_and_brings_lists() {
|
||||
assert!(text(&s).starts_with("one\ntwo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "one two three"}));
|
||||
for (a, b) in [(0, 3), (4, 7), (8, 13)] {
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
}
|
||||
// Copying the same text again adds nothing.
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let items = run(&mut s, "edit.clipboardItems", json!({}));
|
||||
let previews: Vec<&str> = items.as_array().unwrap().iter().map(|i| i["preview"].as_str().unwrap()).collect();
|
||||
assert_eq!(previews, ["three", "two", "one"], "newest first");
|
||||
assert_eq!(run(&mut s, "edit.clipboardPane", json!({}))["value"], true);
|
||||
assert!(s.view.clipboard_pane);
|
||||
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "edit.pasteClipboardItem", json!({"index": 1}));
|
||||
assert_eq!(text(&s), "one two threetwo");
|
||||
assert_eq!(s.clipboard_text, "three", "the clipboard itself is unchanged");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one two three");
|
||||
|
||||
run(&mut s, "edit.pasteAllClipboard", json!({}));
|
||||
assert_eq!(text(&s), "one two threeonetwothree", "Paste All goes in copy order");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one two three", "Paste All is one undo step");
|
||||
|
||||
let left = run(&mut s, "edit.deleteClipboardItem", json!({"index": 0}));
|
||||
assert_eq!(left[0]["preview"], "two");
|
||||
run(&mut s, "edit.clearClipboard", json!({}));
|
||||
assert!(s.clip_history.is_empty());
|
||||
assert!(s.run("edit.pasteClipboardItem", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_pane_is_capped_and_rejects_bad_indexes() {
|
||||
let mut s = s();
|
||||
let words: Vec<String> = (0..30).map(|i| format!("w{i}")).collect();
|
||||
run(&mut s, "document.setText", json!({"text": words.join(" ")}));
|
||||
// Cutting each word in turn leaves the spaces: word `off` starts at `off`.
|
||||
for (off, w) in words.iter().enumerate() {
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": off}, "focus": {"block": 0, "off": off + w.len()}}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
}
|
||||
let items = run(&mut s, "edit.clipboardItems", json!({}));
|
||||
let items = items.as_array().unwrap();
|
||||
assert_eq!(items.len(), crate::cmd::edit::CLIP_MAX_ITEMS);
|
||||
assert_eq!(items[0]["preview"], "w29");
|
||||
assert_eq!(items[23]["preview"], "w6", "the oldest drop out");
|
||||
|
||||
let before = text(&s);
|
||||
for bad in [json!({"index": 24}), json!({"index": -1}), json!({"index": 1e300}), json!({"index": "x"}), json!({})] {
|
||||
assert!(s.run("edit.pasteClipboardItem", &bad).is_err(), "{bad}");
|
||||
assert!(s.run("edit.deleteClipboardItem", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(text(&s), before);
|
||||
assert_eq!(s.clip_history.len(), 24);
|
||||
|
||||
// A huge copy still reaches the clipboard but is not collected.
|
||||
run(&mut s, "document.setText", json!({"text": "x".repeat(3 << 20)}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(s.clipboard_text.len(), 3 << 20);
|
||||
assert_eq!(s.clip_history.items()[0].text, "w29");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_commands() {
|
||||
let mut s = s();
|
||||
@@ -2285,3 +2352,286 @@ fn accessibility_reports_a_chart_without_alt_text() {
|
||||
let issues = v["issues"].as_array().expect("issues");
|
||||
assert!(issues.iter().any(|i| i["issue"] == "Chart or diagram has no alternative text"), "{issues:?}");
|
||||
}
|
||||
|
||||
/// Encrypt with Password (#55): a document saved with a password opens only with it, keeps it
|
||||
/// for the next save, and saves unencrypted once the password is removed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn password_protected_documents_round_trip() {
|
||||
let dir = scratch_dir("password");
|
||||
let path = dir.join("secret.docx");
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let mut a = s();
|
||||
run(&mut a, "document.setText", json!({"text": "Quarterly numbers"}));
|
||||
let r = run(&mut a, "file.save", json!({"path": path_str, "password": "hunter2"}));
|
||||
assert_eq!(r["encrypted"], true);
|
||||
assert!(wordcraft_docx::is_encrypted(&std::fs::read(&path).unwrap()));
|
||||
assert!(a.run("file.save", &json!({"path": dir.join("x.txt").to_string_lossy(), "password": "pw"})).is_err(), "only Word packages encrypt");
|
||||
|
||||
// No password, then a wrong one: clear errors, and the open document stays.
|
||||
let mut t = s();
|
||||
let e = t.run("file.open", &json!({"path": path_str})).unwrap_err().to_string();
|
||||
assert!(crate::io::needs_password(&e), "{e}");
|
||||
let e = t.run("file.open", &json!({"path": path_str, "password": "hunter3"})).unwrap_err().to_string();
|
||||
assert!(crate::io::wrong_password(&e), "{e}");
|
||||
assert!(t.path.is_none());
|
||||
|
||||
let r = run(&mut t, "file.open", json!({"path": path_str, "password": "hunter2"}));
|
||||
assert_eq!(r["encrypted"], true);
|
||||
assert_eq!(text(&t), "Quarterly numbers");
|
||||
assert_eq!(run(&mut t, "file.info", json!({}))["encrypted"], true);
|
||||
// Saving again keeps the password, as Word does.
|
||||
run(&mut t, "text.insert", json!({"text": "Q3 "}));
|
||||
run(&mut t, "file.save", json!({}));
|
||||
let (doc, encrypted) = crate::io::open_path_with(&path, Some("hunter2")).unwrap();
|
||||
assert!(encrypted && doc.plain_text(StoryRef::Body).contains("Q3"));
|
||||
|
||||
// Removing the password saves the document unencrypted.
|
||||
assert!(t.run("file.encrypt", &json!({})).is_err());
|
||||
assert!(t.run("file.encrypt", &json!({"password": "x".repeat(300)})).is_err());
|
||||
t.dirty = false;
|
||||
assert_eq!(run(&mut t, "file.encrypt", json!({"password": null}))["encrypted"], false);
|
||||
assert!(t.dirty, "removing the password is a change to save");
|
||||
run(&mut t, "file.save", json!({}));
|
||||
assert!(crate::io::open_path(&path).unwrap().plain_text(StoryRef::Body).contains("Quarterly"));
|
||||
// A password never shows in debug output.
|
||||
assert_eq!(format!("{:?}", crate::io::Password::new("hunter2")), "Password(***)");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
|
||||
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
|
||||
#[test]
|
||||
fn shape_effects_apply_and_undo() {
|
||||
use wordcraft_doc::effects::{MAX_BLUR, MAX_DISTANCE, Shadow};
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "ellipse"}));
|
||||
let effects = |s: &Session| match cmd::objects::selected(s) {
|
||||
Some((_, wordcraft_doc::InlineObject::Shape { effects, float, .. })) => (effects, float.effect),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
assert!(effects(&s).0.is_empty());
|
||||
run(&mut s, "shape.effects", json!({"shadow": "offsetBottomRight", "glow": {"color": "FF0000", "size": 8}, "softEdge": 5}));
|
||||
let (e, extent) = effects(&s);
|
||||
assert_eq!(e.shadow, Shadow::preset("offsetBottomRight"));
|
||||
assert_eq!(e.glow.map(|g| (g.color, g.size)), Some((wordcraft_doc::props::Rgb(255, 0, 0), 8.0)));
|
||||
assert_eq!(e.soft_edge, Some(5.0));
|
||||
assert!(extent.iter().all(|v| *v >= 8.0), "room for the glow: {extent:?}");
|
||||
// Clearing the glow leaves the shadow and soft edges.
|
||||
run(&mut s, "shape.effects", json!({"glow": null}));
|
||||
let (e, _) = effects(&s);
|
||||
assert!(e.glow.is_none() && e.shadow.is_some() && e.soft_edge == Some(5.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(effects(&s).0.glow.map(|g| g.size), Some(8.0));
|
||||
// Hostile numbers.
|
||||
run(&mut s, "shape.effects", json!({"shadow": {"blur": 1e30, "distance": -5, "angle": 1e20, "transparency": 400}, "softEdge": -3}));
|
||||
let (e, _) = effects(&s);
|
||||
let sh = e.shadow.unwrap();
|
||||
assert!(sh.blur == MAX_BLUR && sh.distance == 0.0 && (0.0..360.0).contains(&sh.angle) && sh.transparency == 100.0, "{sh:?}");
|
||||
assert!(e.soft_edge.is_none());
|
||||
run(&mut s, "shape.effects", json!({"shadow": {"distance": 1e9}}));
|
||||
assert_eq!(effects(&s).0.shadow.map(|s| s.distance), Some(MAX_DISTANCE));
|
||||
assert!(s.run("shape.effects", &json!({"shadow": "perspective"})).is_err());
|
||||
// Off a shape it's disabled.
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
assert!(s.run("shape.effects", &json!({"shadow": null})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asian_typography_sets_flags_on_selected_paragraphs_and_undoes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "一\n二\n三"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let r = run(&mut s, "para.asianTypography", json!({"kinsoku": false, "wordWrap": false, "topLinePunct": true}));
|
||||
assert_eq!(r["asianTypography"]["kinsoku"], false);
|
||||
assert_eq!(r["asianTypography"]["autoSpaceDE"], true, "a flag left out keeps its default");
|
||||
for p in s.doc.body.iter().filter_map(|b| b.as_para()) {
|
||||
assert_eq!(
|
||||
(p.props.kinsoku, p.props.word_wrap, p.props.top_line_punct, p.props.overflow_punct),
|
||||
(Some(false), Some(false), Some(true), None)
|
||||
);
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.kinsoku.is_none() && p.props.word_wrap.is_none()));
|
||||
// Without flags it only reports them.
|
||||
let r = run(&mut s, "para.asianTypography", json!({}));
|
||||
assert_eq!(r["asianTypography"]["kinsoku"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manage_styles_lists_paragraph_character_linked_and_table_styles() {
|
||||
let mut s = s();
|
||||
let list = run(&mut s, "styles.manage", json!({"sort": "alphabetical"}));
|
||||
let list = list.as_array().unwrap();
|
||||
let find = |name: &str| list.iter().find(|x| x["name"] == name).unwrap_or_else(|| panic!("{name} listed"));
|
||||
assert_eq!(find("Normal")["type"], "paragraph");
|
||||
assert_eq!(find("Normal")["builtIn"], true);
|
||||
assert_eq!(find("Normal")["inGallery"], true);
|
||||
assert_eq!(find("Heading 1")["type"], "linked");
|
||||
assert_eq!(find("Heading 1")["basedOn"], "Normal");
|
||||
assert!(find("Heading 1")["format"]["size"].is_number(), "description formatting");
|
||||
assert_eq!(find("Table Grid")["type"], "table");
|
||||
assert!(list.iter().any(|x| x["type"] == "character"));
|
||||
// The character half of a linked pair is listed once, as the linked style.
|
||||
assert!(!list.iter().any(|x| x["id"] == "Heading1Char"));
|
||||
let names: Vec<String> = list.iter().map(|x| x["name"].as_str().unwrap().to_lowercase()).collect();
|
||||
assert!(names.windows(2).all(|w| w[0] <= w[1]), "alphabetical");
|
||||
// Recommended order starts with Normal (priority 0); listing is not an edit.
|
||||
let rec = run(&mut s, "styles.manage", json!({}));
|
||||
assert_eq!(rec[0]["name"], "Normal");
|
||||
assert!(s.run("styles.manage", &json!({"sort": "sideways"})).is_err());
|
||||
assert!(!s.dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_style_falls_back_to_its_base_and_undoes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Styled"}));
|
||||
run(&mut s, "styles.create", json!({"name": "Mine", "basedOn": "Heading 1"}));
|
||||
run(&mut s, "styles.create", json!({"name": "Child", "basedOn": "Mine", "fromSelection": false}));
|
||||
run(&mut s, "styles.apply", json!({"style": "Mine"}));
|
||||
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
|
||||
let r = run(&mut s, "styles.delete", json!({"style": "Mine"}));
|
||||
assert_eq!(r["restyled"], 1);
|
||||
assert!(s.doc.styles.get("Mine").is_none());
|
||||
assert_eq!(para_style(&s, 0).as_deref(), Some("Heading1"), "falls back to its base");
|
||||
assert_eq!(s.doc.styles.get("Child").and_then(|c| c.based_on.clone()).as_deref(), Some("Heading1"), "rebased");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Mine").is_some());
|
||||
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
|
||||
// Built-in styles are refused, unknown ones are errors.
|
||||
assert!(s.run("styles.delete", &json!({"style": "Heading 1"})).is_err());
|
||||
assert!(s.run("styles.delete", &json!({"style": "No Such Style"})).is_err());
|
||||
assert!(s.doc.styles.get("Heading1").is_some());
|
||||
}
|
||||
|
||||
/// Manage Styles' Delete on a table style is Table Design's Delete Table Style: its tables take
|
||||
/// Table Grid, styles based on it keep their look, and Word's built-in table styles are refused.
|
||||
#[test]
|
||||
fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
|
||||
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false}));
|
||||
let r = run(&mut s, "styles.delete", json!({"style": "Base"}));
|
||||
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
|
||||
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
|
||||
let list = run(&mut s, "styles.manage", json!({}));
|
||||
let grid = list.as_array().unwrap().iter().find(|x| x["name"] == "Table Grid").unwrap();
|
||||
assert_eq!(grid["builtIn"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_visibility_round_trips_through_docx() {
|
||||
let mut s = s();
|
||||
run(&mut s, "styles.create", json!({"name": "Mine", "fromSelection": false}));
|
||||
run(&mut s, "styles.setVisibility", json!({"style": "Heading 1", "gallery": false}));
|
||||
run(&mut s, "styles.setVisibility", json!({"style": "Mine", "hidden": true}));
|
||||
run(&mut s, "styles.setVisibility", json!({"style": "Heading 4", "gallery": true}));
|
||||
assert!(s.run("styles.setVisibility", &json!({"style": "Mine"})).is_err());
|
||||
let gallery: Vec<String> = s.doc.styles.gallery().iter().map(|x| x.id.clone()).collect();
|
||||
assert!(!gallery.contains(&"Heading1".to_string()) && !gallery.contains(&"Mine".to_string()) && gallery.contains(&"Heading4".to_string()));
|
||||
let bytes = crate::io::save_bytes("v.docx", &s.doc).unwrap();
|
||||
let doc = crate::io::open_bytes("v.docx", &bytes).unwrap();
|
||||
let st = |id: &str| doc.styles.get(id).cloned().unwrap_or_else(|| panic!("{id} kept"));
|
||||
assert!(!st("Heading1").quick && !st("Heading1").hidden);
|
||||
assert!(st("Mine").hidden && !st("Mine").builtin);
|
||||
assert!(st("Heading4").quick);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable");
|
||||
}
|
||||
|
||||
/// #146: the first/last column, total row and banded column regions apply in layout (later
|
||||
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
|
||||
#[test]
|
||||
fn custom_table_style_column_and_total_regions() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
|
||||
run(
|
||||
&mut s,
|
||||
"table.look",
|
||||
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
|
||||
);
|
||||
let r = run(
|
||||
&mut s,
|
||||
"table.newStyle",
|
||||
json!({"name": "Ledger",
|
||||
"firstColumn": {"fill": "112233", "bold": true},
|
||||
"lastColumn": {"fill": "445566"},
|
||||
"lastRow": {"fill": "778899", "borders": true},
|
||||
"bandedColumns": {"fill": "ABCDEF"}}),
|
||||
);
|
||||
let id = r["id"].as_str().unwrap().to_string();
|
||||
let fills = |s: &mut Session, c: Rgb| {
|
||||
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
|
||||
};
|
||||
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
|
||||
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
|
||||
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
|
||||
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
|
||||
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
|
||||
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
|
||||
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
|
||||
assert_eq!(
|
||||
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
|
||||
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
|
||||
);
|
||||
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
|
||||
}
|
||||
|
||||
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
|
||||
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
|
||||
#[test]
|
||||
fn delete_table_style_falls_back_and_undoes() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
|
||||
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
|
||||
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
|
||||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
|
||||
|
||||
let r = run(&mut s, "table.deleteStyle", json!({}));
|
||||
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert!(s.doc.styles.get("Base").is_none());
|
||||
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
|
||||
let child = s.doc.styles.table_style("Child").unwrap();
|
||||
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
|
||||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
|
||||
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Base").is_some());
|
||||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
|
||||
}
|
||||
|
||||
/// #146: a .docx may mark Word's built-in table styles as custom; they still can't be deleted, so
|
||||
/// no table is left pointing at a style that's gone.
|
||||
#[test]
|
||||
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
for name in ["Table Grid", "Normal Table", "TableGrid"] {
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": name})).is_err(), "{name}");
|
||||
}
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
let id = s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone().unwrap();
|
||||
assert!(s.doc.styles.get(&id).is_some(), "the table's style still exists");
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub enum Draw {
|
||||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
stroke_width: f32,
|
||||
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
},
|
||||
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
|
||||
Path {
|
||||
@@ -211,8 +213,8 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
|
||||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
|
||||
Placed::Image { rect, media, crop, .. } => out.push(Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }),
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width } => {
|
||||
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
|
||||
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
|
||||
}
|
||||
Placed::Graphic { rect, graphic, .. } => out.extend(graphic_draws(doc, graphic, *rect, alpha)),
|
||||
Placed::Cell { .. } | Placed::Object { .. } => {}
|
||||
@@ -546,8 +548,25 @@ fn lines(
|
||||
let rect = inline_rect(obj, cx, base, c.adv, c.obj_h);
|
||||
match obj {
|
||||
Some(InlineObject::Image { media, crop, .. }) => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
|
||||
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => {
|
||||
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. }) => {
|
||||
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
|
||||
}
|
||||
Some(g @ InlineObject::Group { .. }) => {
|
||||
for ([x, y, w, h], c) in g.group_rects(rect.x, rect.y, rect.w, rect.h) {
|
||||
let rect = Rect::new(x, y, w, h);
|
||||
match c {
|
||||
InlineObject::Image { media, crop, .. } => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => out.push(Draw::Shape {
|
||||
rect,
|
||||
kind: *kind,
|
||||
fill: *fill,
|
||||
stroke: *stroke,
|
||||
stroke_width: *stroke_width,
|
||||
effects: *effects,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Graphic { graphic, alt, .. }) => {
|
||||
out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws: graphic_draws(doc, graphic, rect, alpha) })
|
||||
@@ -722,9 +741,14 @@ fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Dra
|
||||
let mut out = Vec::with_capacity(g.items.len());
|
||||
for it in &g.items {
|
||||
match it {
|
||||
GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => {
|
||||
out.push(Draw::Shape { rect: inside(r), kind: *kind, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k })
|
||||
}
|
||||
GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => out.push(Draw::Shape {
|
||||
rect: inside(r),
|
||||
kind: *kind,
|
||||
fill: *fill,
|
||||
stroke: *stroke,
|
||||
stroke_width: stroke_width * k,
|
||||
effects: 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, stroke: *stroke, stroke_width: stroke_width * k })
|
||||
@@ -867,9 +891,6 @@ pub fn text_color(c: &TextColor, background: Option<Rgb>) -> Rgb {
|
||||
/// Where inline object `obj` is drawn, given its cluster's box (`adv` × `obj_h` standing on the
|
||||
/// baseline at `cx`): inside the room kept for its effects.
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect {
|
||||
let [l, t, r, b] = match obj {
|
||||
Some(InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. }) => float.effect_extent(),
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
let [l, t, r, b] = obj.and_then(InlineObject::frame).map_or([0.0; 4], |(_, _, float)| float.effect_extent());
|
||||
Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Asian typography line-breaking rules (the paragraph's `kinsoku` and `word_wrap` flags), applied
|
||||
//! on top of the Unicode line-break opportunities (UAX #14).
|
||||
//!
|
||||
//! - Kinsoku on (the default): no line starts with closing East Asian punctuation (、。,)」…)
|
||||
//! and none ends with opening punctuation ((「【…). UAX #14 already forbids most of these
|
||||
//! breaks; the lists make sure of it for the standard Japanese/Chinese sets.
|
||||
//! - Kinsoku off: a line may break between any two East Asian characters, punctuation included.
|
||||
//! - Word wrap off: Latin words may break at any character.
|
||||
//!
|
||||
//! Only East Asian characters are in the lists, and the extra breaks need letters on both sides,
|
||||
//! so text with no East Asian characters breaks exactly as before unless word wrap is off.
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
/// East Asian characters a line may not start with: closing brackets and quotes, the
|
||||
/// ideographic comma and full stop, fullwidth and halfwidth punctuation, the middle dot,
|
||||
/// iteration and sound marks.
|
||||
const NO_START: &str = "、。,.・:;?!゛゜ヽヾゝゞ々〻〉》」』】〕〗〙〛〞〟)]}⦆。」、・゙゚%¢";
|
||||
|
||||
/// East Asian characters a line may not end with: opening brackets and quotes, currency signs.
|
||||
const NO_END: &str = "〈《「『【〔〖〘〚〝([{⦅「$£¥";
|
||||
|
||||
/// Whether `c` is East Asian text: CJK ideographs, kana, bopomofo, CJK punctuation, and
|
||||
/// fullwidth/halfwidth forms (not spaces, not combining sound marks).
|
||||
pub fn is_east_asian(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x2E80..=0x2FFF // radicals, ideographic description
|
||||
| 0x3001..=0x303F // CJK symbols and punctuation (not the ideographic space)
|
||||
| 0x3040..=0x3098 // hiragana
|
||||
| 0x309B..=0x30FF // sound marks, katakana
|
||||
| 0x3100..=0x312F // bopomofo
|
||||
| 0x3190..=0x31FF // kanbun, CJK strokes, katakana extensions
|
||||
| 0x3400..=0x4DBF // CJK extension A
|
||||
| 0x4E00..=0x9FFF // CJK unified ideographs
|
||||
| 0xF900..=0xFAFF // compatibility ideographs
|
||||
| 0xFE30..=0xFE4F // CJK compatibility forms
|
||||
| 0xFF01..=0xFFEF // fullwidth and halfwidth forms
|
||||
| 0x20000..=0x3FFFF) // supplementary ideographs
|
||||
}
|
||||
|
||||
/// Adjust the break opportunities `opps` (byte offsets into `text`: a line may start there) for
|
||||
/// the paragraph's kinsoku and word-wrap settings. The result is sorted and has no duplicates.
|
||||
pub fn apply(text: &str, opps: &mut Vec<usize>, kinsoku: bool, word_wrap: bool) {
|
||||
let next_char = |i: usize| text.get(i..).and_then(|t| t.chars().next());
|
||||
let prev_char = |i: usize| text.get(..i).and_then(|t| t.chars().next_back());
|
||||
if kinsoku {
|
||||
opps.retain(|&i| !next_char(i).is_some_and(|c| NO_START.contains(c)) && !prev_char(i).is_some_and(|c| NO_END.contains(c)));
|
||||
} else {
|
||||
// Between any two East Asian characters.
|
||||
for (i, c) in text.char_indices().skip(1) {
|
||||
if is_east_asian(c) && prev_char(i).is_some_and(is_east_asian) {
|
||||
opps.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !word_wrap {
|
||||
// Between any two letters or digits that are not East Asian (whole graphemes, so accents
|
||||
// stay with their letters).
|
||||
let latin = |g: &str| g.chars().next().is_some_and(|c| c.is_alphanumeric() && !is_east_asian(c));
|
||||
let mut prev: Option<&str> = None;
|
||||
for (i, g) in text.grapheme_indices(true) {
|
||||
if prev.is_some_and(latin) && latin(g) {
|
||||
opps.push(i);
|
||||
}
|
||||
prev = Some(g);
|
||||
}
|
||||
}
|
||||
opps.sort_unstable();
|
||||
opps.dedup();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::apply;
|
||||
|
||||
/// Where a line may start, as the text from there to the next opportunity.
|
||||
fn breaks(text: &str, kinsoku: bool, word_wrap: bool) -> Vec<String> {
|
||||
let mut opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(i, _)| i).filter(|&i| i < text.len()).collect();
|
||||
apply(text, &mut opps, kinsoku, word_wrap);
|
||||
opps.iter().map(|&i| text.get(i..).and_then(|t| t.chars().next()).map(String::from).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinsoku_and_word_wrap_break_opportunities() {
|
||||
let ja = "日本語、「漢字」。テスト";
|
||||
let on = breaks(ja, true, true);
|
||||
for c in ["、", "」", "。", "漢"] {
|
||||
assert!(!on.iter().any(|s| s == c), "kinsoku: no line starts with {c}: {on:?}");
|
||||
}
|
||||
assert!(on.iter().any(|s| s == "「"), "a line may start at an opening bracket: {on:?}");
|
||||
assert!(on.iter().any(|s| s == "テ"), "{on:?}");
|
||||
let off = breaks(ja, false, true);
|
||||
for c in ["、", "」", "。", "漢"] {
|
||||
assert!(off.iter().any(|s| s == c), "kinsoku off: a line may start with {c}: {off:?}");
|
||||
}
|
||||
// Latin text: unchanged by kinsoku, broken anywhere without word wrap.
|
||||
let en = "Hello wonderful (world), 50%.";
|
||||
assert_eq!(breaks(en, true, true), breaks(en, false, true));
|
||||
assert_eq!(breaks(en, true, true), ["w", "(", "5"]);
|
||||
let any = breaks("Hello café", true, false);
|
||||
assert_eq!(any, ["e", "l", "l", "o", "c", "a", "f", "é"]);
|
||||
}
|
||||
}
|
||||
+51
-41
@@ -11,6 +11,7 @@
|
||||
pub mod display;
|
||||
pub mod fields;
|
||||
pub mod hit;
|
||||
pub mod kinsoku;
|
||||
pub mod math;
|
||||
pub mod para;
|
||||
mod table;
|
||||
@@ -96,6 +97,7 @@ pub enum Placed {
|
||||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
stroke_width: f32,
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
},
|
||||
/// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at
|
||||
/// byte `off` of paragraph `path` (its alt text).
|
||||
@@ -1143,14 +1145,7 @@ struct PageFrame<'a> {
|
||||
|
||||
/// A floating (not inline) picture, chart or shape: its size and placement.
|
||||
fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> {
|
||||
match o {
|
||||
InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { w, h, float, .. } | InlineObject::Shape { w, h, float, .. }
|
||||
if float.wrap != Wrap::Inline =>
|
||||
{
|
||||
Some((*w, *h, float))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
o.frame().filter(|(_, _, float)| float.wrap != Wrap::Inline)
|
||||
}
|
||||
|
||||
/// Where a floating object goes, in the coordinates of the box `frame` places on the page.
|
||||
@@ -1213,20 +1208,26 @@ fn rel_exclusions(excl: &[(Rect, bool)], x0: f32, y0: f32) -> Vec<para::Exclusio
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The item drawing floating object `o` at `rect`.
|
||||
fn float_item(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Option<Placed> {
|
||||
/// The items drawing floating object `o` at `rect` (a group: its members).
|
||||
fn float_items(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: usize) -> Vec<Placed> {
|
||||
match o {
|
||||
InlineObject::Image { media, crop, .. } => {
|
||||
Some(Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off })
|
||||
vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off }]
|
||||
}
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, .. } => {
|
||||
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
|
||||
vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects }]
|
||||
}
|
||||
InlineObject::Graphic { graphic, .. } => Some(Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }),
|
||||
_ => None,
|
||||
InlineObject::Graphic { graphic, .. } => vec![Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }],
|
||||
InlineObject::Group { .. } => group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, story, path, off)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's members, each with its rectangle when the group is at `rect`.
|
||||
fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
|
||||
o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect()
|
||||
}
|
||||
|
||||
/// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in
|
||||
/// the current column, add their wrap areas to the page (replacing any from an earlier attempt,
|
||||
/// from `excl_mark` on) and lay the paragraph out around them, with its list `label`.
|
||||
@@ -1304,23 +1305,19 @@ fn place_objects(
|
||||
if left_out(oi) {
|
||||
continue;
|
||||
}
|
||||
let Some(
|
||||
obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { w, h, float, .. } | InlineObject::Shape { w, h, float, .. }),
|
||||
) = p.objects.get(oi)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(obj) = p.objects.get(oi) else { continue };
|
||||
let Some((w, h, float)) = obj.frame() else { continue };
|
||||
let floating = float.wrap != Wrap::Inline;
|
||||
let rect = if floating {
|
||||
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, *w, *h, float))
|
||||
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, w, h, float))
|
||||
} else {
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h)
|
||||
};
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
// Floating ones are drawn here; inline ones with their line.
|
||||
if floating && let Some(it) = float_item(obj, rect, story, path, c.start) {
|
||||
layer.push(it);
|
||||
if floating {
|
||||
layer.extend(float_items(obj, rect, story, path, c.start));
|
||||
}
|
||||
let text_box = match obj {
|
||||
InlineObject::Shape { story: Some(id), .. } if ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox) => {
|
||||
@@ -1337,16 +1334,30 @@ fn place_objects(
|
||||
wrap: float.wrap,
|
||||
origin: Point::new(at.col.0, at.para_y),
|
||||
});
|
||||
// Its text, unless the box budget says no (a box inside itself, too deep, too many).
|
||||
let Some(id) = text_box.filter(|id| ctx.boxes.enter(*id)) else { continue };
|
||||
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
let inner = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None).items;
|
||||
ctx.boxes.leave();
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word.
|
||||
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
|
||||
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
|
||||
layer.push(it);
|
||||
// Its text (a group: its text boxes'), unless the box budget says no (a box inside
|
||||
// itself, too deep, too many).
|
||||
let boxes: Vec<(u32, Rect)> = match obj {
|
||||
InlineObject::Group { .. } => group_members(obj, rect)
|
||||
.into_iter()
|
||||
.filter_map(|(r, c)| {
|
||||
c.text_box().filter(|id| ctx.doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox)).map(|id| (id, r))
|
||||
})
|
||||
.collect(),
|
||||
_ => text_box.map(|id| (id, rect)).into_iter().collect(),
|
||||
};
|
||||
for (id, rect) in boxes {
|
||||
if !ctx.boxes.enter(id) {
|
||||
continue;
|
||||
}
|
||||
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
let inner = layout_box(ctx, StoryRef::Part(id), &blocks, &[], (rect.w - 2.0 * BOX_INSET_X).max(12.0), None, depth + 1, None).items;
|
||||
ctx.boxes.leave();
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word.
|
||||
for mut it in fit_box(inner, rect.h - 2.0 * BOX_INSET_Y) {
|
||||
it.translate(rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y);
|
||||
layer.push(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1357,12 +1368,14 @@ fn place_objects(
|
||||
/// (on the box's cluster), as (cluster, note id).
|
||||
fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32)> {
|
||||
let mut notes = pl.notes.clone();
|
||||
if p.objects.iter().any(|o| matches!(o, InlineObject::Shape { story: Some(_), .. })) {
|
||||
if p.objects.iter().any(|o| !o.text_boxes().is_empty()) {
|
||||
for (ci, c) in pl.clusters.iter().enumerate() {
|
||||
if let para::ClKind::Object(oi) = c.kind
|
||||
&& let Some(InlineObject::Shape { story: Some(id), .. }) = p.objects.get(oi)
|
||||
&& let Some(o) = p.objects.get(oi)
|
||||
{
|
||||
notes.extend(doc.notes_in_text_box(*id).into_iter().map(|n| (ci, n)));
|
||||
for id in o.text_boxes() {
|
||||
notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1803,10 +1816,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
||||
|
||||
/// Is an object floating (not laid out inline)?
|
||||
pub fn is_floating(o: &InlineObject) -> bool {
|
||||
match o {
|
||||
InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
o.is_floating()
|
||||
}
|
||||
|
||||
/// Milliseconds since an arbitrary epoch (monotonic on native; the browser's clock on the web).
|
||||
|
||||
+341
-21
@@ -11,7 +11,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, math_alnum, parse_linear,
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, MathProps, ScriptKind, is_integral, math_alnum,
|
||||
parse_linear,
|
||||
};
|
||||
use wordcraft_doc::props::{Rgb, TextColor};
|
||||
use wordcraft_doc::resolve::ResolvedChar;
|
||||
@@ -174,14 +175,16 @@ fn split_number(row: &[MNode]) -> Option<(Arg, Arg, usize)> {
|
||||
|
||||
/// Lay out an equation set at the size and colour of `rc`. `display`: an equation on a line of
|
||||
/// its own (bigger operators, limits above and below, full-size fractions).
|
||||
/// `avail` is the width of the line (numbered display equations fill it); `counter` counts the
|
||||
/// document's automatic equation numbers so far and advances past this equation's.
|
||||
/// `avail` is the width of the line (numbered display equations fill it, wider display equations
|
||||
/// wrap to it as `props` says); `counter` counts the document's automatic equation numbers so far
|
||||
/// and advances past this equation's.
|
||||
pub fn layout_equation(
|
||||
math: &wordcraft_doc::math::Math,
|
||||
linear: &str,
|
||||
rc: &ResolvedChar,
|
||||
display: bool,
|
||||
avail: f32,
|
||||
props: &MathProps,
|
||||
counter: &mut u32,
|
||||
) -> MathLayout {
|
||||
let parsed;
|
||||
@@ -213,6 +216,7 @@ pub fn layout_equation(
|
||||
let b = match nodes.as_slice() {
|
||||
[] => cx.hint(st),
|
||||
[MNode::EqArr { rows }] if display && rows.iter().any(|r| split_number(r).is_some()) => cx.numbered(rows, st, avail, counter),
|
||||
_ if display => cx.wrapped(nodes, st, avail, props),
|
||||
_ => cx.arg(nodes, st, 0),
|
||||
};
|
||||
let mut slots = b.slots;
|
||||
@@ -307,6 +311,8 @@ struct Bx {
|
||||
slots: Vec<Slot>,
|
||||
/// Text atoms: x of each character boundary (one more than the characters).
|
||||
cuts: Vec<f32>,
|
||||
/// A manual line break before this atom (`m:brk`; the value is its `m:alnAt`).
|
||||
brk: Option<u8>,
|
||||
}
|
||||
|
||||
impl Bx {
|
||||
@@ -547,6 +553,7 @@ impl Ctx {
|
||||
let path = self.path.clone();
|
||||
match n {
|
||||
MNode::Run(r) => {
|
||||
let first = out.len();
|
||||
for part in self.run_atoms(r, st, u, &path) {
|
||||
match part {
|
||||
RunPart::Atom(b) => out.push((b, seg)),
|
||||
@@ -556,6 +563,9 @@ impl Ctx {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((b, _)) = out.get_mut(first) {
|
||||
b.brk = r.brk;
|
||||
}
|
||||
u += r.text.chars().count();
|
||||
}
|
||||
MNode::Nary { .. } | MNode::Func { .. } => {
|
||||
@@ -1592,6 +1602,198 @@ impl Ctx {
|
||||
out
|
||||
}
|
||||
|
||||
/// A display equation, broken into lines where it is wider than `avail` or a run asks for a
|
||||
/// manual break (`m:brk`). Breaks fall only at top-level binary operators and relations (never
|
||||
/// inside a fraction, radical, script or matrix), before or after the operator as `m:brkBin`
|
||||
/// says; lines are filled greedily and a term wider than the line overflows it. The first line
|
||||
/// starts at the left; the rest are indented by `m:wrapIndent`, set flush right with
|
||||
/// `m:wrapRight`, or aligned at an operator of the first line (`m:alnAt`).
|
||||
fn wrapped(&mut self, a: &[MNode], st: St, avail: f32, mp: &MathProps) -> Bx {
|
||||
let em = self.em(st);
|
||||
let atoms: Vec<Bx> = self.row(a, st, 0, 0).into_iter().map(|(b, _)| b).collect();
|
||||
let gaps = gaps_between(&atoms, st, em);
|
||||
let total: f32 = atoms.iter().map(|b| b.w).sum::<f32>() + gaps.iter().sum::<f32>();
|
||||
let manual = atoms.iter().skip(1).any(|b| b.brk.is_some());
|
||||
if atoms.len() < 2 || (!manual && total <= avail + 0.01) {
|
||||
let mut b = compose(atoms, st, em);
|
||||
finalize(&mut b, &self.path, em);
|
||||
return b;
|
||||
}
|
||||
let cls = classes(&atoms);
|
||||
let n = atoms.len();
|
||||
let is_op = |i: usize| -> bool {
|
||||
atoms.get(i).is_some_and(|b| b.glyph.is_some() && !b.transparent)
|
||||
&& cls.get(i).is_some_and(|c| matches!(c.0, Cls::Bin | Cls::Rel) && c.0 == c.1)
|
||||
};
|
||||
// Chunk starts (after the first): the atom index → forced (`Some(alnAt)`) and the
|
||||
// operator repeated at the start of the line it begins.
|
||||
let mut starts: std::collections::BTreeMap<usize, (Option<u8>, Option<usize>)> = std::collections::BTreeMap::new();
|
||||
for i in 1..n {
|
||||
let op = is_op(i);
|
||||
let at = if op && mp.brk_bin != BrkBin::Before { i + 1 } else { i };
|
||||
if at == 0 || at >= n {
|
||||
continue;
|
||||
}
|
||||
let repeat = (op && mp.brk_bin == BrkBin::Repeat).then_some(i);
|
||||
let forced = atoms.get(i).and_then(|b| b.brk);
|
||||
if op || forced.is_some() {
|
||||
let e = starts.entry(at).or_insert((None, None));
|
||||
if forced.is_some() {
|
||||
e.0 = forced;
|
||||
}
|
||||
if repeat.is_some() {
|
||||
e.1 = repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chunks: [s, e) with their width inside and the gap before them.
|
||||
let mut bounds: Vec<usize> = vec![0];
|
||||
bounds.extend(starts.keys().copied());
|
||||
bounds.push(n);
|
||||
let width_of = |s: usize, e: usize| -> f32 {
|
||||
(s..e).map(|i| atoms.get(i).map_or(0.0, |b| b.w) + if i > s { gaps.get(i).copied().unwrap_or(0.0) } else { 0.0 }).sum()
|
||||
};
|
||||
// The operator glyphs a repeated operator shows at the end of one line and the start of
|
||||
// the next.
|
||||
let repeats: std::collections::HashMap<usize, (Option<Bx>, Bx)> = starts
|
||||
.values()
|
||||
.filter_map(|v| v.1)
|
||||
.filter_map(|i| {
|
||||
let b = atoms.get(i)?;
|
||||
let (end, start) = match op_char(b) {
|
||||
Some('−' | '-') => match mp.brk_bin_sub {
|
||||
BrkBinSub::MinusMinus => ('−', '−'),
|
||||
BrkBinSub::MinusPlus => ('−', '+'),
|
||||
BrkBinSub::PlusMinus => ('+', '−'),
|
||||
},
|
||||
Some(c) => (c, c),
|
||||
None => return None,
|
||||
};
|
||||
let end = (Some(end) != op_char(b)).then(|| self.op_glyph(b, end, st)).flatten();
|
||||
Some((i, (end, self.op_glyph(b, start, st)?)))
|
||||
})
|
||||
.collect();
|
||||
let indent = if mp.wrap_indent.is_finite() { mp.wrap_indent.clamp(0.0, 1584.0) } else { 0.0 }.min(avail * 0.5);
|
||||
// Greedy fill: lines as lists of chunks (index into `bounds`).
|
||||
let mut lines: Vec<(usize, usize)> = Vec::new();
|
||||
let mut cur_w = 0.0f32;
|
||||
let mut auto = false;
|
||||
for c in 0..bounds.len().saturating_sub(1) {
|
||||
let (s, e) = (bounds.get(c).copied().unwrap_or(0), bounds.get(c + 1).copied().unwrap_or(n));
|
||||
let w = width_of(s, e);
|
||||
let lead = gaps.get(s).copied().unwrap_or(0.0);
|
||||
let info = starts.get(&s).copied();
|
||||
let copy_w = info.and_then(|v| v.1).and_then(|i| repeats.get(&i)).map_or(0.0, |r| r.1.w + lead);
|
||||
let line_avail = if lines.len() <= 1 || mp.wrap_right { avail } else { avail - indent };
|
||||
match lines.last_mut() {
|
||||
Some(l) if info.is_some_and(|v| v.0.is_none()) && cur_w + lead + w <= line_avail + 0.01 => {
|
||||
l.1 = c + 1;
|
||||
cur_w += lead + w;
|
||||
}
|
||||
Some(_) if info.is_some_and(|v| v.0.is_none()) => {
|
||||
auto = true;
|
||||
lines.push((c, c + 1));
|
||||
cur_w = copy_w + w;
|
||||
}
|
||||
_ => {
|
||||
lines.push((c, c + 1));
|
||||
cur_w = copy_w + w;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build each line's box.
|
||||
let ops: Vec<bool> = (0..n).map(is_op).collect();
|
||||
let path = self.path.clone();
|
||||
let mut atoms: Vec<Option<Bx>> = atoms.into_iter().map(Some).collect();
|
||||
let mut boxes: Vec<(Bx, Option<u8>)> = Vec::with_capacity(lines.len());
|
||||
let mut ops_x: Vec<f32> = Vec::new();
|
||||
for (li, &(c0, c1)) in lines.iter().enumerate() {
|
||||
let (s, e) = (bounds.get(c0).copied().unwrap_or(0), bounds.get(c1).copied().unwrap_or(n));
|
||||
let info = starts.get(&s).copied();
|
||||
let next = lines.get(li + 1).and_then(|l| bounds.get(l.0)).and_then(|s| starts.get(s)).and_then(|v| v.1);
|
||||
let mut row = Bx::default();
|
||||
let mut x = 0.0f32;
|
||||
let copy = info.and_then(|v| v.1).and_then(|i| repeats.get(&i)).map(|r| r.1.clone());
|
||||
let has_copy = copy.is_some();
|
||||
if let Some(cb) = copy {
|
||||
let w = cb.w;
|
||||
row.put(cb, 0.0, 0.0);
|
||||
x = w;
|
||||
}
|
||||
for i in s..e {
|
||||
let Some(mut b) = atoms.get_mut(i).and_then(Option::take) else { continue };
|
||||
if i > s || has_copy {
|
||||
x += gaps.get(i).copied().unwrap_or(0.0);
|
||||
}
|
||||
if next == Some(i)
|
||||
&& let Some((Some(end), _)) = repeats.get(&i)
|
||||
{
|
||||
let mut r = end.clone();
|
||||
let k = if b.w > 0.0 { r.w / b.w } else { 1.0 };
|
||||
r.slots = std::mem::take(&mut b.slots)
|
||||
.into_iter()
|
||||
.map(|mut sl| {
|
||||
sl.x *= k;
|
||||
sl
|
||||
})
|
||||
.collect();
|
||||
b = r;
|
||||
}
|
||||
if li == 0 && ops.get(i).copied().unwrap_or(false) {
|
||||
ops_x.push(x);
|
||||
}
|
||||
let w = b.w;
|
||||
row.put(b, x, 0.0);
|
||||
x += w;
|
||||
}
|
||||
row.w = x;
|
||||
finalize(&mut row, &path, em);
|
||||
boxes.push((row, info.and_then(|v| v.0)));
|
||||
}
|
||||
let widest = boxes.iter().map(|(b, _)| b.w).fold(0.0f32, f32::max);
|
||||
let right = if auto { avail.max(widest) } else { widest };
|
||||
let mut placed = Vec::with_capacity(boxes.len());
|
||||
for (li, (b, aln)) in boxes.into_iter().enumerate() {
|
||||
let x = if li == 0 {
|
||||
0.0
|
||||
} else if let Some(ax) = aln.filter(|a| *a > 0).and_then(|a| ops_x.get(a as usize - 1)) {
|
||||
*ax
|
||||
} else if mp.wrap_right {
|
||||
(right - b.w).max(0.0)
|
||||
} else {
|
||||
indent
|
||||
};
|
||||
placed.push((b, x));
|
||||
}
|
||||
// Stack the lines down from the first one's baseline.
|
||||
let (gap, skip) = (em * 0.2, em * 1.25);
|
||||
let mut out = Bx::default();
|
||||
let mut y = 0.0f32;
|
||||
let mut prev_d: Option<f32> = None;
|
||||
for (b, x) in placed {
|
||||
if let Some(pd) = prev_d {
|
||||
y -= (pd + gap + b.a).max(skip);
|
||||
}
|
||||
prev_d = Some(b.d);
|
||||
out.w = out.w.max(x + b.w);
|
||||
out.put(b, x, y);
|
||||
}
|
||||
out.w = out.w.max(right);
|
||||
out
|
||||
}
|
||||
|
||||
/// Operator `c` drawn like the operator atom `like` (its face, size and colour), without
|
||||
/// caret slots.
|
||||
fn op_glyph(&self, like: &Bx, c: char, st: St) -> Option<Bx> {
|
||||
let MItem::Glyphs { face, size, color, synth_bold, synth_italic, .. } = like.items.iter().find(|i| matches!(i, MItem::Glyphs { .. }))? else {
|
||||
return None;
|
||||
};
|
||||
let face = if face.covers(c) { *face } else { self.face };
|
||||
let mut b = self.glyphs(face, &c.to_string(), *size, st.level, *color, (*synth_bold, *synth_italic));
|
||||
b.cuts.clear();
|
||||
Some(b.cls(like.first))
|
||||
}
|
||||
|
||||
fn matrix(&mut self, rows: &[Vec<Arg>], col_jc: &[ColJc], st: St, d: usize) -> Bx {
|
||||
let em = self.em(st);
|
||||
let mut cells: Vec<Vec<Bx>> = Vec::new();
|
||||
@@ -1640,6 +1842,18 @@ impl Ctx {
|
||||
}
|
||||
}
|
||||
|
||||
/// The character an operator atom draws.
|
||||
fn op_char(b: &Bx) -> Option<char> {
|
||||
b.items.iter().find_map(|i| match i {
|
||||
MItem::Glyphs { text, .. } => {
|
||||
let mut cs = text.chars();
|
||||
let c = cs.next()?;
|
||||
cs.next().is_none().then_some(c)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
enum RunPart {
|
||||
Atom(Bx),
|
||||
/// An `&` alignment mark (a zero-width atom holding its caret slots).
|
||||
@@ -1661,6 +1875,22 @@ fn finalize(b: &mut Bx, path: &[(usize, usize)], em: f32) {
|
||||
/// relation, an opening bracket or punctuation is ordinary, as is one before a relation,
|
||||
/// closing bracket or punctuation).
|
||||
fn gaps_between(atoms: &[Bx], st: St, em: f32) -> Vec<f32> {
|
||||
let cls = classes(atoms);
|
||||
let solid: Vec<usize> = (0..atoms.len()).filter(|i| atoms.get(*i).is_some_and(|b| !b.transparent)).collect();
|
||||
let mut gaps = vec![0.0f32; atoms.len()];
|
||||
for k in 1..solid.len() {
|
||||
let (Some(&p), Some(&i)) = (solid.get(k - 1), solid.get(k)) else { continue };
|
||||
let (Some(l), Some(r)) = (cls.get(p), cls.get(i)) else { continue };
|
||||
if let Some(g) = gaps.get_mut(i) {
|
||||
*g = spacing_mu(l.1, r.0, st) * em / 18.0;
|
||||
}
|
||||
}
|
||||
gaps
|
||||
}
|
||||
|
||||
/// Each atom's classes (first, last) in context: a binary operator where TeX makes it ordinary
|
||||
/// (a unary minus) becomes ordinary.
|
||||
fn classes(atoms: &[Bx]) -> Vec<(Cls, Cls)> {
|
||||
let mut cls: Vec<(Cls, Cls)> = atoms.iter().map(|b| (b.first, b.last)).collect();
|
||||
let solid: Vec<usize> = (0..atoms.len()).filter(|i| atoms.get(*i).is_some_and(|b| !b.transparent)).collect();
|
||||
for (k, &i) in solid.iter().enumerate() {
|
||||
@@ -1681,15 +1911,7 @@ fn gaps_between(atoms: &[Bx], st: St, em: f32) -> Vec<f32> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut gaps = vec![0.0f32; atoms.len()];
|
||||
for k in 1..solid.len() {
|
||||
let (Some(&p), Some(&i)) = (solid.get(k - 1), solid.get(k)) else { continue };
|
||||
let (Some(l), Some(r)) = (cls.get(p), cls.get(i)) else { continue };
|
||||
if let Some(g) = gaps.get_mut(i) {
|
||||
*g = spacing_mu(l.1, r.0, st) * em / 18.0;
|
||||
}
|
||||
}
|
||||
gaps
|
||||
cls
|
||||
}
|
||||
|
||||
/// Atoms side by side with inter-atom spacing.
|
||||
@@ -1745,7 +1967,96 @@ mod tests {
|
||||
}
|
||||
|
||||
fn lay(lin: &str, display: bool) -> MathLayout {
|
||||
layout_equation(&Math::default(), lin, &rc(), display, 400.0, &mut 0)
|
||||
layout_equation(&Math::default(), lin, &rc(), display, 400.0, &MathProps::default(), &mut 0)
|
||||
}
|
||||
|
||||
/// The glyphs of a laid-out equation by line: (baseline, [(x, text)]), top line first.
|
||||
fn lines(m: &MathLayout) -> Vec<(f32, Vec<(f32, String)>)> {
|
||||
let mut out: Vec<(f32, Vec<(f32, String)>)> = Vec::new();
|
||||
for it in &m.items {
|
||||
if let MItem::Glyphs { glyphs, text, .. } = it
|
||||
&& let Some(&(_, x, y)) = glyphs.first()
|
||||
{
|
||||
match out.iter_mut().find(|l| (l.0 - y).abs() < 0.5) {
|
||||
Some(l) => l.1.push((x, text.clone())),
|
||||
None => out.push((y, vec![(x, text.clone())])),
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
for l in &mut out {
|
||||
l.1.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) -> MathLayout {
|
||||
layout_equation(math, lin, &rc(), display, avail, mp, &mut 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_display_equations_wrap_before_operators_and_indent() {
|
||||
let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w";
|
||||
let mp = MathProps::default();
|
||||
let full = lay_in(&Math::default(), sum, true, 1e4, &mp);
|
||||
assert_eq!(lines(&full).len(), 1, "fits on one line when there is room");
|
||||
let avail = full.width * 0.45;
|
||||
assert!(avail > 2.0 * mp.wrap_indent, "room for the indent: {avail}");
|
||||
let m = lay_in(&Math::default(), sum, true, avail, &mp);
|
||||
let ls = lines(&m);
|
||||
assert!(ls.len() >= 2, "{ls:?}");
|
||||
assert!((m.width - avail).abs() < 0.01, "the wrapped equation fills the line: {} vs {avail}", m.width);
|
||||
for (k, (_, gs)) in ls.iter().enumerate() {
|
||||
let (x0, first) = gs.first().cloned().unwrap();
|
||||
if k == 0 {
|
||||
// Math italic 𝑦 when a font has it; plain y where none does (FreeBSD CI has no fonts).
|
||||
assert!(x0.abs() < 0.01 && (first == "𝑦" || first == "y"), "{gs:?}");
|
||||
} else {
|
||||
// Continuation lines start with the operator, one wrap indent in.
|
||||
assert!((x0 - mp.wrap_indent).abs() < 0.01 && first == "+", "line {k}: {gs:?}");
|
||||
}
|
||||
for (x, t) in gs {
|
||||
assert!(*x < avail, "line {k}: {t} at {x} past {avail}");
|
||||
}
|
||||
}
|
||||
// Breaks after the operator, and the operator repeated, as the settings say.
|
||||
let after = MathProps { brk_bin: BrkBin::After, ..Default::default() };
|
||||
let ls = lines(&lay_in(&Math::default(), sum, true, avail, &after));
|
||||
assert!(ls.len() >= 2 && ls[0].1.last().is_some_and(|g| g.1 == "+") && ls[1].1[0].1 != "+", "{ls:?}");
|
||||
let repeat = MathProps { brk_bin: BrkBin::Repeat, wrap_right: true, ..Default::default() };
|
||||
let m = lay_in(&Math::default(), sum, true, avail, &repeat);
|
||||
let ls = lines(&m);
|
||||
assert!(ls.len() >= 2 && ls[0].1.last().is_some_and(|g| g.1 == "+") && ls[1].1[0].1 == "+", "{ls:?}");
|
||||
// Inline equations never wrap.
|
||||
assert_eq!(lines(&lay_in(&Math::default(), sum, false, avail, &mp)).len(), 1);
|
||||
// A term wider than the line overflows it rather than breaking inside.
|
||||
let frac = lay_in(&Math::default(), "y=(a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w)/2", true, avail, &mp);
|
||||
assert!(frac.width > avail && frac.items.iter().any(|i| matches!(i, MItem::Rect { .. })), "{}", frac.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_breaks_start_new_lines() {
|
||||
let mp = MathProps::default();
|
||||
let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })];
|
||||
let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp);
|
||||
let ls = lines(&m);
|
||||
assert_eq!(ls.len(), 2, "{ls:?}");
|
||||
assert!((ls[1].1[0].0 - mp.wrap_indent).abs() < 0.01 && ls[1].1[0].1 == "+", "{ls:?}");
|
||||
assert!(m.width < 400.0, "a manually broken equation is as wide as its lines");
|
||||
// `m:alnAt`: the new line's operator sits under the first line's first operator.
|
||||
let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(1), ..MRun::new("+c") })];
|
||||
let ls = lines(&lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp));
|
||||
let eq_x = ls[0].1.iter().find(|g| g.1 == "=").unwrap().0;
|
||||
assert!((ls[1].1[0].0 - eq_x).abs() < 0.01, "{ls:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_display_equations_stay_on_one_line() {
|
||||
let mp = MathProps::default();
|
||||
let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp);
|
||||
assert_eq!(lines(&one).len(), 1);
|
||||
let plain = lay_in(&Math::default(), "a+b=c", false, 400.0, &mp);
|
||||
assert!(one.width < 400.0 && (one.width - plain.width).abs() < 0.01, "{} vs {}", one.width, plain.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1823,7 +2134,7 @@ mod tests {
|
||||
MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }),
|
||||
];
|
||||
for n in nodes {
|
||||
let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &mut 0);
|
||||
let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
assert!(m.width > 0.0 && m.ascent + m.descent > 0.0, "{n:?} → {m:?}");
|
||||
for it in &m.items {
|
||||
if let MItem::Glyphs { glyphs, .. } = it {
|
||||
@@ -1837,7 +2148,7 @@ mod tests {
|
||||
fn caret_slots_cover_every_position() {
|
||||
use wordcraft_doc::math_edit::{MathPos, move_right, units};
|
||||
let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() };
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0);
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
// Walking the caret through the equation, every position has a slot.
|
||||
let mut p = MathPos::default();
|
||||
let mut seen = 0;
|
||||
@@ -1861,17 +2172,17 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_slots_show_placeholders_on_screen_only() {
|
||||
let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() };
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0);
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items);
|
||||
assert!(m.slots.iter().any(|s| s.path == vec![(0, 0)] && s.off == 0));
|
||||
let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &mut 0);
|
||||
let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
assert!(empty.width > 10.0 && empty.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_equations_fill_the_line_with_the_number_at_the_right() {
|
||||
let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() };
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0);
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
assert!((m.width - 400.0).abs() < 0.01, "{}", m.width);
|
||||
let xs: Vec<f32> =
|
||||
m.items.iter().filter_map(|i| if let MItem::Glyphs { glyphs, .. } = i { glyphs.first().map(|g| g.1) } else { None }).collect();
|
||||
@@ -1882,7 +2193,7 @@ mod tests {
|
||||
// Automatic numbers count.
|
||||
let auto = Math { nodes: wordcraft_doc::math::parse_linear("a=b#"), ..Default::default() };
|
||||
let mut n = 4;
|
||||
let _ = layout_equation(&auto, "", &rc(), true, 400.0, &mut n);
|
||||
let _ = layout_equation(&auto, "", &rc(), true, 400.0, &MathProps::default(), &mut n);
|
||||
assert_eq!(n, 5);
|
||||
assert_eq!(auto_numbers(&auto, true), 1);
|
||||
}
|
||||
@@ -1905,7 +2216,7 @@ mod tests {
|
||||
for _ in 0..500 {
|
||||
n = vec![MNode::Frac { kind: FracKind::Bar, num: n.clone(), den: Vec::new() }];
|
||||
}
|
||||
let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &mut 0);
|
||||
let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
// Empty everything.
|
||||
let empty = vec![
|
||||
MNode::Nary { chr: '\0', lim_loc: None, grow: true, sub_hide: false, sup_hide: false, sub: vec![], sup: vec![], e: vec![] },
|
||||
@@ -1914,7 +2225,16 @@ mod tests {
|
||||
MNode::EqArr { rows: vec![] },
|
||||
MNode::Run(MRun { text: String::new(), size: Some(f32::NAN), ..Default::default() }),
|
||||
];
|
||||
let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &mut 0);
|
||||
let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &MathProps::default(), &mut 0);
|
||||
assert!(m.width.is_finite() && m.ascent.is_finite());
|
||||
// Breaks everywhere, odd wrap settings, no room at all.
|
||||
let runs: Vec<MNode> = (0..2000).map(|i| MNode::Run(MRun { brk: (i % 3 == 0).then_some((i % 256) as u8), ..MRun::new("−+=") })).collect();
|
||||
for avail in [0.0, f32::NAN, 1e9] {
|
||||
for brk_bin in [BrkBin::Before, BrkBin::After, BrkBin::Repeat] {
|
||||
let mp = MathProps { brk_bin, brk_bin_sub: BrkBinSub::PlusMinus, wrap_indent: f32::INFINITY, wrap_right: avail == 0.0 };
|
||||
let m = layout_equation(&Math { nodes: runs.clone(), ..Default::default() }, "", &rc(), true, avail, &mp, &mut 0);
|
||||
assert!(m.width.is_finite() && m.descent.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-11
@@ -640,6 +640,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let mut maths = Vec::new();
|
||||
let mut displays = Vec::new();
|
||||
let mut eq_counter = env.eq_number;
|
||||
let math_props = doc.settings.math.clone().unwrap_or_default();
|
||||
// The byte ranges of runs left out of the layout, in order, adjacent runs merged.
|
||||
let mut left: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
for (range, props) in p.run_ranges() {
|
||||
@@ -732,10 +733,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let k = obj_index;
|
||||
obj_index += 1;
|
||||
match p.objects.get(k) {
|
||||
Some(InlineObject::Image { w, h, float, .. })
|
||||
| Some(InlineObject::Graphic { w, h, float, .. })
|
||||
| Some(InlineObject::Shape { w, h, float, .. }) => {
|
||||
if float.wrap == wordcraft_doc::para::Wrap::Inline {
|
||||
Some(o) if o.is_drawing() => {
|
||||
if let Some((w, h, float)) = o.frame()
|
||||
&& float.wrap == wordcraft_doc::para::Wrap::Inline
|
||||
{
|
||||
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0);
|
||||
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
|
||||
let s = if w > maxw { maxw / w } else { 1.0 };
|
||||
@@ -768,7 +769,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
}
|
||||
Some(InlineObject::Equation { linear, display, math }) => {
|
||||
let avail = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(12.0);
|
||||
let ml = crate::math::layout_equation(math, linear, &rc, *display, avail, &mut eq_counter);
|
||||
let ml = crate::math::layout_equation(math, linear, &rc, *display, avail, &math_props, &mut eq_counter);
|
||||
let st = b.styles.get(si as usize);
|
||||
// The line is at least as tall as the text around it.
|
||||
let (a, d) = st.map(|s| (s.ascent, s.descent)).unwrap_or((0.0, 0.0));
|
||||
@@ -824,19 +825,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let to_para = |i: usize| -> Option<usize> {
|
||||
if left.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) }
|
||||
};
|
||||
let mut opps = std::collections::HashSet::new();
|
||||
let mut found = Vec::new();
|
||||
for (i, o) in unicode_linebreak::linebreaks(&text) {
|
||||
// Word keeps "and/or" and web addresses whole: no break right after a slash (a word
|
||||
// too long for the line still breaks anywhere).
|
||||
let after_slash =
|
||||
text.get(..i).is_some_and(|t| t.ends_with('/')) && text.get(i..).and_then(|t| t.chars().next()).is_some_and(char::is_alphanumeric);
|
||||
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len())
|
||||
&& !after_slash
|
||||
&& let Some(end) = to_para(i)
|
||||
{
|
||||
opps.insert(end);
|
||||
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < text.len()) && !after_slash {
|
||||
found.push(i);
|
||||
}
|
||||
}
|
||||
// Asian typography: kinsoku and breaking Latin words anywhere.
|
||||
crate::kinsoku::apply(&text, &mut found, rp.kinsoku, rp.word_wrap);
|
||||
let opps: std::collections::HashSet<usize> = found.into_iter().filter_map(to_para).collect();
|
||||
for c in &mut b.clusters {
|
||||
c.break_after = opps.contains(&c.end) || matches!(c.kind, ClKind::Object(_) | ClKind::Tab);
|
||||
}
|
||||
|
||||
+97
-38
@@ -4,7 +4,7 @@
|
||||
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
|
||||
use wordcraft_doc::styles::TableStyleProps;
|
||||
use wordcraft_doc::styles::{TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Blocks, Paragraph, StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
@@ -40,8 +40,9 @@ struct Turned {
|
||||
across: f32,
|
||||
/// List counters and equation count before the cell, to lay it out again the same way.
|
||||
snap: (Counters, u32),
|
||||
/// Its formatting region (header row, total row, first column, row band) and cell index in the row.
|
||||
region: (bool, bool, bool, bool),
|
||||
/// Its formatting region (header row, total row, first column, last column, row band, column
|
||||
/// band) and cell index in the row.
|
||||
region: Region,
|
||||
ci: usize,
|
||||
/// Top margin plus top border band.
|
||||
top: f32,
|
||||
@@ -143,27 +144,20 @@ fn measure_para(p: &wordcraft_doc::Paragraph, env: &crate::para::ParaEnv) -> (f3
|
||||
pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f32, f32)> {
|
||||
use wordcraft_doc::Block;
|
||||
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
|
||||
let parts = style.as_ref().map(|s| &s.parts);
|
||||
let margins_def = default_margins(t, style.as_ref());
|
||||
let ncols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS);
|
||||
let fields = crate::FieldCtx::default();
|
||||
let header_rows = t.props.look.header_row;
|
||||
let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize;
|
||||
let nrows = t.rows.len();
|
||||
let mut cols = vec![(0.0f32, 0.0f32); ncols];
|
||||
// Cells spanning several columns: (first column, span, min, max), applied after the others.
|
||||
let mut spanned: Vec<(usize, usize, f32, f32)> = Vec::new();
|
||||
let mut budget = MEASURE_BUDGET;
|
||||
'rows: for (ri, row) in t.rows.iter().enumerate() {
|
||||
let is_header = header_rows && ri == 0;
|
||||
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2);
|
||||
let mut g = 0usize;
|
||||
for cell in &row.cells {
|
||||
let span = cell.span();
|
||||
let margins = cell.props.margins.unwrap_or(margins_def);
|
||||
let side = margins[1].max(0.0) + margins[3].max(0.0);
|
||||
let text = style.as_ref().map(|st| region_text(st, (is_header, is_total, t.props.look.first_column && g == 0, band)));
|
||||
let text = style.as_ref().map(|st| region_text(st, region_of(t, style.as_ref(), ri, g, span, ncols)));
|
||||
let env = crate::para::ParaEnv {
|
||||
doc,
|
||||
width: MEASURE_WIDE,
|
||||
@@ -261,6 +255,48 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
|
||||
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
|
||||
}
|
||||
|
||||
/// A cell's borders from the table style's conditional regions it is in (`regions`: header row,
|
||||
/// total row, first column, last column, row band, column band), later regions winning. Row
|
||||
/// regions give their left/right edges at the table's sides and `inside_v` between cells; column
|
||||
/// regions their top/bottom edges at the table's top and bottom and `between` between rows.
|
||||
fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders {
|
||||
let [header, total, first_col, last_col, band, col_band] = regions;
|
||||
let row = |b: Option<Borders>| {
|
||||
b.map(|b| Borders {
|
||||
top: b.top,
|
||||
bottom: b.bottom,
|
||||
left: if first_g { b.left } else { b.inside_v },
|
||||
right: if last_g { b.right } else { b.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
})
|
||||
};
|
||||
let col = |b: Option<Borders>| {
|
||||
b.map(|b| Borders {
|
||||
top: if ri == 0 { b.top } else { b.between },
|
||||
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
|
||||
left: b.left,
|
||||
right: b.right,
|
||||
between: None,
|
||||
inside_v: None,
|
||||
})
|
||||
};
|
||||
let mut out = Borders::default();
|
||||
for (on, b) in [
|
||||
(col_band, col(p.col_band_borders)),
|
||||
(band, row(p.band_borders)),
|
||||
(first_col, col(p.first_col_borders)),
|
||||
(last_col, col(p.last_col_borders)),
|
||||
(header, row(p.header_borders)),
|
||||
(total, row(p.total_borders)),
|
||||
] {
|
||||
if on && let Some(b) = b {
|
||||
out.overlay(&b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout {
|
||||
let style = table_style(ctx, t);
|
||||
let parts = style.as_ref().map(|s| &s.parts);
|
||||
@@ -362,15 +398,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let is_header = header_rows && ri == 0;
|
||||
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let band = banded(t, style.as_ref(), ri);
|
||||
// The header row's or the odd band's own cell borders (conditional formatting).
|
||||
let region_borders = if is_header {
|
||||
parts.and_then(|p| p.header_borders)
|
||||
} else if band {
|
||||
parts.and_then(|p| p.band_borders)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.unwrap_or_default();
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
let span = cell.span();
|
||||
let x0 = colx.get(g).copied().unwrap_or(acc);
|
||||
@@ -378,15 +405,31 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let margins = cell.props.margins.unwrap_or(margins_def);
|
||||
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
|
||||
let mut fill = cell.props.shading;
|
||||
let region = region_of(t, style.as_ref(), ri, g);
|
||||
let region = region_of(t, style.as_ref(), ri, g, span, ncols);
|
||||
let (_, _, first_col, last_col, _, col_band) = region;
|
||||
// Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table,
|
||||
// column bands, row bands, first/last column, header/total row.
|
||||
if let Some(p) = parts {
|
||||
if is_header {
|
||||
fill = fill.or(p.header_fill);
|
||||
}
|
||||
if band && fill.is_none() {
|
||||
fill = p.band_fill;
|
||||
}
|
||||
fill = fill.or(p.fill);
|
||||
let row_fill = if is_header {
|
||||
p.header_fill
|
||||
} else if is_total {
|
||||
p.total_fill
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let col_fill = if first_col {
|
||||
p.first_col_fill
|
||||
} else if last_col {
|
||||
p.last_col_fill
|
||||
} else {
|
||||
None
|
||||
};
|
||||
fill = fill
|
||||
.or(row_fill)
|
||||
.or(col_fill)
|
||||
.or(if band { p.band_fill } else { None })
|
||||
.or(if col_band { p.col_band_fill } else { None })
|
||||
.or(p.fill);
|
||||
}
|
||||
if let Some(st) = &style
|
||||
&& !cell_text.iter().any(|(r, _)| *r == region)
|
||||
@@ -402,8 +445,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let edge =
|
||||
|own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
|
||||
let cb = cell.props.borders.unwrap_or_default();
|
||||
let rb = region_borders;
|
||||
let (first_g, last_g) = (g == 0, g + span >= ncols);
|
||||
let rb = parts
|
||||
.map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band]))
|
||||
.unwrap_or_default();
|
||||
let mut borders = Borders {
|
||||
top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between),
|
||||
bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between),
|
||||
@@ -412,7 +457,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
if is_total && let Some(b) = parts.and_then(|p| p.total_border_top) {
|
||||
if is_total
|
||||
&& parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none()))
|
||||
&& let Some(b) = parts.and_then(|p| p.total_border_top)
|
||||
{
|
||||
borders.top = Some(b);
|
||||
}
|
||||
// Word keeps the cell's text clear of its top border (and the last row's of its bottom
|
||||
@@ -763,12 +811,16 @@ fn split_rect(r: Rect, cut: f32, shift: f32) -> (Option<Rect>, Option<Rect>) {
|
||||
}
|
||||
|
||||
/// A cell's region for table-style conditional formatting: (header row, total row, first column,
|
||||
/// banded row).
|
||||
type Region = (bool, bool, bool, bool);
|
||||
/// last column, banded row, banded column).
|
||||
type Region = (bool, bool, bool, bool, bool, bool);
|
||||
|
||||
fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize) -> Region {
|
||||
/// The region of the cell at grid column `g` spanning `span` of `ncols` columns in row `ri`.
|
||||
fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize, span: usize, ncols: usize) -> Region {
|
||||
let (look, nrows) = (&t.props.look, t.rows.len());
|
||||
(look.header_row && ri == 0, look.total_row && ri + 1 == nrows && nrows > 1, look.first_column && g == 0, banded(t, style, ri))
|
||||
let first_col = look.first_column && g == 0;
|
||||
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
|
||||
let col_band = look.banded_columns && !first_col && g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
(look.header_row && ri == 0, look.total_row && ri + 1 == nrows && nrows > 1, first_col, last_col, banded(t, style, ri), col_band)
|
||||
}
|
||||
|
||||
/// Whether row `ri` is in an odd band of the table style's banded rows (bands of the style's
|
||||
@@ -779,16 +831,23 @@ fn banded(t: &Table, style: Option<&TableStyleProps>, ri: usize) -> bool {
|
||||
look.banded_rows && !(look.header_row && ri == 0) && (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2)
|
||||
}
|
||||
|
||||
/// The table style's text formatting in `region`: the whole table's, then the band's, the
|
||||
/// column's and the row's (later regions win, ECMA-376 §17.7.6).
|
||||
fn region_text(st: &TableStyleProps, (header, total, first_col, band): Region) -> CellText {
|
||||
/// The table style's text formatting in `region`: the whole table's, then the column band's, the
|
||||
/// row band's, the first/last column's and the header/total row's (later regions win,
|
||||
/// ECMA-376 §17.7.6).
|
||||
fn region_text(st: &TableStyleProps, (header, total, first_col, last_col, band, col_band): Region) -> CellText {
|
||||
let mut chr = st.chr.clone();
|
||||
if col_band {
|
||||
chr.overlay(&st.parts.col_band_chr);
|
||||
}
|
||||
if band {
|
||||
chr.overlay(&st.parts.band_chr);
|
||||
}
|
||||
if first_col {
|
||||
chr.overlay(&st.parts.first_col_chr);
|
||||
}
|
||||
if last_col {
|
||||
chr.overlay(&st.parts.last_col_chr);
|
||||
}
|
||||
if header {
|
||||
chr.overlay(&st.parts.header_chr);
|
||||
} else if total {
|
||||
@@ -810,7 +869,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
for cell in &row.cells {
|
||||
let span = cell.span();
|
||||
if cell.props.vmerge != VMerge::Continue {
|
||||
let text = style.map(|st| region_text(st, region_of(t, style, ri, g)));
|
||||
let text = style.map(|st| region_text(st, region_of(t, style, ri, g, cell.span(), cols.len())));
|
||||
let (lo, hi) = blocks_content(ctx, &cell.blocks, text.as_ref(), depth);
|
||||
any |= hi > 0.0;
|
||||
let m = cell.props.margins.unwrap_or(margins_def);
|
||||
|
||||
@@ -231,6 +231,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let obj = wordcraft_doc::para::OBJ.to_string();
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
|
||||
@@ -1046,6 +1047,7 @@ fn text_wraps_around_square_float() {
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
@@ -1083,6 +1085,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() {
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let obj = wordcraft_doc::para::OBJ.len_utf8();
|
||||
@@ -1134,6 +1137,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() {
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let obj = wordcraft_doc::para::OBJ.len_utf8();
|
||||
@@ -1170,6 +1174,7 @@ fn line_numbers_borders_text_boxes() {
|
||||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
@@ -1463,6 +1468,7 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w
|
||||
stroke_width: 0.75,
|
||||
float,
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(pos, shape, &Default::default()).unwrap();
|
||||
id
|
||||
@@ -1533,6 +1539,7 @@ fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() {
|
||||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
@@ -1752,6 +1759,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document {
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
let next = *ids.get(k + 1).unwrap_or(id);
|
||||
@@ -1815,7 +1823,17 @@ fn a_text_box_inside_a_text_box_still_shows_its_text() {
|
||||
}
|
||||
|
||||
fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
|
||||
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
|
||||
InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w,
|
||||
h,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1953,7 +1971,17 @@ fn lines_with_tabs_never_shrink() {
|
||||
}
|
||||
|
||||
fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
|
||||
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
|
||||
InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w,
|
||||
h,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shapes(items: &[Placed]) -> Vec<Rect> {
|
||||
@@ -2121,6 +2149,7 @@ fn floating_header_text_box_leaves_the_body_at_the_top_margin() {
|
||||
stroke_width: 0.75,
|
||||
float,
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
anchor.insert_object(0, tb, &Default::default()).unwrap();
|
||||
}
|
||||
@@ -2680,3 +2709,25 @@ fn a_split_row_moves_shading_below_the_cut_with_it() {
|
||||
let nested = moved.get(1).copied().unwrap_or_default();
|
||||
assert!(nested.0 > 0.0 && (nested.1 - 50.0).abs() < 0.01, "the nested shading keeps its place and size: {moved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinsoku_keeps_a_manual_line_break_before_a_closing_bracket() {
|
||||
// #269: kinsoku forbids a line starting with 」, but a manual line break (Shift+Enter) right
|
||||
// before one still ends the line there.
|
||||
let text = "日本語\n」テスト";
|
||||
let mut d = Document::from_text("");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
let l = lay(&d);
|
||||
let mut lines = Vec::new();
|
||||
for p in &l.pages {
|
||||
for it in &p.items {
|
||||
if let Placed::Lines { para, l0, l1, .. } = it {
|
||||
lines.extend(para.lines[*l0..*l1].iter().map(|line| (line.start, line.end)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let bracket = text.find('」').unwrap();
|
||||
assert_eq!(lines.len(), 2, "{lines:?}");
|
||||
assert_eq!(lines[0].1, LineEnd::LineBreak, "{lines:?}");
|
||||
assert_eq!(lines[1].0, bracket, "the second line starts at the bracket: {lines:?}");
|
||||
}
|
||||
|
||||
+58
-1
@@ -321,6 +321,46 @@ fn rect_path(r: &Rect) -> Option<Path> {
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// `r` grown by `g` points on every side and moved by (`dx`, `dy`).
|
||||
fn grown(r: &Rect, g: f32, dx: f32, dy: f32) -> Rect {
|
||||
Rect { x: r.x - g + dx, y: r.y - g + dy, w: r.w + 2.0 * g, h: r.h + 2.0 * g }
|
||||
}
|
||||
|
||||
/// A shape's shadow and glow, behind it: bands of its silhouette (the same approximation of a
|
||||
/// blur as the raster renderer, see [`wordcraft_doc::effects::bands`]).
|
||||
fn shape_effects(s: &mut Surface, kind: ShapeKind, rect: &Rect, fx: &wordcraft_doc::effects::ShapeEffects, filled: bool, stroked: bool, sw: f32) {
|
||||
let sw = if sw.is_finite() { sw.clamp(0.25, 200.0) } else { 0.75 };
|
||||
let silhouette = |s: &mut Surface, c: Rgb, a: f32, g: f32, dx: f32, dy: f32| {
|
||||
if filled {
|
||||
let grow = g + if stroked { sw / 2.0 } else { 0.0 };
|
||||
if let Some(p) = shape_path(kind, &grown(rect, grow, dx, dy)) {
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(fill(c, a)));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
} else if sw + 2.0 * g > 0.05
|
||||
&& let Some(p) = shape_path(kind, &grown(rect, 0.0, dx, dy))
|
||||
{
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke { paint: rgb::Color::new(c.0, c.1, c.2).into(), width: sw + 2.0 * g, opacity: norm(a), ..Default::default() }));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
};
|
||||
if let Some(sh) = fx.shadow {
|
||||
let (dx, dy) = sh.offset();
|
||||
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
|
||||
silhouette(s, sh.color, a, g, dx, dy);
|
||||
}
|
||||
}
|
||||
if let Some(gl) = fx.glow {
|
||||
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
|
||||
silhouette(s, gl.color, a, g, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
/// Outline of a basic shape in a rectangle (same geometry as the raster renderer).
|
||||
fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
|
||||
if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || r.w <= 0.0 || r.h <= 0.0 {
|
||||
@@ -704,9 +744,26 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width } => {
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects } => {
|
||||
let Some(p) = shape_path(*kind, rect) else { return };
|
||||
let can_fill = *kind != ShapeKind::Line;
|
||||
let fx = effects.sanitized();
|
||||
if !fx.is_empty() && (f.is_some() && can_fill || stroke.is_some()) {
|
||||
shape_effects(s, *kind, rect, &fx, f.is_some() && can_fill, stroke.is_some(), *stroke_width);
|
||||
}
|
||||
// Soft edges: the fill fades out toward the outline (which fades with it).
|
||||
if let (Some(rad), Some(c), true) = (fx.soft_edge, f, can_fill) {
|
||||
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
s.set_stroke(None);
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0)) {
|
||||
s.set_fill(Some(fill(*c, a)));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
return;
|
||||
}
|
||||
s.set_fill(f.filter(|_| can_fill).map(|c| fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke {
|
||||
paint: rgb::Color::new(c.0, c.1, c.2).into(),
|
||||
|
||||
+100
-1
@@ -639,10 +639,54 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width } => {
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
|
||||
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);
|
||||
ctx.set_transform(view);
|
||||
let fx = effects.sanitized();
|
||||
let can_fill = *kind != ShapeKind::Line && fill.is_some();
|
||||
if !fx.is_empty() && (can_fill || stroke.is_some()) {
|
||||
let sw = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) as f64 } else { 0.75 };
|
||||
// The shape's silhouette grown by `g` points and moved by `d`, in the current paint.
|
||||
let silhouette = |ctx: &mut RenderContext, g: f32, d: kurbo::Vec2| {
|
||||
let g = g as f64;
|
||||
if can_fill {
|
||||
let grow = g + if stroke.is_some() { sw / 2.0 } else { 0.0 };
|
||||
let rr = r.inflate(grow, grow) + d;
|
||||
if rr.width() > 0.0 && rr.height() > 0.0 {
|
||||
ctx.fill_path(&shape_path(*kind, rr));
|
||||
}
|
||||
} else if sw + 2.0 * g > 0.05 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(sw + 2.0 * g));
|
||||
ctx.stroke_path(&shape_path(*kind, r + d));
|
||||
}
|
||||
};
|
||||
if let Some(sh) = fx.shadow {
|
||||
let (dx, dy) = sh.offset();
|
||||
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
|
||||
ctx.set_paint(color(opts.ink(sh.color), a));
|
||||
silhouette(ctx, g, kurbo::Vec2::new(dx as f64, dy as f64));
|
||||
}
|
||||
}
|
||||
if let Some(gl) = fx.glow {
|
||||
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
|
||||
ctx.set_paint(color(opts.ink(gl.color), a));
|
||||
silhouette(ctx, g, kurbo::Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Soft edges: the fill fades out toward the outline (which fades with it).
|
||||
if let (Some(rad), Some(f), true) = (fx.soft_edge, fill, can_fill) {
|
||||
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
let rr = r.inflate(g as f64, g as f64);
|
||||
if rr.width() > 0.0 && rr.height() > 0.0 {
|
||||
ctx.set_paint(color(opts.ink(*f), a));
|
||||
ctx.fill_path(&shape_path(*kind, rr));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
ctx.set_paint(color(opts.ink(*f), 1.0));
|
||||
ctx.fill_path(&path);
|
||||
@@ -774,6 +818,61 @@ mod tests {
|
||||
assert!(ink(TextDirection::Up) > 30, "up");
|
||||
}
|
||||
|
||||
/// Shape Effects (#275): an outer shadow paints dark pixels beside the shape on the side it is
|
||||
/// cast to, and none on the other; a glow surrounds it.
|
||||
#[test]
|
||||
fn shape_shadow_is_cast_offset_from_the_shape() {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
let draw = |effects: ShapeEffects| {
|
||||
let mut d = Document::from_text("");
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 100.0,
|
||||
h: 60.0,
|
||||
fill: Some(Rgb(0, 0, 255)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
effects,
|
||||
};
|
||||
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 };
|
||||
d.insert_object(&at, shape, &Default::default()).unwrap();
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
render_page(&d, &l.pages[0], 1.0, &RenderOptions::default())
|
||||
};
|
||||
let plain = draw(ShapeEffects::default());
|
||||
let blue = |img: &Rendered| {
|
||||
let px: Vec<(u32, u32)> = (0..612)
|
||||
.flat_map(|x| (0..400).map(move |y| (x, y)))
|
||||
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, _, b, _] if r < 40 && b > 200))
|
||||
.collect();
|
||||
let (x0, x1) = (px.iter().map(|p| p.0).min().unwrap(), px.iter().map(|p| p.0).max().unwrap());
|
||||
let (y0, y1) = (px.iter().map(|p| p.1).min().unwrap(), px.iter().map(|p| p.1).max().unwrap());
|
||||
(x0, y0, x1, y1)
|
||||
};
|
||||
let (x0, y0, x1, y1) = blue(&plain);
|
||||
assert!(x1 - x0 > 90 && y1 - y0 > 50, "{:?}", (x0, y0, x1, y1));
|
||||
// Grey (not white, not blue) pixels in a strip just outside an edge.
|
||||
let grey = |img: &Rendered, xs: std::ops::Range<u32>, ys: std::ops::Range<u32>| {
|
||||
xs.flat_map(|x| ys.clone().map(move |y| (x, y)))
|
||||
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, g, b, _] if r < 200 && r == g && g == b))
|
||||
.count()
|
||||
};
|
||||
let right = |img: &Rendered| grey(img, x1 + 2..x1 + 6, y0 + 10..y1);
|
||||
let left = |img: &Rendered| grey(img, x0.saturating_sub(6)..x0.saturating_sub(2), y0 + 10..y1);
|
||||
assert_eq!((right(&plain), left(&plain)), (0, 0));
|
||||
let shadow = Shadow { color: Rgb::BLACK, transparency: 30.0, blur: 2.0, distance: 8.0, angle: 0.0 };
|
||||
let img = draw(ShapeEffects { shadow: Some(shadow), ..Default::default() });
|
||||
assert_eq!(blue(&img), (x0, y0, x1, y1), "the shape itself is unchanged");
|
||||
assert!(right(&img) > 100, "shadow to the right: {}", right(&img));
|
||||
assert_eq!(left(&img), 0, "nothing on the left");
|
||||
let img = draw(ShapeEffects { glow: Some(Glow { color: Rgb(255, 0, 0), size: 6.0, transparency: 0.0 }), ..Default::default() });
|
||||
let reddish = |x: u32, y: u32| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 200);
|
||||
assert!(reddish(x1 + 2, (y0 + y1) / 2) && reddish(x0 - 2, (y0 + y1) / 2), "glow on both sides");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stem_darkening_follows_the_macos_curve() {
|
||||
assert_eq!(stem_darkening(0.0), 0.0);
|
||||
|
||||
@@ -300,6 +300,24 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
if focused != app.info_editing.map(|(f, _)| f) {
|
||||
app.info_editing = None;
|
||||
}
|
||||
// Protect Document › Encrypt with Password (#55).
|
||||
ui.add_space(18.0);
|
||||
ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0)));
|
||||
let encrypted = info.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
ui.label(if encrypted {
|
||||
tl!("A password is required to open this document.")
|
||||
} else {
|
||||
tl!("Anyone can open this document. Encrypt it with a password to keep it private.")
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let label = if encrypted { tl!("Change Password…") } else { tl!("Encrypt with Password…") };
|
||||
if ui.button(egui::RichText::new(label).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({}));
|
||||
}
|
||||
if encrypted && ui.button(egui::RichText::new(tl!("Remove Password")).font(medium(13.5))).clicked() {
|
||||
let _ = app.run("file.encrypt", json!({"password": null}));
|
||||
}
|
||||
});
|
||||
let ui = &mut cols[1];
|
||||
ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0)));
|
||||
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] {
|
||||
|
||||
+563
-76
@@ -1,13 +1,16 @@
|
||||
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Table Properties,
|
||||
//! Command search, Paste Special, About, Save Changes, and the mail-merge Recipient List, Insert
|
||||
//! Merge Field, Find Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends
|
||||
//! by running a command (or shows one's result), so agents get the same result without the dialog.
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table
|
||||
//! Properties, Command search, Paste Special, About, Save Changes, the password to open a
|
||||
//! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find
|
||||
//! Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by running a
|
||||
//! command (or shows one's result), so agents get the same result without the dialog.
|
||||
|
||||
use egui::{Sense, Ui, vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use wordcraft_engine::Password;
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::{Tokens, semibold};
|
||||
|
||||
@@ -45,6 +48,15 @@ pub enum Dialog {
|
||||
keep_lines: bool,
|
||||
page_break: bool,
|
||||
widow: bool,
|
||||
/// Page: 0 Indents and Spacing, 1 Line and Page Breaks, 2 Asian Typography.
|
||||
tab: u8,
|
||||
/// Asian Typography as the model has it (`para.asianTypography`): kinsoku, word wrap,
|
||||
/// hanging punctuation, compress punctuation at line start, space between Asian and
|
||||
/// Latin text, space between Asian text and numbers.
|
||||
asian: [bool; 6],
|
||||
/// `asian` when the dialog opened: OK sets only the flags that changed.
|
||||
#[serde(skip)]
|
||||
asian_was: [bool; 6],
|
||||
},
|
||||
Find {
|
||||
query: String,
|
||||
@@ -89,8 +101,13 @@ pub enum Dialog {
|
||||
NewStyle {
|
||||
name: String,
|
||||
based_on: String,
|
||||
/// Opened from Manage Styles, which comes back when this closes.
|
||||
#[serde(skip)]
|
||||
back: bool,
|
||||
},
|
||||
ModifyStyle {
|
||||
#[serde(skip)]
|
||||
back: bool,
|
||||
id: String,
|
||||
name: String,
|
||||
font: String,
|
||||
@@ -101,6 +118,12 @@ pub enum Dialog {
|
||||
before: f32,
|
||||
after: f32,
|
||||
},
|
||||
/// Manage Styles: every style with its preview and description; modify, create, delete and
|
||||
/// show or hide them.
|
||||
ManageStyles {
|
||||
alphabetical: bool,
|
||||
selected: String,
|
||||
},
|
||||
/// Table Design › New / Modify Table Style: one formatting region at a time.
|
||||
TableStyle {
|
||||
/// The style being modified; `None` creates one.
|
||||
@@ -108,13 +131,13 @@ pub enum Dialog {
|
||||
name: String,
|
||||
/// Style id.
|
||||
based_on: String,
|
||||
/// Index into `regions`: whole table, header row, banded rows.
|
||||
/// Index into `regions` (see [`REGION_LABELS`]).
|
||||
region: usize,
|
||||
regions: Box<[TableRegion; 3]>,
|
||||
regions: Box<[TableRegion; 7]>,
|
||||
/// What the regions showed when the dialog opened (or the base style changed): only
|
||||
/// changes are sent, so everything else stays inherited.
|
||||
#[serde(skip)]
|
||||
basis: Box<[TableRegion; 3]>,
|
||||
basis: Box<[TableRegion; 7]>,
|
||||
},
|
||||
Commands {
|
||||
query: String,
|
||||
@@ -187,6 +210,25 @@ pub enum Dialog {
|
||||
#[serde(skip)]
|
||||
basis: Box<TableForm>,
|
||||
},
|
||||
/// Opening a password-protected document (#55): the password, then `file.open` with
|
||||
/// `params` again. The password is never serialised or printed.
|
||||
Password {
|
||||
name: String,
|
||||
#[serde(skip)]
|
||||
params: Value,
|
||||
#[serde(skip)]
|
||||
password: Password,
|
||||
message: String,
|
||||
},
|
||||
/// File › Info › Protect Document › Encrypt with Password: a password typed twice
|
||||
/// (`file.encrypt`); left empty, it removes the password.
|
||||
EncryptPassword {
|
||||
#[serde(skip)]
|
||||
password: Password,
|
||||
#[serde(skip)]
|
||||
confirm: Password,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The Table Properties dialog's fields. Lengths are in the interface unit
|
||||
@@ -352,8 +394,11 @@ pub struct TableRegion {
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names).
|
||||
const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"];
|
||||
|
||||
/// The regions of table style `id` as resolved through its based-on chain.
|
||||
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
|
||||
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> {
|
||||
let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() };
|
||||
let p = &st.parts;
|
||||
let hex = |c: Option<wordcraft_doc::Rgb>| c.map(|c| c.hex()).unwrap_or_default();
|
||||
@@ -362,17 +407,136 @@ fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
|
||||
_ => String::new(),
|
||||
};
|
||||
let lines = |b: Option<wordcraft_doc::props::Borders>| b.is_some_and(|b| b.any_visible());
|
||||
let mut header_chr = st.chr.clone();
|
||||
header_chr.overlay(&p.header_chr);
|
||||
let mut band_chr = st.chr.clone();
|
||||
band_chr.overlay(&p.band_chr);
|
||||
// A region's text: the whole table's formatting with the region's over it.
|
||||
let region = |borders: Option<wordcraft_doc::props::Borders>, fill: Option<wordcraft_doc::Rgb>, chr: &wordcraft_doc::CharProps| {
|
||||
let mut c = st.chr.clone();
|
||||
c.overlay(chr);
|
||||
TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) }
|
||||
};
|
||||
let none = wordcraft_doc::CharProps::default();
|
||||
Box::new([
|
||||
TableRegion { borders: lines(p.borders), fill: hex(p.fill), bold: st.chr.bold.unwrap_or(false), color: text(&st.chr) },
|
||||
TableRegion { borders: lines(p.header_borders), fill: hex(p.header_fill), bold: header_chr.bold.unwrap_or(false), color: text(&header_chr) },
|
||||
TableRegion { borders: lines(p.band_borders), fill: hex(p.band_fill), bold: band_chr.bold.unwrap_or(false), color: text(&band_chr) },
|
||||
region(p.borders, p.fill, &none),
|
||||
region(p.header_borders, p.header_fill, &p.header_chr),
|
||||
region(p.total_borders, p.total_fill, &p.total_chr),
|
||||
region(p.first_col_borders, p.first_col_fill, &p.first_col_chr),
|
||||
region(p.last_col_borders, p.last_col_fill, &p.last_col_chr),
|
||||
region(p.band_borders, p.band_fill, &p.band_chr),
|
||||
region(p.col_band_borders, p.col_band_fill, &p.col_band_chr),
|
||||
])
|
||||
}
|
||||
|
||||
/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state:
|
||||
/// only what changed from `basis`, so the rest stays inherited.
|
||||
fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value {
|
||||
let mut v = json!({"name": name.trim(), "basedOn": based_on});
|
||||
for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) {
|
||||
let ch = region_changes(r, b);
|
||||
if ch.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
v[key] = ch;
|
||||
}
|
||||
}
|
||||
if let Some(id) = id {
|
||||
v["style"] = json!(id);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total
|
||||
/// row), showing the regions `look` turns on: the Table Style dialog's live preview.
|
||||
fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) {
|
||||
use wordcraft_doc::props::{Border, Borders};
|
||||
const COLS: usize = 5;
|
||||
const ROWS: usize = 5;
|
||||
const TEXT: [[&str; COLS]; ROWS] = [
|
||||
["", "Mon", "Tue", "Wed", "Sum"],
|
||||
["North", "4", "7", "2", "13"],
|
||||
["South", "6", "1", "5", "12"],
|
||||
["West", "3", "8", "4", "15"],
|
||||
["Total", "13", "16", "11", "40"],
|
||||
];
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
// The page is white whatever the interface theme.
|
||||
painter.rect_filled(rect, 2.0, egui::Color32::WHITE);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let Some(st) = style else { return };
|
||||
let p = &st.parts;
|
||||
let table = rect.shrink2(vec2(14.0, 10.0));
|
||||
let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32);
|
||||
let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2);
|
||||
let tb = p.borders.unwrap_or_default();
|
||||
for (ri, row) in TEXT.iter().enumerate() {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == ROWS;
|
||||
let band = look.banded_rows
|
||||
&& !header
|
||||
&& (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2);
|
||||
for (ci, txt) in row.iter().enumerate() {
|
||||
let first = look.first_column && ci == 0;
|
||||
let last = look.last_column && ci + 1 == COLS;
|
||||
let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh));
|
||||
// Regions from lowest to highest priority, as layout applies them.
|
||||
let regions = [
|
||||
(col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders),
|
||||
(band, p.band_fill, &p.band_chr, p.band_borders),
|
||||
(first, p.first_col_fill, &p.first_col_chr, p.first_col_borders),
|
||||
(last, p.last_col_fill, &p.last_col_chr, p.last_col_borders),
|
||||
(header, p.header_fill, &p.header_chr, p.header_borders),
|
||||
(total, p.total_fill, &p.total_chr, p.total_borders),
|
||||
];
|
||||
let mut fill = p.fill;
|
||||
let mut chr = st.chr.clone();
|
||||
let mut b = Borders {
|
||||
top: if ri == 0 { tb.top } else { tb.between },
|
||||
bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between },
|
||||
left: if ci == 0 { tb.left } else { tb.inside_v },
|
||||
right: if ci + 1 == COLS { tb.right } else { tb.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
for (on, f, c, rb) in regions {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
fill = f.or(fill);
|
||||
chr.overlay(c);
|
||||
if let Some(rb) = rb {
|
||||
b.overlay(&Borders { between: None, inside_v: None, ..rb });
|
||||
}
|
||||
}
|
||||
if total && p.total_borders.is_none_or(|x| x.top.is_none()) {
|
||||
b.top = p.total_border_top.or(b.top);
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
painter.rect_filled(cell, 0.0, rgb(f));
|
||||
}
|
||||
let edge = |from: egui::Pos2, to: egui::Pos2, e: Option<Border>| {
|
||||
if let Some(e) = e.filter(Border::is_visible) {
|
||||
let color = e.color.map_or(egui::Color32::BLACK, rgb);
|
||||
painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color));
|
||||
}
|
||||
};
|
||||
edge(cell.left_top(), cell.right_top(), b.top);
|
||||
edge(cell.left_bottom(), cell.right_bottom(), b.bottom);
|
||||
edge(cell.left_top(), cell.left_bottom(), b.left);
|
||||
edge(cell.right_top(), cell.right_bottom(), b.right);
|
||||
let color = match chr.color {
|
||||
Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c),
|
||||
_ => egui::Color32::BLACK,
|
||||
};
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
let pos = cell.left_center() + vec2(4.0, 0.0);
|
||||
painter.text(pos, egui::Align2::LEFT_CENTER, *txt, font.clone(), color);
|
||||
if chr.bold.unwrap_or(false) {
|
||||
// A second pass a hair to the right reads as bold at this size.
|
||||
painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, *txt, font, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The style of the table at the caret, when it is a table style.
|
||||
fn current_table_style(app: &WordApp) -> Option<String> {
|
||||
let s = &app.session;
|
||||
@@ -446,6 +610,7 @@ impl Dialog {
|
||||
Dialog::Watermark { .. } => "watermark",
|
||||
Dialog::NewStyle { .. } => "newStyle",
|
||||
Dialog::ModifyStyle { .. } => "modifyStyle",
|
||||
Dialog::ManageStyles { .. } => "manageStyles",
|
||||
Dialog::TableStyle { id: None, .. } => "newTableStyle",
|
||||
Dialog::TableStyle { .. } => "modifyTableStyle",
|
||||
Dialog::Commands { .. } => "commands",
|
||||
@@ -460,6 +625,8 @@ impl Dialog {
|
||||
Dialog::MatchFields { .. } => "matchFields",
|
||||
Dialog::CheckErrors { .. } => "checkErrors",
|
||||
Dialog::TableProperties { .. } => "tableProperties",
|
||||
Dialog::Password { .. } => "password",
|
||||
Dialog::EncryptPassword { .. } => "encryptPassword",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +666,7 @@ impl Dialog {
|
||||
color: s("color"),
|
||||
spacing: 0.0,
|
||||
},
|
||||
"paragraph" => {
|
||||
"paragraph" | "asianTypography" => {
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
|
||||
let rp = rp?;
|
||||
let line = match rp.line_spacing {
|
||||
@@ -520,6 +687,9 @@ impl Dialog {
|
||||
keep_lines: rp.keep_lines,
|
||||
page_break: rp.page_break_before,
|
||||
widow: rp.widow_control,
|
||||
tab: if name == "asianTypography" { 2 } else { 0 },
|
||||
asian: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
|
||||
asian_was: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
|
||||
}
|
||||
}
|
||||
"find" | "replace" => Dialog::Find {
|
||||
@@ -548,7 +718,8 @@ impl Dialog {
|
||||
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
|
||||
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
|
||||
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
|
||||
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() },
|
||||
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false },
|
||||
"manageStyles" => Dialog::ManageStyles { alphabetical: false, selected: s("style") },
|
||||
"newTableStyle" => {
|
||||
let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into());
|
||||
let styles = &app.session.doc.styles;
|
||||
@@ -588,6 +759,7 @@ impl Dialog {
|
||||
Dialog::InsertMergeField { field: fields.first().cloned().unwrap_or_default(), fields }
|
||||
}
|
||||
"findRecipient" => Dialog::FindRecipient { text: String::new(), message: String::new() },
|
||||
"encryptPassword" => Dialog::EncryptPassword { password: Password::default(), confirm: Password::default(), message: String::new() },
|
||||
"ruleIf" | "ruleSkipIf" => {
|
||||
let fields = app.session.merge.headers.clone();
|
||||
Dialog::MergeRule {
|
||||
@@ -630,6 +802,7 @@ impl Dialog {
|
||||
);
|
||||
let rp = app.session.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.into()), ..Default::default() });
|
||||
Some(Dialog::ModifyStyle {
|
||||
back: false,
|
||||
id: id.into(),
|
||||
name: st.name.clone(),
|
||||
font: rc.font,
|
||||
@@ -702,6 +875,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
Dialog::Watermark { .. } => "Custom Watermark",
|
||||
Dialog::NewStyle { .. } => "Create New Style",
|
||||
Dialog::ModifyStyle { .. } => "Modify Style",
|
||||
Dialog::ManageStyles { .. } => "Manage Styles",
|
||||
Dialog::TableStyle { id: None, .. } => "New Table Style",
|
||||
Dialog::TableStyle { .. } => "Modify Table Style",
|
||||
Dialog::Commands { .. } => "Search Commands",
|
||||
@@ -716,6 +890,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
Dialog::MatchFields { .. } => "Match Fields",
|
||||
Dialog::CheckErrors { .. } => "Check for Errors",
|
||||
Dialog::TableProperties { .. } => "Table Properties",
|
||||
Dialog::Password { .. } => "Password",
|
||||
Dialog::EncryptPassword { .. } => "Encrypt with Password",
|
||||
};
|
||||
egui::Window::new(tl!(title))
|
||||
.id(egui::Id::new(("dialog", title)))
|
||||
@@ -835,48 +1011,94 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => {
|
||||
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
|
||||
Dialog::Paragraph {
|
||||
rtl,
|
||||
align,
|
||||
left,
|
||||
right,
|
||||
first,
|
||||
before,
|
||||
after,
|
||||
line,
|
||||
keep_next,
|
||||
keep_lines,
|
||||
page_break,
|
||||
widow,
|
||||
tab,
|
||||
asian,
|
||||
asian_was,
|
||||
} => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Direction:"));
|
||||
ui.radio_value(rtl, true, tl!("Right-to-left"));
|
||||
ui.radio_value(rtl, false, tl!("Left-to-right"));
|
||||
});
|
||||
egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| {
|
||||
for a in ["left", "center", "right", "justify"] {
|
||||
ui.selectable_value(align, a.to_string(), a);
|
||||
for (k, name) in ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].into_iter().enumerate() {
|
||||
if ui.selectable_label(*tab as usize == k, tl!(name)).clicked() {
|
||||
*tab = k as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
|
||||
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
|
||||
ui.label(tl!("Left:"));
|
||||
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.label(tl!("Right:"));
|
||||
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
ui.label(tl!("First line:"));
|
||||
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
|
||||
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
|
||||
ui.label(tl!("Before:"));
|
||||
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.label(tl!("Line spacing:"));
|
||||
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
|
||||
ui.end_row();
|
||||
ui.label(tl!("After:"));
|
||||
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Line and Page Breaks")).font(semibold(12.5)));
|
||||
ui.checkbox(widow, tl!("Widow/Orphan control"));
|
||||
ui.checkbox(keep_next, tl!("Keep with next"));
|
||||
ui.checkbox(keep_lines, tl!("Keep lines together"));
|
||||
ui.checkbox(page_break, tl!("Page break before"));
|
||||
ui.separator();
|
||||
match *tab {
|
||||
0 => {
|
||||
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Direction:"));
|
||||
ui.radio_value(rtl, true, tl!("Right-to-left"));
|
||||
ui.radio_value(rtl, false, tl!("Left-to-right"));
|
||||
});
|
||||
egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| {
|
||||
for a in ["left", "center", "right", "justify"] {
|
||||
ui.selectable_value(align, a.to_string(), a);
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5)));
|
||||
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
|
||||
ui.label(tl!("Left:"));
|
||||
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.label(tl!("Right:"));
|
||||
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
ui.label(tl!("First line:"));
|
||||
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5)));
|
||||
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
|
||||
ui.label(tl!("Before:"));
|
||||
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.label(tl!("Line spacing:"));
|
||||
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
|
||||
ui.end_row();
|
||||
ui.label(tl!("After:"));
|
||||
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
1 => {
|
||||
ui.label(egui::RichText::new(tl!("Line and Page Breaks")).font(semibold(12.5)));
|
||||
ui.checkbox(widow, tl!("Widow/Orphan control"));
|
||||
ui.checkbox(keep_next, tl!("Keep with next"));
|
||||
ui.checkbox(keep_lines, tl!("Keep lines together"));
|
||||
ui.checkbox(page_break, tl!("Page break before"));
|
||||
}
|
||||
_ => {
|
||||
let [kinsoku, word_wrap, overflow, top_line, de, dn] = asian;
|
||||
ui.label(egui::RichText::new(tl!("Line breaking")).font(semibold(12.5)));
|
||||
ui.checkbox(kinsoku, tl!("Apply Asian line-breaking rules (kinsoku)"));
|
||||
// The model's flag is "wrap whole words"; the box offers the opposite.
|
||||
let mut mid_word = !*word_wrap;
|
||||
if ui.checkbox(&mut mid_word, tl!("Allow Latin text to wrap in the middle of a word")).changed() {
|
||||
*word_wrap = !mid_word;
|
||||
}
|
||||
ui.checkbox(overflow, tl!("Allow hanging punctuation"));
|
||||
ui.label(egui::RichText::new(tl!("Character Spacing")).font(semibold(12.5)));
|
||||
ui.checkbox(top_line, tl!("Compress punctuation at the start of a line"));
|
||||
ui.checkbox(de, tl!("Add space between Asian and Latin text"));
|
||||
ui.checkbox(dn, tl!("Add space between Asian text and numbers"));
|
||||
}
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
apply_paragraph(app, *rtl, align, *left, *right, *first, *before, *after, *line, [*keep_next, *keep_lines, *page_break, *widow]);
|
||||
apply_asian(app, *asian, *asian_was);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
@@ -1113,7 +1335,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::NewStyle { name, based_on } => {
|
||||
Dialog::NewStyle { name, based_on, back } => {
|
||||
egui::Grid::new("ns").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name);
|
||||
@@ -1124,12 +1346,17 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("The new style takes the formatting of the current paragraph.")).small().weak());
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
let mut created = None;
|
||||
if ok {
|
||||
let _ = app.run("styles.create", json!({"name": name, "basedOn": based_on}));
|
||||
created =
|
||||
app.run("styles.create", json!({"name": name, "basedOn": based_on})).ok().and_then(|v| v.get("id")?.as_str().map(str::to_string));
|
||||
}
|
||||
if (ok || cancel) && *back {
|
||||
app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: created.unwrap_or_default() });
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after } => {
|
||||
Dialog::ModifyStyle { back, id, name, font, size, bold, italic, color, before, after } => {
|
||||
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
ui.text_edit_singleline(name);
|
||||
@@ -1163,8 +1390,12 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let _ =
|
||||
app.run("styles.modify", json!({"style": id, "name": name, "chr": chr, "para": {"spaceBefore": *before, "spaceAfter": *after}}));
|
||||
}
|
||||
if (ok || cancel) && *back {
|
||||
app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: id.clone() });
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::ManageStyles { alphabetical, selected } => manage_styles(app, ui, alphabetical, selected),
|
||||
Dialog::TableStyle { id, name, based_on, region, regions, basis } => {
|
||||
let styles: Vec<(String, String)> = app
|
||||
.session
|
||||
@@ -1192,12 +1423,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Apply formatting to:"));
|
||||
let names = ["Whole Table", "Header Row", "Banded Rows"];
|
||||
egui::ComboBox::from_id_salt("tstyle_region")
|
||||
.width(200.0)
|
||||
.selected_text(tl!(names.get(*region).copied().unwrap_or("Whole Table")))
|
||||
.selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table")))
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
for (i, n) in REGION_LABELS.iter().enumerate() {
|
||||
ui.selectable_value(region, i, tl!(n));
|
||||
}
|
||||
});
|
||||
@@ -1224,24 +1454,32 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
// Live preview: the style as OK would leave it, on the current table's options with
|
||||
// the region being edited turned on.
|
||||
let v = table_style_params(id.as_deref(), name, based_on, regions, basis);
|
||||
let mut look = app
|
||||
.session
|
||||
.sel
|
||||
.focus
|
||||
.path
|
||||
.cell()
|
||||
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look))
|
||||
.unwrap_or_default();
|
||||
match *region {
|
||||
1 => look.header_row = true,
|
||||
2 => look.total_row = true,
|
||||
3 => look.first_column = true,
|
||||
4 => look.last_column = true,
|
||||
5 => look.banded_rows = true,
|
||||
6 => look.banded_columns = true,
|
||||
_ => {}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
|
||||
table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look);
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
let mut v = json!({"name": name.trim(), "basedOn": based_on});
|
||||
for (i, key) in ["wholeTable", "headerRow", "bandedRows"].into_iter().enumerate() {
|
||||
if let (Some(r), Some(b)) = (regions.get(i), basis.get(i)) {
|
||||
let ch = region_changes(r, b);
|
||||
if ch.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
v[key] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
let cmd = match id {
|
||||
Some(sid) => {
|
||||
v["style"] = json!(sid);
|
||||
"table.modifyStyle"
|
||||
}
|
||||
None => "table.newStyle",
|
||||
};
|
||||
let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" };
|
||||
if let Err(e) = app.run(cmd, v) {
|
||||
app.status(e);
|
||||
return false;
|
||||
@@ -1399,6 +1637,86 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
cancel
|
||||
}
|
||||
Dialog::Password { name, params, password, message } => {
|
||||
ui.label(crate::i18n::fmt(tl!("{name} is protected with a password."), &[("name", name)]));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Password:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(password.as_mut_string()).password(true).desired_width(220.0));
|
||||
if password.is_empty() && !r.has_focus() {
|
||||
r.request_focus();
|
||||
}
|
||||
});
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message.as_str()).small().color(ui.visuals().error_fg_color));
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok && !password.is_empty() {
|
||||
let mut p = params.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("password".into(), json!(password.as_str()));
|
||||
}
|
||||
// `execute`, not `run`: Save Changes was asked when the open began.
|
||||
match app.execute("file.open", p) {
|
||||
Ok(_) => {
|
||||
app.ui.backstage = false;
|
||||
return true;
|
||||
}
|
||||
Err(e) if wordcraft_engine::io::wrong_password(&e) => {
|
||||
*message = tl!("That password isn't right. Check it (passwords are case-sensitive) and try again.").to_string();
|
||||
password.as_mut_string().clear();
|
||||
}
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
cancel
|
||||
}
|
||||
Dialog::EncryptPassword { password, confirm, message } => {
|
||||
ui.label(tl!("Anyone who opens the document will need this password."));
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("encrypt_password").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label(tl!("Password:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(password.as_mut_string()).password(true).desired_width(220.0));
|
||||
if password.is_empty() && confirm.is_empty() && !r.has_focus() {
|
||||
r.request_focus();
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label(tl!("Confirm password:"));
|
||||
ui.add(egui::TextEdit::singleline(confirm.as_mut_string()).password(true).desired_width(220.0));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(tl!(
|
||||
"Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password."
|
||||
))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message.as_str()).small().color(ui.visuals().error_fg_color));
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
if password != confirm {
|
||||
*message = tl!("The passwords don't match.").to_string();
|
||||
confirm.as_mut_string().clear();
|
||||
return false;
|
||||
}
|
||||
let pw = if password.is_empty() { Value::Null } else { json!(password.as_str()) };
|
||||
match app.run("file.encrypt", json!({"password": pw})) {
|
||||
Ok(_) => {
|
||||
app.status(if password.is_empty() {
|
||||
tl!("The password is removed. Save the document to keep the change.")
|
||||
} else {
|
||||
tl!("The document will be saved with a password. Save it to protect it.")
|
||||
});
|
||||
return true;
|
||||
}
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
cancel
|
||||
}
|
||||
Dialog::FindRecipient { text, message } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Find:"));
|
||||
@@ -1677,6 +1995,175 @@ fn apply_paragraph(
|
||||
);
|
||||
}
|
||||
|
||||
/// The Paragraph dialog's Asian Typography: sets the flags that changed since it opened, so an
|
||||
/// untouched tab adds no direct formatting.
|
||||
fn apply_asian(app: &mut WordApp, asian: [bool; 6], was: [bool; 6]) {
|
||||
let keys = ["kinsoku", "wordWrap", "overflowPunct", "topLinePunct", "autoSpaceDE", "autoSpaceDN"];
|
||||
let changed: serde_json::Map<String, Value> =
|
||||
keys.iter().zip(asian.iter().zip(was)).filter(|(_, (now, was))| **now != *was).map(|(k, (now, _))| (k.to_string(), json!(now))).collect();
|
||||
if !changed.is_empty() {
|
||||
let _ = app.run("para.asianTypography", Value::Object(changed));
|
||||
}
|
||||
}
|
||||
|
||||
/// Manage Styles' body. Reads the list from `styles.manage`'s data (never re-running the command,
|
||||
/// which would ask to open this dialog again) and acts through commands.
|
||||
fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, selected: &mut String) -> bool {
|
||||
let list = wordcraft_engine::cmd::para::manage_list(&app.session, *alphabetical);
|
||||
let list = list.as_array().cloned().unwrap_or_default();
|
||||
let str_of = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
if !list.iter().any(|x| str_of(x, "id") == *selected) {
|
||||
*selected = list.first().map(|x| str_of(x, "id")).unwrap_or_default();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Sort order:"));
|
||||
egui::ComboBox::from_id_salt("manage_styles_sort")
|
||||
.selected_text(if *alphabetical { tl!("Alphabetical") } else { tl!("As Recommended") })
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(alphabetical, false, tl!("As Recommended"));
|
||||
ui.selectable_value(alphabetical, true, tl!("Alphabetical"));
|
||||
});
|
||||
});
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let shown = egui::Id::new("manage_styles_shown");
|
||||
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("manage_styles_list").max_height(200.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.set_width(380.0);
|
||||
for st in &list {
|
||||
let id = str_of(st, "id");
|
||||
let glyph = match st.get("type").and_then(Value::as_str) {
|
||||
Some("character") => "a",
|
||||
Some("linked") => "¶a",
|
||||
Some("table") => "⊞",
|
||||
_ => "¶",
|
||||
};
|
||||
let hidden = st.get("hidden").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut name = egui::RichText::new(format!("{glyph} {}", str_of(st, "name")));
|
||||
if hidden {
|
||||
name = name.weak();
|
||||
}
|
||||
let r = ui.selectable_label(*selected == id, name);
|
||||
// Bring the selection into view once, when it changes (opening, New Style…).
|
||||
if *selected == id && ui.data(|d| d.get_temp::<String>(shown)).as_deref() != Some(id.as_str()) {
|
||||
r.scroll_to_me(Some(egui::Align::Center));
|
||||
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
|
||||
}
|
||||
if r.clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(shown, id.clone()));
|
||||
*selected = id.clone();
|
||||
}
|
||||
if r.double_clicked() {
|
||||
*selected = id;
|
||||
if let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) =
|
||||
Dialog::modify_style(app, selected)
|
||||
{
|
||||
app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let leave = |ui: &mut Ui| {
|
||||
ui.data_mut(|d| d.remove::<String>(shown));
|
||||
true
|
||||
};
|
||||
if app.dialog.is_some() {
|
||||
return leave(ui);
|
||||
}
|
||||
let Some(cur) = list.iter().find(|x| str_of(x, "id") == *selected).cloned() else { return close_button(ui) && leave(ui) };
|
||||
let ty = str_of(&cur, "type");
|
||||
ui.add_space(6.0);
|
||||
crate::previews::style_preview(app, ui, selected, &ty, vec2(388.0, if ty == "table" { 64.0 } else { 40.0 }));
|
||||
ui.add_space(4.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(describe(&cur)).small()).wrap());
|
||||
ui.add_space(6.0);
|
||||
let builtin = cur.get("builtIn").and_then(Value::as_bool).unwrap_or(true);
|
||||
if ty != "table" {
|
||||
let mut gallery = cur.get("inGallery").and_then(Value::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut gallery, tl!("Show in the Styles gallery")).changed() {
|
||||
let _ = app.run("styles.setVisibility", json!({"style": selected, "gallery": gallery}));
|
||||
}
|
||||
}
|
||||
let mut hidden = cur.get("hidden").and_then(Value::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut hidden, tl!("Hide from style lists")).changed() {
|
||||
let _ = app.run("styles.setVisibility", json!({"style": selected, "hidden": hidden}));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
// Table styles are edited from Table Design.
|
||||
if ui.add_enabled(ty != "table", egui::Button::new(tl!("Modify…"))).clicked()
|
||||
&& let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) = Dialog::modify_style(app, selected)
|
||||
{
|
||||
app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after });
|
||||
}
|
||||
if ui.button(tl!("New Style…")).clicked() {
|
||||
app.dialog = Some(Dialog::NewStyle { name: "Style1".into(), based_on: str_of(&cur, "name"), back: true });
|
||||
}
|
||||
let del = ui.add_enabled(!builtin, egui::Button::new(tl!("Delete")));
|
||||
let del = if builtin { del.on_disabled_hover_text(tl!("Built-in styles can't be deleted.")) } else { del };
|
||||
if del.clicked() {
|
||||
let _ = app.run("styles.delete", json!({"style": selected}));
|
||||
selected.clear();
|
||||
}
|
||||
});
|
||||
(app.dialog.is_some() || close_button(ui)) && leave(ui)
|
||||
}
|
||||
|
||||
/// A dialog's single Close button (Escape too).
|
||||
fn close_button(ui: &mut Ui) -> bool {
|
||||
ui.add_space(8.0);
|
||||
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close |= ui.add(egui::Button::new(egui::RichText::new(tl!("Close")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked();
|
||||
});
|
||||
close
|
||||
}
|
||||
|
||||
/// A style's description: its type, the formatting it sets and what it is based on.
|
||||
fn describe(st: &Value) -> String {
|
||||
let f = st.get("format").cloned().unwrap_or_default();
|
||||
let fs = |k: &str| f.get(k).and_then(Value::as_str);
|
||||
let fnum = |k: &str| f.get(k).and_then(Value::as_f64);
|
||||
let pt = |x: f64| format!("{}", (x * 10.0).round() / 10.0);
|
||||
let mut parts: Vec<String> = vec![
|
||||
match st.get("type").and_then(Value::as_str) {
|
||||
Some("character") => tl!("Character style"),
|
||||
Some("linked") => tl!("Linked style (paragraph and character)"),
|
||||
Some("table") => tl!("Table style"),
|
||||
_ => tl!("Paragraph style"),
|
||||
}
|
||||
.to_string(),
|
||||
];
|
||||
if st.get("builtIn").and_then(Value::as_bool).unwrap_or(false) {
|
||||
parts.push(tl!("Built-in style").to_string());
|
||||
}
|
||||
if let Some(x) = fs("font") {
|
||||
parts.push(crate::i18n::fmt(tl!("Font: {font}"), &[("font", x)]));
|
||||
}
|
||||
if let Some(x) = fnum("size") {
|
||||
parts.push(crate::i18n::fmt(tl!("{size} pt"), &[("size", &pt(x))]));
|
||||
}
|
||||
if f.get("bold").and_then(Value::as_bool) == Some(true) {
|
||||
parts.push(tl!("Bold").to_string());
|
||||
}
|
||||
if f.get("italic").and_then(Value::as_bool) == Some(true) {
|
||||
parts.push(tl!("Italic").to_string());
|
||||
}
|
||||
if let Some(x) = fs("color") {
|
||||
parts.push(crate::i18n::fmt(tl!("Color: {color}"), &[("color", &format!("#{x}"))]));
|
||||
}
|
||||
if let Some(x) = fnum("spaceBefore") {
|
||||
parts.push(crate::i18n::fmt(tl!("Space before: {pt} pt"), &[("pt", &pt(x))]));
|
||||
}
|
||||
if let Some(x) = fnum("spaceAfter") {
|
||||
parts.push(crate::i18n::fmt(tl!("Space after: {pt} pt"), &[("pt", &pt(x))]));
|
||||
}
|
||||
if let Some(b) = st.get("basedOn").and_then(Value::as_str) {
|
||||
parts.push(crate::i18n::fmt(tl!("Based on: {style}"), &[("style", b)]));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1695,7 +2182,7 @@ mod tests {
|
||||
|
||||
/// Open the Paragraph dialog and press OK without changing anything.
|
||||
fn ok_unchanged(app: &mut WordApp) {
|
||||
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow }) =
|
||||
let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, .. }) =
|
||||
Dialog::open("paragraph", app)
|
||||
else {
|
||||
panic!("no paragraph dialog")
|
||||
|
||||
@@ -151,7 +151,7 @@ impl WordApp {
|
||||
if !self.safely_saved(saved) || document != self.session.document_id() {
|
||||
return Ok(json!({"done": false}));
|
||||
}
|
||||
self.execute(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
Accept This Change Aceptar este cambio
|
||||
Accessibility Accesibilidad
|
||||
Add Añadir
|
||||
Add Object to Selection Añadir objeto a la selección
|
||||
Add Space Before Paragraph Añadir espacio antes del párrafo
|
||||
Add space between Asian and Latin text Añadir espacio entre texto asiático y latino
|
||||
Add space between Asian text and numbers Añadir espacio entre texto asiático y números
|
||||
Add Text Añadir texto
|
||||
Add a Table Añadir una tabla
|
||||
Add a comment Añadir un comentario
|
||||
@@ -46,13 +49,17 @@
|
||||
All Caps Mayúsculas
|
||||
All Markup Todas las marcas
|
||||
All caps Mayúsculas
|
||||
Allow hanging punctuation Permitir puntuación colgante
|
||||
Allow Latin text to wrap in the middle of a word Permitir que el texto latino se divida en mitad de una palabra
|
||||
Alt Text Texto alternativo
|
||||
Apply Asian line-breaking rules (kinsoku) Aplicar las reglas asiáticas de división de líneas (kinsoku)
|
||||
Apply Character Style Aplicar estilo de carácter
|
||||
Apply Style Aplicar estilo
|
||||
Arrange Organizar
|
||||
Arrange\nAll Organizar\ntodo
|
||||
Arrow Flecha
|
||||
Artistic Effects Efectos artísticos
|
||||
Asian Typography Tipografía asiática
|
||||
Author Autor
|
||||
AutoCorrect Options Opciones de autocorrección
|
||||
AutoFit Ajustar automáticamente
|
||||
@@ -134,6 +141,14 @@
|
||||
Clear Formatting Level Borrar nivel de formato
|
||||
Clear Paragraph Formatting Borrar formato de párrafo
|
||||
Clipboard Portapapeles
|
||||
Clipboard Pane Panel del portapapeles
|
||||
Clipboard Items Elementos del portapapeles
|
||||
Paste Clipboard Item Pegar elemento del portapapeles
|
||||
Paste All Pegar todo
|
||||
Delete Clipboard Item Eliminar elemento del portapapeles
|
||||
Click an item to paste it. Haz clic en un elemento para pegarlo.
|
||||
Nothing collected yet. Items you copy or cut appear here. Aún no hay nada. Lo que copies o cortes aparecerá aquí.
|
||||
(picture or object) (imagen u objeto)
|
||||
Close Cerrar
|
||||
Close Header and Footer Cerrar encabezado y pie de página
|
||||
Collapse Selection Contraer selección
|
||||
@@ -152,6 +167,7 @@
|
||||
Compact Compacto
|
||||
Compare Comparar
|
||||
Compress Pictures Comprimir imágenes
|
||||
Compress punctuation at the start of a line Comprimir la puntuación al inicio de la línea
|
||||
Continuous Continuo
|
||||
Convert Convertir
|
||||
Convert Text to Table Convertir texto en tabla
|
||||
@@ -329,6 +345,7 @@
|
||||
Indent Sangría
|
||||
Indentation Sangría
|
||||
Indents Sangrías
|
||||
Indents and Spacing Sangría y espaciado
|
||||
Index Índice
|
||||
Info Información
|
||||
Ink to\nMath Tinta a\nfórmula
|
||||
@@ -385,6 +402,7 @@
|
||||
Letter Carta
|
||||
Line Línea
|
||||
Line Break Salto de línea
|
||||
Line breaking División de líneas
|
||||
Line End Fin de línea
|
||||
Line Numbers Números de línea
|
||||
Line Spacing Interlineado
|
||||
@@ -765,6 +783,7 @@
|
||||
Two Dos
|
||||
Type Text Escribir texto
|
||||
Type a command, e.g. "insert table" Escribe un comando, p. ej. "insertar tabla"
|
||||
Ungroup Desagrupar
|
||||
UPPERCASE MAYÚSCULAS
|
||||
Underline Subrayado
|
||||
Underline Words Only Subrayar solo las palabras
|
||||
@@ -1045,3 +1064,57 @@
|
||||
Leave the equation (Esc) Salir de la ecuación (Esc)
|
||||
Preview Vista previa
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects Efectos de forma
|
||||
Glow Resplandor
|
||||
Soft Edges Bordes suaves
|
||||
No Shadow Sin sombra
|
||||
No Glow Sin resplandor
|
||||
No Soft Edges Sin bordes suaves
|
||||
Glow Colors Colores de resplandor
|
||||
Weight Grosor
|
||||
Shadow, glow and soft edges Sombra, resplandor y bordes suaves
|
||||
Offset: Bottom Right Desplazamiento: abajo a la derecha
|
||||
Offset: Bottom Desplazamiento: abajo
|
||||
Offset: Bottom Left Desplazamiento: abajo a la izquierda
|
||||
Offset: Right Desplazamiento: derecha
|
||||
Offset: Center Desplazamiento: centro
|
||||
Offset: Left Desplazamiento: izquierda
|
||||
Offset: Top Right Desplazamiento: arriba a la derecha
|
||||
Offset: Top Desplazamiento: arriba
|
||||
Offset: Top Left Desplazamiento: arriba a la izquierda
|
||||
Manage Styles Administrar estilos
|
||||
Show or Hide Style Mostrar u ocultar estilo
|
||||
Manage Styles… Administrar estilos…
|
||||
Sort order: Orden:
|
||||
As Recommended Recomendado
|
||||
Alphabetical Alfabético
|
||||
Font: {font} Fuente: {font}
|
||||
{size} pt {size} pt
|
||||
Color: {color} Color: {color}
|
||||
Space before: {pt} pt Espacio antes: {pt} pt
|
||||
Space after: {pt} pt Espacio después: {pt} pt
|
||||
Based on: {style} Basado en: {style}
|
||||
Paragraph style Estilo de párrafo
|
||||
Character style Estilo de carácter
|
||||
Linked style (paragraph and character) Estilo vinculado (párrafo y carácter)
|
||||
Table style Estilo de tabla
|
||||
Built-in style Estilo integrado
|
||||
Show in the Styles gallery Mostrar en la galería de estilos
|
||||
Hide from style lists Ocultar de las listas de estilos
|
||||
Built-in styles can't be deleted. Los estilos integrados no se pueden eliminar.
|
||||
Delete Table Style Eliminar estilo de tabla
|
||||
Password Contraseña
|
||||
Password: Contraseña:
|
||||
Confirm password: Confirmar contraseña:
|
||||
Encrypt with Password… Cifrar con contraseña…
|
||||
Change Password… Cambiar contraseña…
|
||||
Remove Password Quitar contraseña
|
||||
A password is required to open this document. Se necesita una contraseña para abrir este documento.
|
||||
Anyone can open this document. Encrypt it with a password to keep it private. Cualquiera puede abrir este documento. Cífralo con una contraseña para mantenerlo privado.
|
||||
Anyone who opens the document will need this password. Quien abra el documento necesitará esta contraseña.
|
||||
{name} is protected with a password. {name} está protegido con contraseña.
|
||||
That password isn't right. Check it (passwords are case-sensitive) and try again. La contraseña no es correcta. Revísala (distingue mayúsculas de minúsculas) e inténtalo de nuevo.
|
||||
The document will be saved with a password. Save it to protect it. El documento se guardará con contraseña. Guárdalo para protegerlo.
|
||||
The password is removed. Save the document to keep the change. Se quitó la contraseña. Guarda el documento para conservar el cambio.
|
||||
The passwords don't match. Las contraseñas no coinciden.
|
||||
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Guarda la contraseña en un lugar seguro: sin ella nadie, ni siquiera WordCraft, puede abrir el documento. Deja ambos cuadros vacíos para quitar la contraseña.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -12,7 +12,10 @@
|
||||
Add 追加
|
||||
Add a comment コメントを追加
|
||||
Add a Table 表を追加
|
||||
Add Object to Selection オブジェクトを選択範囲に追加
|
||||
Add Space Before Paragraph 段落前に間隔を追加
|
||||
Add space between Asian and Latin text アジア文字と欧文の間隔を空ける
|
||||
Add space between Asian text and numbers アジア文字と数字の間隔を空ける
|
||||
Add Text テキストの追加
|
||||
Add to Dictionary 辞書に追加
|
||||
Add to Style Gallery スタイル ギャラリーに追加
|
||||
@@ -31,7 +34,10 @@
|
||||
All Caps すべて大文字
|
||||
All caps すべて大文字
|
||||
All Markup すべての変更履歴
|
||||
Allow hanging punctuation 句読点のぶら下げを行う
|
||||
Allow Latin text to wrap in the middle of a word 欧文を単語の途中で折り返す
|
||||
Alt Text 代替テキスト
|
||||
Apply Asian line-breaking rules (kinsoku) アジア文字の禁則処理を行う
|
||||
Apply Character Style 文字スタイルを適用
|
||||
Apply Style スタイルを適用
|
||||
Apply Styles スタイルを選んで適用
|
||||
@@ -39,6 +45,7 @@
|
||||
Arrange\nAll 整列
|
||||
Arrow 矢印
|
||||
Artistic Effects アート効果
|
||||
Asian Typography アジア文字の組版
|
||||
Author 作成者
|
||||
AutoCorrect Options オートコレクトのオプション
|
||||
AutoFit 自動調整
|
||||
@@ -53,9 +60,12 @@
|
||||
AutoSave documents you have saved in WordCraft WordCraft で保存した文書を自動保存する
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動保存できませんでした: {error}。文書を保存し直すと、自動保存が再開します。
|
||||
Bottom Margin 下余白
|
||||
Compress punctuation at the start of a line 行頭の約物を詰める
|
||||
Dark page (white text on black) ダーク ページ (黒地に白い文字)
|
||||
Edit Comment コメントを編集
|
||||
Left Margin 左余白
|
||||
Indents and Spacing インデントと間隔
|
||||
Line breaking 行の分割
|
||||
More templates → その他のテンプレート →
|
||||
No templates match your search. 検索に一致するテンプレートはありません。
|
||||
Reopen 再度開く
|
||||
@@ -126,6 +136,15 @@
|
||||
Clear クリア
|
||||
Clear All Formatting すべての書式をクリア
|
||||
Clipboard クリップボード
|
||||
Clipboard Pane クリップボード ウィンドウ
|
||||
Clipboard Items クリップボードの項目
|
||||
Paste Clipboard Item クリップボードの項目を貼り付け
|
||||
Paste All すべて貼り付け
|
||||
Delete Clipboard Item クリップボードの項目を削除
|
||||
Clear All すべてクリア
|
||||
Click an item to paste it. 項目をクリックすると貼り付けます。
|
||||
Nothing collected yet. Items you copy or cut appear here. まだ何もありません。コピーまたは切り取った項目がここに表示されます。
|
||||
(picture or object) (画像またはオブジェクト)
|
||||
Close 閉じる
|
||||
Close Header and Footer ヘッダーとフッターを閉じる
|
||||
Collapse Selection 選択範囲を縮小
|
||||
@@ -745,6 +764,7 @@
|
||||
Underline Words Only 単語のみ下線
|
||||
Undo 元に戻す
|
||||
Undo {action} 元に戻す: {action}
|
||||
Ungroup グループ解除
|
||||
Up 上
|
||||
Update Field フィールドの更新
|
||||
Update Index 索引の更新
|
||||
@@ -898,7 +918,6 @@
|
||||
Clear Paragraph Formatting 段落の書式をクリア
|
||||
Clear Character Style 文字スタイルをクリア
|
||||
Clear Character Formatting 文字の書式をクリア
|
||||
Clear All すべてクリア
|
||||
Paste Special 形式を選択して貼り付け
|
||||
Paste Special… 形式を選択して貼り付け…
|
||||
Paste as: 貼り付ける形式:
|
||||
@@ -1029,3 +1048,57 @@
|
||||
Leave the equation (Esc) 数式を終了 (Esc)
|
||||
Preview プレビュー
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects 図形の効果
|
||||
Glow 光彩
|
||||
Soft Edges 輪郭のぼかし
|
||||
No Shadow 影なし
|
||||
No Glow 光彩なし
|
||||
No Soft Edges ぼかしなし
|
||||
Glow Colors 光彩の色
|
||||
Weight 太さ
|
||||
Shadow, glow and soft edges 影、光彩、輪郭のぼかし
|
||||
Offset: Bottom Right オフセット: 右下
|
||||
Offset: Bottom オフセット: 下
|
||||
Offset: Bottom Left オフセット: 左下
|
||||
Offset: Right オフセット: 右
|
||||
Offset: Center オフセット: 中央
|
||||
Offset: Left オフセット: 左
|
||||
Offset: Top Right オフセット: 右上
|
||||
Offset: Top オフセット: 上
|
||||
Offset: Top Left オフセット: 左上
|
||||
Manage Styles スタイルの管理
|
||||
Show or Hide Style スタイルの表示/非表示
|
||||
Manage Styles… スタイルの管理…
|
||||
Sort order: 並べ替え順:
|
||||
As Recommended 推奨順
|
||||
Alphabetical アルファベット順
|
||||
Font: {font} フォント: {font}
|
||||
{size} pt {size} pt
|
||||
Color: {color} 色: {color}
|
||||
Space before: {pt} pt 前の間隔: {pt} pt
|
||||
Space after: {pt} pt 後の間隔: {pt} pt
|
||||
Based on: {style} 基準: {style}
|
||||
Paragraph style 段落スタイル
|
||||
Character style 文字スタイル
|
||||
Linked style (paragraph and character) リンク スタイル (段落と文字)
|
||||
Table style 表スタイル
|
||||
Built-in style 組み込みスタイル
|
||||
Show in the Styles gallery スタイル ギャラリーに表示
|
||||
Hide from style lists スタイルの一覧に表示しない
|
||||
Built-in styles can't be deleted. 組み込みスタイルは削除できません。
|
||||
Delete Table Style 表スタイルの削除
|
||||
Password パスワード
|
||||
Password: パスワード:
|
||||
Confirm password: パスワードの確認:
|
||||
Encrypt with Password… パスワードを使用して暗号化…
|
||||
Change Password… パスワードの変更…
|
||||
Remove Password パスワードの削除
|
||||
A password is required to open this document. この文書を開くにはパスワードが必要です。
|
||||
Anyone can open this document. Encrypt it with a password to keep it private. この文書は誰でも開けます。パスワードで暗号化すると内容を保護できます。
|
||||
Anyone who opens the document will need this password. この文書を開くすべての人にこのパスワードが必要になります。
|
||||
{name} is protected with a password. {name} はパスワードで保護されています。
|
||||
That password isn't right. Check it (passwords are case-sensitive) and try again. パスワードが正しくありません。確認して(大文字と小文字は区別されます)もう一度お試しください。
|
||||
The document will be saved with a password. Save it to protect it. 文書はパスワード付きで保存されます。保護するには文書を保存してください。
|
||||
The password is removed. Save the document to keep the change. パスワードを削除しました。変更を保持するには文書を保存してください。
|
||||
The passwords don't match. パスワードが一致しません。
|
||||
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. パスワードは安全な場所に保管してください。パスワードがないと、WordCraft を含め誰も文書を開けません。パスワードを削除するには両方のボックスを空にします。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 439 and column 13.
|
@@ -23,7 +23,10 @@
|
||||
Add Adicionar
|
||||
Add a comment Adicionar um comentário
|
||||
Add a Table Adicionar uma tabela
|
||||
Add Object to Selection Adicionar objeto à seleção
|
||||
Add Space Before Paragraph Adicionar espaço antes do parágrafo
|
||||
Add space between Asian and Latin text Adicionar espaço entre texto asiático e latino
|
||||
Add space between Asian text and numbers Adicionar espaço entre texto asiático e números
|
||||
Add Text Adicionar texto
|
||||
Add to Dictionary Adicionar ao dicionário
|
||||
Add to Style Gallery Adicionar à galeria de estilos
|
||||
@@ -42,13 +45,17 @@
|
||||
All Caps Tudo em maiúsculas
|
||||
All caps Tudo em maiúsculas
|
||||
All Markup Todas as marcações
|
||||
Allow hanging punctuation Permitir pontuação pendente
|
||||
Allow Latin text to wrap in the middle of a word Permitir que o texto latino quebre no meio de uma palavra
|
||||
Alt Text Texto alternativo
|
||||
Apply Asian line-breaking rules (kinsoku) Aplicar as regras asiáticas de quebra de linha (kinsoku)
|
||||
Apply Character Style Aplicar estilo de caractere
|
||||
Apply Style Aplicar estilo
|
||||
Arrange Organizar
|
||||
Arrange\nAll Organizar\ntudo
|
||||
Arrow Seta
|
||||
Artistic Effects Efeitos artísticos
|
||||
Asian Typography Tipografia asiática
|
||||
Author Autor
|
||||
AutoCorrect Options Opções de autocorreção
|
||||
AutoFit Autoajuste
|
||||
@@ -124,6 +131,15 @@
|
||||
Clear Limpar
|
||||
Clear All Formatting Limpar toda a formatação
|
||||
Clipboard Área de transferência
|
||||
Clipboard Pane Painel da área de transferência
|
||||
Clipboard Items Itens da área de transferência
|
||||
Paste Clipboard Item Colar item da área de transferência
|
||||
Paste All Colar tudo
|
||||
Delete Clipboard Item Excluir item da área de transferência
|
||||
Clear All Limpar tudo
|
||||
Click an item to paste it. Clique em um item para colá-lo.
|
||||
Nothing collected yet. Items you copy or cut appear here. Nada coletado ainda. Os itens que você copiar ou recortar aparecem aqui.
|
||||
(picture or object) (imagem ou objeto)
|
||||
Close Fechar
|
||||
Close Header and Footer Fechar cabeçalho e rodapé
|
||||
Collapse Selection Recolher seleção
|
||||
@@ -142,6 +158,7 @@
|
||||
Compact Compacto
|
||||
Compare Comparar
|
||||
Compress Pictures Compactar imagens
|
||||
Compress punctuation at the start of a line Comprimir a pontuação no início da linha
|
||||
Continuous Contínua
|
||||
Convert Converter
|
||||
Convert Text to Table Converter texto em tabela
|
||||
@@ -186,6 +203,8 @@
|
||||
Design Design
|
||||
Edit Comment Editar comentário
|
||||
Left Margin Margem esquerda
|
||||
Indents and Spacing Recuos e espaçamento
|
||||
Line breaking Quebra de linhas
|
||||
More templates → Mais modelos →
|
||||
No templates match your search. Nenhum modelo corresponde à pesquisa.
|
||||
Reopen Reabrir
|
||||
@@ -745,6 +764,7 @@
|
||||
Underline Words Only Sublinhar somente palavras
|
||||
Undo Desfazer
|
||||
Undo {action} Desfazer {action}
|
||||
Ungroup Desagrupar
|
||||
Up Para cima
|
||||
Update Field Atualizar campo
|
||||
Update Index Atualizar índice
|
||||
@@ -910,7 +930,6 @@
|
||||
Clear Paragraph Formatting Limpar formatação do parágrafo
|
||||
Clear Character Style Limpar estilo de caractere
|
||||
Clear Character Formatting Limpar formatação de caractere
|
||||
Clear All Limpar tudo
|
||||
Paste Special Colar especial
|
||||
Paste Special… Colar especial…
|
||||
Paste as: Colar como:
|
||||
@@ -1041,3 +1060,57 @@
|
||||
Leave the equation (Esc) Sair da equação (Esc)
|
||||
Preview Visualizar
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects Efeitos de forma
|
||||
Glow Brilho
|
||||
Soft Edges Bordas suaves
|
||||
No Shadow Sem sombra
|
||||
No Glow Sem brilho
|
||||
No Soft Edges Sem bordas suaves
|
||||
Glow Colors Cores do brilho
|
||||
Weight Espessura
|
||||
Shadow, glow and soft edges Sombra, brilho e bordas suaves
|
||||
Offset: Bottom Right Deslocamento: inferior direito
|
||||
Offset: Bottom Deslocamento: inferior
|
||||
Offset: Bottom Left Deslocamento: inferior esquerdo
|
||||
Offset: Right Deslocamento: direita
|
||||
Offset: Center Deslocamento: centro
|
||||
Offset: Left Deslocamento: esquerda
|
||||
Offset: Top Right Deslocamento: superior direito
|
||||
Offset: Top Deslocamento: superior
|
||||
Offset: Top Left Deslocamento: superior esquerdo
|
||||
Manage Styles Gerenciar estilos
|
||||
Show or Hide Style Mostrar ou ocultar estilo
|
||||
Manage Styles… Gerenciar estilos…
|
||||
Sort order: Ordem:
|
||||
As Recommended Recomendada
|
||||
Alphabetical Alfabética
|
||||
Font: {font} Fonte: {font}
|
||||
{size} pt {size} pt
|
||||
Color: {color} Cor: {color}
|
||||
Space before: {pt} pt Espaço antes: {pt} pt
|
||||
Space after: {pt} pt Espaço depois: {pt} pt
|
||||
Based on: {style} Baseado em: {style}
|
||||
Paragraph style Estilo de parágrafo
|
||||
Character style Estilo de caractere
|
||||
Linked style (paragraph and character) Estilo vinculado (parágrafo e caractere)
|
||||
Table style Estilo de tabela
|
||||
Built-in style Estilo integrado
|
||||
Show in the Styles gallery Mostrar na galeria de estilos
|
||||
Hide from style lists Ocultar das listas de estilos
|
||||
Built-in styles can't be deleted. Estilos integrados não podem ser excluídos.
|
||||
Delete Table Style Excluir estilo de tabela
|
||||
Password Senha
|
||||
Password: Senha:
|
||||
Confirm password: Confirmar senha:
|
||||
Encrypt with Password… Criptografar com senha…
|
||||
Change Password… Alterar senha…
|
||||
Remove Password Remover senha
|
||||
A password is required to open this document. É necessária uma senha para abrir este documento.
|
||||
Anyone can open this document. Encrypt it with a password to keep it private. Qualquer pessoa pode abrir este documento. Criptografe-o com uma senha para mantê-lo privado.
|
||||
Anyone who opens the document will need this password. Quem abrir o documento precisará desta senha.
|
||||
{name} is protected with a password. {name} está protegido por senha.
|
||||
That password isn't right. Check it (passwords are case-sensitive) and try again. A senha está incorreta. Verifique-a (as senhas diferenciam maiúsculas de minúsculas) e tente novamente.
|
||||
The document will be saved with a password. Save it to protect it. O documento será salvo com senha. Salve-o para protegê-lo.
|
||||
The password is removed. Save the document to keep the change. A senha foi removida. Salve o documento para manter a alteração.
|
||||
The passwords don't match. As senhas não coincidem.
|
||||
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Guarde a senha em um lugar seguro: sem ela, ninguém, nem o WordCraft, consegue abrir o documento. Deixe as duas caixas vazias para remover a senha.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -12,8 +12,10 @@
|
||||
# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters.
|
||||
|
||||
(no suggestions) (nema predloga)
|
||||
(picture or object) (slika ili objekat)
|
||||
1.5 Line Spacing Proređenost 1,5
|
||||
A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Besplatan, otvoreni obrađivač teksta napisan od nule u Rust-u.\nDeo Crafting Apps paketa tima ArtCraft.
|
||||
A password is required to open this document. Za otvaranje ovog dokumenta potrebna je lozinka.
|
||||
About O programu
|
||||
About WordCraft O WordCraft-u
|
||||
Accept Prihvati
|
||||
@@ -23,7 +25,10 @@
|
||||
Add Dodaj
|
||||
Add a comment Dodaj komentar
|
||||
Add a Table Dodaj tabelu
|
||||
Add Object to Selection Dodaj objekat u izbor
|
||||
Add Space Before Paragraph Dodaj razmak pre pasusa
|
||||
Add space between Asian and Latin text Dodaj razmak između azijskog i latiničnog teksta
|
||||
Add space between Asian text and numbers Dodaj razmak između azijskog teksta i brojeva
|
||||
Add Text Dodaj tekst
|
||||
Add to Dictionary Dodaj u rečnik
|
||||
Add to Style Gallery Dodaj u galeriju stilova
|
||||
@@ -42,7 +47,13 @@
|
||||
All Caps Sva velika slova
|
||||
All caps Sva velika slova
|
||||
All Markup Sve izmene
|
||||
Allow hanging punctuation Dozvoli viseću interpunkciju
|
||||
Allow Latin text to wrap in the middle of a word Dozvoli prelom latiničnog teksta usred reči
|
||||
Alphabetical Abecedno
|
||||
Alt Text Alternativni tekst
|
||||
Anyone can open this document. Encrypt it with a password to keep it private. Svako može da otvori ovaj dokument. Šifrujte ga lozinkom da bi ostao privatan.
|
||||
Anyone who opens the document will need this password. Svakome ko otvori dokument biće potrebna ova lozinka.
|
||||
Apply Asian line-breaking rules (kinsoku) Primeni azijska pravila preloma redova (kinsoku)
|
||||
Apply Character Style Primeni stil znakova
|
||||
Apply Style Primeni stil
|
||||
Apply Styles Izaberi i primeni stilove
|
||||
@@ -50,6 +61,8 @@
|
||||
Arrange\nAll Rasporedi\nsve
|
||||
Arrow Strelica
|
||||
Artistic Effects Umetnički efekti
|
||||
As Recommended Kao što je preporučeno
|
||||
Asian Typography Azijska tipografija
|
||||
Author Autor
|
||||
AutoCorrect Options Opcije automatske ispravke
|
||||
AutoFit Automatsko uklapanje
|
||||
@@ -64,11 +77,57 @@
|
||||
AutoSave documents you have saved in WordCraft Automatski čuvaj dokumente koje ste već sačuvali u WordCraft-u
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. Automatsko čuvanje neuspelo: {error}. Sačuvajte dokument da biste ga ponovo uključili.
|
||||
Bottom Margin Donja margina
|
||||
Based on: {style} Zasnovan na: {style}
|
||||
Built-in style Ugrađeni stil
|
||||
Built-in styles can't be deleted. Ugrađeni stilovi ne mogu da se obrišu.
|
||||
Change Password… Promeni lozinku…
|
||||
Character style Stil znakova
|
||||
Click an item to paste it. Kliknite na stavku da biste je nalepili.
|
||||
Clipboard Items Stavke ostave
|
||||
Clipboard Pane Okno ostave
|
||||
Color: {color} Boja: {color}
|
||||
Compress punctuation at the start of a line Sažmi interpunkciju na početku reda
|
||||
Confirm password: Potvrdite lozinku:
|
||||
Dark page (white text on black) Tamna stranica (beo tekst na crnom)
|
||||
Edit Comment Uredi komentar
|
||||
Left Margin Leva margina
|
||||
Reopen Ponovo otvori
|
||||
Right Margin Desna margina
|
||||
Delete Clipboard Item Obriši stavku iz ostave
|
||||
Delete Table Style Obriši stil tabele
|
||||
Encrypt with Password… Šifruj lozinkom…
|
||||
Font: {font} Font: {font}
|
||||
Glow Sjaj
|
||||
Glow Colors Boje sjaja
|
||||
Hide from style lists Sakrij sa lista stilova
|
||||
Indents and Spacing Uvlačenja i razmaci
|
||||
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Čuvajte lozinku na sigurnom mestu: bez nje niko, pa ni WordCraft, ne može da otvori dokument. Ostavite oba polja prazna da biste uklonili lozinku.
|
||||
Line breaking Prelom redova
|
||||
Linked style (paragraph and character) Povezani stil (pasus i znakovi)
|
||||
Manage Styles Upravljanje stilovima
|
||||
Manage Styles… Upravljanje stilovima…
|
||||
No Glow Bez sjaja
|
||||
No Shadow Bez senke
|
||||
No Soft Edges Bez mekih ivica
|
||||
Nothing collected yet. Items you copy or cut appear here. Još ništa nije prikupljeno. Ovde se pojavljuje ono što kopirate ili isečete.
|
||||
Offset: Bottom Pomak: dole
|
||||
Offset: Bottom Left Pomak: dole levo
|
||||
Offset: Bottom Right Pomak: dole desno
|
||||
Offset: Center Pomak: centar
|
||||
Offset: Left Pomak: levo
|
||||
Offset: Right Pomak: desno
|
||||
Offset: Top Pomak: gore
|
||||
Offset: Top Left Pomak: gore levo
|
||||
Offset: Top Right Pomak: gore desno
|
||||
Paragraph style Stil pasusa
|
||||
Password Lozinka
|
||||
Password: Lozinka:
|
||||
Paste All Nalepi sve
|
||||
Paste Clipboard Item Nalepi stavku iz ostave
|
||||
Preview Pregled
|
||||
Remove Password Ukloni lozinku
|
||||
Shadow, glow and soft edges Senka, sjaj i meke ivice
|
||||
Shape Effects Efekti oblika
|
||||
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Prikaži dokumente sa invertovanim bojama, kao kod Prikaz › Promeni režim. Čuvanje, štampanje i PDF nisu pogođeni.
|
||||
AutoSave saves every change to the file (needs a saved document) Automatsko čuvanje upisuje svaku izmenu u fajl (dokument mora biti već sačuvan)
|
||||
AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Automatsko čuvanje nije dostupno u pregledaču: ne može da upisuje u vaše fajlove. Čuvanje preuzima kopiju.
|
||||
@@ -646,8 +705,10 @@
|
||||
Show Prikaži
|
||||
Show all formatting marks Prikaži sve znake formatiranja
|
||||
Show Comments Prikaži komentare
|
||||
Show in the Styles gallery Prikaži u galeriji stilova
|
||||
Show Markup Prikaži izmene
|
||||
Show Notes Prikaži napomene
|
||||
Show or Hide Style Prikaži ili sakrij stil
|
||||
Show rulers Prikaži lenjire
|
||||
Show the community button in the title bar Prikaži dugme zajednice u traci naslova
|
||||
Show/Hide ¶ Prikaži/sakrij ¶
|
||||
@@ -663,9 +724,13 @@
|
||||
Small Caps Kapitelke
|
||||
Small caps kapitelke
|
||||
SmartArt SmartArt
|
||||
Soft Edges Meke ivice
|
||||
Sort Sortiraj
|
||||
Sort order: Redosled sortiranja:
|
||||
Source code: github.com/storytold/wordcraft Izvorni kod: github.com/storytold/wordcraft
|
||||
Space after: {pt} pt Razmak posle: {pt} pt
|
||||
Space before/after: Razmak pre/posle:
|
||||
Space before: {pt} pt Razmak pre: {pt} pt
|
||||
Spacing Razmak
|
||||
Spacing (pt): Razmak (pt):
|
||||
Speech Govor
|
||||
@@ -708,6 +773,7 @@
|
||||
Table of\nContents Sadržaj
|
||||
Table Properties Svojstva tabele
|
||||
Table Row Height Visina reda tabele
|
||||
Table style Stil tabele
|
||||
Table Style Options Opcije stila tabele
|
||||
Table Styles Stilovi tabele
|
||||
Tables Tabele
|
||||
@@ -726,7 +792,11 @@
|
||||
Text\nBox Okvir\nza tekst
|
||||
Text\nDirection Smer\nteksta
|
||||
That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Taj format ne čuva sve, pa je dokument i dalje otvoren. Sačuvajte ga kao Word dokument (.docx) da biste nastavili.
|
||||
That password isn't right. Check it (passwords are case-sensitive) and try again. Lozinka nije ispravna. Proverite je (lozinke razlikuju velika i mala slova) i pokušajte ponovo.
|
||||
The document will be saved with a password. Save it to protect it. Dokument će biti sačuvan sa lozinkom. Sačuvajte ga da biste ga zaštitili.
|
||||
The new style takes the formatting of the current paragraph. Novi stil preuzima formatiranje trenutnog pasusa.
|
||||
The password is removed. Save the document to keep the change. Lozinka je uklonjena. Sačuvajte dokument da biste zadržali izmenu.
|
||||
The passwords don't match. Lozinke se ne podudaraju.
|
||||
Theme Colors Boje teme
|
||||
Theme Fonts Fontovi teme
|
||||
Theme List Lista tema
|
||||
@@ -753,6 +823,7 @@
|
||||
Underline Words Only Podvuci samo reči
|
||||
Undo Opozovi
|
||||
Undo {action} Opozovi {action}
|
||||
Ungroup Razgrupisanje
|
||||
Up Gore
|
||||
Update Field Ažuriraj polje
|
||||
Update Index Ažuriraj indeks
|
||||
@@ -778,6 +849,7 @@
|
||||
Web Layout Veb raspored
|
||||
Web page (*.html) Veb strana (*.html)
|
||||
Web\nLayout Veb\nraspored
|
||||
Weight Debljina
|
||||
Whole page Cela strana
|
||||
Whole words Samo cele reči
|
||||
Wide Široko
|
||||
@@ -802,6 +874,8 @@
|
||||
Zoom Out Umanji
|
||||
{group} settings Podešavanja za {group}
|
||||
{key}+Click to follow link {key}+klik za otvaranje veze
|
||||
{name} is protected with a password. {name} je zaštićen lozinkom.
|
||||
{size} pt {size} pt
|
||||
Browse… Potraži…
|
||||
{words} words {words} reči
|
||||
{selected} of {words} words {selected} od {words} reči
|
||||
@@ -1038,5 +1112,4 @@
|
||||
Reset Crop Poništi isecanje
|
||||
Insert a new equation (Alt+=) Umetni novu jednačinu (Alt+=)
|
||||
Leave the equation (Esc) Napusti jednačinu (Esc)
|
||||
Preview Pregled
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -16,8 +16,10 @@
|
||||
# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters.
|
||||
|
||||
(no suggestions) (нема предлога)
|
||||
(picture or object) (слика или објекат)
|
||||
1.5 Line Spacing Проређеност 1,5
|
||||
A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Бесплатан, отворени обрађивач текста написан од нуле у Rust-у.\nДео Crafting Apps пакета тима ArtCraft.
|
||||
A password is required to open this document. За отварање овог документа потребна је лозинка.
|
||||
About О програму
|
||||
About WordCraft О WordCraft-у
|
||||
Accept Прихвати
|
||||
@@ -27,7 +29,10 @@
|
||||
Add Додај
|
||||
Add a comment Додај коментар
|
||||
Add a Table Додај табелу
|
||||
Add Object to Selection Додај објекат у избор
|
||||
Add Space Before Paragraph Додај размак пре пасуса
|
||||
Add space between Asian and Latin text Додај размак између азијског и латиничног текста
|
||||
Add space between Asian text and numbers Додај размак између азијског текста и бројева
|
||||
Add Text Додај текст
|
||||
Add to Dictionary Додај у речник
|
||||
Add to Style Gallery Додај у галерију стилова
|
||||
@@ -46,7 +51,13 @@
|
||||
All Caps Сва велика слова
|
||||
All caps Сва велика слова
|
||||
All Markup Све измене
|
||||
Allow hanging punctuation Дозволи висећу интерпункцију
|
||||
Allow Latin text to wrap in the middle of a word Дозволи прелом латиничног текста усред речи
|
||||
Alphabetical Азбучно
|
||||
Alt Text Алтернативни текст
|
||||
Anyone can open this document. Encrypt it with a password to keep it private. Свако може да отвори овај документ. Шифрујте га лозинком да би остао приватан.
|
||||
Anyone who opens the document will need this password. Свакоме ко отвори документ биће потребна ова лозинка.
|
||||
Apply Asian line-breaking rules (kinsoku) Примени азијска правила прелома редова (кинсоку)
|
||||
Apply Character Style Примени стил знакова
|
||||
Apply Style Примени стил
|
||||
Apply Styles Изабери и примени стилове
|
||||
@@ -54,6 +65,8 @@
|
||||
Arrange\nAll Распореди\nсве
|
||||
Arrow Стрелица
|
||||
Artistic Effects Уметнички ефекти
|
||||
As Recommended Као што је препоручено
|
||||
Asian Typography Азијска типографија
|
||||
Author Аутор
|
||||
AutoCorrect Options Опције аутоматске исправке
|
||||
AutoFit Аутоматско уклапање
|
||||
@@ -68,11 +81,57 @@
|
||||
AutoSave documents you have saved in WordCraft Аутоматски чувај документе које сте већ сачували у WordCraft-у
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. Аутоматско чување неуспело: {error}. Сачувајте документ да бисте га поново укључили.
|
||||
Bottom Margin Доња маргина
|
||||
Based on: {style} Заснован на: {style}
|
||||
Built-in style Уграђени стил
|
||||
Built-in styles can't be deleted. Уграђени стилови не могу да се обришу.
|
||||
Change Password… Промени лозинку…
|
||||
Character style Стил знакова
|
||||
Click an item to paste it. Кликните на ставку да бисте је налепили.
|
||||
Clipboard Items Ставке оставе
|
||||
Clipboard Pane Окно оставе
|
||||
Color: {color} Боја: {color}
|
||||
Compress punctuation at the start of a line Сажми интерпункцију на почетку реда
|
||||
Confirm password: Потврдите лозинку:
|
||||
Dark page (white text on black) Тамна страница (бео текст на црном)
|
||||
Edit Comment Уреди коментар
|
||||
Left Margin Лева маргина
|
||||
Reopen Поново отвори
|
||||
Right Margin Десна маргина
|
||||
Delete Clipboard Item Обриши ставку из оставе
|
||||
Delete Table Style Обриши стил табеле
|
||||
Encrypt with Password… Шифруј лозинком…
|
||||
Font: {font} Фонт: {font}
|
||||
Glow Сјај
|
||||
Glow Colors Боје сјаја
|
||||
Hide from style lists Сакриј са листа стилова
|
||||
Indents and Spacing Увлачења и размаци
|
||||
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Чувајте лозинку на сигурном месту: без ње нико, па ни WordCraft, не може да отвори документ. Оставите оба поља празна да бисте уклонили лозинку.
|
||||
Line breaking Прелом редова
|
||||
Linked style (paragraph and character) Повезани стил (пасус и знакови)
|
||||
Manage Styles Управљање стиловима
|
||||
Manage Styles… Управљање стиловима…
|
||||
No Glow Без сјаја
|
||||
No Shadow Без сенке
|
||||
No Soft Edges Без меких ивица
|
||||
Nothing collected yet. Items you copy or cut appear here. Још ништа није прикупљено. Овде се појављује оно што копирате или исечете.
|
||||
Offset: Bottom Помак: доле
|
||||
Offset: Bottom Left Помак: доле лево
|
||||
Offset: Bottom Right Помак: доле десно
|
||||
Offset: Center Помак: центар
|
||||
Offset: Left Помак: лево
|
||||
Offset: Right Помак: десно
|
||||
Offset: Top Помак: горе
|
||||
Offset: Top Left Помак: горе лево
|
||||
Offset: Top Right Помак: горе десно
|
||||
Paragraph style Стил пасуса
|
||||
Password Лозинка
|
||||
Password: Лозинка:
|
||||
Paste All Налепи све
|
||||
Paste Clipboard Item Налепи ставку из оставе
|
||||
Preview Преглед
|
||||
Remove Password Уклони лозинку
|
||||
Shadow, glow and soft edges Сенка, сјај и меке ивице
|
||||
Shape Effects Ефекти облика
|
||||
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Прикажи документе са инвертованим бојама, као код Приказ › Промени режим. Чување, штампање и PDF нису погођени.
|
||||
AutoSave saves every change to the file (needs a saved document) Аутоматско чување уписује сваку измену у фајл (документ мора бити већ сачуван)
|
||||
AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Аутоматско чување није доступно у прегледачу: не може да уписује у ваше фајлове. Чување преузима копију.
|
||||
@@ -650,8 +709,10 @@
|
||||
Show Прикажи
|
||||
Show all formatting marks Прикажи све знаке форматирања
|
||||
Show Comments Прикажи коментаре
|
||||
Show in the Styles gallery Прикажи у галерији стилова
|
||||
Show Markup Прикажи измене
|
||||
Show Notes Прикажи напомене
|
||||
Show or Hide Style Прикажи или сакриј стил
|
||||
Show rulers Прикажи лењире
|
||||
Show the community button in the title bar Прикажи дугме заједнице у траци наслова
|
||||
Show/Hide ¶ Прикажи/сакриј ¶
|
||||
@@ -667,9 +728,13 @@
|
||||
Small Caps Капителке
|
||||
Small caps капителке
|
||||
SmartArt SmartArt
|
||||
Soft Edges Меке ивице
|
||||
Sort Сортирај
|
||||
Sort order: Редослед сортирања:
|
||||
Source code: github.com/storytold/wordcraft Изворни код: github.com/storytold/wordcraft
|
||||
Space after: {pt} pt Размак после: {pt} пт
|
||||
Space before/after: Размак пре/после:
|
||||
Space before: {pt} pt Размак пре: {pt} пт
|
||||
Spacing Размак
|
||||
Spacing (pt): Размак (пт):
|
||||
Speech Говор
|
||||
@@ -712,6 +777,7 @@
|
||||
Table of\nContents Садржај
|
||||
Table Properties Својства табеле
|
||||
Table Row Height Висина реда табеле
|
||||
Table style Стил табеле
|
||||
Table Style Options Опције стила табеле
|
||||
Table Styles Стилови табеле
|
||||
Tables Табеле
|
||||
@@ -730,7 +796,11 @@
|
||||
Text\nBox Оквир\nза текст
|
||||
Text\nDirection Смер\nтекста
|
||||
That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Тај формат не чува све, па је документ и даље отворен. Сачувајте га као Word документ (.docx) да бисте наставили.
|
||||
That password isn't right. Check it (passwords are case-sensitive) and try again. Лозинка није исправна. Проверите је (лозинке разликују велика и мала слова) и покушајте поново.
|
||||
The document will be saved with a password. Save it to protect it. Документ ће бити сачуван са лозинком. Сачувајте га да бисте га заштитили.
|
||||
The new style takes the formatting of the current paragraph. Нови стил преузима форматирање тренутног пасуса.
|
||||
The password is removed. Save the document to keep the change. Лозинка је уклоњена. Сачувајте документ да бисте задржали измену.
|
||||
The passwords don't match. Лозинке се не подударају.
|
||||
Theme Colors Боје теме
|
||||
Theme Fonts Фонтови теме
|
||||
Theme List Листа тема
|
||||
@@ -757,6 +827,7 @@
|
||||
Underline Words Only Подвуци само речи
|
||||
Undo Опозови
|
||||
Undo {action} Опозови {action}
|
||||
Ungroup Разгруписање
|
||||
Up Горе
|
||||
Update Field Ажурирај поље
|
||||
Update Index Ажурирај индекс
|
||||
@@ -782,6 +853,7 @@
|
||||
Web Layout Веб распоред
|
||||
Web page (*.html) Веб страна (*.html)
|
||||
Web\nLayout Веб\nраспоред
|
||||
Weight Дебљина
|
||||
Whole page Цела страна
|
||||
Whole words Само целе речи
|
||||
Wide Широко
|
||||
@@ -806,6 +878,8 @@
|
||||
Zoom Out Умањи
|
||||
{group} settings Подешавања за {group}
|
||||
{key}+Click to follow link {key}+клик за отварање везе
|
||||
{name} is protected with a password. {name} је заштићен лозинком.
|
||||
{size} pt {size} пт
|
||||
Browse… Потражи…
|
||||
{words} words {words} речи
|
||||
{selected} of {words} words {selected} од {words} речи
|
||||
@@ -1042,5 +1116,4 @@
|
||||
Reset Crop Поништи исецање
|
||||
Insert a new equation (Alt+=) Уметни нову једначину (Alt+=)
|
||||
Leave the equation (Esc) Напусти једначину (Esc)
|
||||
Preview Преглед
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -23,7 +23,10 @@
|
||||
Add Додати
|
||||
Add a comment Додати коментар
|
||||
Add a Table Додати таблицю
|
||||
Add Object to Selection Додати об'єкт до виділення
|
||||
Add Space Before Paragraph Додати інтервал перед абзацом
|
||||
Add space between Asian and Latin text Додавати інтервал між азійським і латинським текстом
|
||||
Add space between Asian text and numbers Додавати інтервал між азійським текстом і числами
|
||||
Add Text Додати текст
|
||||
Add to Dictionary Додати до словника
|
||||
Add to Style Gallery Додати до колекції стилів
|
||||
@@ -44,13 +47,17 @@
|
||||
All Caps Усі великі літери
|
||||
All caps Усі великі літери
|
||||
All Markup Усі позначки
|
||||
Allow hanging punctuation Дозволити висячу пунктуацію
|
||||
Allow Latin text to wrap in the middle of a word Дозволити розрив латинського тексту посеред слова
|
||||
Alt Text Альтернативний текст
|
||||
Apply Asian line-breaking rules (kinsoku) Застосовувати азійські правила розриву рядків (кінсоку)
|
||||
Apply Character Style Застосувати стиль символів
|
||||
Apply Style Застосувати стиль
|
||||
Arrange Упорядкувати
|
||||
Arrange\nAll Упорядкувати\nвсі
|
||||
Arrow Стрілка
|
||||
Artistic Effects Художні ефекти
|
||||
Asian Typography Азійська типографіка
|
||||
Author Автор
|
||||
AutoCorrect Options Параметри автовиправлення
|
||||
AutoFit Добір розміру
|
||||
@@ -132,6 +139,14 @@
|
||||
Clear Formatting Level Очистити рівень форматування
|
||||
Clear Paragraph Formatting Очистити форматування абзацу
|
||||
Clipboard Буфер обміну
|
||||
Clipboard Pane Панель буфера обміну
|
||||
Clipboard Items Елементи буфера обміну
|
||||
Paste Clipboard Item Вставити елемент буфера обміну
|
||||
Paste All Вставити все
|
||||
Delete Clipboard Item Видалити елемент буфера обміну
|
||||
Click an item to paste it. Клацніть елемент, щоб вставити його.
|
||||
Nothing collected yet. Items you copy or cut appear here. Поки що порожньо. Тут з'являться елементи, які ви скопіюєте або виріжете.
|
||||
(picture or object) (зображення або об'єкт)
|
||||
Close Закрити
|
||||
Close Header and Footer Закрити колонтитули
|
||||
Collapse Selection Зняти виділення
|
||||
@@ -150,6 +165,7 @@
|
||||
Compact Компактний
|
||||
Compare Порівняти
|
||||
Compress Pictures Стиснути зображення
|
||||
Compress punctuation at the start of a line Стискати розділові знаки на початку рядка
|
||||
Continuous Безперервний
|
||||
Convert Перетворити
|
||||
Convert Text to Table Перетворити текст на таблицю
|
||||
@@ -194,6 +210,8 @@
|
||||
Design Дизайн
|
||||
Edit Comment Редагувати коментар
|
||||
Left Margin Ліве поле
|
||||
Indents and Spacing Відступи й інтервали
|
||||
Line breaking Розрив рядків
|
||||
More templates → Більше шаблонів →
|
||||
No templates match your search. Немає шаблонів, що відповідають пошуку.
|
||||
Reopen Відкрити знову
|
||||
@@ -771,6 +789,7 @@
|
||||
Underline Words Only Підкреслити лише слова
|
||||
Undo Скасувати
|
||||
Undo {action} Скасувати {action}
|
||||
Ungroup Розгрупувати
|
||||
Unformatted Text Неформатований текст
|
||||
Up Угору
|
||||
Update Field Оновити поле
|
||||
@@ -1041,3 +1060,57 @@
|
||||
Leave the equation (Esc) Вийти з рівняння (Esc)
|
||||
Preview Попередній перегляд
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects Ефекти фігури
|
||||
Glow Світіння
|
||||
Soft Edges М'які краї
|
||||
No Shadow Без тіні
|
||||
No Glow Без світіння
|
||||
No Soft Edges Без м'яких країв
|
||||
Glow Colors Кольори світіння
|
||||
Weight Товщина
|
||||
Shadow, glow and soft edges Тінь, світіння та м'які краї
|
||||
Offset: Bottom Right Зміщення: вниз праворуч
|
||||
Offset: Bottom Зміщення: вниз
|
||||
Offset: Bottom Left Зміщення: вниз ліворуч
|
||||
Offset: Right Зміщення: праворуч
|
||||
Offset: Center Зміщення: по центру
|
||||
Offset: Left Зміщення: ліворуч
|
||||
Offset: Top Right Зміщення: вгору праворуч
|
||||
Offset: Top Зміщення: вгору
|
||||
Offset: Top Left Зміщення: вгору ліворуч
|
||||
Manage Styles Керування стилями
|
||||
Show or Hide Style Показати або сховати стиль
|
||||
Manage Styles… Керування стилями…
|
||||
Sort order: Порядок сортування:
|
||||
As Recommended Як рекомендовано
|
||||
Alphabetical За абеткою
|
||||
Font: {font} Шрифт: {font}
|
||||
{size} pt {size} пт
|
||||
Color: {color} Колір: {color}
|
||||
Space before: {pt} pt Інтервал перед: {pt} пт
|
||||
Space after: {pt} pt Інтервал після: {pt} пт
|
||||
Based on: {style} На основі: {style}
|
||||
Paragraph style Стиль абзацу
|
||||
Character style Стиль символів
|
||||
Linked style (paragraph and character) Зв’язаний стиль (абзац і символи)
|
||||
Table style Стиль таблиці
|
||||
Built-in style Вбудований стиль
|
||||
Show in the Styles gallery Показувати в колекції стилів
|
||||
Hide from style lists Сховати зі списків стилів
|
||||
Built-in styles can't be deleted. Вбудовані стилі не можна видалити.
|
||||
Delete Table Style Видалити стиль таблиці
|
||||
Password Пароль
|
||||
Password: Пароль:
|
||||
Confirm password: Підтвердження пароля:
|
||||
Encrypt with Password… Зашифрувати паролем…
|
||||
Change Password… Змінити пароль…
|
||||
Remove Password Видалити пароль
|
||||
A password is required to open this document. Щоб відкрити цей документ, потрібен пароль.
|
||||
Anyone can open this document. Encrypt it with a password to keep it private. Цей документ може відкрити будь-хто. Зашифруйте його паролем, щоб захистити.
|
||||
Anyone who opens the document will need this password. Кожному, хто відкриватиме документ, знадобиться цей пароль.
|
||||
{name} is protected with a password. {name} захищено паролем.
|
||||
That password isn't right. Check it (passwords are case-sensitive) and try again. Пароль неправильний. Перевірте його (паролі враховують регістр) і спробуйте ще раз.
|
||||
The document will be saved with a password. Save it to protect it. Документ буде збережено з паролем. Збережіть його, щоб захистити.
|
||||
The password is removed. Save the document to keep the change. Пароль видалено. Збережіть документ, щоб зберегти зміну.
|
||||
The passwords don't match. Паролі не збігаються.
|
||||
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Зберігайте пароль у надійному місці: без нього ніхто, зокрема WordCraft, не зможе відкрити документ. Залиште обидва поля порожніми, щоб видалити пароль.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -23,7 +23,10 @@
|
||||
Add 添加
|
||||
Add a comment 添加批注
|
||||
Add a Table 添加表格
|
||||
Add Object to Selection 将对象添加到所选内容
|
||||
Add Space Before Paragraph 在段前添加间距
|
||||
Add space between Asian and Latin text 在亚洲文字与西文之间添加间距
|
||||
Add space between Asian text and numbers 在亚洲文字与数字之间添加间距
|
||||
Add Text 添加文字
|
||||
Add to Dictionary 添加到词典
|
||||
Add to Style Gallery 添加到样式库
|
||||
@@ -42,7 +45,10 @@
|
||||
All Caps 全部大写
|
||||
All caps 全部大写
|
||||
All Markup 所有标记
|
||||
Allow hanging punctuation 允许标点溢出边界
|
||||
Allow Latin text to wrap in the middle of a word 允许西文在单词中间换行
|
||||
Alt Text 替代文字
|
||||
Apply Asian line-breaking rules (kinsoku) 应用亚洲文字的断行规则(禁则)
|
||||
Apply Character Style 应用字符样式
|
||||
Apply Style 应用样式
|
||||
Apply Styles 选择并应用样式
|
||||
@@ -50,6 +56,7 @@
|
||||
Arrange\nAll 全部重排
|
||||
Arrow 箭头
|
||||
Artistic Effects 艺术效果
|
||||
Asian Typography 亚洲版式
|
||||
Author 作者
|
||||
AutoCorrect Options 自动更正选项
|
||||
AutoFit 自动调整
|
||||
@@ -64,9 +71,12 @@
|
||||
AutoSave documents you have saved in WordCraft 自动保存在 WordCraft 中保存过的文档
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自动保存失败:{error}。重新保存文档后将恢复自动保存。
|
||||
Bottom Margin 下边距
|
||||
Compress punctuation at the start of a line 压缩行首标点
|
||||
Dark page (white text on black) 深色页面(黑底白字)
|
||||
Edit Comment 编辑批注
|
||||
Left Margin 左边距
|
||||
Indents and Spacing 缩进和间距
|
||||
Line breaking 断行
|
||||
More templates → 更多模板 →
|
||||
No templates match your search. 没有与搜索匹配的模板。
|
||||
Reopen 重新打开
|
||||
@@ -137,6 +147,15 @@
|
||||
Clear 清除
|
||||
Clear All Formatting 清除所有格式
|
||||
Clipboard 剪贴板
|
||||
Clipboard Pane 剪贴板窗格
|
||||
Clipboard Items 剪贴板项目
|
||||
Paste Clipboard Item 粘贴剪贴板项目
|
||||
Paste All 全部粘贴
|
||||
Delete Clipboard Item 删除剪贴板项目
|
||||
Clear All 全部清除
|
||||
Click an item to paste it. 单击项目即可粘贴。
|
||||
Nothing collected yet. Items you copy or cut appear here. 尚无内容。复制或剪切的项目会显示在这里。
|
||||
(picture or object) (图片或对象)
|
||||
Close 关闭
|
||||
Close Header and Footer 关闭页眉和页脚
|
||||
Collapse Selection 折叠选定内容
|
||||
@@ -756,6 +775,7 @@
|
||||
Underline Words Only 仅给字词加下划线
|
||||
Undo 撤消
|
||||
Undo {action} 撤消{action}
|
||||
Ungroup 取消组合
|
||||
Up 上
|
||||
Update Field 更新域
|
||||
Update Index 更新索引
|
||||
@@ -909,7 +929,6 @@
|
||||
Clear Paragraph Formatting 清除段落格式
|
||||
Clear Character Style 清除字符样式
|
||||
Clear Character Formatting 清除字符格式
|
||||
Clear All 全部清除
|
||||
Paste Special 选择性粘贴
|
||||
Paste Special… 选择性粘贴…
|
||||
Paste as: 粘贴为:
|
||||
@@ -1040,3 +1059,57 @@
|
||||
Leave the equation (Esc) 退出公式 (Esc)
|
||||
Preview 预览
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects 形状效果
|
||||
Glow 发光
|
||||
Soft Edges 柔化边缘
|
||||
No Shadow 无阴影
|
||||
No Glow 无发光
|
||||
No Soft Edges 无柔化边缘
|
||||
Glow Colors 发光颜色
|
||||
Weight 粗细
|
||||
Shadow, glow and soft edges 阴影、发光和柔化边缘
|
||||
Offset: Bottom Right 偏移:右下
|
||||
Offset: Bottom 偏移:下
|
||||
Offset: Bottom Left 偏移:左下
|
||||
Offset: Right 偏移:右
|
||||
Offset: Center 偏移:居中
|
||||
Offset: Left 偏移:左
|
||||
Offset: Top Right 偏移:右上
|
||||
Offset: Top 偏移:上
|
||||
Offset: Top Left 偏移:左上
|
||||
Manage Styles 管理样式
|
||||
Show or Hide Style 显示或隐藏样式
|
||||
Manage Styles… 管理样式…
|
||||
Sort order: 排序方式:
|
||||
As Recommended 按推荐
|
||||
Alphabetical 按字母顺序
|
||||
Font: {font} 字体:{font}
|
||||
{size} pt {size} 磅
|
||||
Color: {color} 颜色:{color}
|
||||
Space before: {pt} pt 段前间距:{pt} 磅
|
||||
Space after: {pt} pt 段后间距:{pt} 磅
|
||||
Based on: {style} 基于:{style}
|
||||
Paragraph style 段落样式
|
||||
Character style 字符样式
|
||||
Linked style (paragraph and character) 链接样式(段落和字符)
|
||||
Table style 表格样式
|
||||
Built-in style 内置样式
|
||||
Show in the Styles gallery 在样式库中显示
|
||||
Hide from style lists 在样式列表中隐藏
|
||||
Built-in styles can't be deleted. 内置样式不能删除。
|
||||
Delete Table Style 删除表格样式
|
||||
Password 密码
|
||||
Password: 密码:
|
||||
Confirm password: 确认密码:
|
||||
Encrypt with Password… 用密码进行加密…
|
||||
Change Password… 更改密码…
|
||||
Remove Password 删除密码
|
||||
A password is required to open this document. 打开此文档需要密码。
|
||||
Anyone can open this document. Encrypt it with a password to keep it private. 任何人都可以打开此文档。用密码加密可保护其隐私。
|
||||
Anyone who opens the document will need this password. 打开文档的任何人都需要此密码。
|
||||
{name} is protected with a password. {name} 受密码保护。
|
||||
That password isn't right. Check it (passwords are case-sensitive) and try again. 密码不正确。请检查密码(区分大小写)后重试。
|
||||
The document will be saved with a password. Save it to protect it. 文档将使用密码保存。保存文档以保护它。
|
||||
The password is removed. Save the document to keep the change. 密码已删除。保存文档以保留此更改。
|
||||
The passwords don't match. 两次输入的密码不一致。
|
||||
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. 请妥善保管密码:没有密码,任何人(包括 WordCraft)都无法打开文档。两个框都留空可删除密码。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -13,7 +13,10 @@
|
||||
Add 新增
|
||||
Add a comment 新增註解
|
||||
Add a Table 新增表格
|
||||
Add Object to Selection 將物件加入選取範圍
|
||||
Add Space Before Paragraph 在段落前新增間距
|
||||
Add space between Asian and Latin text 在亞洲文字與西文之間加入間距
|
||||
Add space between Asian text and numbers 在亞洲文字與數字之間加入間距
|
||||
Add Text 新增文字
|
||||
Add to Dictionary 新增至字典
|
||||
Add to Style Gallery 新增至樣式庫
|
||||
@@ -32,7 +35,10 @@
|
||||
All Caps 全部大寫
|
||||
All caps 全部大寫
|
||||
All Markup 所有標記
|
||||
Allow hanging punctuation 允許標點溢出邊界
|
||||
Allow Latin text to wrap in the middle of a word 允許西文在單字中間換行
|
||||
Alt Text 替代文字
|
||||
Apply Asian line-breaking rules (kinsoku) 套用亞洲文字的斷行規則(禁則)
|
||||
Apply Character Style 套用字元樣式
|
||||
Apply Style 套用樣式
|
||||
Apply Styles 選擇並套用樣式
|
||||
@@ -40,6 +46,7 @@
|
||||
Arrange\nAll 全部排列
|
||||
Arrow 箭號
|
||||
Artistic Effects 美術效果
|
||||
Asian Typography 亞洲版式
|
||||
Author 作者
|
||||
AutoCorrect Options 自動校正選項
|
||||
AutoFit 自動調整
|
||||
@@ -54,9 +61,12 @@
|
||||
AutoSave documents you have saved in WordCraft 自動儲存在 WordCraft 中儲存過的文件
|
||||
AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動儲存失敗:{error}。重新儲存文件後將恢復自動儲存。
|
||||
Bottom Margin 下邊界
|
||||
Compress punctuation at the start of a line 壓縮行首標點
|
||||
Dark page (white text on black) 深色頁面(黑底白字)
|
||||
Edit Comment 編輯註解
|
||||
Left Margin 左邊界
|
||||
Indents and Spacing 縮排和間距
|
||||
Line breaking 斷行
|
||||
More templates → 更多範本 →
|
||||
No templates match your search. 沒有符合搜尋的範本。
|
||||
Reopen 重新開啟
|
||||
@@ -127,6 +137,15 @@
|
||||
Clear 清除
|
||||
Clear All Formatting 清除所有格式設定
|
||||
Clipboard 剪貼簿
|
||||
Clipboard Pane 剪貼簿窗格
|
||||
Clipboard Items 剪貼簿項目
|
||||
Paste Clipboard Item 貼上剪貼簿項目
|
||||
Paste All 全部貼上
|
||||
Delete Clipboard Item 刪除剪貼簿項目
|
||||
Clear All 全部清除
|
||||
Click an item to paste it. 按一下項目即可貼上。
|
||||
Nothing collected yet. Items you copy or cut appear here. 尚無內容。複製或剪下的項目會顯示在這裡。
|
||||
(picture or object) (圖片或物件)
|
||||
Close 關閉
|
||||
Close Header and Footer 關閉頁首及頁尾
|
||||
Collapse Selection 摺疊選取範圍
|
||||
@@ -746,6 +765,7 @@
|
||||
Underline Words Only 只在文字下加底線
|
||||
Undo 復原
|
||||
Undo {action} 復原{action}
|
||||
Ungroup 取消群組
|
||||
Up 上
|
||||
Update Field 更新功能變數
|
||||
Update Index 更新索引
|
||||
@@ -899,7 +919,6 @@
|
||||
Clear Paragraph Formatting 清除段落格式設定
|
||||
Clear Character Style 清除字元樣式
|
||||
Clear Character Formatting 清除字元格式設定
|
||||
Clear All 全部清除
|
||||
Paste Special 選擇性貼上
|
||||
Paste Special… 選擇性貼上…
|
||||
Paste as: 貼上為:
|
||||
@@ -1030,3 +1049,57 @@
|
||||
Leave the equation (Esc) 離開方程式 (Esc)
|
||||
Preview 預覽
|
||||
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
|
||||
Shape Effects 圖案效果
|
||||
Glow 光暈
|
||||
Soft Edges 柔化邊緣
|
||||
No Shadow 無陰影
|
||||
No Glow 無光暈
|
||||
No Soft Edges 無柔化邊緣
|
||||
Glow Colors 光暈色彩
|
||||
Weight 粗細
|
||||
Shadow, glow and soft edges 陰影、光暈和柔化邊緣
|
||||
Offset: Bottom Right 位移:右下
|
||||
Offset: Bottom 位移:下
|
||||
Offset: Bottom Left 位移:左下
|
||||
Offset: Right 位移:右
|
||||
Offset: Center 位移:置中
|
||||
Offset: Left 位移:左
|
||||
Offset: Top Right 位移:右上
|
||||
Offset: Top 位移:上
|
||||
Offset: Top Left 位移:左上
|
||||
Manage Styles 管理樣式
|
||||
Show or Hide Style 顯示或隱藏樣式
|
||||
Manage Styles… 管理樣式…
|
||||
Sort order: 排序方式:
|
||||
As Recommended 依建議
|
||||
Alphabetical 依字母順序
|
||||
Font: {font} 字型:{font}
|
||||
{size} pt {size} 點
|
||||
Color: {color} 色彩:{color}
|
||||
Space before: {pt} pt 段落前間距:{pt} 點
|
||||
Space after: {pt} pt 段落後間距:{pt} 點
|
||||
Based on: {style} 根據:{style}
|
||||
Paragraph style 段落樣式
|
||||
Character style 字元樣式
|
||||
Linked style (paragraph and character) 連結樣式(段落和字元)
|
||||
Table style 表格樣式
|
||||
Built-in style 內建樣式
|
||||
Show in the Styles gallery 在樣式庫中顯示
|
||||
Hide from style lists 從樣式清單中隱藏
|
||||
Built-in styles can't be deleted. 無法刪除內建樣式。
|
||||
Delete Table Style 刪除表格樣式
|
||||
Password 密碼
|
||||
Password: 密碼:
|
||||
Confirm password: 確認密碼:
|
||||
Encrypt with Password… 以密碼加密…
|
||||
Change Password… 變更密碼…
|
||||
Remove Password 移除密碼
|
||||
A password is required to open this document. 開啟此文件需要密碼。
|
||||
Anyone can open this document. Encrypt it with a password to keep it private. 任何人都可以開啟此文件。以密碼加密可保護其隱私。
|
||||
Anyone who opens the document will need this password. 開啟文件的任何人都需要此密碼。
|
||||
{name} is protected with a password. {name} 受密碼保護。
|
||||
That password isn't right. Check it (passwords are case-sensitive) and try again. 密碼不正確。請檢查密碼(區分大小寫)後再試一次。
|
||||
The document will be saved with a password. Save it to protect it. 文件將以密碼儲存。儲存文件以保護它。
|
||||
The password is removed. Save the document to keep the change. 密碼已移除。儲存文件以保留此變更。
|
||||
The passwords don't match. 兩次輸入的密碼不一致。
|
||||
Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. 請妥善保管密碼:沒有密碼,任何人(包括 WordCraft)都無法開啟文件。兩個方塊都留空可移除密碼。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 440 and column 13.
|
@@ -312,6 +312,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
||||
pen.rect(3.0, 3.0, 11.0, 11.0, c);
|
||||
pen.fcircle(13.0, 13.0, 4.5, a);
|
||||
}
|
||||
// Shape Effects: a square casting a soft shadow down and to the right.
|
||||
"shapeEffects" => {
|
||||
for (k, alpha) in [(2.0, 30), (1.0, 50), (0.0, 80)] {
|
||||
pen.frect(7.0 - k, 7.0 - k, 18.0 + k, 18.0 + k, Color32::from_rgba_unmultiplied(0, 0, 0, alpha));
|
||||
}
|
||||
pen.frect(3.0, 3.0, 14.0, 14.0, a);
|
||||
pen.rect(3.0, 3.0, 14.0, 14.0, c);
|
||||
}
|
||||
"icons" => {
|
||||
pen.circle(10.0, 10.0, 7.5, c);
|
||||
pen.fcircle(7.5, 8.0, 1.0, c);
|
||||
|
||||
+131
-12
@@ -114,7 +114,8 @@ pub struct UiState {
|
||||
/// that follows ends a chord (Alt+click, Alt+drag column selection), not a keytip tap.
|
||||
#[serde(skip)]
|
||||
pub alt_chord_used: bool,
|
||||
/// View › Switch Modes: show pages dark (white text on black), kept between runs.
|
||||
/// View › Switch Modes: show pages dark (white text on black), kept between runs. Only the
|
||||
/// pages: the interface follows [`UiState::theme`] (#312).
|
||||
pub dark_page: bool,
|
||||
}
|
||||
|
||||
@@ -175,7 +176,8 @@ pub struct WordApp {
|
||||
/// File ▸ Options: load the installed CJK fallback font if no embedded face covers it.
|
||||
pub(crate) want_system_cjk: bool,
|
||||
fonts_frames: u32,
|
||||
applied_dark: Option<bool>,
|
||||
/// The interface theme setting last installed in egui ([`theme::apply`]).
|
||||
applied_theme: Option<theme::Appearance>,
|
||||
pub frame_ms: f64,
|
||||
/// The window title last sent; a viewport command schedules a repaint, so only send changes.
|
||||
sent_title: String,
|
||||
@@ -245,7 +247,7 @@ impl WordApp {
|
||||
fonts_system_cjk: false,
|
||||
want_system_cjk: false,
|
||||
fonts_frames: 0,
|
||||
applied_dark: None,
|
||||
applied_theme: None,
|
||||
frame_ms: 0.0,
|
||||
sent_title: String::new(),
|
||||
quit_requested: false,
|
||||
@@ -326,7 +328,7 @@ impl WordApp {
|
||||
{
|
||||
return r;
|
||||
}
|
||||
let r = self.execute(id, params);
|
||||
let r = self.execute_user(id, params);
|
||||
// Match Fields and Check for Errors show what they found.
|
||||
if let Ok(v) = &r
|
||||
&& let Some(d) = dialogs::Dialog::report(id, v)
|
||||
@@ -363,11 +365,23 @@ impl WordApp {
|
||||
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 = 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")
|
||||
&& let Some(pw) = params.get("password").filter(|v| !v.is_null())
|
||||
{
|
||||
if !wordcraft_engine::io::can_encrypt(&name) || pw.as_str().is_none_or(str::is_empty) {
|
||||
return Err("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password, and it can't be empty".into());
|
||||
}
|
||||
self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
// A save is a save, download or not: advance the revision and modified stamp (#262).
|
||||
if matches!(id, "file.save" | "file.saveAs") {
|
||||
self.session.stamp_save();
|
||||
}
|
||||
let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?;
|
||||
// Word documents are encrypted with the document's password, if it has one.
|
||||
let password =
|
||||
if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None };
|
||||
let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password)?;
|
||||
if let Some(d) = &self.services.download
|
||||
&& let Err(e) = d(&name, &bytes)
|
||||
{
|
||||
@@ -434,7 +448,23 @@ impl WordApp {
|
||||
if !go {
|
||||
return Ok(json!({"done": false}));
|
||||
}
|
||||
self.execute(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
}
|
||||
|
||||
/// [`WordApp::execute`] for something the user did: a document that turns out to be
|
||||
/// password-protected asks for the password (and opens with it) instead of failing (#55).
|
||||
pub(crate) fn execute_user(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let retry = (id == "file.open").then(|| params.clone());
|
||||
let r = self.execute(id, params);
|
||||
if let (Err(e), Some(params)) = (&r, retry)
|
||||
&& wordcraft_engine::io::needs_password(e)
|
||||
{
|
||||
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() });
|
||||
return Ok(json!({"pending": "password"}));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Save before New/Open/Close (and the mailings that replace the document): true once the
|
||||
@@ -736,12 +766,12 @@ impl WordApp {
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
self.styled = true;
|
||||
}
|
||||
// Re-checked every frame, so `System` follows an OS appearance change live (egui reports it
|
||||
// and repaints).
|
||||
let dark = self.ui.theme.is_dark(ctx.system_theme()) || self.session.view.dark_mode;
|
||||
if self.applied_dark != Some(dark) {
|
||||
theme::apply(ctx, &if dark { theme::Tokens::dark() } else { theme::Tokens::light() });
|
||||
self.applied_dark = Some(dark);
|
||||
// Both palettes are installed and egui picks one: with `System` it follows an OS appearance
|
||||
// change live (it reports it and repaints, #311). The interface follows the Interface theme
|
||||
// setting alone; Dark page only changes how pages are drawn (#312).
|
||||
if self.applied_theme != Some(self.ui.theme) {
|
||||
theme::apply(ctx, self.ui.theme);
|
||||
self.applied_theme = Some(self.ui.theme);
|
||||
}
|
||||
if self.ctx.is_none() {
|
||||
self.ctx = Some(ctx.clone());
|
||||
@@ -1052,6 +1082,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
|
||||
"mailings.findRecipient" if !has("text") => Some("findRecipient"),
|
||||
// Table Properties without settings shows the dialog (with settings it applies them).
|
||||
"table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"),
|
||||
// `null` is an answer here: it removes the password.
|
||||
"file.encrypt" if params.get("password").is_none() => Some("encryptPassword"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1411,6 +1443,34 @@ mod tests {
|
||||
assert!(!body_text(&a).trim().is_empty());
|
||||
}
|
||||
|
||||
/// #55: a password-protected document asks for its password (after Save Changes) instead of
|
||||
/// opening blank; scripts get the error instead of a dialog; Encrypt with Password asks for one.
|
||||
#[test]
|
||||
fn password_protected_documents_ask_for_the_password() {
|
||||
let dir = scratch("password");
|
||||
let path = dir.join("locked.docx");
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Locked away"));
|
||||
s.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap();
|
||||
|
||||
let mut a = typed();
|
||||
assert!(a.execute("file.open", json!({"path": path.to_string_lossy()})).is_err(), "a script gets the error");
|
||||
assert_eq!(prompt(&a), None);
|
||||
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||||
assert_eq!(prompt(&a), Some("password"));
|
||||
assert!(body_text(&a).contains(UNSAVED), "nothing is replaced until the password fits");
|
||||
let state = serde_json::to_string(&a.dialog).unwrap();
|
||||
assert!(state.contains("locked.docx") && !state.contains("sesame"));
|
||||
|
||||
let mut b = app();
|
||||
b.run("file.encrypt", json!({})).unwrap();
|
||||
assert_eq!(prompt(&b), Some("encryptPassword"));
|
||||
b.dialog = None;
|
||||
b.run("file.encrypt", json!({"password": null})).unwrap();
|
||||
assert_eq!(prompt(&b), None, "null removes the password without asking");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Open (after the file is picked), recent files and dropped files all go through `file.open`.
|
||||
#[test]
|
||||
fn open_asks_before_discarding_unsaved_work() {
|
||||
@@ -2164,6 +2224,65 @@ mod tests {
|
||||
assert_eq!(a.run("ui.theme", json!({})).unwrap()["theme"], "light");
|
||||
}
|
||||
|
||||
/// One app frame with the OS appearance egui reports; returns the window commands it sent.
|
||||
fn theme_frame(ctx: &egui::Context, a: &mut WordApp, os: Option<egui::Theme>) -> Vec<egui::ViewportCommand> {
|
||||
let input = egui::RawInput { system_theme: os, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| a.logic(ui.ctx()));
|
||||
let commands = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||||
out.drop_without_applying_deltas();
|
||||
commands
|
||||
}
|
||||
|
||||
/// #311: with System, egui's theme preference stays System, so the native window is never
|
||||
/// pinned to a concrete appearance (macOS would stop reporting OS changes), and our palette
|
||||
/// follows each OS appearance egui reports.
|
||||
#[test]
|
||||
fn system_theme_follows_the_os_without_pinning_the_window() {
|
||||
use egui::{SystemTheme, Theme, ThemePreference, ViewportCommand};
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.run("ui.theme", json!({"value": "system"})).unwrap();
|
||||
let mut sent = Vec::new();
|
||||
for (os, dark) in [(Some(Theme::Light), false), (Some(Theme::Dark), true), (Some(Theme::Light), false), (None, false)] {
|
||||
sent.extend(theme_frame(&ctx, &mut a, os));
|
||||
assert_eq!(ctx.options(|o| o.theme_preference), ThemePreference::System, "OS at {os:?}");
|
||||
let want = if dark { theme::Tokens::dark() } else { theme::Tokens::light() };
|
||||
assert_eq!(theme::Tokens::get(&ctx).dark, dark, "OS at {os:?}");
|
||||
// Our palette, not egui's default style for that theme.
|
||||
assert_eq!(ctx.global_style().visuals.panel_fill, want.ribbon, "OS at {os:?}");
|
||||
assert_eq!(a.ui_is_dark(), dark, "OS at {os:?}");
|
||||
}
|
||||
assert!(sent.contains(&ViewportCommand::SetTheme(SystemTheme::SystemDefault)), "{sent:?}");
|
||||
assert!(!sent.iter().any(|c| matches!(c, ViewportCommand::SetTheme(SystemTheme::Light | SystemTheme::Dark))), "{sent:?}");
|
||||
// A manual choice pins the window, whatever the OS says.
|
||||
a.run("ui.theme", json!({"value": "dark"})).unwrap();
|
||||
let sent = theme_frame(&ctx, &mut a, Some(Theme::Light));
|
||||
assert!(sent.contains(&ViewportCommand::SetTheme(SystemTheme::Dark)), "{sent:?}");
|
||||
assert!(theme::Tokens::get(&ctx).dark);
|
||||
}
|
||||
|
||||
/// #312: Dark page darkens only the pages; the interface keeps following its own setting.
|
||||
#[test]
|
||||
fn dark_page_leaves_the_interface_theme_alone() {
|
||||
use egui::Theme::{Dark, Light};
|
||||
for (setting, os, dark) in [
|
||||
("system", Some(Light), false),
|
||||
("system", None, false),
|
||||
("light", Some(Dark), false),
|
||||
("system", Some(Dark), true),
|
||||
("dark", Some(Light), true),
|
||||
] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.run("ui.theme", json!({"value": setting})).unwrap();
|
||||
a.run("view.darkMode", json!({"value": true})).unwrap();
|
||||
theme_frame(&ctx, &mut a, os);
|
||||
assert!(a.session.view.dark_mode, "{setting} with the OS at {os:?}");
|
||||
assert_eq!(theme::Tokens::get(&ctx).dark, dark, "{setting} with the OS at {os:?}");
|
||||
assert_eq!(a.run("ui.theme", json!({})).unwrap()["dark"], dark, "{setting} with the OS at {os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_setting_survives_a_restart() {
|
||||
let mut a = app();
|
||||
|
||||
@@ -94,6 +94,12 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect]
|
||||
return false;
|
||||
}
|
||||
let Some((object, grab)) = grab_at(app, layout, pages, scale, at) else { return false };
|
||||
// Shift/Ctrl+click adds an object to the selected ones (or takes it out), for Group.
|
||||
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
if pressed && adding && grab == Grab::Move && selected(app).is_some() {
|
||||
let _ = app.run("select.addObject", json!({"pos": object.pos()}));
|
||||
return true;
|
||||
}
|
||||
if pressed {
|
||||
let pos = object.pos();
|
||||
let end = Pos { off: pos.off + OBJ.len_utf8(), ..pos.clone() };
|
||||
@@ -161,6 +167,12 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p
|
||||
frame::paint(painter, t, to, false, false);
|
||||
return;
|
||||
}
|
||||
// The others selected along with it (Shift+click): their outlines.
|
||||
for p in &app.session.also_selected {
|
||||
if let Some(f) = layout.object(p, app.session.page_hint).and_then(|o| screen(pages, scale, o.page, o.rect)) {
|
||||
frame::paint(painter, t, f, false, false);
|
||||
}
|
||||
}
|
||||
if let Some((o, editing)) = active(app, layout)
|
||||
&& let Some(f) = screen(pages, scale, o.page, o.rect)
|
||||
{
|
||||
@@ -238,6 +250,7 @@ mod tests {
|
||||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let (app, hit) = app_with(shape);
|
||||
assert!(!fixed(&app, &hit));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Side panes: Navigation (headings, pages, search results), Styles, Style Inspector, Comments.
|
||||
//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, Comments.
|
||||
|
||||
use egui::{Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -16,6 +16,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| nav(app, ui));
|
||||
}
|
||||
if app.session.view.clipboard_pane {
|
||||
egui::Panel::left("clipboard_pane")
|
||||
.default_size(250.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| clipboard(app, ui));
|
||||
}
|
||||
if app.session.view.styles_pane {
|
||||
egui::Panel::right("styles_pane")
|
||||
.default_size(250.0)
|
||||
@@ -146,6 +153,51 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret.
|
||||
fn clipboard(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Clipboard") {
|
||||
let _ = app.run("edit.clipboardPane", json!({"value": false}));
|
||||
return;
|
||||
}
|
||||
let items: Vec<String> = app.session.clip_history.items().iter().map(|i| i.preview()).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() {
|
||||
let _ = app.run("edit.pasteAllClipboard", json!({}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() {
|
||||
let _ = app.run("edit.clearClipboard", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let hint = if items.is_empty() { "Nothing collected yet. Items you copy or cut appear here." } else { "Click an item to paste it." };
|
||||
ui.label(egui::RichText::new(tl!(hint)).small().weak());
|
||||
ui.separator();
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for (i, preview) in items.iter().enumerate() {
|
||||
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let text = if preview.is_empty() { tl!("(picture or object)").to_string() } else { preview.clone() };
|
||||
let mut job =
|
||||
egui::text::LayoutJob::single_section(text, egui::TextFormat { font_id: regular(12.5), color: t.text, ..Default::default() });
|
||||
job.wrap.max_width = ui.available_width();
|
||||
job.wrap.max_rows = 3;
|
||||
if ui.add(egui::Button::new(job).frame(false)).on_hover_text(tl!("Paste")).clicked() {
|
||||
let _ = app.run("edit.pasteClipboardItem", json!({"index": i}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
if ui.small_button(tl!("Delete")).clicked() {
|
||||
let _ = app.run("edit.deleteClipboardItem", json!({"index": i}));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn styles(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Styles") {
|
||||
let _ = app.run("view.stylesPane", json!({"value": false}));
|
||||
@@ -168,6 +220,9 @@ fn styles(app: &mut WordApp, ui: &mut Ui) {
|
||||
let _ = app.run("format.clear", json!({}));
|
||||
}
|
||||
});
|
||||
if ui.button(tl!("Manage Styles…")).clicked() {
|
||||
let _ = app.run("styles.manage", json!({}));
|
||||
}
|
||||
if ui.selectable_label(app.session.view.style_inspector, tl!("Style Inspector")).clicked() {
|
||||
let _ = app.run("styles.inspector", json!({}));
|
||||
}
|
||||
|
||||
@@ -218,6 +218,55 @@ fn snippet_blocks(
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px))
|
||||
}
|
||||
|
||||
/// Manage Styles' preview of one style (`ty`: the `styles.manage` type), `size` points wide and
|
||||
/// tall: a line of sample text, or a small table for a table style.
|
||||
pub fn style_preview(app: &mut WordApp, ui: &mut Ui, id: &str, ty: &str, size: egui::Vec2) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||
ui.painter().rect(r, 3.0, egui::Color32::WHITE, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let rev = app.session.rev();
|
||||
let skey = app.previews.styles_key(&app.session.doc, rev);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let (w, h) = (size.x - 12.0, size.y - 8.0);
|
||||
let key = format!("manage:{id}:{ty}:{skey}:{ppp}:{w}x{h}");
|
||||
let doc = &app.session.doc;
|
||||
let tex = app.previews.get_or(ui.ctx(), &key, || {
|
||||
let block = match ty {
|
||||
"table" => {
|
||||
let mut tb = Table::new(3, 4, w - 4.0);
|
||||
tb.props.style = Some(id.to_string());
|
||||
for (ri, row) in tb.rows.iter_mut().enumerate() {
|
||||
for (ci, cell) in row.cells.iter_mut().enumerate() {
|
||||
let txt = match (ri, ci) {
|
||||
(0, c) => ["", "A", "B", "C"].get(c).copied().unwrap_or(""),
|
||||
(_, 0) => "Row",
|
||||
_ => "1",
|
||||
};
|
||||
cell.blocks = vec![para_block(Paragraph::with_text(txt, CharProps::default()))];
|
||||
}
|
||||
}
|
||||
Block::Table(tb)
|
||||
}
|
||||
"character" => Block::Para(Paragraph::with_text("AaBbCcYyZz", CharProps { style: Some(id.to_string()), ..CharProps::default() })),
|
||||
_ => {
|
||||
let mut p = Paragraph::with_text("AaBbCcYyZz", CharProps::default()).styled(id);
|
||||
p.props.space_before = Some(0.0);
|
||||
p.props.indent_left = Some(0.0);
|
||||
p.props.indent_first = Some(0.0);
|
||||
p.props.numbering = None;
|
||||
Block::Para(p)
|
||||
}
|
||||
};
|
||||
snippet_blocks(doc, vec![block], w, h, ppp, 4.0, None)
|
||||
});
|
||||
if let Some(hd) = tex {
|
||||
let sz = hd.size_vec2() / ppp;
|
||||
let ir = Rect::from_min_size(pos2(r.min.x + 2.0, r.min.y + 2.0), sz.min(r.size() - vec2(4.0, 4.0)));
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0)));
|
||||
ui.painter().with_clip_rect(r).image(hd.id(), ir, uv, egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Home tab's Styles gallery.
|
||||
pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
+133
-15
@@ -14,6 +14,11 @@ pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool {
|
||||
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. })))
|
||||
}
|
||||
|
||||
/// Whether the selection is a shape or text box (so Shape Format is shown).
|
||||
pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool {
|
||||
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Shape { .. })))
|
||||
}
|
||||
|
||||
/// Contextual tabs for the current selection (pure, tested).
|
||||
pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
|
||||
let mut tabs = Vec::new();
|
||||
@@ -24,6 +29,9 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
|
||||
if has_picture_selected(s) {
|
||||
tabs.push("Picture Format");
|
||||
}
|
||||
if has_shape_selected(s) {
|
||||
tabs.push("Shape Format");
|
||||
}
|
||||
// Editing an equation.
|
||||
if s.math.is_some() {
|
||||
tabs.push("Equation");
|
||||
@@ -77,7 +85,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
for tab in tabs {
|
||||
let contextual = tab.starts_with("Table ") || tab == "Picture Format" || tab == "Equation";
|
||||
let contextual = tab.starts_with("Table ") || tab.ends_with(" Format") || tab == "Equation";
|
||||
let shown = tl!(tab);
|
||||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
@@ -171,6 +179,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
"Table Layout" => table_layout(app, ui),
|
||||
"Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui),
|
||||
"Picture Format" => picture_format(app, ui),
|
||||
"Shape Format" => shape_format(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
});
|
||||
@@ -244,7 +253,7 @@ fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str
|
||||
fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
group(ui, "Clipboard", None, app, |ui, app| {
|
||||
group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| {
|
||||
menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| {
|
||||
mi(ui, app, "Paste", "edit.paste", json!({}));
|
||||
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
|
||||
@@ -803,12 +812,20 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "align", None, "Align", "arrange.align", json!({}), false);
|
||||
small(ui, app, "group", None, "Group", "arrange.group", json!({}), false);
|
||||
group_menu(ui, app, None);
|
||||
small(ui, app, "rotate", None, "Rotate", "arrange.rotate", json!({}), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Arrange › Group: Group (Shift+click objects to select several) and Ungroup.
|
||||
fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) {
|
||||
menu_button(ui, app, "group", label, "Group", false, |ui, app| {
|
||||
mi(ui, app, "Group", "arrange.group", json!({}));
|
||||
mi(ui, app, "Ungroup", "arrange.ungroup", json!({}));
|
||||
});
|
||||
}
|
||||
|
||||
fn references(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Table of Contents", None, app, |ui, app| {
|
||||
menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, |ui, app| {
|
||||
@@ -1103,6 +1120,83 @@ fn help(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Shape Format: fill, outline and effects of the selected shape, and its arrangement.
|
||||
fn shape_format(app: &mut WordApp, ui: &mut Ui) {
|
||||
let (stroke, glow_size) = match wordcraft_engine::cmd::objects::selected(&app.session) {
|
||||
Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, .. })) => (stroke, effects.glow.map(|g| g.size)),
|
||||
_ => (None, None),
|
||||
};
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
group(ui, "Shape Styles", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, |ui, app| {
|
||||
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("shape.fill", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, |ui, app| {
|
||||
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("shape.outline", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Weight"), |ui| {
|
||||
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
|
||||
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
|
||||
mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w}));
|
||||
}
|
||||
});
|
||||
});
|
||||
menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, |ui, app| {
|
||||
shape_effects_menu(ui, app, &theme, glow_size)
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Arrange", None, app, |ui, app| {
|
||||
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
|
||||
big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "rotate", Some("Rotate"), "Rotate", "arrange.rotate", json!({}), false);
|
||||
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry.
|
||||
fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option<f32>) {
|
||||
ui.menu_button(tl!("Shadow"), |ui| {
|
||||
mi(ui, app, "No Shadow", "shape.effects", json!({"shadow": null}));
|
||||
ui.separator();
|
||||
for (id, label) in wordcraft_doc::effects::SHADOW_PRESETS {
|
||||
mi(ui, app, label, "shape.effects", json!({"shadow": id}));
|
||||
}
|
||||
});
|
||||
ui.menu_button(tl!("Glow"), |ui| {
|
||||
mi(ui, app, "No Glow", "shape.effects", json!({"glow": null}));
|
||||
ui.separator();
|
||||
for size in [5.0, 8.0, 11.0, 18.0] {
|
||||
mi(ui, app, &format!("{size} pt"), "shape.effects", json!({"glow": size}));
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Glow Colors"), |ui| {
|
||||
if let Some(hex) = color_grid(ui, theme) {
|
||||
let _ = app.run("shape.effects", json!({"glow": {"color": hex, "size": glow_size.unwrap_or(8.0)}}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.menu_button(tl!("Soft Edges"), |ui| {
|
||||
mi(ui, app, "No Soft Edges", "shape.effects", json!({"softEdge": null}));
|
||||
ui.separator();
|
||||
for r in [1.0, 2.5, 5.0, 10.0, 25.0, 50.0] {
|
||||
mi(ui, app, &format!("{r} pt"), "shape.effects", json!({"softEdge": r}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn picture_format(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Adjust", None, app, |ui, app| {
|
||||
menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, |ui, app| {
|
||||
@@ -1158,6 +1252,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) {
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "rotate", Some("Rotate"), "Rotate", "arrange.rotate", json!({}), false);
|
||||
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
|
||||
group_menu(ui, app, Some("Group"));
|
||||
});
|
||||
});
|
||||
group(ui, "Size", None, app, |ui, app| {
|
||||
@@ -1255,18 +1350,20 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, "Table Styles", None, app, |ui, app| {
|
||||
let styles: Vec<(String, String)> = app
|
||||
// The document's own styles first, so a new one shows without scrolling.
|
||||
let mut styles: Vec<(bool, String, String)> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
|
||||
.map(|s| (s.id.clone(), s.name.clone()))
|
||||
.map(|s| (s.builtin, s.id.clone(), s.name.clone()))
|
||||
.collect();
|
||||
styles.sort_by_key(|(builtin, _, _)| *builtin);
|
||||
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (id, name) in styles {
|
||||
for (_, id, name) in styles {
|
||||
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
|
||||
let _ = app.run("table.style", json!({"style": id}));
|
||||
}
|
||||
@@ -1274,21 +1371,31 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
let can_modify = look.is_some()
|
||||
&& app
|
||||
.session
|
||||
.sel
|
||||
.focus
|
||||
.path
|
||||
.cell()
|
||||
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone())
|
||||
.is_some();
|
||||
let current = look.and_then(|_| {
|
||||
let (tp, _, _) = app.session.sel.focus.path.cell()?;
|
||||
let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?;
|
||||
app.session
|
||||
.doc
|
||||
.styles
|
||||
.get(&id)
|
||||
.filter(|st| st.kind == wordcraft_doc::StyleKind::Table)
|
||||
.map(wordcraft_engine::cmd::table_style::is_builtin_table_style)
|
||||
});
|
||||
let can_modify = current.is_some();
|
||||
// Built-in table styles can't be deleted.
|
||||
let can_delete = current == Some(false);
|
||||
menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| {
|
||||
mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"}));
|
||||
if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
if let Err(e) = app.run("table.deleteStyle", json!({})) {
|
||||
app.status(e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
|
||||
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
|
||||
@@ -1464,6 +1571,17 @@ mod tests {
|
||||
assert!(!has_picture_selected(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_format_tab_appears_when_shape_selected() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
assert!(contextual_tabs(&s).contains(&"Shape Format"));
|
||||
assert!(!contextual_tabs(&s).contains(&"Picture Format"));
|
||||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert!(!contextual_tabs(&s).contains(&"Shape Format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_tab_falls_back_when_contextual_tab_expires() {
|
||||
let all = ["Home", "Table Design", "Picture Format"];
|
||||
|
||||
+42
-19
@@ -8,8 +8,9 @@ use egui::{Align2, Color32, CornerRadius, FontData, FontDefinitions, FontFamily,
|
||||
pub const APP_COLOR: Color32 = Color32::from_rgb(0x3B, 0x5B, 0xDB);
|
||||
pub const APP_INK: Color32 = Color32::from_rgb(0x2B, 0x47, 0xB5);
|
||||
|
||||
/// The interface theme setting (File › Options › General, View › Dark Mode): a fixed light or
|
||||
/// dark palette, or `System`, which follows the OS light/dark appearance live (#115).
|
||||
/// The interface theme setting (File › Options › General, View › Interface Theme): a fixed light or
|
||||
/// dark palette, or `System`, which follows the OS light/dark appearance live (#115). Dark page
|
||||
/// (View › Switch Modes) is separate: it changes how pages are drawn, never the interface (#312).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum Appearance {
|
||||
@@ -54,6 +55,15 @@ impl Appearance {
|
||||
Appearance::System => system == Some(egui::Theme::Dark),
|
||||
}
|
||||
}
|
||||
|
||||
/// The egui theme preference: `System` stays automatic rather than resolving to a fixed theme.
|
||||
pub fn preference(self) -> egui::ThemePreference {
|
||||
match self {
|
||||
Appearance::Light => egui::ThemePreference::Light,
|
||||
Appearance::Dark => egui::ThemePreference::Dark,
|
||||
Appearance::System => egui::ThemePreference::System,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown saved value reads as the default rather than discarding the whole `ui.json`.
|
||||
@@ -365,8 +375,35 @@ fn tracked_job(text: &str, font: &FontId, tracking: f32, color: Color32) -> egui
|
||||
egui::text::LayoutJob::single_section(text.to_string(), format)
|
||||
}
|
||||
|
||||
/// Apply tokens to egui's style.
|
||||
pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
/// Apply the interface theme setting to egui. Both palettes go in, our light one as egui's light
|
||||
/// style and our dark one as its dark style, and egui picks between them each frame: `System`
|
||||
/// leaves egui's theme preference on `System`, so it follows the OS appearance it reports (light
|
||||
/// when the OS reports none, like [`Appearance::is_dark`]) and the native window keeps its own
|
||||
/// appearance; a manual Light or Dark pins egui (and the window decorations) to that theme.
|
||||
/// Setting a concrete theme for `System` would pin the native window too, and macOS then stops
|
||||
/// reporting appearance changes (#311).
|
||||
pub fn apply(ctx: &egui::Context, appearance: Appearance) {
|
||||
ctx.set_visuals_of(egui::Theme::Light, visuals(&Tokens::light()));
|
||||
ctx.set_visuals_of(egui::Theme::Dark, visuals(&Tokens::dark()));
|
||||
ctx.all_styles_mut(|s| {
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
s.spacing.button_padding = egui::vec2(8.0, 3.0);
|
||||
s.spacing.interact_size = egui::vec2(24.0, 22.0);
|
||||
s.spacing.menu_margin = egui::Margin::same(6);
|
||||
s.text_styles.insert(egui::TextStyle::Body, regular(12.5));
|
||||
s.text_styles.insert(egui::TextStyle::Button, regular(12.5));
|
||||
s.text_styles.insert(egui::TextStyle::Small, regular(10.5));
|
||||
s.text_styles.insert(egui::TextStyle::Heading, semibold(17.0));
|
||||
s.interaction.tooltip_delay = 0.45;
|
||||
});
|
||||
ctx.options_mut(|o| {
|
||||
o.fallback_theme = egui::Theme::Light;
|
||||
o.theme_preference = appearance.preference();
|
||||
});
|
||||
}
|
||||
|
||||
/// egui's visuals for one of our palettes.
|
||||
fn visuals(t: &Tokens) -> Visuals {
|
||||
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
|
||||
v.panel_fill = t.ribbon;
|
||||
v.window_fill = t.menu;
|
||||
@@ -398,21 +435,7 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
v.widgets.open.weak_bg_fill = t.pressed;
|
||||
v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 40 }) };
|
||||
v.window_shadow = egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 50 }) };
|
||||
// Pin egui's own theme to ours: left on its default (follow the OS), egui would swap to its
|
||||
// other, unstyled light/dark style the moment the OS appearance changed.
|
||||
ctx.set_theme(if t.dark { egui::Theme::Dark } else { egui::Theme::Light });
|
||||
ctx.set_visuals(v);
|
||||
ctx.global_style_mut(|s| {
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
s.spacing.button_padding = egui::vec2(8.0, 3.0);
|
||||
s.spacing.interact_size = egui::vec2(24.0, 22.0);
|
||||
s.spacing.menu_margin = egui::Margin::same(6);
|
||||
s.text_styles.insert(egui::TextStyle::Body, regular(12.5));
|
||||
s.text_styles.insert(egui::TextStyle::Button, regular(12.5));
|
||||
s.text_styles.insert(egui::TextStyle::Small, regular(10.5));
|
||||
s.text_styles.insert(egui::TextStyle::Heading, semibold(17.0));
|
||||
s.interaction.tooltip_delay = 0.45;
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
|
||||
|
||||
@@ -471,7 +471,7 @@ mod tests {
|
||||
fn drawn_buttons_fade_in_on_hover_and_darken_while_pressed() {
|
||||
// #305's drawn replacements for ✕, 💬, ✎ and ➕ must react like egui's buttons.
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::apply(&ctx, &Tokens::light());
|
||||
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
|
||||
let clock = std::cell::Cell::new(0u32);
|
||||
let seen = std::cell::Cell::new((Rect::NOTHING, Rect::NOTHING, Color32::PLACEHOLDER, Color32::PLACEHOLDER));
|
||||
// One 60 fps frame; returns both buttons' rects and fills (framed, frameless).
|
||||
|
||||
@@ -60,7 +60,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Group shapes (`wpg:`), drawing canvas | ❌ | ❌ | Dropped |
|
||||
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) |
|
||||
| Charts (`c:chart`), SmartArt (`dgm`), ink, 3D models, OLE objects (`w:object`) | ❌ | ❌ | Dropped on read, not preserved on save. A document with a chart loses it silently |
|
||||
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191 |
|
||||
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
|
||||
| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read |
|
||||
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) |
|
||||
| Ruby, `w:eastAsianLayout`, `w:fitText`, document grid (`w:docGrid`) | ❌ | ❌ | East Asian layout |
|
||||
@@ -80,5 +80,6 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | trivial | Equation breaks and `m:mathPr` wrapping options round-trip (#326) |
|
||||
| 2026-10-10 | trivial | Custom table styles round-trip (#256 merged) |
|
||||
| 2026-10-10 | major | First version: Word's full format list, read/write status, DOCX element coverage from the source, fidelity and hours |
|
||||
|
||||
+4
-2
@@ -13,7 +13,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures; "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Charts, SmartArt, OLE objects, ink and group shapes are dropped on open and lost on save, silently** | B | No `c:chart`, `dgm`, `wpg`, `w:object` handling in `crates/docx` | Business and academic documents lose content without warning | 10–15 to preserve and show fallback pictures; 45–70 to render and edit charts and SmartArt | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; columns don't balance; long footnotes don't continue; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
@@ -39,7 +39,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
| 26 | **Minor formats**: Flat OPC XML, Word 2003 XML, MHT, Works/WordPerfect | | | Rare | 20–35 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
| 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) |
|
||||
| 29 | **Equations**: long display equations don't break across lines; ink equations | | #191 notes | Maths-heavy documents | 3–4 | [layout-parity.md](layout-parity.md) |
|
||||
| 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) |
|
||||
|
||||
**Beta needs #1–#7** (~150–230 h with the stability and layout work they imply): they are the
|
||||
blocking gaps, and closing them lifts file formats, layout, stability and UI enough to take the
|
||||
@@ -64,6 +64,8 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) |
|
||||
| 2026-10-10 | trivial | #3: Windows lag on GPUs without a DirectX 12 driver fixed with an OpenGL fallback (#316) |
|
||||
| 2026-10-10 | trivial | #6: desktop File › Print opens the system PDF viewer (#286) |
|
||||
| 2026-10-10 | trivial | Table Properties dialog landed (#44); removed from the dialog-depth list |
|
||||
| 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list |
|
||||
|
||||
@@ -11,7 +11,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
|
||||
| Hardware | Word | WordCraft macOS | Windows | Linux / BSD | Web | Parity | Hours |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 PDF opens in Preview to print (#286) | 🟡 PDF opens in the default viewer (#286) | 🟡 PDF opens in the default viewer (#286) | ✅ browser print dialog (#209) | 35% | 5–8 |
|
||||
| GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50); startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) |
|
||||
| GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316); startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) |
|
||||
| Multiple monitors, per-monitor DPI, remembered window position | ✅ | ✅ (#38, #76) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 |
|
||||
| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | ❌ | ❌ | ❌ | ❌ | 0% | (Draw tab, 15–25) |
|
||||
| Microphone: Dictate | ✅ (cloud speech service) | ❌ | ❌ | ❌ | ❌ | 0% | 8–15 + owner (speech model choice) |
|
||||
@@ -23,6 +23,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | trivial | Windows falls back to OpenGL when DirectX 12 offers only the software adapter (#316) |
|
||||
| 2026-10-10 | trivial | Desktop File › Print opens the PDF in the system viewer (#286) |
|
||||
| 2026-10-10 | trivial | Inertial touchpad scrolling landed (#252) |
|
||||
| 2026-10-10 | major | First version |
|
||||
|
||||
@@ -49,7 +49,7 @@ font can't match Word's metrics.
|
||||
| Vertical text (`tbRl`) in sections and text boxes; cell text direction | 🟡 | cell text direction landed (#245); page-level and text-box vertical text missing | 8–12 |
|
||||
| Ruby / phonetic guide, enclose characters, combined characters | ❌ | | 4–6 |
|
||||
| Bidirectional paragraphs, mixed-direction lines | 🟡 | #207; RTL sections and tables (`w:bidiVisual`) missing | 4–6 |
|
||||
| Equations: display layout, numbering | 🟡 | #191; long display equations don't break across lines | 3–4 |
|
||||
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — |
|
||||
| Hidden text excluded from breaking and hyphenation | ✅ | #173 | — |
|
||||
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52 | — |
|
||||
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 |
|
||||
@@ -68,5 +68,6 @@ font can't match Word's metrics.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | trivial | Long display equations wrap across lines (#326) |
|
||||
| 2026-10-10 | trivial | AutoFit Contents measures the text (#44) |
|
||||
| 2026-10-10 | major | First version: layout and pagination checklist with evidence from `crates/layout`, PRs and issues |
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**384 of 430 catalog features have commands (89.3%).**
|
||||
**390 of 431 catalog features have commands (90.5%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
| File | 20 | 20 |
|
||||
| Quick Access | 4 | 4 |
|
||||
| Home | 95 | 101 |
|
||||
| Home | 98 | 101 |
|
||||
| Insert | 42 | 53 |
|
||||
| Draw | 0 | 11 |
|
||||
| Design | 10 | 10 |
|
||||
| Layout | 18 | 19 |
|
||||
| Layout | 19 | 19 |
|
||||
| References | 24 | 24 |
|
||||
| Mailings | 17 | 17 |
|
||||
| Review | 26 | 29 |
|
||||
| View | 25 | 29 |
|
||||
| Table Design | 7 | 7 |
|
||||
| Table Design | 8 | 8 |
|
||||
| Table Layout | 28 | 30 |
|
||||
| Equation | 21 | 22 |
|
||||
| Picture Format | 13 | 13 |
|
||||
| Shape Format | 2 | 6 |
|
||||
| Shape Format | 3 | 6 |
|
||||
| Header & Footer | 5 | 5 |
|
||||
| Editing | 24 | 24 |
|
||||
| Tools | 3 | 6 |
|
||||
@@ -30,11 +30,8 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
|
||||
### Home
|
||||
|
||||
- Clipboard › Clipboard Pane (`edit.clipboardPane`)
|
||||
- Font › Phonetic Guide (`format.phonetic`)
|
||||
- Font › Enclose Characters (`format.enclose`)
|
||||
- Paragraph › Asian Typography (`para.asianTypography`)
|
||||
- Styles › Manage Styles (`styles.manage`)
|
||||
- Voice › Dictate (`tools.dictate`)
|
||||
|
||||
### Insert
|
||||
@@ -65,10 +62,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
- Insert › Drawing Canvas (`insert.canvas`)
|
||||
- Replay › Ink Replay (`draw.replay`)
|
||||
|
||||
### Layout
|
||||
|
||||
- Arrange › Group (`arrange.group`)
|
||||
|
||||
### Review
|
||||
|
||||
- Language › Translate (`review.translate`)
|
||||
@@ -93,7 +86,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
|
||||
### Shape Format
|
||||
|
||||
- Shape Styles › Shape Effects (`shape.effects`)
|
||||
- Text › Text Direction (`shape.textDirection`)
|
||||
- Text › Align Text (`shape.alignText`)
|
||||
- Text › Create Link (`shape.link`)
|
||||
|
||||
@@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **384 / 430 (89.3%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
|
||||
| Ribbon/menu catalog coverage | **390 / 431 (90.5%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
|
||||
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
|
||||
| Feature depth (weighted by use, table below) | **~68%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
|
||||
|
||||
+2
-1
@@ -27,7 +27,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
|
||||
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
|
||||
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — |
|
||||
| Interface themes (light, dark, system #249), dark page (#194) | ✅ | ✅ | 90% | — |
|
||||
| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — |
|
||||
| Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 |
|
||||
| Screen readers (VoiceOver, Narrator, Orca) | AccessKit on, document canvas exposure untested | full | 25% | 10–15 |
|
||||
| Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization |
|
||||
@@ -36,6 +36,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) |
|
||||
| 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) |
|
||||
| 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) |
|
||||
| 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed |
|
||||
|
||||
Reference in New Issue
Block a user