mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-02 06:47:11 -06:00
Compare commits
24
Commits
Generated
-159
@@ -121,17 +121,6 @@ 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"
|
||||
@@ -499,15 +488,6 @@ 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"
|
||||
@@ -692,16 +672,6 @@ 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"
|
||||
@@ -711,12 +681,6 @@ 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"
|
||||
@@ -772,12 +736,6 @@ 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"
|
||||
@@ -827,21 +785,6 @@ 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"
|
||||
@@ -891,24 +834,6 @@ 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"
|
||||
@@ -939,18 +864,6 @@ 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"
|
||||
@@ -1493,11 +1406,9 @@ 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]]
|
||||
@@ -1828,24 +1739,6 @@ 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"
|
||||
@@ -1995,15 +1888,6 @@ 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"
|
||||
@@ -3534,28 +3418,6 @@ 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"
|
||||
@@ -4023,12 +3885,6 @@ 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"
|
||||
@@ -5071,21 +4927,13 @@ 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",
|
||||
]
|
||||
|
||||
@@ -5111,7 +4959,6 @@ dependencies = [
|
||||
"wordcraft-pdf",
|
||||
"wordcraft-proof",
|
||||
"wordcraft-render",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -5545,12 +5392,6 @@ 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,14 +52,6 @@ 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,12 +234,6 @@ 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 |
|
||||
|
||||
+1
-4
@@ -130,8 +130,7 @@ 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); 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-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-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) |
|
||||
@@ -142,8 +141,6 @@ 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 |
|
||||
|
||||
@@ -1,122 +1,33 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, DeviceType},
|
||||
};
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::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) {
|
||||
/// 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>) {
|
||||
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 = backends;
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(backends: Backends) -> Backends {
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
configure(&mut options, backend_override);
|
||||
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.
|
||||
@@ -124,32 +35,13 @@ mod tests {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
|
||||
assert_eq!(configured_backends(None), 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()] {
|
||||
// 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);
|
||||
assert_eq!(configured_backends(Some(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,8 +206,7 @@ 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, or OpenGL when
|
||||
// DirectX 12 has no GPU driver (see graphics.rs).
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
let restored = saved_window();
|
||||
|
||||
@@ -331,9 +331,6 @@ 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.
|
||||
@@ -397,7 +394,6 @@ impl Default for Settings {
|
||||
compat_mode: COMPAT_MODE_CURRENT,
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
math: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,55 +54,6 @@ 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")]
|
||||
@@ -149,10 +100,6 @@ 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 {
|
||||
@@ -715,7 +662,6 @@ 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;
|
||||
|
||||
@@ -15,18 +15,6 @@ 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 }
|
||||
|
||||
@@ -1,741 +0,0 @@
|
||||
//! 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,7 +7,6 @@
|
||||
//! 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;
|
||||
@@ -15,26 +14,9 @@ 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)]
|
||||
@@ -96,14 +78,4 @@ 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,8 +1,6 @@
|
||||
//! Office Math (OMML, ECMA-376 Part 1 §22.1) → [`wordcraft_doc::math`] tree.
|
||||
|
||||
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::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, merge_runs};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
use crate::xml::El;
|
||||
@@ -22,27 +20,6 @@ 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") {
|
||||
@@ -274,10 +251,6 @@ 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,16 +63,6 @@ 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());
|
||||
@@ -661,7 +651,6 @@ 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" => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
|
||||
};
|
||||
use wordcraft_doc::math::{Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, ScriptKind, parse_linear};
|
||||
|
||||
use crate::xml::W;
|
||||
|
||||
@@ -88,30 +86,6 @@ 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)]);
|
||||
}
|
||||
@@ -408,7 +382,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 || r.brk.is_some() {
|
||||
if sty.is_some() || scr.is_some() || r.nor || r.lit {
|
||||
w.open("m:rPr", &[]);
|
||||
if r.lit {
|
||||
w.empty("m:lit", &[]);
|
||||
@@ -422,11 +396,6 @@ 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", &[]);
|
||||
|
||||
@@ -802,9 +802,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -962,12 +962,6 @@ 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();
|
||||
@@ -1245,26 +1239,3 @@ fn shape_effects_round_trip() {
|
||||
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,7 +26,6 @@ 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).
|
||||
|
||||
@@ -29,20 +29,9 @@ 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, "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.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.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") };
|
||||
@@ -123,39 +112,15 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.ui_requests.push(json!({"open": "openFile"}));
|
||||
return sel_result(s);
|
||||
};
|
||||
let password = p::str(v, "password");
|
||||
let (doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let doc = 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_with(path, &bytes, password).map_err(CmdError::Failed)?
|
||||
crate::io::open_bytes(path, &bytes).map_err(CmdError::Failed)?
|
||||
} else {
|
||||
crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)?
|
||||
crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?
|
||||
};
|
||||
s.set_document(doc);
|
||||
s.path = Some(path.into());
|
||||
// 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()}))
|
||||
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count()}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -166,26 +131,14 @@ 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();
|
||||
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());
|
||||
crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?;
|
||||
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(), "encrypted": encrypted}))
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy()}))
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -263,7 +216,6 @@ 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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -173,6 +173,11 @@ 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}"#),
|
||||
|
||||
+8
-82
@@ -36,63 +36,9 @@ 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}"))?,
|
||||
@@ -102,7 +48,7 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
|
||||
},
|
||||
};
|
||||
doc.ensure_nonempty();
|
||||
Ok((doc, false))
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Serialise a document; `name` gives the format by extension.
|
||||
@@ -118,23 +64,8 @@ pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`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> {
|
||||
pub fn open_path(path: &std::path::Path) -> Result<Document, 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()));
|
||||
@@ -146,9 +77,9 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<
|
||||
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, false));
|
||||
return Ok(doc);
|
||||
}
|
||||
open_bytes_with(&name, &bytes, password)
|
||||
open_bytes(&name, &bytes)
|
||||
}
|
||||
|
||||
/// The pictures an HTML file names by a path relative to its folder.
|
||||
@@ -255,18 +186,13 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
|
||||
Err(format!("{}: files are opened through the browser on the web", path.display()))
|
||||
}
|
||||
|
||||
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
|
||||
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)?;
|
||||
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
|
||||
let bytes = save_bytes(&path.to_string_lossy(), doc)?;
|
||||
// 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()))?;
|
||||
@@ -274,7 +200,7 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>) -> Result<(), String> {
|
||||
pub fn save_path(path: &std::path::Path, _doc: &Document) -> Result<(), String> {
|
||||
Err(format!("{}: files are saved through the browser on the web", path.display()))
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ 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;
|
||||
|
||||
@@ -247,10 +247,6 @@ 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>,
|
||||
@@ -333,7 +329,6 @@ impl Session {
|
||||
also_selected: Vec::new(),
|
||||
prefs: Prefs::default(),
|
||||
read_aloud: Default::default(),
|
||||
password: None,
|
||||
column: None,
|
||||
column_mode: false,
|
||||
}
|
||||
@@ -496,7 +491,6 @@ 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();
|
||||
@@ -569,8 +563,6 @@ 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: _,
|
||||
|
||||
@@ -2353,52 +2353,6 @@ fn accessibility_reports_a_chart_without_alt_text() {
|
||||
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]
|
||||
|
||||
+21
-341
@@ -11,8 +11,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, MathProps, ScriptKind, is_integral, math_alnum,
|
||||
parse_linear,
|
||||
Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, math_alnum, parse_linear,
|
||||
};
|
||||
use wordcraft_doc::props::{Rgb, TextColor};
|
||||
use wordcraft_doc::resolve::ResolvedChar;
|
||||
@@ -175,16 +174,14 @@ 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, 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.
|
||||
/// `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.
|
||||
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;
|
||||
@@ -216,7 +213,6 @@ 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;
|
||||
@@ -311,8 +307,6 @@ 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 {
|
||||
@@ -553,7 +547,6 @@ 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)),
|
||||
@@ -563,9 +556,6 @@ impl Ctx {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((b, _)) = out.get_mut(first) {
|
||||
b.brk = r.brk;
|
||||
}
|
||||
u += r.text.chars().count();
|
||||
}
|
||||
MNode::Nary { .. } | MNode::Func { .. } => {
|
||||
@@ -1602,198 +1592,6 @@ 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();
|
||||
@@ -1842,18 +1640,6 @@ 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).
|
||||
@@ -1875,22 +1661,6 @@ 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() {
|
||||
@@ -1911,7 +1681,15 @@ fn classes(atoms: &[Bx]) -> Vec<(Cls, Cls)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
cls
|
||||
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
|
||||
}
|
||||
|
||||
/// Atoms side by side with inter-atom spacing.
|
||||
@@ -1967,96 +1745,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn lay(lin: &str, display: bool) -> MathLayout {
|
||||
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);
|
||||
layout_equation(&Math::default(), lin, &rc(), display, 400.0, &mut 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2134,7 +1823,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, &MathProps::default(), &mut 0);
|
||||
let m = layout_equation(&Math { nodes: vec![n.clone()], ..Default::default() }, "", &rc(), true, 400.0, &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 {
|
||||
@@ -2148,7 +1837,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, &MathProps::default(), &mut 0);
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &mut 0);
|
||||
// Walking the caret through the equation, every position has a slot.
|
||||
let mut p = MathPos::default();
|
||||
let mut seen = 0;
|
||||
@@ -2172,17 +1861,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, &MathProps::default(), &mut 0);
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &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, &MathProps::default(), &mut 0);
|
||||
let empty = layout_equation(&Math::default(), "", &rc(), true, 400.0, &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, &MathProps::default(), &mut 0);
|
||||
let m = layout_equation(&math, "", &rc(), true, 400.0, &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();
|
||||
@@ -2193,7 +1882,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, &MathProps::default(), &mut n);
|
||||
let _ = layout_equation(&auto, "", &rc(), true, 400.0, &mut n);
|
||||
assert_eq!(n, 5);
|
||||
assert_eq!(auto_numbers(&auto, true), 1);
|
||||
}
|
||||
@@ -2216,7 +1905,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, &MathProps::default(), &mut 0);
|
||||
let _ = layout_equation(&Math { nodes: n, ..Default::default() }, "", &rc(), true, 400.0, &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![] },
|
||||
@@ -2225,16 +1914,7 @@ 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, &MathProps::default(), &mut 0);
|
||||
let m = layout_equation(&Math { nodes: empty, ..Default::default() }, "", &rc(), true, 400.0, &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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,7 +640,6 @@ 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() {
|
||||
@@ -769,7 +768,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, &math_props, &mut eq_counter);
|
||||
let ml = crate::math::layout_equation(math, linear, &rc, *display, avail, &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));
|
||||
|
||||
@@ -300,24 +300,6 @@ 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")] {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
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};
|
||||
|
||||
@@ -210,25 +208,6 @@ 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
|
||||
@@ -625,8 +604,6 @@ impl Dialog {
|
||||
Dialog::MatchFields { .. } => "matchFields",
|
||||
Dialog::CheckErrors { .. } => "checkErrors",
|
||||
Dialog::TableProperties { .. } => "tableProperties",
|
||||
Dialog::Password { .. } => "password",
|
||||
Dialog::EncryptPassword { .. } => "encryptPassword",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,7 +736,6 @@ 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 {
|
||||
@@ -890,8 +866,6 @@ 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)))
|
||||
@@ -1637,86 +1611,6 @@ 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:"));
|
||||
|
||||
@@ -151,7 +151,7 @@ impl WordApp {
|
||||
if !self.safely_saved(saved) || document != self.session.document_id() {
|
||||
return Ok(json!({"done": false}));
|
||||
}
|
||||
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
self.execute(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,18 +1103,3 @@
|
||||
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.
|
@@ -1087,18 +1087,3 @@
|
||||
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.
|
@@ -1099,18 +1099,3 @@
|
||||
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.
|
@@ -15,7 +15,6 @@
|
||||
(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
|
||||
@@ -51,8 +50,6 @@
|
||||
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
|
||||
@@ -80,14 +77,12 @@
|
||||
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
|
||||
@@ -95,13 +90,11 @@
|
||||
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
|
||||
@@ -120,12 +113,9 @@
|
||||
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.
|
||||
@@ -792,11 +782,7 @@
|
||||
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
|
||||
@@ -874,7 +860,6 @@
|
||||
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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -19,7 +19,6 @@
|
||||
(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 Прихвати
|
||||
@@ -55,8 +54,6 @@
|
||||
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 Примени стил
|
||||
@@ -84,14 +81,12 @@
|
||||
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 Лева маргина
|
||||
@@ -99,13 +94,11 @@
|
||||
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 Управљање стиловима
|
||||
@@ -124,12 +117,9 @@
|
||||
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 нису погођени.
|
||||
@@ -796,11 +786,7 @@
|
||||
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 Листа тема
|
||||
@@ -878,7 +864,6 @@
|
||||
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} речи
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -1099,18 +1099,3 @@
|
||||
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.
|
@@ -1098,18 +1098,3 @@
|
||||
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.
|
@@ -1088,18 +1088,3 @@
|
||||
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.
|
+12
-131
@@ -114,8 +114,7 @@ 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. Only the
|
||||
/// pages: the interface follows [`UiState::theme`] (#312).
|
||||
/// View › Switch Modes: show pages dark (white text on black), kept between runs.
|
||||
pub dark_page: bool,
|
||||
}
|
||||
|
||||
@@ -176,8 +175,7 @@ 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,
|
||||
/// The interface theme setting last installed in egui ([`theme::apply`]).
|
||||
applied_theme: Option<theme::Appearance>,
|
||||
applied_dark: Option<bool>,
|
||||
pub frame_ms: f64,
|
||||
/// The window title last sent; a viewport command schedules a repaint, so only send changes.
|
||||
sent_title: String,
|
||||
@@ -247,7 +245,7 @@ impl WordApp {
|
||||
fonts_system_cjk: false,
|
||||
want_system_cjk: false,
|
||||
fonts_frames: 0,
|
||||
applied_theme: None,
|
||||
applied_dark: None,
|
||||
frame_ms: 0.0,
|
||||
sent_title: String::new(),
|
||||
quit_requested: false,
|
||||
@@ -328,7 +326,7 @@ impl WordApp {
|
||||
{
|
||||
return r;
|
||||
}
|
||||
let r = self.execute_user(id, params);
|
||||
let r = self.execute(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)
|
||||
@@ -365,23 +363,11 @@ 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();
|
||||
}
|
||||
// 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)?;
|
||||
let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?;
|
||||
if let Some(d) = &self.services.download
|
||||
&& let Err(e) = d(&name, &bytes)
|
||||
{
|
||||
@@ -448,23 +434,7 @@ impl WordApp {
|
||||
if !go {
|
||||
return Ok(json!({"done": false}));
|
||||
}
|
||||
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
|
||||
self.execute(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
}
|
||||
|
||||
/// Save before New/Open/Close (and the mailings that replace the document): true once the
|
||||
@@ -766,12 +736,12 @@ impl WordApp {
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
self.styled = true;
|
||||
}
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
if self.ctx.is_none() {
|
||||
self.ctx = Some(ctx.clone());
|
||||
@@ -1082,8 +1052,6 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -1443,34 +1411,6 @@ 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() {
|
||||
@@ -2224,65 +2164,6 @@ 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();
|
||||
|
||||
+19
-42
@@ -8,9 +8,8 @@ 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 › 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).
|
||||
/// 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).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum Appearance {
|
||||
@@ -55,15 +54,6 @@ 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`.
|
||||
@@ -375,35 +365,8 @@ fn tracked_job(text: &str, font: &FontId, tracking: f32, color: Color32) -> egui
|
||||
egui::text::LayoutJob::single_section(text.to_string(), format)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Apply tokens to egui's style.
|
||||
pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
|
||||
v.panel_fill = t.ribbon;
|
||||
v.window_fill = t.menu;
|
||||
@@ -435,7 +398,21 @@ fn visuals(t: &Tokens) -> Visuals {
|
||||
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 }) };
|
||||
v
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
||||
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, crate::theme::Appearance::Light);
|
||||
crate::theme::apply(&ctx, &Tokens::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; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
|
||||
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191 |
|
||||
| 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,6 +80,5 @@ 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 |
|
||||
|
||||
+2
-4
@@ -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) (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) |
|
||||
| 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) |
|
||||
| 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**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-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) |
|
||||
|
||||
**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,8 +64,6 @@ 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), 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) |
|
||||
| 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) |
|
||||
| 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,7 +23,6 @@ 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, 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) | — |
|
||||
| Equations: display layout, numbering | 🟡 | #191; long display equations don't break across lines | 3–4 |
|
||||
| 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,6 +68,5 @@ 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 |
|
||||
|
||||
+1
-2
@@ -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, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — |
|
||||
| Interface themes (light, dark, system #249), dark page (#194) | ✅ | ✅ | 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,7 +36,6 @@ 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