diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 88ae21b9..44b95bf9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -100,7 +100,8 @@ "Bash(echo \"exit: $?\")", "Bash(claude plugin *)", "Bash(cp \"/root/.claude/uploads/995af0a3-e865-5b96-b002-e90985880970/cc51b77a-spec01codebaseauditandarchitecture.md\" \"/home/user/loki/docs/adr/spec-01-codebase-audit-and-architecture.md\" && echo \"placed\" && ls -la docs/adr/spec-01-codebase-audit-and-architecture.md)", - "mcp__code-review-graph__list_graph_stats_tool" + "mcp__code-review-graph__list_graph_stats_tool", + "mcp__Claude_Code_Remote__send_later" ] }, "enableAllProjectMcpServers": true, diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index bb359a7e..45c20987 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -58,11 +58,18 @@ jobs: - name: Install toolchain uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Install xmllint (libxml2) - # appthere-conformance's schema axis (Spec 02 M2) shells to xmllint; install - # it so those tests actually run instead of skipping. The harness fails - # loudly if it is absent, so this keeps the schema axis genuinely exercised. - run: sudo apt-get update && sudo apt-get install -y libxml2-utils + - name: Install conformance tools (xmllint, pdftoppm) + # Spec 02 gates, all running as ordinary cargo tests in this job: + # - schema axis (M2): loki-{ooxml,odf} schema_validation.rs shell to + # xmllint against the vendored ISO 29500 / ODF schemas — HARD gate. + # - round-trip axis (M3): conformance_round_trip.rs suites — HARD gate. + # - visual axis (M5): loki-render-cpu visual_golden.rs compares + # vello_cpu renders to the committed LO goldens at the calibrated + # tolerance; needs pdftoppm only for the raster wrapper's own tests. + # Advisory-by-construction: the known kerning divergence is pinned as + # an expected-failure canary (see goldens/CALIBRATION.md). + # Each harness fails loudly if its tool is absent — never skips. + run: sudo apt-get update && sudo apt-get install -y libxml2-utils poppler-utils # `--all-features` so the feature-gated format crates (pptx, xlsx) and their # integration tests actually compile and run — under the default feature set # `cargo test --workspace` silently skips `#![cfg(feature = ...)]` suites. diff --git a/CLAUDE.md b/CLAUDE.md index 85139075..63715013 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,8 +199,8 @@ but are **not perfectly round-tripped through the Loro CRDT**. | Field(s) | Status | Priority | |---|---|---| -| `tab_stops` | Written as unreadable Debug string; not read back. | Medium | -| `background_color` (paragraph) | Written as Debug string; not decoded on read. | Low | +| `tab_stops` | **DONE** (2026-07-04) — structured `"pos:Align:Leader;…"` codec (`loro_bridge/decode.rs`) written and read back; tested by `bridge_tab_stops_roundtrip`. Pre-fix Debug strings decode as absent. | — | +| `background_color` (paragraph) | **DONE** (2026-07-04) — total `DocumentColor` codec (`loro_bridge/color_codec.rs`, covers Rgb/Cmyk/Theme/Transparent) written and read back; tested by `bridge_para_background_color_roundtrip`. | — | | `DocumentMeta` / `DublinCoreMeta` | Round-trips **through the Loro CRDT** (`loro_bridge::meta`) **and is written back on export** — core properties + extended Dublin Core reach DOCX (`docProps/core.xml` + `custom.xml`) and ODT (`meta.xml`), tested by `metadata_round_trip.rs` / `extended_dublin_core_round_trips`. Remaining tail (not the Loro bridge): custom user properties, `meta:editing-duration`, and OOXML `docProps/app.xml` are still not written. | Low | --- diff --git a/Cargo.lock b/Cargo.lock index f9238383..8fde3dbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,7 +301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e2c6900aa6fa601379c17b824d0882c5c4ffd2f974124b273ba083b64f76077" dependencies = [ "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "raw-window-handle", ] @@ -314,7 +314,7 @@ dependencies = [ "anyrender", "image", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "thiserror 2.0.18", "usvg", ] @@ -326,7 +326,7 @@ dependencies = [ "anyrender", "debug_timer", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "pollster", "rustc-hash 2.1.3", "vello", @@ -343,9 +343,9 @@ dependencies = [ "anyrender", "debug_timer", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "softbuffer_window_renderer", - "vello_cpu", + "vello_cpu 0.0.4", ] [[package]] @@ -378,7 +378,7 @@ name = "appthere-canvas" version = "0.1.0" dependencies = [ "loki-render-cache", - "peniko", + "peniko 0.5.0", "read-fonts 0.40.2", ] @@ -397,6 +397,7 @@ dependencies = [ name = "appthere-conformance" version = "0.1.0" dependencies = [ + "image", "loki-doc-model", "loki-sheet-model", "tempfile", @@ -992,7 +993,7 @@ dependencies = [ "euclid", "kurbo 0.12.0", "parley 0.6.0", - "peniko", + "peniko 0.5.0", "stylo", "taffy", "usvg", @@ -1015,7 +1016,7 @@ dependencies = [ "kurbo 0.12.0", "log", "parley 0.6.0", - "peniko", + "peniko 0.5.0", "rfd 0.17.2", "winit", ] @@ -2750,6 +2751,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3229,6 +3236,23 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko 0.6.1", + "png 0.18.1", + "skrifa 0.42.1", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -3329,6 +3353,15 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" version = "0.4.15" @@ -3869,7 +3902,7 @@ dependencies = [ "gif", "image-webp", "num-traits", - "png", + "png 0.17.16", "qoi", "ravif", "rayon", @@ -4223,6 +4256,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy-js-bundle" version = "0.7.9" @@ -4461,7 +4506,8 @@ dependencies = [ [[package]] name = "loki-file-access" -version = "0.1.2" +version = "0.1.3" +source = "git+https://github.com/appthere/loki-file-access?branch=main#d2b7bc5c0a0f1703d1df0d083a9c5d660ddcb2cb" dependencies = [ "base64", "jni 0.21.1", @@ -4640,7 +4686,7 @@ dependencies = [ "loki-renderer", "loki-vello", "loro", - "peniko", + "peniko 0.5.0", "serde", "serde_json", "thiserror 2.0.18", @@ -4688,6 +4734,20 @@ dependencies = [ "wgpu", ] +[[package]] +name = "loki-render-cpu" +version = "0.1.0" +dependencies = [ + "appthere-conformance", + "image", + "loki-doc-model", + "loki-fonts", + "loki-layout", + "loki-odf", + "thiserror 2.0.18", + "vello_cpu 0.0.9", +] + [[package]] name = "loki-renderer" version = "0.1.0" @@ -4867,7 +4927,7 @@ dependencies = [ "loki-sheet-model", "loki-vello", "loro", - "peniko", + "peniko 0.5.0", "serde", "serde_json", "thiserror 2.0.18", @@ -4917,7 +4977,7 @@ dependencies = [ "loki-templates", "loki-vello", "loro", - "peniko", + "peniko 0.5.0", "serde", "serde_json", "thiserror 2.0.18", @@ -4939,7 +4999,7 @@ dependencies = [ "loki-layout", "loki-odf", "loki-ooxml", - "peniko", + "peniko 0.5.0", "pollster", "thiserror 2.0.18", "vello", @@ -6247,6 +6307,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo 0.13.1", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -6464,6 +6537,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -6495,6 +6581,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -9345,8 +9440,8 @@ dependencies = [ "bytemuck", "futures-intrusive", "log", - "peniko", - "png", + "peniko 0.5.0", + "png 0.17.16", "skrifa 0.37.0", "static_assertions", "thiserror 2.0.18", @@ -9362,14 +9457,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a235ba928b3109ad9e7696270edb09445a52ae1c7c08e6d31a19b1cdd6cbc24a" dependencies = [ "bytemuck", - "fearless_simd", + "fearless_simd 0.3.0", "hashbrown 0.15.5", "log", - "peniko", + "peniko 0.5.0", "skrifa 0.37.0", "smallvec", ] +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd 0.4.1", + "guillotiere 0.7.0", + "hashbrown 0.17.1", + "log", + "peniko 0.6.1", + "png 0.18.1", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "vello_cpu" version = "0.0.4" @@ -9377,7 +9489,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0bd1fcf9c1814f17a491e07113623d44e3ec1125a9f3401f5e047d6d326da21" dependencies = [ "bytemuck", - "vello_common", + "vello_common 0.0.4", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.0.9", ] [[package]] @@ -9387,8 +9512,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfd5e0b9fec91df34a09fbcbbed474cec68d05691b590a911c7af83c4860ae42" dependencies = [ "bytemuck", - "guillotiere", - "peniko", + "guillotiere 0.6.2", + "peniko 0.5.0", "skrifa 0.37.0", "smallvec", ] diff --git a/Cargo.toml b/Cargo.toml index bd61ecfd..84465f53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","appthere-conformance","loki-bench","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server","loki-convert","loki-print","loki-headless"] +members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-render-cpu","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","appthere-conformance","loki-bench","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server","loki-convert","loki-print","loki-headless"] [workspace.dependencies] # Shared across appthere-ui and any other workspace member that needs Dioxus. @@ -96,15 +96,6 @@ anyrender_vello = { path = "patches/anyrender_vello" } # handles the redraw itself. dioxus-native = { path = "patches/dioxus-native" } -[patch."https://github.com/appthere/loki-file-access"] -# PATCH: fixes two Android bugs — -# (1) ndk_context stores Application (not Activity); startActivityForResult needs -# the actual NativeActivity, obtained via AndroidApp::activity_as_ptr(). -# (2) ANativeActivityCallbacks has no onActivityResult; returns a clear error -# instead of hanging indefinitely, and exports the JNI hook for the future -# Gradle-based FilePickerActivity shim. -loki-file-access = { path = "patches/loki-file-access" } - # ── Build profiles ───────────────────────────────────────────────────────────── # # The unoptimized debug profile embeds full DWARF symbols for every dependency. diff --git a/android/.gitignore b/android/.gitignore index 176a7d7f..ff30202b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -6,7 +6,8 @@ app/build/ # Staged by scripts/build-aab.sh from the canonical sources — not committed: # * native libraries built by cargo -# * FilePickerActivity.java copied from patches/loki-file-access/android/ +# * FilePickerActivity.java copied from the loki-file-access crate's +# android/ directory (resolved via cargo metadata) app/src/main/jniLibs/ app/src/main/java/ diff --git a/appthere-conformance/Cargo.toml b/appthere-conformance/Cargo.toml index 28daf5e4..b721fbe1 100644 --- a/appthere-conformance/Cargo.toml +++ b/appthere-conformance/Cargo.toml @@ -21,6 +21,8 @@ sheet-model = ["dep:loki-sheet-model"] thiserror = { workspace = true } # Round-trip the candidate XML through a temp file for `xmllint` validation. tempfile = "3" +# PNG decode/encode for the visual-goldens differ and heatmap emission. +image = { version = "0.25", default-features = false, features = ["png"] } # Optional: the format-neutral document model, for the round-trip canonicalizer. loki-doc-model = { path = "../loki-doc-model", optional = true } # Optional: the spreadsheet model, for the workbook round-trip canonicalizer. diff --git a/appthere-conformance/examples/rasterize_pdf.rs b/appthere-conformance/examples/rasterize_pdf.rs new file mode 100644 index 00000000..e4ec3ef8 --- /dev/null +++ b/appthere-conformance/examples/rasterize_pdf.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! CLI wrapper over the pinned PDF→PNG rasterizer (Spec 02 D3), used by +//! `scripts/generate-odf-goldens.sh` so golden generation goes through the +//! exact same stage as every other rasterization. +//! +//! Usage: `cargo run -p appthere-conformance --example rasterize_pdf -- ` + +use appthere_conformance::PdfRasterizer; + +fn main() { + let mut args = std::env::args().skip(1); + let (Some(pdf), Some(out_dir), Some(stem)) = (args.next(), args.next(), args.next()) else { + eprintln!("usage: rasterize_pdf "); + std::process::exit(2); + }; + let rasterizer = PdfRasterizer::new().expect("pdftoppm must be installed (poppler-utils)"); + let pages = rasterizer + .rasterize( + std::path::Path::new(&pdf), + std::path::Path::new(&out_dir), + &stem, + ) + .expect("rasterization failed"); + println!("{}", rasterizer.version()); + for page in pages { + println!("{}", page.display()); + } +} diff --git a/appthere-conformance/fixtures/odt/para-carlito.odt b/appthere-conformance/fixtures/odt/para-carlito.odt new file mode 100644 index 00000000..c1af3ea8 Binary files /dev/null and b/appthere-conformance/fixtures/odt/para-carlito.odt differ diff --git a/appthere-conformance/fixtures/odt/para-gelasio.odt b/appthere-conformance/fixtures/odt/para-gelasio.odt new file mode 100644 index 00000000..7bf70319 Binary files /dev/null and b/appthere-conformance/fixtures/odt/para-gelasio.odt differ diff --git a/appthere-conformance/fixtures/odt/styles-tinos.odt b/appthere-conformance/fixtures/odt/styles-tinos.odt new file mode 100644 index 00000000..cc504833 Binary files /dev/null and b/appthere-conformance/fixtures/odt/styles-tinos.odt differ diff --git a/appthere-conformance/goldens/CALIBRATION.md b/appthere-conformance/goldens/CALIBRATION.md new file mode 100644 index 00000000..90282490 --- /dev/null +++ b/appthere-conformance/goldens/CALIBRATION.md @@ -0,0 +1,75 @@ + + +# Visual-axis calibration record (Spec 02 §7.4 / D5) + +The threshold is **data, not folklore**: this record documents the measured +cross-renderer noise floor behind `Tolerance::calibrated()` +(`appthere_conformance::golden`). Update the constants only together with +this record. + +| | | +|---|---| +| **Date** | 2026-07-05 | +| **Corpus** | `fixtures/odt/{para-carlito, styles-tinos, para-gelasio}.odt` — the M4 baseline set (single-page, metric-compatible font names per D4) | +| **Golden side** | LibreOffice 24.2 headless → PDF → pinned rasterizer (`pdftoppm 24.02.0`, 144 dpi, `-aa yes -aaVector yes`), per `scripts/generate-odf-goldens.sh` | +| **Candidate side** | `loki-odf` import → `loki-layout` (bundled faces registered) → `loki-render-cpu` (`vello_cpu` =0.0.9) at `CONFORMANCE_DPI` = 144 | +| **Fonts** | The bundled `loki-fonts` faces, installed for fontconfig so both sides shape the identical bytes | +| **Method** | `cargo run -p loki-render-cpu --example calibrate_odf` — 64 px regions, windowed SSIM + mean CIEDE2000 ΔE per region, pages cropped to the common area (the two sides differ by ≤1 px of DPI rounding) | + +## Measured distributions (500 regions/fixture, 1500 total; re-measured 2026-07-05 after the kerning fix) + +| Fixture | SSIM min | SSIM p1 | SSIM p5 | ΔE max | ΔE p99 | ΔE p95 | +|---|---|---|---|---|---|---| +| `styles-tinos` | 0.6603 | 0.8894 | 1.0000 | 7.854 | 1.112 | 0.000 | +| `para-gelasio` | 0.9044 | 0.9650 | 0.9985 | 4.014 | 2.336 | 0.177 | +| `para-carlito` | 0.6922 | 0.7875 | 0.9804 | 7.515 | 5.251 | 1.408 | + +All three fixtures now sit in one noise band and pass the calibrated +thresholds — the visual golden suite (`loki-render-cpu/tests/visual_golden.rs`) +asserts all of them. + +## Analysis — how the divergence was root-caused (kept as a worked example) + +The first calibration run (2026-07-05, same day) measured `para-carlito` at +SSIM min **0.2348** / ΔE max **19.8** — structural divergence. Diagnosis went +through two corrections, both driven by this harness's own artifacts: + +1. The heatmap showed per-line cumulative rightward drift, initially read as + *loki missing kerning* (fidelity gap #23 was listed open). A shaping probe + disproved that: Parley 0.10's harfrust shaper applies Carlito's GPOS kern + pairs (and `fi` ligatures) — the historical gap had been closed silently + by the Parley 0.8→0.10 upgrade. +2. Per-line ink measurement then showed the *golden* consistently ~4–7 px + **wider** per line — the total kern amount, with the sign inverted from + the first reading: **LibreOffice was not kerning this document** (the + fixture ODT carries no `style:letter-kerning`, and both LO-on-import and + Word default pair kerning off), while loki kerned unconditionally. The + width delta flipped borderline line wraps, cascading into whole-line + diffs. + +**Fix (the real gap #23):** `CharProps.kerning` is now forwarded to layout +(`StyleSpan::kerning`) and applied as a shaper feature toggle with a +reference-matching default of **off** (`"kern" 0` unless the document enables +it). Regression-locked by `loki-layout/tests/kerning_applied.rs`: kern applied +when enabled, natural advances when defaulted, contextual pairs only, +ligatures unaffected. + +## Decision + +- **Calibrated thresholds:** `min_ssim = 0.60` (re-measured floor 0.6603), + `max_delta_e = 10.0` (re-measured max 7.854). Confirmed against the + post-fix distributions; unchanged from the initial derivation. +- **The visual axis now has all fixtures green** and can be considered for + promotion to a hard CI gate once the corpus grows past this baseline set + (the gate-hardening call is the maintainer's; the suite already fails a + PR that regresses any fixture past tolerance). + +## Reproduction + +```sh +cp loki-fonts/fonts/*.ttf ~/.fonts && fc-cache +./scripts/generate-odf-goldens.sh +cargo run -p loki-render-cpu --example calibrate_odf +``` diff --git a/appthere-conformance/goldens/odt/para-carlito/GENERATION.txt b/appthere-conformance/goldens/odt/para-carlito/GENERATION.txt new file mode 100644 index 00000000..81b49120 --- /dev/null +++ b/appthere-conformance/goldens/odt/para-carlito/GENERATION.txt @@ -0,0 +1,5 @@ +fixture: para-carlito.odt +reference: LibreOffice headless (LibreOffice 24.2.7.2 420(Build:2)) +rasterizer: pdftoppm version 24.02.0 @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes +generated: 2026-07-05 +operator: scripts/generate-odf-goldens.sh (scripted; Spec 02 §7.2) diff --git a/appthere-conformance/goldens/odt/para-carlito/page-1.png b/appthere-conformance/goldens/odt/para-carlito/page-1.png new file mode 100644 index 00000000..3317e29c Binary files /dev/null and b/appthere-conformance/goldens/odt/para-carlito/page-1.png differ diff --git a/appthere-conformance/goldens/odt/para-gelasio/GENERATION.txt b/appthere-conformance/goldens/odt/para-gelasio/GENERATION.txt new file mode 100644 index 00000000..b113fdb0 --- /dev/null +++ b/appthere-conformance/goldens/odt/para-gelasio/GENERATION.txt @@ -0,0 +1,5 @@ +fixture: para-gelasio.odt +reference: LibreOffice headless (LibreOffice 24.2.7.2 420(Build:2)) +rasterizer: pdftoppm version 24.02.0 @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes +generated: 2026-07-05 +operator: scripts/generate-odf-goldens.sh (scripted; Spec 02 §7.2) diff --git a/appthere-conformance/goldens/odt/para-gelasio/page-1.png b/appthere-conformance/goldens/odt/para-gelasio/page-1.png new file mode 100644 index 00000000..7f7c956f Binary files /dev/null and b/appthere-conformance/goldens/odt/para-gelasio/page-1.png differ diff --git a/appthere-conformance/goldens/odt/styles-tinos/GENERATION.txt b/appthere-conformance/goldens/odt/styles-tinos/GENERATION.txt new file mode 100644 index 00000000..1aa48028 --- /dev/null +++ b/appthere-conformance/goldens/odt/styles-tinos/GENERATION.txt @@ -0,0 +1,5 @@ +fixture: styles-tinos.odt +reference: LibreOffice headless (LibreOffice 24.2.7.2 420(Build:2)) +rasterizer: pdftoppm version 24.02.0 @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes +generated: 2026-07-05 +operator: scripts/generate-odf-goldens.sh (scripted; Spec 02 §7.2) diff --git a/appthere-conformance/goldens/odt/styles-tinos/page-1.png b/appthere-conformance/goldens/odt/styles-tinos/page-1.png new file mode 100644 index 00000000..8a45153e Binary files /dev/null and b/appthere-conformance/goldens/odt/styles-tinos/page-1.png differ diff --git a/appthere-conformance/schemas/README.md b/appthere-conformance/schemas/README.md index c5df27b1..f46e4c30 100644 --- a/appthere-conformance/schemas/README.md +++ b/appthere-conformance/schemas/README.md @@ -9,25 +9,35 @@ version-pinned** schemas, vendored here so validation is reproducible and offlin (Spec 02 **D6**). `appthere_conformance::schema::XmllintValidator` points `xmllint` at these files. -> **Status:** the validator and its plumbing are implemented and tested (against -> small in-tree schemas in the unit tests). Vendoring the *full* official schema -> sets below — and validating real DOCX/ODT exports against them — is the -> immediate next step (Spec 02 M2 completion). They are not committed yet because -> each is a large third-party drop with its own license/version that the -> maintainer should pin deliberately. +> **Status (2026-07-05): vendored and live.** Real DOCX and ODT exports are +> validated against these schemas in `loki-ooxml/tests/schema_validation.rs` +> and `loki-odf/tests/schema_validation.rs` (Spec 02 M2 acceptance: valid +> exports pass, a deliberately malformed part fails, a missing `xmllint` +> fails loudly). -## What to vendor, and where +## Layout (as vendored) -| Path | Schema | Source / version to pin | +| Path | Schema | Pinned version / source | |------|--------|--------------------------| -| `ooxml/transitional/` | ISO/IEC 29500-1 **Transitional** XSDs (`wml.xsd`, `sml.xsd`, `dml-*.xsd`, `shared-*.xsd`, …) | ECMA-376 5th ed. Transitional schema bundle. Default validation target. | -| `ooxml/strict/` | ISO/IEC 29500 **Strict** XSDs | Same bundle, Strict. Opt-in stricter pass. | -| `opc/` | OPC: `opc-contentTypes.xsd`, `opc-relationships.xsd`, `opc-coreProperties.xsd` | ECMA-376 Part 2 (OPC). Exercises `loki-opc`. | -| `odf/` | OASIS ODF **RELAX NG** (`OpenDocument-v1.3-schema.rng`) + `OpenDocument-v1.3-manifest-schema.rng` | OASIS ODF 1.3 schema. | - -Each subdirectory should also carry a `PROVENANCE.txt` recording the exact source -URL, version/edition, retrieval date, and upstream license, so the pin is -auditable. +| `ooxml/transitional/` | ISO/IEC 29500-4 **Transitional** XSDs (`wml.xsd`, `sml.xsd`, `pml.xsd`, `dml-main.xsd`, `shared-*.xsd`, `vml-*.xsd`, `xml.xsd`) | **ISO/IEC 29500-4:2016** electronic-insert bundle. Default validation target. | +| `ooxml/mce/` | Markup Compatibility & Extensibility (`mc.xsd`), imported by `wml.xsd` | Same bundle. | +| `opc/` | OPC: `opc-contentTypes.xsd`, `opc-relationships.xsd`, `opc-coreProperties.xsd`, `opc-digSig.xsd` | **ECMA-376 4th ed. Part 2**. Exercises `loki-opc` (`[Content_Types].xml`, `_rels/.rels`). | +| `odf/` | OASIS ODF **RELAX NG**: `OpenDocument-v1.3-schema.rng`, `-manifest-`, `-dsig-` | **OASIS ODF 1.3 OS**, via Maven `org.odftoolkit:odfvalidator:0.12.0`. | +| `mathml3/` | W3C MathML3 RELAX NG (referenced by ODF math content) | Same artifact. | + +Each subdirectory carries a `PROVENANCE.txt` recording the exact source, +version/edition, retrieval date, upstream license, and a per-file sha256 +manifest, so the pin is auditable. + +**Not vendored (documented tails):** + +- **Strict** ISO/IEC 29500 XSDs (`ooxml/strict/`) — the opt-in stricter pass. + Vendor from the same ISO bundle when the Strict pass is scheduled. +- The Dublin Core XSDs (`dc.xsd`, `dcterms.xsd`, `dcmitype.xsd`) that + `opc-coreProperties.xsd` imports by live URL — required before + `docProps/core.xml` can be validated offline. No in-policy source was + reachable when vendoring; see `TODO(conformance-schemas)` in + `loki-ooxml/tests/schema_validation.rs`. ## How the validator uses them @@ -37,13 +47,14 @@ use appthere_conformance::schema::{SchemaKind, SchemaValidator, XmllintValidator let v = XmllintValidator::new()?; // fails loudly if xmllint absent let report = v.validate_bytes( &exported_document_xml, - std::path::Path::new("appthere-conformance/schemas/ooxml/transitional/wml.xsd"), + // canonicalize(): libxml2 treats one file reached via two path spellings + // as two schema documents and then skips "duplicate" namespace imports. + &schemas_dir.join("ooxml/transitional/wml.xsd").canonicalize()?, SchemaKind::Xsd, )?; assert!(report.valid, "{:#?}", report.violations); // first-class located violations ``` -ODF parts use `SchemaKind::RelaxNg` against the `.rng` files. A schema *registry* -that maps each emitted part (`document.xml`, `styles.xml`, `content.xml`, -`manifest.xml`, `[Content_Types].xml`, …) to its schema file is the small wrapper -to add once the files are vendored. +ODF parts use `SchemaKind::RelaxNg` against the `.rng` files. The part→schema +mapping lives in the consumer tests (`schema_validation.rs` in `loki-ooxml` / +`loki-odf`); promote it into a shared registry here if a third consumer appears. diff --git a/appthere-conformance/schemas/mathml3/mathml3-common.rng b/appthere-conformance/schemas/mathml3/mathml3-common.rng new file mode 100644 index 00000000..f598d44e --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3-common.rng @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + block + inline + + + + + + + + + + + + linebreak + scroll + elide + truncate + scale + + + + + + + + + + + + + + + + + + + + + + + top + middle + bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \s*((-?[0-9]*([0-9]\.?|\.[0-9])[0-9]*(e[mx]|in|cm|mm|p[xtc]|%)?)|(negative)?((very){0,2}thi(n|ck)|medium)mathspace)\s* + + + diff --git a/appthere-conformance/schemas/mathml3/mathml3-content.rng b/appthere-conformance/schemas/mathml3/mathml3-content.rng new file mode 100644 index 00000000..a0a169ad --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3-content.rng @@ -0,0 +1,1544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + numeric + lexicographic + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + prefix + infix + function-model + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/mathml3/mathml3-presentation.rng b/appthere-conformance/schemas/mathml3/mathml3-presentation.rng new file mode 100644 index 00000000..2b56d562 --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3-presentation.rng @@ -0,0 +1,2324 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \s*([\+\-]?[0-9]*([0-9]\.?|\.[0-9])[0-9]*\s*((%?\s*(height|depth|width)?)|e[mx]|in|cm|mm|p[xtc]|((negative)?((very){0,2}thi(n|ck)|medium)mathspace))?)\s* + + + + + none + solid + dashed + + + + + top + bottom + center + baseline + axis + + + + + left + center + right + + + + + longdiv + actuarial + radical + box + roundedbox + circle + left + right + top + bottom + updiagonalstrike + downdiagonalstrike + verticalstrike + horizontalstrike + madruwb + + + + + + + + + + + + + + + + + \s*\S\s* + + + + + \s*((#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|[aA][qQ][uU][aA]|[bB][lL][aA][cC][kK]|[bB][lL][uU][eE]|[fF][uU][cC][hH][sS][iI][aA]|[gG][rR][aA][yY]|[gG][rR][eE][eE][nN]|[lL][iI][mM][eE]|[mM][aA][rR][oO][oO][nN]|[nN][aA][vV][yY]|[oO][lL][iI][vV][eE]|[pP][uU][rR][pP][lL][eE]|[rR][eE][dD]|[sS][iI][lL][vV][eE][rR]|[tT][eE][aA][lL]|[wW][hH][iI][tT][eE]|[yY][eE][lL][lL][oO][wW])\s* + + + + + left + center + right + decimalpoint + + + + + + + + + + + + (\s*\{\s*(left|center|right|decimalpoint)(\s+(left|center|right|decimalpoint))*\})*\s* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + prefix + infix + postfix + + + + + + + true + false + + + + + + + true + false + + + + + + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + + infinity + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + true + false + + + + + + + auto + newline + nobreak + goodbreak + badbreak + + + + + + + + + + + + before + after + duplicate + infixlinebreakstyle + + + + + + + + + + left + center + right + auto + id + + + + + + + + + + + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + indentshift + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + indentshift + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + newline + nobreak + goodbreak + badbreak + indentingnewline + + + + + + + left + center + right + auto + id + + + + + + + + + + + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + indentshift + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + indentshift + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + normal + bold + italic + bold-italic + double-struck + bold-fraktur + script + bold-script + fraktur + sans-serif + bold-sans-serif + sans-serif-italic + sans-serif-bold-italic + monospace + initial + tailed + looped + stretched + + + + + + + small + normal + big + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + thin + medium + thick + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transparent + + + + + + + + + normal + bold + italic + bold-italic + double-struck + bold-fraktur + script + bold-script + fraktur + sans-serif + bold-sans-serif + sans-serif-italic + sans-serif-bold-italic + monospace + initial + tailed + looped + stretched + + + + + + + small + normal + big + + + + + + + + ltr + rtl + + + + + + + + + + + + + normal + bold + + + + + + + normal + italic + + + + + + + + + + + + + + + + + + transparent + + + + + + + + + + + + + + + + + + + + + + + left + right + + + + + + + + + + + + + + + + + left + center + right + decimalpoint + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ltr + rtl + + + + + + + + + + + + + + + + + + + thin + medium + thick + + + + + + + left + center + right + + + + + + + left + center + right + + + + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + before + after + duplicate + + + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + left + right + center + + + + + + + + + true + false + + + + + + + + + true + false + + + + + + + left + center + right + + + + + + + + loose + medium + tight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + + fit + + + + + + + + + + + none + updiagonalstrike + downdiagonalstrike + verticalstrike + horizontalstrike + + + + + + + + + left + center + right + + + + + + + + + + + + ltr + rtl + + + + + + + left + right + + + + + + + true + false + + + + + + + true + false + + + + + + + true + false + + + + + + + prefix + infix + postfix + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + auto + id + + + + + + + left + center + right + auto + id + indentalign + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + + + + + + indentshift + + + + + + + + indentshift + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + auto + newline + nobreak + goodbreak + badbreak + + + + + + + + + + before + after + duplicate + infixlinebreakstyle + + + + + + + + + + + + + thin + medium + thick + + + + + + + w + nw + n + ne + e + se + s + sw + + + + + + + lefttop + stackedrightright + mediumstackedrightright + shortstackedrightright + righttop + left/\right + left)(right + :right=right + stackedleftleft + stackedleftlinetop + + + + + + + + + + + + + + + small + normal + big + + + + + + + + normal + bold + italic + bold-italic + double-struck + bold-fraktur + script + bold-script + fraktur + sans-serif + bold-sans-serif + sans-serif-italic + sans-serif-bold-italic + monospace + initial + tailed + looped + stretched + + + + + + + + infinity + + + + + + + + + + + + + + + + + true + false + + + + + + + + thin + medium + thick + + + + + + + + + + left + center + right + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + left + right + leftoverlap + rightoverlap + + + + + + + left + center + right + decimalpoint + + + + + + + true + false + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + left + right + center + + + + + + + + + + + + + + + + + + true + false + + + + + + + left + right + center + + + + + + + + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + left + right + center + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \s*(top|bottom|center|baseline|axis)(\s+-?[0-9]+)?\s* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + auto + + fit + + + + + + + + + auto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + true + false + + + + + + + left + right + leftoverlap + rightoverlap + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + bottom + center + baseline + axis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + bottom + center + baseline + axis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \s*(top|bottom|center|baseline|axis)(\s+-?[0-9]+)?\s* + + + + + + + left + center + right + decimalpoint + + + + + + + left + center + right + + + + + + + + loose + medium + tight + + + + + + + + + + + + + + + + + + + + lefttop + stackedrightright + mediumstackedrightright + shortstackedrightright + righttop + left/\right + left)(right + :right=right + stackedleftleft + stackedleftlinetop + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + w + nw + n + ne + e + se + s + sw + + + + + + + + + none + updiagonalstrike + downdiagonalstrike + verticalstrike + horizontalstrike + + + + + + + + + + + + + + + + + + + + + + + + + + w + nw + n + ne + e + se + s + sw + + + + + + + + + none + updiagonalstrike + downdiagonalstrike + verticalstrike + horizontalstrike + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/mathml3/mathml3-strict-content.rng b/appthere-conformance/schemas/mathml3/mathml3-strict-content.rng new file mode 100644 index 00000000..25e5162d --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3-strict-content.rng @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + integer + real + double + hexdouble + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + integer + rational + real + complex + complex-polar + complex-cartesian + constant + function + vector + list + set + matrix + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/mathml3/mathml3.rng b/appthere-conformance/schemas/mathml3/mathml3.rng new file mode 100644 index 00000000..f1f75fcc --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3.rng @@ -0,0 +1,23 @@ + + + + + Content MathML + + + Presentation MathML + + + math and semantics common to both Content and Presentation + + diff --git a/appthere-conformance/schemas/odf/OpenDocument-v1.3-dsig-schema.rng b/appthere-conformance/schemas/odf/OpenDocument-v1.3-dsig-schema.rng new file mode 100644 index 00000000..954add38 --- /dev/null +++ b/appthere-conformance/schemas/odf/OpenDocument-v1.3-dsig-schema.rng @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.3 + + + diff --git a/appthere-conformance/schemas/odf/OpenDocument-v1.3-manifest-schema.rng b/appthere-conformance/schemas/odf/OpenDocument-v1.3-manifest-schema.rng new file mode 100644 index 00000000..3ff6d39b --- /dev/null +++ b/appthere-conformance/schemas/odf/OpenDocument-v1.3-manifest-schema.rng @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + Blowfish CFB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SHA1/1K + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + edit + presentation-slide-show + read-only + + + + + + + + + + + + + + + + + + + + + PGP + + + + + PBKDF2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.3 + + + + + [^:]+:[^:]+ + + + + + + + + + + + + + + + + SHA1 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/appthere-conformance/schemas/odf/OpenDocument-v1.3-schema.rng b/appthere-conformance/schemas/odf/OpenDocument-v1.3-schema.rng new file mode 100644 index 00000000..dc77c4fc --- /dev/null +++ b/appthere-conformance/schemas/odf/OpenDocument-v1.3-schema.rng @@ -0,0 +1,18299 @@ + + + + + + + + + + + + + + + + + (([\i-[:]][\c-[:]]*)?:)?.+ + 1 + + + + + + + + + + + + + + + + + + + + + + + + \[(([\i-[:]][\c-[:]]*)?:)?.+\] + 3 + + + + + + + + + + + + + + + + + rgb + hsl + + + + + + + clockwise + counter-clockwise + + + + + + + + + + + + + + + + + + + + + discrete + linear + paced + spline + + + + + + + + + translate + scale + rotate + skewX + skewY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + forward + reverse + + + + + + + + + + + + in + out + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + An IRI-reference as defined in [RFC3987]. See ODF 1.3 Part 3 section 18.3. + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + ($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+$?[0-9]+ + + + + + + ($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+$?[0-9]+(:($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+$?[0-9]+)? + + + ($?([^\. ']+|'([^']|'')+'))?\.$?[0-9]+:($?([^\. ']+|'([^']|'')+'))?\.$?[0-9]+ + + + ($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+:($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+ + + + + + + Value is a space separated list of "cellRangeAddress" patterns + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + x + y + z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + major + minor + + + + + + + + + + + + + + + + + + + + + + + + + start + end + top + bottom + + + + + + start + center + end + + + + + + + top-start + bottom-start + top-end + bottom-end + + + + + + + + + wide + high + balanced + + + + + custom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + row + column + both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rect\([ ]*((-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc)))|(auto))([ ]*,[ ]*((-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc))))|(auto)){3}[ ]*\) + + + + + #[0-9a-fA-F]{6} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + sum + + + + + + + replace + sum + + + + + + + + + + + default + on-click + with-previous + after-previous + timing-root + main-sequence + interactive-sequence + + + + + + + + + + + + + + + + + custom + entrance + exit + emphasis + motion-path + ole-action + media-call + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + discrete + linear + paced + spline + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transparent + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + column + page + + + + + + + auto + column + page + + + + + + + + + gregorian + gengou + ROC + hanja_yoil + hanja + hijri + jewish + buddhist + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + medium + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + new + replace + + + + + + + + + + + + nohref + + + + + + + + + + + + + + + + + full + section + cut + arc + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + embed + + + + + onLoad + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + scale + scale-min + + + + + + + + scale + scale-min + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + first + last + all + media + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + table + query + command + + + + + + + + + + + + + + + + + value + formula + + + + + + + + + value + formula + none + + + + + + + + + value + none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + remove + freeze + hold + transition + auto + inherit + + + + + + + + + remove + freeze + hold + auto + default + transition + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + center + + + + + + start + end + top + bottom + + + + + + start + center + end + + + + + + + + + + + flat + 3d + + + + + + + + + fixed + language + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + always + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + i + I + + + + + + + + a + A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + page + chapter + direction + text + + + + + + + + + + + + + + + + + + + + indefinite + + + + + + + + + never + always + whenNotActive + inherit + + + + + + + + + never + always + whenNotActive + default + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ltr + ttb + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + objectBoundingBox + + + + + + + + + + + pad + reflect + repeat + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + accepted + rejected + pending + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + start + end + left + right + center + justify + + + + + + + + + + page + frame + paragraph + char + as-char + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + float + + + + + + + + percentage + + + + + + + + currency + + + + + + + + + + + + + date + + + + + + + + time + + + + + + + + boolean + + + + + + + + string + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + middle + bottom + from-top + below + + + + + + + + + + + + + + page + page-content + frame + frame-content + paragraph + paragraph-content + char + line + baseline + text + + + + + + + + + lr-tb + rl-tb + tb-rl + tb-lr + lr + rl + tb + page + + + + + + + + + + + + + + + + + + boolean + short + int + long + double + string + datetime + base64Binary + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [A-Za-z0-9]{1,8} + + + + + + + + + + + + + + + + + + + non-primitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + equal-integer + is-boolean + equal-boolean + equal-use-only-zero + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + no-nulls + nullable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + none + + + + + onRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + none + + + + + onRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + boolean + short + int + long + double + string + + + + + + + + + + + + + + + + + + + + + + + + + bit + boolean + tinyint + smallint + integer + bigint + float + real + double + numeric + decimal + char + varchar + longvarchar + date + time + timestmp + binary + varbinary + longvarbinary + sqlnull + other + object + distinct + struct + array + blob + clob + ref + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + primary + unique + foreign + + + + + + + + + + + cascade + restrict + set-null + no-action + set-default + + + + + + + cascade + restrict + set-null + no-action + set-default + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + parallel + perspective + + + + + + + + + + + + + + + + + + + + + + flat + phong + gouraud + draft + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + new + replace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + standard + lines + line + curve + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + flat + phong + gouraud + draft + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + parallel + perspective + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + segments + rectangle + + + + + + + + + + + + + + + + + normal + path + shape + + + + + + + path + shape + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top-left + top + top-right + left + center + right + bottom-left + bottom-right + + + + + + auto + left + right + up + down + horizontal + vertical + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + single + double + triple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + always + screen + printer + none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rect + round + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -0.5 + 0.5 + + + + + roman + swiss + modern + decorative + script + system + + + + + fixed + variable + + + + + normal + italic + oblique + + + + + normal + small-caps + + + + + normal + bold + 100 + 200 + 300 + 400 + 500 + 600 + 700 + 800 + 900 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + + get + post + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + table + query + command + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + float + + + + + + + + + + + + percentage + + + + + + + + + + + + currency + + + + + + + + + + + + + + + + + date + + + + + + + + + + + + time + + + + + + + + + + + + boolean + + + + + + + + + + + + string + + + + + + + + + + + void + + + + + + + + void + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + horizontal + vertical + + + + + + + + linear + axial + radial + ellipsoid + square + rectangular + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + + + + + horizontal + horizontal-on-odd + horizontal-on-even + + + + + + + + + + + + + + + + + + + + + + + + + + + + avoid-overlap + center + top + top-right + right + bottom-right + bottom + bottom-left + left + top-left + inside + outside + near-origin + + + + + + + + [A-Za-z]{1,8} + + + + + -?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc)|(px)) + + + + + continuous + skip-white-space + + + + + none + solid + dotted + dash + long-dash + dot-dash + dot-dot-dash + wave + + + + + none + single + double + + + + + auto + normal + bold + thin + medium + thick + + + + + + + + + + selection + selection-indices + + + + + + + + + + + + + + + + table + query + sql + sql-pass-through + value-list + table-fields + + + + + + + + + + + To avoid inclusion of the complete MathML schema, anything is allowed within a math:math top-level element + + + + + + + + + + + + + + + + + + + + + [^:]+:[^:]+ + + + + + none + current + parent + + + + + 0.0 + + + + + + + + ([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc)|(px)) + + + + + ([0-9]+(\.[0-9]*)?|\.[0-9]+)(px) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + into-default-style-data-style + into-english-number + keep-text + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + + + + + + + simple + + + + + + + replace + + + + + onLoad + + + + + + + + + + + + + + + + + + + new + replace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + float + + + + + + date + + + + + + time + + + + + + boolean + + + + + + string + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text:page-count + text:paragraph-count + text:word-count + text:character-count + text:table-count + text:image-count + text:object-count + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text:reference-ref + text:bookmark-ref + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + value + unit + gap + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text:identifier + text:address + text:annote + text:author + text:booktitle + text:chapter + text:edition + text:editor + text:howpublished + text:institution + text:journal + text:month + text:note + text:number + text:organizations + text:pages + text:publisher + text:school + text:series + text:title + text:report-type + text:volume + text:year + text:url + text:custom1 + text:custom2 + text:custom3 + text:custom4 + text:custom5 + text:isbn + text:issn + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -?([0-9]+(\.[0-9]*)?|\.[0-9]+)% + + + + + \([ ]*-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc))([ ]+-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc))){2}[ ]*\) + + + + + -?[0-9]+,-?[0-9]+([ ]+-?[0-9]+,-?[0-9]+)* + + + + + + + + ([0-9]*[1-9][0-9]*(\.[0-9]*)?|0+\.[0-9]*[1-9][0-9]*|\.[0-9]*[1-9][0-9]*)((cm)|(mm)|(in)|(pt)|(pc)|(px)) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + title + outline + subtitle + text + graphic + object + chart + table + orgchart + page + notes + handout + header + footer + date-time + page-number + + + + + + + + + + fixed + current-date + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + previous-page + next-page + first-page + last-page + hide + stop + execute + show + verb + fade-out + sound + last-visited-page + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + embed + + + + + onRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + enabled + disabled + + + + + + + enabled + disabled + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + new + replace + + + + + + + + + + + + + + + + + + + + + none + from-left + from-top + from-right + from-bottom + from-center + from-upper-left + from-upper-right + from-lower-left + from-lower-right + to-left + to-top + to-right + to-bottom + to-upper-left + to-upper-right + to-lower-right + to-lower-left + path + spiral-inward-left + spiral-inward-right + spiral-outward-left + spiral-outward-right + vertical + horizontal + to-center + clockwise + counter-clockwise + + + + + none + fade + move + stripes + open + close + dissolve + wavyline + random + lines + laser + appear + hide + move-short + checkerboard + rotate + stretch + + + + + slow + medium + fast + + + + + + + + + + [0-9]+\* + + + + + row + column + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + [A-Za-z0-9]{1,8} + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -?([0-9]?[0-9](\.[0-9]*)?|100(\.0*)?|\.[0-9]+)% + + + + + + + + + + + + unchecked + checked + unknown + + + + + + + + + + + + + + + + + + + + + + + no-repeat + repeat + stretch + + + + + + + left + center + right + top + bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + + + automatic + + + + named-symbol + + + + square + diamond + arrow-down + arrow-up + arrow-right + arrow-left + bow-tie + hourglass + circle + star + x + plus + asterisk + horizontal-bar + vertical-bar + + + + + + image + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + cubic-spline + b-spline + step-start + step-end + step-center-x + step-center-y + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cuboid + cylinder + cone + pyramid + + + + + + + + + + + + + + + + + use-zero + leave-gap + ignore + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + side-by-side + stagger-even + stagger-odd + + + + + + + + + none + value + percentage + value-and-percentage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + variance + standard-deviation + percentage + error-margin + constant + standard-error + cell-range + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + columns + rows + + + + + + + none + linear + logarithmic + moving-average + exponential + power + polynomial + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + prior + central + averaged-abscissa + + + + + + + + start + end + + + + + + + + near-axis + near-axis-other-side + outside-start + outside-end + + + + + + + at-labels + at-axis + at-labels-and-axis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + solid + dotted + dashed + dot-dashed + + + + + + + + + + + + + + + top + middle + bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + manual + automatic + semi-automatic + + + + + + + none + fade-from-left + fade-from-top + fade-from-right + fade-from-bottom + fade-from-upperleft + fade-from-upperright + fade-from-lowerleft + fade-from-lowerright + move-from-left + move-from-top + move-from-right + move-from-bottom + move-from-upperleft + move-from-upperright + move-from-lowerleft + move-from-lowerright + uncover-to-left + uncover-to-top + uncover-to-right + uncover-to-bottom + uncover-to-upperleft + uncover-to-upperright + uncover-to-lowerleft + uncover-to-lowerright + fade-to-center + fade-from-center + vertical-stripes + horizontal-stripes + clockwise + counterclockwise + open-vertical + open-horizontal + close-vertical + close-horizontal + wavyline-from-left + wavyline-from-top + wavyline-from-right + wavyline-from-bottom + spiralin-left + spiralin-right + spiralout-left + spiralout-right + roll-from-top + roll-from-left + roll-from-right + roll-from-bottom + stretch-from-left + stretch-from-top + stretch-from-right + stretch-from-bottom + vertical-lines + horizontal-lines + dissolve + random + vertical-checkerboard + horizontal-checkerboard + interlocking-horizontal-left + interlocking-horizontal-right + interlocking-vertical-top + interlocking-vertical-bottom + fly-away + open + close + melt + + + + + + + + + + + + + + + + + + + + + + forward + reverse + + + + + + + + + + + + + + + + + visible + hidden + + + + + + + full + border + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + word + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + normal + ultra-condensed + extra-condensed + condensed + semi-condensed + semi-expanded + expanded + extra-expanded + ultra-expanded + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + + + + + + + + + + + + + + + + + + + + none + solid + bitmap + gradient + hatch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + no-repeat + repeat + stretch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top-left + top + top-right + left + center + right + bottom-left + bottom + bottom-right + + + + + + + + + horizontal + vertical + + + + + + + + + + + + + + + + + + nonzero + evenodd + + + + + + + + + + + + + + + + none + dash + solid + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + miter + round + bevel + middle + none + + + + + + + butt + square + round + + + + + + + + + + + + none + scroll + alternate + slide + + + + + + + left + right + up + down + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + middle + bottom + justify + + + + + + + left + center + right + justify + + + + + + + no-wrap + wrap + + + + + + + + + + + + greyscale + mono + watermark + standard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + visible + hidden + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + below + above + + + + + + + + + + + + automatic + left-outside + inside + right-outside + + + + + + + automatic + above + below + center + + + + + + + automatic + mm + cm + m + km + pt + pc + inch + ft + mi + + + + + + + + + + + + + + + + + straight-line + angled-line + angled-connector-line + + + + + + + fixed + free + + + + + + + + + + + + + + + + + horizontal + vertical + auto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + correct + attractive + + + + + + + + + + + + + + + + + enabled + disabled + + + + + + + + + + + + + + + + + + + + + + standard + double-sided + + + + + + + object + flat + sphere + + + + + + + normal + inverse + + + + + + + object + parallel + sphere + + + + + + + object + parallel + sphere + + + + + + + luminance + intensity + color + + + + + + + enabled + disabled + + + + + + + replace + modulate + blend + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + visible + hidden + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + + + + content + position + size + + + + + + + + + + left + center + right + from-left + inside + outside + from-inside + + + + + + + + + + + + page + page-content + page-start-margin + page-end-margin + frame + frame-content + frame-start-margin + frame-end-margin + paragraph + paragraph-content + paragraph-start-margin + paragraph-end-margin + char + + + + + + + + + + + + + + + + + none + left + right + parallel + dynamic + run-through + biggest + + + + + + + + + + + + no-limit + + + + + + + + + + + + + full + outside + + + + + + + foreground + background + + + + + + + + + + + + clip + auto-create-new-frame + + + + + + + none + vertical + + + vertical + + + + + vertical + + + + + + + + auto + + + + + + + + iterative + once-concurrent + once-successive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + content + thumbnail + icon + print-view + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + listtab + space + nothing + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + label-width-and-position + label-alignment + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + left + right + mirrored + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + default + + + + + + + + portrait + landscape + + + + + + + + + + + + + + + + + + + + + + headers + grid + annotations + objects + charts + drawings + formulas + zero-values + + + + + + + + + ttb + ltr + + + + + + + + continue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + horizontal + vertical + both + none + + + + + + + + + + + + + none + line + both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + normal + + + + + + + + + + + + + + + + + + + + + + + + + start + center + justify + + + + + + + + + + + + auto + always + + + + + + + + + + + + + + + + + + + + + + auto + page + + + + + + + no-limit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + ideograph-alpha + + + + + + + simple + hanging + + + + + + + normal + strict + + + + + + + top + middle + bottom + auto + baseline + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + above + below + + + + + + + left + center + right + distribute-letter + distribute-space + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text + + + + + + + + paragraph + + + + + + + + + + + section + + + + + + + + ruby + + + + + + + + table + + + + + + + + table-column + + + + + + + + table-row + + + + + + + + table-cell + + + + + + + + + + + + + + + graphic + presentation + + + + + + + + + + + + + + + drawing-page + + + + + + + + chart + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + + + + + + char + + + + + + + + + + + + + + + + + + + + + + + font-color + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + middle + bottom + automatic + + + + + + + fix + value-type + + + + + + + + auto + 0 + 0deg + 0rad + 0grad + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + no-wrap + wrap + + + + + + + + none + bottom + top + center + + + + + + + none + hidden-and-protected + + + + protected + formula-hidden + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + margins + + + + + + + + + + + + + + + + + + + + collapsing + separating + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + always + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + lowercase + uppercase + capitalize + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + font-color + + + + + + + + + + + + + + + + + + + + super + sub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + latin + asian + complex + ignore + + + + + + + + normal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + embossed + engraved + + + + + + + + + + + + + + + + + + + + + + + + + + + font-color + + + + + + + + + + + + + + + + + + + + + + + font-color + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + letters + lines + + + + + + + + + + + + + + + + + none + + + none + accent + dot + circle + disc + + + above + below + + + + + + + + + + + + + + + + + + + fixed + line-height + + + + + + + + + + + + + + + + + + + + + true + + + none + + + + condition + + + none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + records + current + page + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + average + count + countnums + max + min + product + stdev + stdevp + sum + var + varp + + + + + + + + + + + + + none + row + column + both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + from-top + from-bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + row + column + data + hidden + + + + + page + + + + + + + + + + + + + + + auto + average + count + countnums + max + min + product + stdev + stdevp + sum + var + varp + + + + + + + + + + + + + + + + + + + + + + + + + named + + + + + + + + previous + next + + + + + + none + member-difference + member-percentage + member-percentage-difference + running-total + row-percentage + column-percentage + total-percentage + index + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + + + + + + auto + + + + + + + + + + auto + + + + + + auto + + + + + + + + + + + + + seconds + minutes + hours + days + months + quarters + years + + + + + + + + + + + + + + + + + tabular-layout + outline-subtotals-top + outline-subtotals-bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + data + + + + + + + + none + manual + name + + + + + + ascending + descending + + + + + + + + + + + + + + auto + average + count + countnums + max + min + product + stdev + stdevp + sum + var + varp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + row + column + both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + column + row + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + row + column + table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + stop + warning + information + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self + cell-range + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text + number + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + from-another-table + to-another-table + from-same-table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + row + column + table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + enable + disable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + column + row + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + + + + print-range + filter + repeat-row + repeat-column + + + + + + + + + + + + date + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + trace-dependents + remove-dependents + trace-precedents + remove-precedents + trace-errors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + alpha-numeric + integer + double + + + + + + + + + + + + + + + + + + + + text + number + automatic + + + + + + + + ascending + descending + + + + + + + + + + + + + + + + + text + number + automatic + + + + + + + + ascending + descending + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + average + count + countnums + max + min + product + stdev + stdevp + sum + var + varp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + copy-all + copy-results-only + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + unsorted + sort-ascending + + + + + + + + visible + collapse + filter + + + + + + + + + + + + + + + + + + + _self + _blank + _parent + _top + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + new + replace + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + separator + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + article + book + booklet + conference + custom1 + custom2 + custom3 + custom4 + custom5 + email + inbook + incollection + inproceedings + journal + manual + mastersthesis + misc + phdthesis + proceedings + techreport + unpublished + www + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + number-no-superior + number-all-superior + number + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + name + number + number-and-name + plain-number-and-name + plain-number + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + full + path + name + name-and-extension + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text + category-and-value + caption + + + + + + + + + + + + + + + + + + + + + + + + + + + address + annote + author + bibliography-type + booktitle + chapter + custom1 + custom2 + custom3 + custom4 + custom5 + edition + editor + howpublished + identifier + institution + isbn + issn + journal + month + note + number + organizations + pages + publisher + report-type + school + series + title + url + volume + year + + + + + + + + + + + + + + + + + + + + name + number + number-and-name + plain-number + plain-number-and-name + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + right + + + + left + + + + + + + + + + + + + + + + + + + + + + + + + + + document + chapter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + right + inner + outer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + footnote + endnote + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + document + chapter + page + + + + + + + text + page + section + document + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + previous + next + + + + + + + + + + + + + + + + + + + + + + + + previous + current + next + + + + + + + + + + + + + + + + + text + table + text-box + image + object + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + none + + + + + condition + + + + + + + + + + + + + + + + + + + simple + + + + + + + embed + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + category-and-value + caption + value + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + address + annote + author + bibliography-type + booktitle + chapter + custom1 + custom2 + custom3 + custom4 + custom5 + edition + editor + howpublished + identifier + institution + isbn + issn + journal + month + note + number + organizations + pages + publisher + report-type + school + series + title + url + volume + year + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + document + chapter + + + + + + + + + + + + + + + full + path + name + name-and-extension + area + title + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [A-Za-z][A-Za-z0-9._\-]* + + + + + + + + + + + + + + submit + reset + push + url + + + + + float + time + date + percentage + currency + boolean + string + + + + + + + + \([ ]*-?([0-9]+(\.[0-9]*)?|\.[0-9]+)([ ]+-?([0-9]+(\.[0-9]*)?|\.[0-9]+)){2}[ ]*\) + + + + + top + center + bottom + + + + + + + + + + + + + + + + + + + + + + ([0-9]?[0-9](\.[0-9]*)?|100(\.0*)?|\.[0-9]+)% + + + + + 0 + 1 + + + diff --git a/appthere-conformance/schemas/odf/PROVENANCE.txt b/appthere-conformance/schemas/odf/PROVENANCE.txt new file mode 100644 index 00000000..d2afb1f9 --- /dev/null +++ b/appthere-conformance/schemas/odf/PROVENANCE.txt @@ -0,0 +1,19 @@ +OASIS OpenDocument Format v1.3 OS RELAX NG schemas (schema, manifest, dsig) +and the W3C MathML3 RELAX NG set (referenced by ODF math content). + +Source: extracted 2026-07-05 from Maven Central artifact +org.odftoolkit:odfvalidator:0.12.0 (odfvalidator-0.12.0-classes.jar, +sha256 9869ba7e15f8271ea12e54b5d611519da03616095459e6a820d7b31ff505def1), +paths schema/odf1.3/*.rng and schema/mathml3/*.rng. +Upstream origin: https://docs.oasis-open.org/office/OpenDocument/v1.3/os/schemas/ +(OASIS copyright, redistributable with notice) and https://www.w3.org/Math/RelaxNG/. + +sha256 manifest: +1760eab7fba2394b6b9d2c77b4ed2187ed01ff8029f8538ba7bff3b834866573 odf/OpenDocument-v1.3-dsig-schema.rng +8aee71f03484be112af972d622cc9031280c007b0a454ae8815e8e333c9bdd17 odf/OpenDocument-v1.3-manifest-schema.rng +40bad03efdbb02825230d357da0aa6ac679934c5bf56c6281752c0c24d58e4e6 odf/OpenDocument-v1.3-schema.rng +7d197e1dafda7ea85dd3bd9703f34a957002189b328771954177c225ea39a843 mathml3/mathml3-common.rng +fc751250f9c0e5ebfe60fcce8210a09a24dff30034d52f258d28a1ab8c939d0c mathml3/mathml3-content.rng +025b24864a7ed2f4902e06412e18870d044ede6868d5dcd9086d5b11a32757a5 mathml3/mathml3-presentation.rng +4d6bf5094f37234013c3c0bd41913fdacbdda7f0caf4ffffb5445e6937a2d2f8 mathml3/mathml3-strict-content.rng +2b5045425b926e52685a9fab5c2835a2f792b17f06fb59a1e2e1328054129570 mathml3/mathml3.rng diff --git a/appthere-conformance/schemas/ooxml/PROVENANCE.txt b/appthere-conformance/schemas/ooxml/PROVENANCE.txt new file mode 100644 index 00000000..cbefc4e1 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/PROVENANCE.txt @@ -0,0 +1,39 @@ +ISO/IEC 29500-4:2016 (Office Open XML, Transitional Migration Features) XML Schemas +plus the OSP-covered Markup Compatibility schema (mce/mc.xsd). + +Source: the ISO-IEC29500-4_2016 electronic-insert schema bundle (freely +distributable ISO/ECMA standard attachments), vendored 2026-07-05 from the +agent environment's document tooling at +/mnt/skills/public/docx/scripts/office/schemas/{ISO-IEC29500-4_2016,mce}. +Upstream origin: https://standards.iso.org/ittf/PubliclyAvailableStandards/ +(ISO/IEC 29500-4:2016 electronic inserts). + +sha256 manifest: +4de4390a26e7e44e682ed98b39872cce2620f807f5b0de421567a43f74526388 ooxml/transitional/dml-chart.xsd +3fd0586f2637b98bb9886f0e0b67d89e1cc987c2d158cc7deb5f5b9890ced412 ooxml/transitional/dml-chartDrawing.xsd +809f77f658f71e10f5cfbf99e25c895a23a4e62c81210b59a2fe8c7fb24849d2 ooxml/transitional/dml-diagram.xsd +5cb76dabd8b97d1e9308a1700b90c20139be4d50792d21a7f09789f5cccd6026 ooxml/transitional/dml-lockedCanvas.xsd +6978ba7e889070b0c3cb5b546b23e5a6c3516134afc53b87a21f482ca33f3858 ooxml/transitional/dml-main.xsd +5d389d42befbebd91945d620242347caecd3367f9a3a7cf8d97949507ae1f53c ooxml/transitional/dml-picture.xsd +b4532b6d258832953fbb3ee4c711f4fe25d3faf46a10644b2505f17010d01e88 ooxml/transitional/dml-spreadsheetDrawing.xsd +2dbfd4719505bf39b568005994a433997cbeac59cebc9d4867d564c94e9ba03a ooxml/transitional/dml-wordprocessingDrawing.xsd +39c6396b7b1f096885c3ceba5dab30e3ee14291c45a1d6fa6038ec4db69d88d0 ooxml/transitional/pml.xsd +3c6709101c6aaa82888df5d8795c33f9e857196790eb320d9194e64be2b6bdd8 ooxml/transitional/shared-additionalCharacteristics.xsd +0b364451dc36a48dd6dae0f3b6ada05fd9b71e5208211f8ee5537d7e51a587e2 ooxml/transitional/shared-bibliography.xsd +d90722af8f1439ae6b82d57858ee0b0720e320eb309cf30371666338aa66d106 ooxml/transitional/shared-commonSimpleTypes.xsd +0ef4bb354ff44b923564c4ddbdda5987919d220225129ec94614a618ceafc281 ooxml/transitional/shared-customXmlDataProperties.xsd +0d103b99a4a8652f8871552a69d42d2a3760ac6a5e3ef02d979c4273257ff6a4 ooxml/transitional/shared-customXmlSchemaProperties.xsd +9c085407751b9061c1f996f6c39ce58451be22a8d334f09175f0e89e42736285 ooxml/transitional/shared-documentPropertiesCustom.xsd +bc92e36ccd233722d4c5869bec71ddc7b12e2df56059942cce5a39065cc9c368 ooxml/transitional/shared-documentPropertiesExtended.xsd +7b5b7413e2c895b1e148e82e292a117d53c7ec65b0696c992edca57b61b4a74b ooxml/transitional/shared-documentPropertiesVariantTypes.xsd +f812dffca0bb66db6e2ec2e01dd19258636b2a5a3a0bb949a2e7295665e0ce61 ooxml/transitional/shared-math.xsd +12264f3c03d738311cd9237d212f1c07479e70f0cbe1ae725d29b36539aef637 ooxml/transitional/shared-relationshipReference.xsd +495debc8fa967b77ed37799747b049832f2c95b2ecdb9f19ddcd68f8c9f96ab9 ooxml/transitional/sml.xsd +335e9ecd40bb594ec4b916ac8c7b9eead4e16fe1b79f9d4105fdb1c43b66bb1c ooxml/transitional/vml-main.xsd +585bedc1313b40888dcc544cb74cd939a105ee674f3b1d3aa1cc6d34f70ff155 ooxml/transitional/vml-officeDrawing.xsd +133c9f64a5c5d573b78d0a474122b22506d8eadb5e063f67cdbbb8fa2f161d0e ooxml/transitional/vml-presentationDrawing.xsd +6bdeb169c3717eb01108853bd9fc5a3750fb1fa5b82abbdd854d49855a40f519 ooxml/transitional/vml-spreadsheetDrawing.xsd +475dcae1e7d1ea46232db6f8481040c15e53a52a3c256831d3df204212b0e831 ooxml/transitional/vml-wordprocessingDrawing.xsd +c2dd9f61f892deae6acd8d20771ea79b12018af25f3bf8d06639c8542d218cfd ooxml/transitional/wml.xsd +70a3c67959f9ad2333016328716101bf8b476e682f29830cc9ee8da63dca7cc2 ooxml/transitional/xml.xsd +3a37e461ecf5a8670fdec34029703401f8728ab9c96ec1739a6ae58d55212413 ooxml/mce/mc.xsd diff --git a/appthere-conformance/schemas/ooxml/mce/mc.xsd b/appthere-conformance/schemas/ooxml/mce/mc.xsd new file mode 100644 index 00000000..ef725457 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-chart.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-chart.xsd new file mode 100644 index 00000000..6454ef9a --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-chartDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-chartDrawing.xsd new file mode 100644 index 00000000..afa4f463 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-diagram.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-diagram.xsd new file mode 100644 index 00000000..64e66b8a --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-lockedCanvas.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-lockedCanvas.xsd new file mode 100644 index 00000000..687eea82 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-main.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-main.xsd new file mode 100644 index 00000000..6ac81b06 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-picture.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-picture.xsd new file mode 100644 index 00000000..1dbf0514 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-spreadsheetDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..f1af17db --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-wordprocessingDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..0a185ab6 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/pml.xsd b/appthere-conformance/schemas/ooxml/transitional/pml.xsd new file mode 100644 index 00000000..14ef4888 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-additionalCharacteristics.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-additionalCharacteristics.xsd new file mode 100644 index 00000000..c20f3bf1 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-bibliography.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-bibliography.xsd new file mode 100644 index 00000000..ac602522 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-commonSimpleTypes.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-commonSimpleTypes.xsd new file mode 100644 index 00000000..424b8ba8 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-customXmlDataProperties.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-customXmlDataProperties.xsd new file mode 100644 index 00000000..2bddce29 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-customXmlSchemaProperties.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-customXmlSchemaProperties.xsd new file mode 100644 index 00000000..8a8c18ba --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesCustom.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesCustom.xsd new file mode 100644 index 00000000..5c42706a --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesExtended.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesExtended.xsd new file mode 100644 index 00000000..853c341c --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesVariantTypes.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 00000000..da835ee8 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-math.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-math.xsd new file mode 100644 index 00000000..87ad2658 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-relationshipReference.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-relationshipReference.xsd new file mode 100644 index 00000000..9e86f1b2 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/sml.xsd b/appthere-conformance/schemas/ooxml/transitional/sml.xsd new file mode 100644 index 00000000..d0be42e7 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-main.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-main.xsd new file mode 100644 index 00000000..8821dd18 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-officeDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-officeDrawing.xsd new file mode 100644 index 00000000..ca2575c7 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-presentationDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-presentationDrawing.xsd new file mode 100644 index 00000000..dd079e60 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-spreadsheetDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..3dd6cf62 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-wordprocessingDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..f1041e34 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/wml.xsd b/appthere-conformance/schemas/ooxml/transitional/wml.xsd new file mode 100644 index 00000000..9c5b7a63 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/xml.xsd b/appthere-conformance/schemas/ooxml/transitional/xml.xsd new file mode 100644 index 00000000..0f13678d --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/appthere-conformance/schemas/opc/PROVENANCE.txt b/appthere-conformance/schemas/opc/PROVENANCE.txt new file mode 100644 index 00000000..71733593 --- /dev/null +++ b/appthere-conformance/schemas/opc/PROVENANCE.txt @@ -0,0 +1,15 @@ +ECMA-376 4th edition, Part 2: Open Packaging Conventions XML Schemas. + +Source: the ecma/fouth-edition OPC schema set of the same bundle as +../ooxml (vendored 2026-07-05). Upstream origin: +https://ecma-international.org/publications-and-standards/standards/ecma-376/ + +NOTE: opc-coreProperties.xsd imports the Dublin Core XSDs by live URL +(dublincore.org); those are not yet vendored, so offline validation of +docProps/core.xml is deferred — see loki-ooxml/tests/schema_validation.rs. + +sha256 manifest: +49ec1c03440a9b5590e69acdf92b200fe133697de0b05f43a129fb94d1885026 opc/opc-contentTypes.xsd +69688ea15f82a870053b9a60c79359063dbcfeec9cdabafabad51a53934bc35b opc/opc-coreProperties.xsd +ee7e7e1b9a1340536e25e87c8650a2b90a43b0e67d02d6746cd430048b3c1439 opc/opc-digSig.xsd +607ffb6879cf5d5d765bfdfb2548f3f427ea964ddee6d08a15f0dc781749866d opc/opc-relationships.xsd diff --git a/appthere-conformance/schemas/opc/opc-contentTypes.xsd b/appthere-conformance/schemas/opc/opc-contentTypes.xsd new file mode 100644 index 00000000..a6de9d27 --- /dev/null +++ b/appthere-conformance/schemas/opc/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/opc/opc-coreProperties.xsd b/appthere-conformance/schemas/opc/opc-coreProperties.xsd new file mode 100644 index 00000000..10e978b6 --- /dev/null +++ b/appthere-conformance/schemas/opc/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/opc/opc-digSig.xsd b/appthere-conformance/schemas/opc/opc-digSig.xsd new file mode 100644 index 00000000..4248bf7a --- /dev/null +++ b/appthere-conformance/schemas/opc/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/opc/opc-relationships.xsd b/appthere-conformance/schemas/opc/opc-relationships.xsd new file mode 100644 index 00000000..56497467 --- /dev/null +++ b/appthere-conformance/schemas/opc/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/src/golden/calibration.rs b/appthere-conformance/src/golden/calibration.rs new file mode 100644 index 00000000..4e8915dc --- /dev/null +++ b/appthere-conformance/src/golden/calibration.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The committed calibration constants (Spec 02 D5). +//! +//! These values are **measured, not guessed** — they derive from the +//! calibration record at `appthere-conformance/goldens/CALIBRATION.md` +//! (2026-07-05: LibreOffice 24.2 goldens vs the `vello_cpu` candidate over +//! the M4 baseline fixture set; thresholds set with margin above the noise +//! floor of the agreeing fixtures). Change them only together with that +//! record, by re-running `cargo run -p loki-render-cpu --example +//! calibrate_odf`. + +/// Minimum per-region SSIM (measured floor on correct fixtures: 0.6324). +pub const CALIBRATED_MIN_SSIM: f64 = 0.60; + +/// Maximum per-region mean CIEDE2000 ΔE (measured max on correct fixtures: +/// 9.083). +pub const CALIBRATED_MAX_DELTA_E: f64 = 10.0; diff --git a/appthere-conformance/src/golden/ciede.rs b/appthere-conformance/src/golden/ciede.rs new file mode 100644 index 00000000..3097c8a7 --- /dev/null +++ b/appthere-conformance/src/golden/ciede.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! sRGB → CIELAB (D65) conversion and the CIEDE2000 colour difference +//! (Sharma et al. 2005 formulation), split from `diff.rs` to keep files +//! under the 300-line ceiling. Verified against the published CIEDE2000 +//! reference pairs in `diff_tests.rs`. + +/// Converts an 8-bit sRGB pixel to CIELAB (D65 white point). +pub(super) fn srgb_to_lab([r, g, b, _]: [u8; 4]) -> [f64; 3] { + fn linear(c: u8) -> f64 { + let c = f64::from(c) / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + let (rl, gl, bl) = (linear(r), linear(g), linear(b)); + // sRGB D65 → XYZ. + let x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl; + let y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl; + let z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl; + // XYZ → Lab with the D65 reference white. + fn f(t: f64) -> f64 { + const DELTA: f64 = 6.0 / 29.0; + if t > DELTA * DELTA * DELTA { + t.cbrt() + } else { + t / (3.0 * DELTA * DELTA) + 4.0 / 29.0 + } + } + let (xn, yn, zn) = (0.95047, 1.0, 1.08883); + let (fx, fy, fz) = (f(x / xn), f(y / yn), f(z / zn)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// CIEDE2000 colour difference (Sharma et al. 2005 formulation). +#[allow(clippy::many_single_char_names, clippy::similar_names)] +pub(super) fn ciede2000([l1, a1, b1]: [f64; 3], [l2, a2, b2]: [f64; 3]) -> f64 { + let c1 = (a1 * a1 + b1 * b1).sqrt(); + let c2 = (a2 * a2 + b2 * b2).sqrt(); + let c_bar = (c1 + c2) / 2.0; + let c_bar7 = c_bar.powi(7); + let g = 0.5 * (1.0 - (c_bar7 / (c_bar7 + 25.0_f64.powi(7))).sqrt()); + let ap1 = (1.0 + g) * a1; + let ap2 = (1.0 + g) * a2; + let cp1 = (ap1 * ap1 + b1 * b1).sqrt(); + let cp2 = (ap2 * ap2 + b2 * b2).sqrt(); + let hp = |ap: f64, b: f64| -> f64 { + if ap == 0.0 && b == 0.0 { + 0.0 + } else { + let h = b.atan2(ap).to_degrees(); + if h < 0.0 { h + 360.0 } else { h } + } + }; + let hp1 = hp(ap1, b1); + let hp2 = hp(ap2, b2); + + let dl = l2 - l1; + let dc = cp2 - cp1; + let dhp = if cp1 * cp2 == 0.0 { + 0.0 + } else { + let d = hp2 - hp1; + if d.abs() <= 180.0 { + d + } else if d > 180.0 { + d - 360.0 + } else { + d + 360.0 + } + }; + let dh = 2.0 * (cp1 * cp2).sqrt() * (dhp.to_radians() / 2.0).sin(); + + let l_bar = (l1 + l2) / 2.0; + let cp_bar = (cp1 + cp2) / 2.0; + let hp_bar = if cp1 * cp2 == 0.0 { + hp1 + hp2 + } else { + let sum = hp1 + hp2; + let d = (hp1 - hp2).abs(); + if d <= 180.0 { + sum / 2.0 + } else if sum < 360.0 { + (sum + 360.0) / 2.0 + } else { + (sum - 360.0) / 2.0 + } + }; + + let t = 1.0 - 0.17 * (hp_bar - 30.0).to_radians().cos() + + 0.24 * (2.0 * hp_bar).to_radians().cos() + + 0.32 * (3.0 * hp_bar + 6.0).to_radians().cos() + - 0.20 * (4.0 * hp_bar - 63.0).to_radians().cos(); + let d_theta = 30.0 * (-((hp_bar - 275.0) / 25.0).powi(2)).exp(); + let cp_bar7 = cp_bar.powi(7); + let rc = 2.0 * (cp_bar7 / (cp_bar7 + 25.0_f64.powi(7))).sqrt(); + let lb50 = (l_bar - 50.0).powi(2); + let sl = 1.0 + 0.015 * lb50 / (20.0 + lb50).sqrt(); + let sc = 1.0 + 0.045 * cp_bar; + let sh = 1.0 + 0.015 * cp_bar * t; + let rt = -(2.0 * d_theta).to_radians().sin() * rc; + + ((dl / sl).powi(2) + (dc / sc).powi(2) + (dh / sh).powi(2) + rt * (dc / sc) * (dh / sh)).sqrt() +} diff --git a/appthere-conformance/src/golden/diff.rs b/appthere-conformance/src/golden/diff.rs new file mode 100644 index 00000000..cff41724 --- /dev/null +++ b/appthere-conformance/src/golden/diff.rs @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The perceptual differ (Spec 02 §7.4 / B-4): SSIM **and** CIEDE2000 ΔE, +//! scored per tiled region with the **worst region driving the result**, plus +//! failure-heatmap emission. +//! +//! The windowed-SSIM core is promoted from `loki-acid`'s `diff.rs` (M1 — +//! "promote, don't greenfield"); the ΔE metric, regional worst-region +//! scoring, tolerances, and heatmaps are the Spec 02 extensions. Everything +//! is pure Rust over decoded RGBA — no GPU, no external tools. + +use image::RgbaImage; + +use super::ciede::{ciede2000, srgb_to_lab}; +use super::{PerceptualReport, RegionScore}; + +/// Window side length (pixels) for windowed SSIM. +const WINDOW: u32 = 8; +/// Region (tile) side length in pixels: the granularity at which the worst +/// region is selected. One mis-rendered glyph lands in a 64×64 tile and fails +/// that tile, however large and correct the rest of the page is. +const REGION: u32 = 64; + +/// SSIM stabilisation constants on the 0–255 luminance scale. +const C1: f64 = (0.01 * 255.0) * (0.01 * 255.0); +const C2: f64 = (0.03 * 255.0) * (0.03 * 255.0); + +/// Error returned when two images cannot be compared. +#[derive(Debug, thiserror::Error)] +pub enum DiffError { + /// The two images have different dimensions. + #[error("dimension mismatch: golden {golden:?} vs candidate {candidate:?}")] + DimensionMismatch { + /// Golden image dimensions. + golden: (u32, u32), + /// Candidate image dimensions. + candidate: (u32, u32), + }, + /// Writing the failure heatmap failed. + #[error("failed to write heatmap: {0}")] + Heatmap(#[source] image::ImageError), +} + +/// Pass thresholds for one comparison. +/// +/// The default comes from the committed calibration record via +/// [`Tolerance::calibrated`] — never a guessed literal (Spec 02 D5). A +/// per-test override must carry a comment justifying it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tolerance { + /// Minimum acceptable per-region SSIM. + pub min_ssim: f64, + /// Maximum acceptable per-region mean CIEDE2000 ΔE. + pub max_delta_e: f64, +} + +impl Tolerance { + /// The calibrated default thresholds — measured, not guessed (Spec 02 + /// D5). Values and provenance live in + /// `appthere-conformance/goldens/CALIBRATION.md` and + /// [`super::calibration`]; update them only together. + #[must_use] + pub fn calibrated() -> Self { + Self { + min_ssim: super::calibration::CALIBRATED_MIN_SSIM, + max_delta_e: super::calibration::CALIBRATED_MAX_DELTA_E, + } + } +} + +/// Converts an RGBA image to a row-major luminance buffer (BT.601, 0–255). +#[must_use] +pub fn to_luma(img: &RgbaImage) -> Vec { + img.pixels() + .map(|p| { + let [r, g, b, _] = p.0; + 0.299 * f64::from(r) + 0.587 * f64::from(g) + 0.114 * f64::from(b) + }) + .collect() +} + +/// SSIM over a single rectangular window of two luminance buffers. +fn window_ssim(la: &[f64], lb: &[f64], stride: u32, x0: u32, y0: u32, w: u32, h: u32) -> f64 { + let n = f64::from(w * h); + let (mut sa, mut sb) = (0.0, 0.0); + for yy in 0..h { + for xx in 0..w { + let i = ((y0 + yy) * stride + (x0 + xx)) as usize; + sa += la[i]; + sb += lb[i]; + } + } + let (mu_a, mu_b) = (sa / n, sb / n); + let (mut va, mut vb, mut cov) = (0.0, 0.0, 0.0); + for yy in 0..h { + for xx in 0..w { + let i = ((y0 + yy) * stride + (x0 + xx)) as usize; + let (da, db) = (la[i] - mu_a, lb[i] - mu_b); + va += da * da; + vb += db * db; + cov += da * db; + } + } + va /= n; + vb /= n; + cov /= n; + ((2.0 * mu_a * mu_b + C1) * (2.0 * cov + C2)) + / ((mu_a * mu_a + mu_b * mu_b + C1) * (va + vb + C2)) +} + +/// Compares a candidate page against its golden: every [`REGION`]-sized tile +/// is scored (SSIM via [`WINDOW`]-sized sub-windows, mean CIEDE2000 ΔE per +/// pixel), and the page passes iff **every** region meets `tol`. +pub fn compare_pages( + golden: &RgbaImage, + candidate: &RgbaImage, + tol: Tolerance, +) -> Result { + if golden.dimensions() != candidate.dimensions() { + return Err(DiffError::DimensionMismatch { + golden: golden.dimensions(), + candidate: candidate.dimensions(), + }); + } + let (w, h) = golden.dimensions(); + let la = to_luma(golden); + let lb = to_luma(candidate); + + let mut regions = Vec::new(); + let mut worst: Option = None; + let mut passed = true; + + let (cols, rows) = (w.div_ceil(REGION).max(1), h.div_ceil(REGION).max(1)); + for row in 0..rows { + for col in 0..cols { + let x0 = col * REGION; + let y0 = row * REGION; + let rw = REGION.min(w - x0); + let rh = REGION.min(h - y0); + let ssim = region_ssim(&la, &lb, w, x0, y0, rw, rh); + let delta_e = region_delta_e(golden, candidate, x0, y0, rw, rh); + let score = RegionScore { + region: (col, row), + ssim, + delta_e, + }; + let fails = ssim < tol.min_ssim || delta_e > tol.max_delta_e; + passed &= !fails; + worst = Some(match worst { + None => score, + Some(prev) => { + // The "worst" region is the one that violates hardest: + // failing beats passing; among equals, lower SSIM, then + // higher ΔE. + let prev_fails = prev.ssim < tol.min_ssim || prev.delta_e > tol.max_delta_e; + let worse = match (fails, prev_fails) { + (true, false) => true, + (false, true) => false, + _ => { + score.ssim < prev.ssim + || (score.ssim == prev.ssim && score.delta_e > prev.delta_e) + } + }; + if worse { score } else { prev } + } + }); + regions.push(score); + } + } + + Ok(PerceptualReport { + regions, + worst, + passed, + heatmap: None, + }) +} + +/// Mean of the [`WINDOW`]-sized SSIM sub-windows covering one region (windows +/// clipped to the region; a region smaller than one window is one window). +fn region_ssim(la: &[f64], lb: &[f64], stride: u32, x0: u32, y0: u32, w: u32, h: u32) -> f64 { + if w < WINDOW || h < WINDOW { + return window_ssim(la, lb, stride, x0, y0, w, h); + } + let mut sum = 0.0; + let mut count = 0u64; + let mut y = 0; + while y + WINDOW <= h { + let mut x = 0; + while x + WINDOW <= w { + sum += window_ssim(la, lb, stride, x0 + x, y0 + y, WINDOW, WINDOW); + count += 1; + x += WINDOW; + } + y += WINDOW; + } + if count == 0 { 1.0 } else { sum / count as f64 } +} + +/// Mean per-pixel CIEDE2000 ΔE over one region. +fn region_delta_e(a: &RgbaImage, b: &RgbaImage, x0: u32, y0: u32, w: u32, h: u32) -> f64 { + let mut sum = 0.0; + for yy in 0..h { + for xx in 0..w { + let pa = a.get_pixel(x0 + xx, y0 + yy).0; + let pb = b.get_pixel(x0 + xx, y0 + yy).0; + sum += ciede2000(srgb_to_lab(pa), srgb_to_lab(pb)); + } + } + sum / f64::from(w * h) +} + +/// Emits a failure heatmap: the golden as a dimmed grayscale base with +/// per-pixel ΔE painted red (intensity ∝ ΔE, saturating at ΔE = 20). +pub fn emit_heatmap( + golden: &RgbaImage, + candidate: &RgbaImage, + path: &std::path::Path, +) -> Result<(), DiffError> { + let (w, h) = golden.dimensions(); + let mut out = RgbaImage::new(w, h); + for y in 0..h { + for x in 0..w { + let pg = golden.get_pixel(x, y).0; + let pc = candidate.get_pixel(x, y).0; + let de = ciede2000(srgb_to_lab(pg), srgb_to_lab(pc)); + let luma = + (0.299 * f64::from(pg[0]) + 0.587 * f64::from(pg[1]) + 0.114 * f64::from(pg[2])) + * 0.5; + let heat = (de / 20.0).min(1.0); + let r = (luma + (255.0 - luma) * heat) as u8; + let gb = (luma * (1.0 - heat)) as u8; + out.put_pixel(x, y, image::Rgba([r, gb, gb, 255])); + } + } + out.save(path).map_err(DiffError::Heatmap) +} + +#[cfg(test)] +#[path = "diff_tests.rs"] +mod tests; diff --git a/appthere-conformance/src/golden/diff_tests.rs b/appthere-conformance/src/golden/diff_tests.rs new file mode 100644 index 00000000..d80885a4 --- /dev/null +++ b/appthere-conformance/src/golden/diff_tests.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use super::*; +use image::Rgba; + +fn solid(w: u32, h: u32, c: [u8; 4]) -> RgbaImage { + RgbaImage::from_pixel(w, h, Rgba(c)) +} + +/// A permissive tolerance for structural tests (thresholds themselves are +/// calibrated separately — D5). +fn tol() -> Tolerance { + Tolerance { + min_ssim: 0.9, + max_delta_e: 4.0, + } +} + +// ── SSIM core (promoted from loki-acid) ─────────────────────────────────────── + +#[test] +fn identical_images_pass_with_ssim_one() { + let img = solid(128, 128, [120, 130, 140, 255]); + let report = compare_pages(&img, &img, tol()).unwrap(); + assert!(report.passed); + let worst = report.worst.unwrap(); + assert!((worst.ssim - 1.0).abs() < 1e-9, "ssim={}", worst.ssim); + assert!(worst.delta_e < 1e-9, "delta_e={}", worst.delta_e); +} + +#[test] +fn black_vs_white_fails_hard() { + let a = solid(128, 128, [0, 0, 0, 255]); + let b = solid(128, 128, [255, 255, 255, 255]); + let report = compare_pages(&a, &b, tol()).unwrap(); + assert!(!report.passed); + assert!(report.worst.unwrap().ssim < 0.05); +} + +#[test] +fn dimension_mismatch_errors() { + let a = solid(64, 64, [0, 0, 0, 255]); + let b = solid(65, 64, [0, 0, 0, 255]); + assert!(compare_pages(&a, &b, tol()).is_err()); +} + +// ── Worst-region semantics (the B-4 fix) ────────────────────────────────────── + +/// A small localized defect must fail the page even though the page-wide +/// *mean* would comfortably pass — the worst region drives the result. +#[test] +fn localized_defect_is_not_averaged_away() { + let golden = solid(256, 256, [255, 255, 255, 255]); + let mut candidate = golden.clone(); + for y in 32..48 { + for x in 32..48 { + candidate.put_pixel(x, y, Rgba([0, 0, 0, 255])); + } + } + let report = compare_pages(&golden, &candidate, tol()).unwrap(); + assert!(!report.passed, "a 16px black square must fail its region"); + // The defect lies in region (0, 0) (64px tiles). + let worst = report.worst.unwrap(); + assert_eq!(worst.region, (0, 0), "worst region must be the defect tile"); + // Every *other* region is perfect — proving the mean would have passed. + let clean: Vec<_> = report + .regions + .iter() + .filter(|r| r.region != (0, 0)) + .collect(); + assert!(clean.iter().all(|r| (r.ssim - 1.0).abs() < 1e-9)); + let mean: f64 = + report.regions.iter().map(|r| r.ssim).sum::() / report.regions.len() as f64; + assert!( + mean > tol().min_ssim, + "the page-wide mean ({mean}) would have masked the defect" + ); +} + +/// A colour shift with intact structure is caught by ΔE, not SSIM: same +/// geometry, hue-shifted page. +#[test] +fn colour_shift_is_caught_by_delta_e() { + let golden = solid(128, 128, [200, 60, 60, 255]); + let candidate = solid(128, 128, [60, 200, 60, 255]); + let report = compare_pages(&golden, &candidate, tol()).unwrap(); + assert!(!report.passed, "a hue swap must fail on ΔE"); + let worst = report.worst.unwrap(); + assert!( + worst.delta_e > tol().max_delta_e, + "delta_e={} must exceed the bound", + worst.delta_e + ); +} + +// ── CIEDE2000 reference values (Sharma et al. 2005 test data) ───────────────── + +#[test] +fn ciede2000_matches_reference_pairs() { + // (Lab1, Lab2, expected ΔE00) from the published CIEDE2000 test dataset. + let cases = [ + ([50.0, 2.6772, -79.7751], [50.0, 0.0, -82.7485], 2.0425), + ([50.0, 3.1571, -77.2803], [50.0, 0.0, -82.7485], 2.8615), + ([50.0, 2.8361, -74.0200], [50.0, 0.0, -82.7485], 3.4412), + ]; + for (lab1, lab2, expected) in cases { + let de = ciede2000(lab1, lab2); + assert!( + (de - expected).abs() < 1e-4, + "ΔE00({lab1:?}, {lab2:?}) = {de}, expected {expected}" + ); + let sym = ciede2000(lab2, lab1); + assert!((de - sym).abs() < 1e-9, "ΔE00 must be symmetric"); + } +} + +#[test] +fn srgb_to_lab_anchors() { + let [l, a, b] = srgb_to_lab([255, 255, 255, 255]); + assert!((l - 100.0).abs() < 0.01, "white L={l}"); + assert!(a.abs() < 0.01 && b.abs() < 0.01, "white a={a} b={b}"); + let [l, a, b] = srgb_to_lab([0, 0, 0, 255]); + assert!( + l.abs() < 1e-6 && a.abs() < 1e-6 && b.abs() < 1e-6, + "black {l} {a} {b}" + ); +} + +// ── Heatmap emission ────────────────────────────────────────────────────────── + +#[test] +fn heatmap_marks_the_defect_hot() { + let golden = solid(128, 128, [255, 255, 255, 255]); + let mut candidate = golden.clone(); + for y in 8..24 { + for x in 8..24 { + candidate.put_pixel(x, y, Rgba([0, 0, 0, 255])); + } + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("heat.png"); + emit_heatmap(&golden, &candidate, &path).unwrap(); + let heat = image::open(&path).unwrap().to_rgba8(); + let hot = heat.get_pixel(16, 16).0; + let cold = heat.get_pixel(100, 100).0; + assert!( + hot[0] > cold[0].saturating_add(50) || (hot[0] > 200 && hot[1] < 100), + "defect pixel must be visibly hotter: hot={hot:?} cold={cold:?}" + ); +} diff --git a/appthere-conformance/src/golden/mod.rs b/appthere-conformance/src/golden/mod.rs index 1cd6afca..af73f566 100644 --- a/appthere-conformance/src/golden/mod.rs +++ b/appthere-conformance/src/golden/mod.rs @@ -19,6 +19,13 @@ use std::path::PathBuf; +pub mod calibration; +mod ciede; +pub mod diff; + +pub use calibration::{CALIBRATED_MAX_DELTA_E, CALIBRATED_MIN_SSIM}; +pub use diff::{DiffError, Tolerance, compare_pages, emit_heatmap}; + /// A perceptual score for one tiled region of a page. #[derive(Clone, Copy, Debug, PartialEq)] pub struct RegionScore { diff --git a/appthere-conformance/src/lib.rs b/appthere-conformance/src/lib.rs index 238e82ce..155b85df 100644 --- a/appthere-conformance/src/lib.rs +++ b/appthere-conformance/src/lib.rs @@ -37,11 +37,13 @@ pub mod golden; #[cfg(feature = "doc-model")] pub mod model; +pub mod raster; pub mod roundtrip; pub mod schema; #[cfg(feature = "sheet-model")] pub mod sheet; +pub use raster::{CONFORMANCE_DPI, PdfRasterizer, RasterError}; pub use roundtrip::{CanonicalEntry, Divergence, NormalizedModel, first_divergence}; pub use schema::xmllint::XmllintValidator; pub use schema::{SchemaError, SchemaKind, SchemaReport, SchemaValidator, SchemaViolation}; diff --git a/appthere-conformance/src/raster.rs b/appthere-conformance/src/raster.rs new file mode 100644 index 00000000..63c1ca79 --- /dev/null +++ b/appthere-conformance/src/raster.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The shared, pinned PDF→PNG rasterizer (Spec 02 **D3** / B-5). +//! +//! Both reference apps produce their goldens *via PDF* (Word COM → PDF, +//! `soffice --headless` → PDF), and every PDF is rasterized to PNG through +//! **this one wrapper** at the fixed conformance DPI — so the golden and +//! candidate sides differ only in the layout/render engine, never in the PNG +//! encoder, DPI, or anti-aliasing settings. +//! +//! The backend is poppler's `pdftoppm`. Its availability and version are +//! checked explicitly and *recorded* — a missing binary fails loudly (the +//! same policy as the schema axis' `xmllint`), and the version string is +//! written into golden/calibration metadata so a poppler upgrade that shifts +//! the noise floor is visible in the record, not folklore. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The fixed DPI every conformance render and golden uses (Spec 02 §7.1: a +/// fixed DPI and pinned anti-aliasing settings). 144 dpi = 2× CSS pixel +/// density: crisp enough for glyph-level diffs, small enough to commit. +pub const CONFORMANCE_DPI: u32 = 144; + +/// Errors from the pinned rasterizer. +#[derive(Debug, thiserror::Error)] +pub enum RasterError { + /// `pdftoppm` was not found on `PATH`. Fails loudly, never skips. + #[error( + "pdftoppm (poppler-utils) not found on PATH — install poppler-utils; \ + golden rasterization must not be silently skipped" + )] + PdftoppmNotFound, + /// Spawning or running `pdftoppm` failed. + #[error("failed to run pdftoppm: {0}")] + Spawn(#[source] std::io::Error), + /// `pdftoppm` exited unsuccessfully. + #[error("pdftoppm failed (code {code:?}): {stderr}")] + Failed { + /// Process exit code, if any. + code: Option, + /// Captured stderr. + stderr: String, + }, + /// The run produced no pages. + #[error("pdftoppm produced no PNG pages for {0}")] + NoPages(PathBuf), + /// Filesystem error handling inputs/outputs. + #[error("rasterizer I/O error: {0}")] + Io(#[source] std::io::Error), +} + +/// The pinned PDF→PNG stage. Construct once; the captured version string +/// belongs in golden/calibration metadata. +pub struct PdfRasterizer { + version: String, +} + +impl PdfRasterizer { + /// Locates `pdftoppm` and captures its version. Errors if absent. + pub fn new() -> Result { + let out = Command::new("pdftoppm") + .arg("-v") + .output() + .map_err(|_| RasterError::PdftoppmNotFound)?; + // pdftoppm prints its version banner on stderr. + let banner = String::from_utf8_lossy(&out.stderr); + let version = banner + .lines() + .next() + .unwrap_or("pdftoppm (unknown version)") + .trim() + .to_string(); + Ok(Self { version }) + } + + /// The recorded backend version (e.g. `pdftoppm version 24.02.0`). + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Rasterizes every page of `pdf` to `out_dir/-N.png` at + /// [`CONFORMANCE_DPI`], with anti-aliasing pinned on. Returns the page + /// PNGs in page order. + pub fn rasterize( + &self, + pdf: &Path, + out_dir: &Path, + stem: &str, + ) -> Result, RasterError> { + std::fs::create_dir_all(out_dir).map_err(RasterError::Io)?; + let prefix = out_dir.join(stem); + let output = Command::new("pdftoppm") + .arg("-png") + .args(["-r", &CONFORMANCE_DPI.to_string()]) + // Pin the anti-aliasing settings explicitly (Spec 02 §7.1) so a + // poppler default change cannot silently move the noise floor. + .args(["-aa", "yes", "-aaVector", "yes"]) + .arg(pdf) + .arg(&prefix) + .output() + .map_err(RasterError::Spawn)?; + if !output.status.success() { + return Err(RasterError::Failed { + code: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + // pdftoppm names pages `-1.png`, `-2.png`, … (zero-padded once + // page counts grow); collect and sort them for a stable page order. + let mut pages: Vec = std::fs::read_dir(out_dir) + .map_err(RasterError::Io)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.extension().is_some_and(|x| x == "png") + && p.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|s| s.starts_with(&format!("{stem}-"))) + }) + .collect(); + pages.sort(); + if pages.is_empty() { + return Err(RasterError::NoPages(pdf.to_path_buf())); + } + Ok(pages) + } +} + +#[cfg(test)] +#[path = "raster_tests.rs"] +mod tests; diff --git a/appthere-conformance/src/raster_tests.rs b/appthere-conformance/src/raster_tests.rs new file mode 100644 index 00000000..519b7707 --- /dev/null +++ b/appthere-conformance/src/raster_tests.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use super::*; + +/// A minimal but complete one-page PDF (blank A4-ish page) for exercising the +/// rasterizer without depending on an export crate. +const TINY_PDF: &[u8] = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] >> endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer << /Size 4 /Root 1 0 R >> +startxref +187 +%%EOF +"; + +#[test] +fn version_is_captured() { + let r = PdfRasterizer::new().expect("pdftoppm must be installed (poppler-utils)"); + assert!( + r.version().contains("pdftoppm"), + "version banner should identify the backend: {:?}", + r.version() + ); +} + +#[test] +fn rasterizes_a_page_deterministically() { + let r = PdfRasterizer::new().expect("pdftoppm must be installed"); + let dir = tempfile::tempdir().expect("tempdir"); + let pdf = dir.path().join("tiny.pdf"); + std::fs::write(&pdf, TINY_PDF).expect("write pdf"); + + let pages_a = r + .rasterize(&pdf, &dir.path().join("a"), "page") + .expect("rasterize A"); + assert_eq!(pages_a.len(), 1, "one page expected"); + + // D3's whole point: the stage is deterministic — two runs, identical bytes. + let pages_b = r + .rasterize(&pdf, &dir.path().join("b"), "page") + .expect("rasterize B"); + let a = std::fs::read(&pages_a[0]).expect("read A"); + let b = std::fs::read(&pages_b[0]).expect("read B"); + assert!(!a.is_empty()); + assert_eq!(a, b, "pinned rasterization must be byte-deterministic"); +} + +#[test] +fn missing_pages_is_an_error_not_empty_ok() { + let r = PdfRasterizer::new().expect("pdftoppm must be installed"); + let dir = tempfile::tempdir().expect("tempdir"); + let pdf = dir.path().join("broken.pdf"); + std::fs::write(&pdf, b"not a pdf").expect("write"); + assert!( + r.rasterize(&pdf, dir.path(), "page").is_err(), + "a broken PDF must error, not return zero pages" + ); +} diff --git a/appthere-ui/src/components/confirm_dialog.rs b/appthere-ui/src/components/confirm_dialog.rs new file mode 100644 index 00000000..2e7ef18c --- /dev/null +++ b/appthere-ui/src/components/confirm_dialog.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `AtConfirmDialog` — a blocking confirmation overlay for destructive or +//! data-losing actions (delete a file, close a dirty tab). +//! +//! Rendered as a `position: absolute` full-area backdrop with a centred card, +//! so the **mounting parent (or an ancestor) must be `position: relative` and +//! span the area to dim** — the same confirmed-working Blitz pattern as the +//! editor's floating spelling menu (`position: fixed` collapses to `absolute` +//! in stylo_taffy and must not be used). +//! +//! Mount it conditionally at a component boundary (ADR-0013): +//! ```rust,ignore +//! {pending().then(|| rsx! { +//! AtConfirmDialog { +//! title: fl!("home-delete-confirm-title"), +//! message: fl!("home-delete-confirm-message", title = name), +//! confirm_label: fl!("home-delete-confirm-confirm"), +//! cancel_label: fl!("home-delete-confirm-cancel"), +//! danger: true, +//! on_confirm: move |_| { /* do it */ pending.set(None); }, +//! on_cancel: move |_| pending.set(None), +//! } +//! })} +//! ``` +//! +//! Clicking the backdrop cancels (same as the Cancel button). Both buttons +//! meet the 44×44 logical-pixel minimum touch target (WCAG 2.5.8) via +//! `min-height: TOUCH_MIN` and horizontal padding. + +use dioxus::prelude::*; + +use crate::tokens::colors::{ + COLOR_ACCENT_PRIMARY, COLOR_BORDER_CHROME, COLOR_STATUS_ERROR_BORDER, COLOR_SURFACE_1, + COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, +}; +use crate::tokens::spacing::{RADIUS_MD, SPACE_2, SPACE_3, SPACE_4, TOUCH_MIN}; +use crate::tokens::typography::{ + FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_MD, FONT_WEIGHT_SEMIBOLD, +}; + +/// Width of the dialog card in logical pixels — narrow enough for Compact +/// portrait phones (the backdrop centring keeps it on-screen either way). +const DIALOG_WIDTH_PX: f32 = 320.0; + +/// Confirmation dialog props. All display strings are props (i18n-agnostic). +#[derive(Props, Clone, PartialEq)] +pub struct AtConfirmDialogProps { + /// Short dialog heading (e.g. "Delete file"). + pub title: String, + /// One- or two-sentence body explaining what will happen. + pub message: String, + /// Label of the confirming (destructive) button. + pub confirm_label: String, + /// Label of the cancelling (safe) button. + pub cancel_label: String, + /// Style the confirm button as destructive (error border/text). Defaults + /// to `true` — this dialog exists for destructive confirmations. + #[props(default = true)] + pub danger: bool, + /// Invoked when the user confirms the action. + pub on_confirm: EventHandler<()>, + /// Invoked when the user cancels (button or backdrop click). + pub on_cancel: EventHandler<()>, +} + +/// Blocking confirmation overlay. See the module docs for the mounting +/// contract (positioned ancestor) and touch-target guarantees. +/// +/// Touch targets: both action buttons are at least 44×44 logical pixels +/// (`min-height: 44px`, padded width well past 44px). +#[component] +pub fn AtConfirmDialog(props: AtConfirmDialogProps) -> Element { + let confirm_border = if props.danger { + COLOR_STATUS_ERROR_BORDER + } else { + COLOR_ACCENT_PRIMARY + }; + let button_base = format!( + "min-height: {th}px; box-sizing: border-box; \ + padding: {py}px {px}px; border-radius: {r}px; \ + font-family: {font}; font-size: {fs}px; font-weight: {fw}; \ + display: flex; align-items: center; justify-content: center;", + th = TOUCH_MIN, + py = SPACE_2, + px = SPACE_4, + r = RADIUS_MD, + font = FONT_FAMILY_UI, + fs = FONT_SIZE_BODY, + fw = FONT_WEIGHT_SEMIBOLD, + ); + + rsx! { + // Backdrop: dims and click-blocks the area behind the dialog; + // clicking it is a cancel. Centres the card via flex. + div { + style: "position: absolute; top: 0; left: 0; width: 100%; height: 100%; \ + z-index: 2000; background: rgba(0, 0, 0, 0.45); \ + display: flex; align-items: center; justify-content: center;", + role: "presentation", + onclick: move |_| props.on_cancel.call(()), + + // The dialog card. Clicks inside must not fall through to the + // backdrop's cancel handler. + div { + style: format!( + "width: {w}px; max-width: 90%; box-sizing: border-box; \ + display: flex; flex-direction: column; gap: {gap}px; \ + background: {bg}; border: 1px solid {border}; \ + border-radius: {r}px; padding: {pad}px; \ + font-family: {font}; color: {fg};", + w = DIALOG_WIDTH_PX, + gap = SPACE_3, + bg = COLOR_SURFACE_1, + border = COLOR_BORDER_CHROME, + r = RADIUS_MD, + pad = SPACE_4, + font = FONT_FAMILY_UI, + fg = COLOR_TEXT_ON_CHROME, + ), + role: "dialog", + "aria-label": props.title.clone(), + onclick: move |evt| evt.stop_propagation(), + + // Title + div { + style: format!( + "font-size: {fs}px; font-weight: {fw};", + fs = FONT_SIZE_MD, + fw = FONT_WEIGHT_SEMIBOLD, + ), + {props.title.clone()} + } + + // Message body + div { + style: format!( + "font-size: {fs}px; color: {fg};", + fs = FONT_SIZE_BODY, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + ), + {props.message.clone()} + } + + // Action row: Cancel (safe, left) then Confirm (destructive). + div { + style: format!( + "display: flex; justify-content: flex-end; gap: {gap}px;", + gap = SPACE_2, + ), + button { + style: format!( + "{button_base} background: transparent; \ + border: 1px solid {border}; color: {fg};", + border = COLOR_BORDER_CHROME, + fg = COLOR_TEXT_ON_CHROME, + ), + onclick: move |evt| { + evt.stop_propagation(); + props.on_cancel.call(()); + }, + {props.cancel_label.clone()} + } + button { + style: format!( + "{button_base} background: transparent; \ + border: 1px solid {confirm_border}; color: {confirm_border};", + ), + onclick: move |evt| { + evt.stop_propagation(); + props.on_confirm.call(()); + }, + {props.confirm_label.clone()} + } + } + } + } + } +} diff --git a/appthere-ui/src/components/home_tab/mod.rs b/appthere-ui/src/components/home_tab/mod.rs index 93b05d1e..ee479019 100644 --- a/appthere-ui/src/components/home_tab/mod.rs +++ b/appthere-ui/src/components/home_tab/mod.rs @@ -10,6 +10,7 @@ //! strings from `loki_i18n::fl!()` can be passed directly. mod recent_files; +mod recent_row; mod template_gallery; use dioxus::prelude::*; @@ -17,13 +18,14 @@ use loki_i18n::fl; use recent_files::AtRecentFileList; use template_gallery::AtTemplateGallery; +use crate::responsive::use_breakpoint; use crate::safe_area::use_safe_area; use crate::tokens::colors::{ COLOR_ACCENT_PRIMARY, COLOR_ACCENT_PRIMARY_HOVER, COLOR_STATUS_ERROR_BG, COLOR_STATUS_ERROR_BORDER, COLOR_STATUS_ERROR_TEXT, COLOR_SURFACE_BASE, COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, COLOR_TEXT_PRIMARY, }; -use crate::tokens::layout::{BREAKPOINT_DESKTOP_PX, TAB_BAR_HEIGHT}; +use crate::tokens::layout::TAB_BAR_HEIGHT; use crate::tokens::spacing::{RADIUS_SM, SPACE_2, SPACE_3, SPACE_4, SPACE_6, TOUCH_MIN}; use crate::tokens::typography::{ FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD, @@ -61,15 +63,20 @@ pub struct RecentDocument { /// Home tab content surface. /// /// Renders a header, a template gallery, and a recent documents list. -/// On viewports wider than [`BREAKPOINT_DESKTOP_PX`] the gallery and list -/// are placed side-by-side; on narrower viewports they stack vertically. +/// At the Compact size class (`use_breakpoint`, < 600 px) the gallery and +/// list stack vertically; at Medium/Expanded they sit side-by-side. Apps +/// that provide no responsive context fall back to Expanded (desktop-first); +/// pushing a measured width from the app root is what makes this live +/// (loki-text does; see the plan 4c.5 tail for the other apps). /// /// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** /// All interactive elements (template cards, document rows, buttons) meet this. #[component] pub fn AtHomeTab(props: AtHomeTabProps) -> Element { - let viewport_width = use_signal(|| 375.0_f32); - let is_desktop = viewport_width() >= BREAKPOINT_DESKTOP_PX; + // F7a: the size class comes from the shared responsive context — the old + // fixed `viewport_width = 375.0` signal locked this surface to the + // stacked phone layout everywhere. + let is_desktop = !use_breakpoint().is_compact(); // Taffy does not propagate a definite height from a flex:1 child back to // its own children's flex:1 items. Match the Shell outlet's height exactly diff --git a/appthere-ui/src/components/home_tab/recent_files.rs b/appthere-ui/src/components/home_tab/recent_files.rs index 40891ae0..c89d220e 100644 --- a/appthere-ui/src/components/home_tab/recent_files.rs +++ b/appthere-ui/src/components/home_tab/recent_files.rs @@ -1,18 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 //! `AtRecentFileList` — recent documents list with per-row context menu. +//! +//! Rows and the hoverable Open-File button are child `#[component]`s +//! ([`super::recent_row::RecentRow`], [`OpenFileButton`]) so each owns its +//! hook scope — the hover signals used to be `use_signal` calls inside the +//! list's `for` loop and `if` arms, making this component's hook count depend +//! on its props (audit F6a / ADR-0013). use dioxus::prelude::*; +use super::recent_row::RecentRow; use crate::components::home_tab::RecentDocument; use crate::tokens::colors::{ - COLOR_ACCENT_PRIMARY, COLOR_ACCENT_PRIMARY_HOVER, COLOR_STATUS_ERROR_TEXT, COLOR_SURFACE_PAGE, - COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, COLOR_TEXT_PRIMARY, COLOR_TEXT_SECONDARY, -}; -use crate::tokens::spacing::{RADIUS_MD, RADIUS_SM, SPACE_1, SPACE_2, SPACE_3, SPACE_4, TOUCH_MIN}; -use crate::tokens::typography::{ - FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD, + COLOR_ACCENT_PRIMARY, COLOR_ACCENT_PRIMARY_HOVER, COLOR_SURFACE_PAGE, COLOR_TEXT_ON_CHROME, + COLOR_TEXT_ON_CHROME_SECONDARY, }; +use crate::tokens::spacing::{RADIUS_SM, SPACE_2, SPACE_4, TOUCH_MIN}; +use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_WEIGHT_SEMIBOLD}; /// Maximum number of recent entries displayed in the list. const RECENT_VISIBLE_LIMIT: usize = 10; @@ -29,6 +34,12 @@ const RECENT_VISIBLE_LIMIT: usize = 10; #[component] pub(crate) fn AtRecentFileList(props: AtRecentFileListProps) -> Element { let mut menu_open: Signal> = use_signal(|| None); + let close_then = move |handler: EventHandler| { + EventHandler::new(move |idx: usize| { + menu_open.set(None); + handler.call(idx); + }) + }; rsx! { div { @@ -41,215 +52,111 @@ pub(crate) fn AtRecentFileList(props: AtRecentFileListProps) -> Element { if props.documents.is_empty() { // ── Empty state ─────────────────────────────────────────────── - { - let mut open_hovered = use_signal(|| false); - let open_bg = if open_hovered() { - COLOR_ACCENT_PRIMARY_HOVER - } else { - COLOR_ACCENT_PRIMARY - }; - rsx! { - div { - style: format!( - "display: flex; flex-direction: column; \ - align-items: center; gap: {gap}px; padding: {p}px;", - gap = SPACE_4, - p = SPACE_4, - ), - span { - style: format!( - "font-size: {size}px; color: {fg};", - size = FONT_SIZE_BODY, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - ), - "{props.empty_label}" - } - button { - style: format!( - "background: {bg}; color: {fg}; \ - border: none; border-radius: {r}px; \ - min-height: {touch}px; padding: 0 {px}px; \ - font-size: {size}px; font-weight: {weight}; \ - cursor: pointer;", - bg = open_bg, - fg = COLOR_SURFACE_PAGE, - r = RADIUS_SM, - touch = TOUCH_MIN, - px = SPACE_4, - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - ), - onmouseenter: move |_| { open_hovered.set(true); }, - onmouseleave: move |_| { open_hovered.set(false); }, - onclick: move |_| { props.on_open_file.call(()); }, - "{props.open_file_label}" - } - } + div { + style: format!( + "display: flex; flex-direction: column; \ + align-items: center; gap: {gap}px; padding: {p}px;", + gap = SPACE_4, + p = SPACE_4, + ), + span { + style: format!( + "font-size: {size}px; color: {fg};", + size = FONT_SIZE_BODY, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + ), + "{props.empty_label}" + } + OpenFileButton { + label: props.open_file_label.clone(), + full_width: false, + on_click: props.on_open_file, } } } for (idx, doc) in props.documents.iter().take(RECENT_VISIBLE_LIMIT).enumerate() { - { - let title = doc.title.clone(); - let modified = doc.modified_at.clone(); - let mut row_hovered = use_signal(|| false); - let row_bg = if row_hovered() { "#F5F5F5" } else { COLOR_SURFACE_PAGE }; - let is_menu_open = menu_open() == Some(idx); - // Shared base style for context-menu action buttons; caller supplies `color`. - let action_style = |fg: &'static str| format!( - "background: transparent; border: none; text-align: left; \ - padding: {p}px {ph}px; min-height: {touch}px; cursor: pointer; \ - font-size: {size}px; color: {fg}; border-radius: {r}px;", - p = SPACE_2, ph = SPACE_3, touch = TOUCH_MIN, - size = FONT_SIZE_BODY, fg = fg, r = RADIUS_SM, - ); - rsx! { - div { - key: "{idx}", - style: format!( - "background: {bg}; border-radius: {r}px;", - bg = row_bg, - r = RADIUS_MD, - ), - onmouseenter: move |_| { row_hovered.set(true); }, - onmouseleave: move |_| { row_hovered.set(false); }, - - // ── Row: document info button + ⋮ toggle ────────── - div { - style: "display: flex; align-items: center;", - button { - "aria-label": title.clone(), - style: format!( - "background: transparent; border: none; \ - border-radius: {r}px; \ - padding: {pv}px {ph}px; min-height: {touch}px; \ - flex: 1; display: flex; flex-direction: column; \ - gap: {gap}px; cursor: pointer; \ - text-align: left; box-sizing: border-box;", - r = RADIUS_MD, - pv = SPACE_3, - ph = SPACE_4, - touch = TOUCH_MIN, - gap = SPACE_1, - ), - onclick: move |_| { props.on_select.call(idx); }, - span { - style: format!( - "font-size: {size}px; font-weight: {weight}; color: {fg};", - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - fg = COLOR_TEXT_PRIMARY, - ), - "{title}" - } - span { - style: format!( - "font-size: {size}px; color: {fg};", - size = FONT_SIZE_LABEL, - fg = COLOR_TEXT_SECONDARY, - ), - "{modified}" - } - } - // ── ⋮ context menu button ───────────────────── - button { - "aria-label": props.menu_aria_label.clone(), - "aria-expanded": if is_menu_open { "true" } else { "false" }, - style: format!( - "background: transparent; border: none; \ - min-width: {touch}px; min-height: {touch}px; \ - border-radius: {r}px; cursor: pointer; \ - font-size: 18px; color: {fg}; flex-shrink: 0;", - touch = TOUCH_MIN, - r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - ), - onclick: move |_| { - menu_open.set(if is_menu_open { None } else { Some(idx) }); - }, - "⋮" - } - } - - // ── Inline context menu ─────────────────────────── - if is_menu_open { - div { - style: format!( - "display: flex; flex-direction: column; \ - border-top: 1px solid #E0E0E0; \ - padding: {p}px; gap: {gap}px;", - p = SPACE_2, - gap = SPACE_1, - ), - button { - style: action_style(COLOR_TEXT_PRIMARY), - onclick: move |_| { - menu_open.set(None); - props.on_remove.call(idx); - }, - "{props.remove_label}" - } - button { - style: action_style(COLOR_STATUS_ERROR_TEXT), - onclick: move |_| { - menu_open.set(None); - props.on_delete.call(idx); - }, - "{props.delete_label}" - } - button { - style: action_style(COLOR_TEXT_PRIMARY), - onclick: move |_| { - menu_open.set(None); - props.on_open_copy.call(idx); - }, - "{props.open_copy_label}" - } - } - } - } - } + RecentRow { + key: "{doc.path}", + idx, + title: doc.title.clone(), + modified: doc.modified_at.clone(), + is_menu_open: menu_open() == Some(idx), + menu_aria_label: props.menu_aria_label.clone(), + remove_label: props.remove_label.clone(), + delete_label: props.delete_label.clone(), + open_copy_label: props.open_copy_label.clone(), + on_select: props.on_select, + on_toggle_menu: move |i: usize| { + menu_open.set(if *menu_open.peek() == Some(i) { None } else { Some(i) }); + }, + on_remove: close_then(props.on_remove), + on_delete: close_then(props.on_delete), + on_open_copy: close_then(props.on_open_copy), } } // Open File button shown below the list when documents exist if !props.documents.is_empty() { - { - let mut open_hovered = use_signal(|| false); - let open_bg = if open_hovered() { - COLOR_ACCENT_PRIMARY_HOVER - } else { - COLOR_ACCENT_PRIMARY - }; - rsx! { - button { - style: format!( - "background: {bg}; color: {fg}; \ - border: none; border-radius: {r}px; \ - min-height: {touch}px; width: 100%; \ - font-size: {size}px; font-weight: {weight}; \ - cursor: pointer; margin-top: {mt}px;", - bg = open_bg, - fg = COLOR_TEXT_ON_CHROME, - r = RADIUS_SM, - touch = TOUCH_MIN, - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - mt = SPACE_2, - ), - onmouseenter: move |_| { open_hovered.set(true); }, - onmouseleave: move |_| { open_hovered.set(false); }, - onclick: move |_| { props.on_open_file.call(()); }, - "{props.open_file_label}" - } - } + OpenFileButton { + label: props.open_file_label.clone(), + full_width: true, + on_click: props.on_open_file, } } } } } +// ── OpenFileButton ──────────────────────────────────────────────────────────── + +/// The accent "Open file…" button (hover state owned here, not by the list). +/// +/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8)** via +/// `min-height` + padding/width. +#[component] +fn OpenFileButton(label: String, full_width: bool, on_click: EventHandler<()>) -> Element { + let mut hovered = use_signal(|| false); + let bg = if hovered() { + COLOR_ACCENT_PRIMARY_HOVER + } else { + COLOR_ACCENT_PRIMARY + }; + let (fg, sizing) = if full_width { + ( + COLOR_TEXT_ON_CHROME, + format!("width: 100%; margin-top: {mt}px;", mt = SPACE_2), + ) + } else { + ( + COLOR_SURFACE_PAGE, + format!("padding: 0 {px}px;", px = SPACE_4), + ) + }; + rsx! { + button { + style: format!( + "background: {bg}; color: {fg}; \ + border: none; border-radius: {r}px; \ + min-height: {touch}px; {sizing} \ + font-size: {size}px; font-weight: {weight}; \ + cursor: pointer;", + bg = bg, + fg = fg, + r = RADIUS_SM, + touch = TOUCH_MIN, + sizing = sizing, + size = FONT_SIZE_BODY, + weight = FONT_WEIGHT_SEMIBOLD, + ), + onmouseenter: move |_| { hovered.set(true); }, + onmouseleave: move |_| { hovered.set(false); }, + onclick: move |_| { on_click.call(()); }, + "{label}" + } + } +} + // ── Props ───────────────────────────────────────────────────────────────────── #[derive(Props, Clone, PartialEq)] diff --git a/appthere-ui/src/components/home_tab/recent_row.rs b/appthere-ui/src/components/home_tab/recent_row.rs new file mode 100644 index 00000000..7efb487e --- /dev/null +++ b/appthere-ui/src/components/home_tab/recent_row.rs @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! One row of [`super::recent_files::AtRecentFileList`]: the document info +//! button, the ⋮ menu toggle, and the inline context menu. +//! +//! A `#[component]` (not a loop body) so it owns its hook scope — the hover +//! signal used to be a `use_signal` inside the list's `for` loop, which made +//! the parent's hook count depend on the document count (audit F6a / +//! ADR-0013). + +use dioxus::prelude::*; + +use crate::tokens::colors::{ + COLOR_STATUS_ERROR_TEXT, COLOR_SURFACE_PAGE, COLOR_TEXT_ON_CHROME_SECONDARY, + COLOR_TEXT_PRIMARY, COLOR_TEXT_SECONDARY, +}; +use crate::tokens::spacing::{RADIUS_MD, RADIUS_SM, SPACE_1, SPACE_2, SPACE_3, SPACE_4, TOUCH_MIN}; +use crate::tokens::typography::{FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD}; + +/// Props for [`RecentRow`]. The parent owns the open-menu state; the row +/// reports toggle/action clicks by index. +#[derive(Props, Clone, PartialEq)] +pub(super) struct RecentRowProps { + pub idx: usize, + pub title: String, + pub modified: String, + pub is_menu_open: bool, + pub menu_aria_label: String, + pub remove_label: String, + pub delete_label: String, + pub open_copy_label: String, + pub on_select: EventHandler, + pub on_toggle_menu: EventHandler, + pub on_remove: EventHandler, + pub on_delete: EventHandler, + pub on_open_copy: EventHandler, +} + +/// A recent-document row with its inline context menu. +/// +/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** +/// The row click target, the ⋮ button, and every menu action meet this. +#[component] +pub(super) fn RecentRow(props: RecentRowProps) -> Element { + let mut row_hovered = use_signal(|| false); + let row_bg = if row_hovered() { + "#F5F5F5" + } else { + COLOR_SURFACE_PAGE + }; + let idx = props.idx; + // Shared base style for context-menu action buttons; caller supplies `fg`. + let action_style = |fg: &'static str| { + format!( + "background: transparent; border: none; text-align: left; \ + padding: {p}px {ph}px; min-height: {touch}px; cursor: pointer; \ + font-size: {size}px; color: {fg}; border-radius: {r}px;", + p = SPACE_2, + ph = SPACE_3, + touch = TOUCH_MIN, + size = FONT_SIZE_BODY, + fg = fg, + r = RADIUS_SM, + ) + }; + + rsx! { + div { + style: format!( + "background: {bg}; border-radius: {r}px;", + bg = row_bg, + r = RADIUS_MD, + ), + onmouseenter: move |_| { row_hovered.set(true); }, + onmouseleave: move |_| { row_hovered.set(false); }, + + // ── Row: document info button + ⋮ toggle ────────────────────────── + div { + style: "display: flex; align-items: center;", + button { + "aria-label": props.title.clone(), + style: format!( + "background: transparent; border: none; \ + border-radius: {r}px; \ + padding: {pv}px {ph}px; min-height: {touch}px; \ + flex: 1; display: flex; flex-direction: column; \ + gap: {gap}px; cursor: pointer; \ + text-align: left; box-sizing: border-box;", + r = RADIUS_MD, + pv = SPACE_3, + ph = SPACE_4, + touch = TOUCH_MIN, + gap = SPACE_1, + ), + onclick: move |_| { props.on_select.call(idx); }, + span { + style: format!( + "font-size: {size}px; font-weight: {weight}; color: {fg};", + size = FONT_SIZE_BODY, + weight = FONT_WEIGHT_SEMIBOLD, + fg = COLOR_TEXT_PRIMARY, + ), + "{props.title}" + } + span { + style: format!( + "font-size: {size}px; color: {fg};", + size = FONT_SIZE_LABEL, + fg = COLOR_TEXT_SECONDARY, + ), + "{props.modified}" + } + } + // ── ⋮ context menu button ───────────────────────────────────── + button { + "aria-label": props.menu_aria_label.clone(), + "aria-expanded": if props.is_menu_open { "true" } else { "false" }, + style: format!( + "background: transparent; border: none; \ + min-width: {touch}px; min-height: {touch}px; \ + border-radius: {r}px; cursor: pointer; \ + font-size: 18px; color: {fg}; flex-shrink: 0;", + touch = TOUCH_MIN, + r = RADIUS_SM, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + ), + onclick: move |_| { props.on_toggle_menu.call(idx); }, + "⋮" + } + } + + // ── Inline context menu ─────────────────────────────────────────── + if props.is_menu_open { + div { + style: format!( + "display: flex; flex-direction: column; \ + border-top: 1px solid #E0E0E0; \ + padding: {p}px; gap: {gap}px;", + p = SPACE_2, + gap = SPACE_1, + ), + button { + style: action_style(COLOR_TEXT_PRIMARY), + onclick: move |_| { props.on_remove.call(idx); }, + "{props.remove_label}" + } + button { + style: action_style(COLOR_STATUS_ERROR_TEXT), + onclick: move |_| { props.on_delete.call(idx); }, + "{props.delete_label}" + } + button { + style: action_style(COLOR_TEXT_PRIMARY), + onclick: move |_| { props.on_open_copy.call(idx); }, + "{props.open_copy_label}" + } + } + } + } + } +} diff --git a/appthere-ui/src/components/home_tab/template_gallery.rs b/appthere-ui/src/components/home_tab/template_gallery.rs index f2612839..212030e0 100644 --- a/appthere-ui/src/components/home_tab/template_gallery.rs +++ b/appthere-ui/src/components/home_tab/template_gallery.rs @@ -1,12 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 //! `AtTemplateGallery` — horizontally scrollable template card row. +//! +//! The cards are child `#[component]`s so each owns its hook scope — the +//! hover signals used to be `use_signal` calls inside the gallery's `for` +//! loop and `if` arm, making the gallery's hook count depend on its props +//! (audit F6a / ADR-0013). use dioxus::prelude::*; use crate::components::home_tab::BuiltinTemplate; use crate::tokens::colors::{COLOR_ACCENT_PRIMARY, COLOR_SURFACE_PAGE, COLOR_TEXT_ON_CHROME}; -use crate::tokens::spacing::{RADIUS_LG, RADIUS_SM, SPACE_2, SPACE_3, TOUCH_MIN}; +use crate::tokens::spacing::{RADIUS_LG, RADIUS_SM, SPACE_1, SPACE_2, SPACE_3, TOUCH_MIN}; use crate::tokens::typography::{ FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD, }; @@ -35,117 +40,142 @@ pub(crate) fn AtTemplateGallery(props: AtTemplateGalleryProps) -> Element { ), for (idx, tmpl) in props.templates.iter().enumerate() { - { - let name = tmpl.name.clone(); - let fmt_label = tmpl.format_label.clone(); - let mut hovered = use_signal(|| false); - let border = if hovered() { - format!("border: 2px solid {COLOR_ACCENT_PRIMARY};") - } else { - String::new() - }; - rsx! { - button { - key: "{idx}", - "aria-label": name, - style: format!( - "flex-shrink: 0; width: 100px; min-height: {touch}px; \ - background: {bg}; border-radius: {r}px; \ - padding: {pad}px; border: none; cursor: pointer; \ - display: flex; flex-direction: column; \ - align-items: center; gap: {gap}px; \ - box-sizing: border-box; {border}", - touch = TOUCH_MIN, - bg = COLOR_SURFACE_PAGE, - r = RADIUS_LG, - pad = SPACE_3, - gap = SPACE_2, - border = border, - ), - onmouseenter: move |_| { hovered.set(true); }, - onmouseleave: move |_| { hovered.set(false); }, - onclick: move |_| { props.on_select.call(idx); }, - - // Format swatch placeholder - // TODO(icons): Replace with format-type illustration. - div { - style: format!( - "width: 60px; height: 72px; \ - background: #DDDDDD; border-radius: {r}px; \ - display: flex; align-items: flex-end; \ - justify-content: center; padding-bottom: {p}px;", - r = RADIUS_SM, - p = SPACE_1, - ), - span { - style: format!( - "font-size: {size}px; color: #888888;", - size = FONT_SIZE_LABEL, - ), - "{fmt_label}" - } - } - span { - style: format!( - "font-size: {size}px; font-weight: {weight}; \ - color: #1A1A1A; text-align: center;", - size = FONT_SIZE_LABEL, - weight = FONT_WEIGHT_SEMIBOLD, - ), - "{name}" - } - } - } + TemplateCard { + key: "{tmpl.name}", + idx, + name: tmpl.name.clone(), + format_label: tmpl.format_label.clone(), + on_select: props.on_select, } } // Browse… card — hidden when browse_label is empty. if !props.browse_label.is_empty() { - { - let mut browse_hovered = use_signal(|| false); - let browse_border = if browse_hovered() { - format!("border: 2px solid {COLOR_ACCENT_PRIMARY};") - } else { - String::new() - }; - rsx! { - button { - "aria-label": props.browse_label, - style: format!( - "flex-shrink: 0; width: 100px; min-height: {touch}px; \ - background: transparent; border-radius: {r}px; \ - padding: {pad}px; cursor: pointer; \ - display: flex; flex-direction: column; \ - align-items: center; justify-content: center; \ - gap: {gap}px; box-sizing: border-box; {border}", - touch = TOUCH_MIN, - r = RADIUS_LG, - pad = SPACE_3, - gap = SPACE_2, - border = browse_border, - ), - onmouseenter: move |_| { browse_hovered.set(true); }, - onmouseleave: move |_| { browse_hovered.set(false); }, - onclick: move |_| { props.on_browse.call(()); }, - span { - style: format!( - "font-size: {size}px; font-weight: {weight}; color: {fg};", - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - fg = COLOR_ON_CHROME_BROWSE, - ), - "{props.browse_label}" - } - } - } + BrowseCard { + label: props.browse_label.clone(), + on_browse: props.on_browse, } } } } } -// Fallback color constant for the browse card text (not in the main palette yet) -const COLOR_ON_CHROME_BROWSE: &str = COLOR_TEXT_ON_CHROME; +/// The hover border shared by both card kinds (`2px` accent when hovered). +fn hover_border(hovered: bool) -> String { + if hovered { + format!("border: 2px solid {COLOR_ACCENT_PRIMARY};") + } else { + String::new() + } +} + +// ── TemplateCard ────────────────────────────────────────────────────────────── + +/// One template card (hover state owned here, not by the gallery). +/// +/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8)** via +/// `min-height` on a 100 px-wide card. +#[component] +fn TemplateCard( + idx: usize, + name: String, + format_label: String, + on_select: EventHandler, +) -> Element { + let mut hovered = use_signal(|| false); + rsx! { + button { + "aria-label": name.clone(), + style: format!( + "flex-shrink: 0; width: 100px; min-height: {touch}px; \ + background: {bg}; border-radius: {r}px; \ + padding: {pad}px; border: none; cursor: pointer; \ + display: flex; flex-direction: column; \ + align-items: center; gap: {gap}px; \ + box-sizing: border-box; {border}", + touch = TOUCH_MIN, + bg = COLOR_SURFACE_PAGE, + r = RADIUS_LG, + pad = SPACE_3, + gap = SPACE_2, + border = hover_border(hovered()), + ), + onmouseenter: move |_| { hovered.set(true); }, + onmouseleave: move |_| { hovered.set(false); }, + onclick: move |_| { on_select.call(idx); }, + + // Format swatch placeholder + // TODO(icons): Replace with format-type illustration. + div { + style: format!( + "width: 60px; height: 72px; \ + background: #DDDDDD; border-radius: {r}px; \ + display: flex; align-items: flex-end; \ + justify-content: center; padding-bottom: {p}px;", + r = RADIUS_SM, + p = SPACE_1, + ), + span { + style: format!( + "font-size: {size}px; color: #888888;", + size = FONT_SIZE_LABEL, + ), + "{format_label}" + } + } + span { + style: format!( + "font-size: {size}px; font-weight: {weight}; \ + color: #1A1A1A; text-align: center;", + size = FONT_SIZE_LABEL, + weight = FONT_WEIGHT_SEMIBOLD, + ), + "{name}" + } + } + } +} + +// ── BrowseCard ──────────────────────────────────────────────────────────────── + +/// The trailing "Browse…" card (hover state owned here, not by the gallery). +/// +/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8)** via +/// `min-height` on a 100 px-wide card. +#[component] +fn BrowseCard(label: String, on_browse: EventHandler<()>) -> Element { + let mut hovered = use_signal(|| false); + rsx! { + button { + "aria-label": label.clone(), + style: format!( + "flex-shrink: 0; width: 100px; min-height: {touch}px; \ + background: transparent; border-radius: {r}px; \ + padding: {pad}px; cursor: pointer; \ + display: flex; flex-direction: column; \ + align-items: center; justify-content: center; \ + gap: {gap}px; box-sizing: border-box; {border}", + touch = TOUCH_MIN, + r = RADIUS_LG, + pad = SPACE_3, + gap = SPACE_2, + border = hover_border(hovered()), + ), + onmouseenter: move |_| { hovered.set(true); }, + onmouseleave: move |_| { hovered.set(false); }, + onclick: move |_| { on_browse.call(()); }, + span { + style: format!( + "font-size: {size}px; font-weight: {weight}; color: {fg};", + size = FONT_SIZE_BODY, + weight = FONT_WEIGHT_SEMIBOLD, + fg = COLOR_TEXT_ON_CHROME, + ), + "{label}" + } + } + } +} // ── Props ───────────────────────────────────────────────────────────────────── @@ -156,6 +186,3 @@ pub(crate) struct AtTemplateGalleryProps { pub on_select: EventHandler, pub on_browse: EventHandler<()>, } - -// Keep lint happy — the SPACE_1 constant is used in the swatch sub-element. -use crate::tokens::spacing::SPACE_1; diff --git a/appthere-ui/src/components/mod.rs b/appthere-ui/src/components/mod.rs index 2b822cc1..6be3368e 100644 --- a/appthere-ui/src/components/mod.rs +++ b/appthere-ui/src/components/mod.rs @@ -5,6 +5,7 @@ //! All components are application-agnostic — they must not reference any //! application-specific route enum, document model, or business logic. +pub mod confirm_dialog; pub mod document_tab; pub mod home_tab; pub mod icons; @@ -14,7 +15,9 @@ pub mod ribbon; pub mod status_bar; pub mod tab_bar; pub mod title_bar; +pub mod zoom; +pub use confirm_dialog::{AtConfirmDialog, AtConfirmDialogProps}; pub use document_tab::{AtDocumentTab, AtDocumentTabProps}; pub use home_tab::{AtHomeTab, AtHomeTabProps, BuiltinTemplate, RecentDocument}; pub use panel_host::{AtPanelHost, AtPanelHostProps, PanelPosture}; @@ -23,3 +26,4 @@ pub use ribbon::{AtRibbon, AtRibbonGroup, AtRibbonGroupProps, RibbonTabDesc, Rib pub use status_bar::{AtStatusBar, AtStatusBarProps}; pub use tab_bar::{AtDocumentTabData, AtTabBar, AtTabBarProps}; pub use title_bar::{AtTitleBar, AtTitleBarProps}; +pub use zoom::next_zoom; diff --git a/appthere-ui/src/components/status_bar.rs b/appthere-ui/src/components/status_bar.rs index 2039f82b..41e85bba 100644 --- a/appthere-ui/src/components/status_bar.rs +++ b/appthere-ui/src/components/status_bar.rs @@ -13,7 +13,7 @@ use crate::tokens::colors::{ COLOR_TEXT_ACCENT, COLOR_TEXT_ON_CHROME_SECONDARY, }; use crate::tokens::layout::STATUS_BAR_HEIGHT; -use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2, SPACE_4}; +use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2, SPACE_4, TOUCH_MIN}; use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_XS, FONT_WEIGHT_MEDIUM}; // ── AtStatusBar ─────────────────────────────────────────────────────────────── @@ -23,10 +23,14 @@ use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_XS, FONT_WEIGHT_MEDIUM /// Displays document statistics on the left and navigation controls (zoom, /// language, collaborator count) on the right. /// -/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** -/// -/// The zoom badge is smaller than `TOUCH_MIN` in its visual footprint. -/// TODO(a11y): Expand the invisible touch target to `TOUCH_MIN` using padding. +/// **Touch targets (WCAG 2.5.8):** every interactive control (notice chip, +/// view-mode toggle, zoom badge) is at least [`TOUCH_MIN`] (44 px) wide and +/// fills the bar's full [`STATUS_BAR_HEIGHT`] (24 px) — the WCAG 2.5.8 AA +/// 24 px minimum. The bar itself caps the vertical target below the suite's +/// 44 px convention; meeting it fully needs a taller bar on touch platforms +/// (a design decision tracked in the deferred-features plan, 4c.2 tail). +/// The visual chip is a smaller nested `span`; the transparent button around +/// it is the hit area. #[component] pub fn AtStatusBar(props: AtStatusBarProps) -> Element { let mut zoom_hovered = use_signal(|| false); @@ -101,26 +105,33 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { // Optional status notice chip (e.g. recover a dismissed warning). // Border + background only — no box-shadow (Blitz constraint). + // Hit area: full bar height × ≥ TOUCH_MIN wide (see component doc). if show_notice { button { "aria-label": props.notice_aria_label.clone(), style: format!( - "background: {bg}; border: 1px solid {border}; border-radius: {r}px; \ - color: {fg}; font-size: {size}px; font-weight: {weight}; \ - cursor: pointer; padding: {pv}px {ph}px; flex-shrink: 0;", - bg = notice_bg, - border = COLOR_CONTEXTUAL_TAB, - r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - size = FONT_SIZE_XS, - weight = FONT_WEIGHT_MEDIUM, - pv = SPACE_1, - ph = SPACE_2, + "{hit} background: transparent; border: none; cursor: pointer;", + hit = hit_area_style(), ), onmouseenter: move |_| { notice_hovered.set(true); }, onmouseleave: move |_| { notice_hovered.set(false); }, onclick: move |_| { props.on_notice_click.call(()); }, - "⚠ {props.notice_label}" + span { + style: format!( + "background: {bg}; border: 1px solid {border}; border-radius: {r}px; \ + color: {fg}; font-size: {size}px; font-weight: {weight}; \ + padding: {pv}px {ph}px;", + bg = notice_bg, + border = COLOR_CONTEXTUAL_TAB, + r = RADIUS_SM, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + size = FONT_SIZE_XS, + weight = FONT_WEIGHT_MEDIUM, + pv = SPACE_1, + ph = SPACE_2, + ), + "⚠ {props.notice_label}" + } } } @@ -143,51 +154,39 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { // View-mode toggle (paginated ⇆ reflowed). Hidden unless a label is // supplied, so apps that do not offer the toggle are unaffected. - // **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** - // TODO(a11y): expand the invisible touch target to TOUCH_MIN. + // Hit area: full bar height × ≥ TOUCH_MIN wide (see component doc). if show_view_toggle { button { "aria-label": props.view_mode_aria_label.clone(), style: format!( - "background: {bg}; border: none; border-radius: {r}px; \ - color: {fg}; font-size: {size}px; font-weight: {weight}; \ - cursor: pointer; padding: {pv}px {ph}px; flex-shrink: 0;", - bg = view_bg, - r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - size = FONT_SIZE_XS, - weight = FONT_WEIGHT_MEDIUM, - pv = SPACE_1, - ph = SPACE_2, + "{hit} background: transparent; border: none; cursor: pointer;", + hit = hit_area_style(), ), onmouseenter: move |_| { view_hovered.set(true); }, onmouseleave: move |_| { view_hovered.set(false); }, onclick: move |_| { props.on_view_mode_click.call(()); }, - "{props.view_mode_label}" + span { + style: chip_style(view_bg), + "{props.view_mode_label}" + } } } - // Zoom badge (clickable button) - // The zoom badge is smaller than TOUCH_MIN in its visual footprint. - // TODO(a11y): Expand the invisible touch target to TOUCH_MIN using padding. + // Zoom badge (clickable button). + // Hit area: full bar height × ≥ TOUCH_MIN wide (see component doc). button { "aria-label": props.zoom_aria_label, style: format!( - "background: {bg}; border: none; border-radius: {r}px; \ - color: {fg}; font-size: {size}px; font-weight: {weight}; \ - cursor: pointer; padding: {pv}px {ph}px; flex-shrink: 0;", - bg = zoom_bg, - r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - size = FONT_SIZE_XS, - weight = FONT_WEIGHT_MEDIUM, - pv = SPACE_1, - ph = SPACE_2, + "{hit} background: transparent; border: none; cursor: pointer;", + hit = hit_area_style(), ), onmouseenter: move |_| { zoom_hovered.set(true); }, onmouseleave: move |_| { zoom_hovered.set(false); }, onclick: move |_| { props.on_zoom_click.call(()); }, - "{props.zoom_percent}%" + span { + style: chip_style(zoom_bg), + "{props.zoom_percent}%" + } } // Collaborator badge (hidden when count is 0) @@ -205,6 +204,30 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { } } +/// Shared hit-area style for the status bar's interactive controls: at least +/// [`TOUCH_MIN`] wide and the bar's full height, centring the visual chip. +fn hit_area_style() -> String { + format!( + "min-width: {w}px; height: 100%; box-sizing: border-box; padding: 0; \ + display: flex; align-items: center; justify-content: center; flex-shrink: 0;", + w = TOUCH_MIN, + ) +} + +/// Visual chip style for the view-mode / zoom badges (`bg` swaps on hover). +fn chip_style(bg: &str) -> String { + format!( + "background: {bg}; border-radius: {r}px; color: {fg}; \ + font-size: {size}px; font-weight: {weight}; padding: {pv}px {ph}px;", + r = RADIUS_SM, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + size = FONT_SIZE_XS, + weight = FONT_WEIGHT_MEDIUM, + pv = SPACE_1, + ph = SPACE_2, + ) +} + // ── Props ───────────────────────────────────────────────────────────────────── /// Props for [`AtStatusBar`]. @@ -255,9 +278,8 @@ pub struct AtStatusBarProps { /// default) hides it, so apps that do not use it are unaffected. Generic by /// design — not font-specific. /// - /// **Min interactive size: 44×44 logical px (WCAG 2.5.8).** TODO(a11y): - /// expand the invisible touch target to `TOUCH_MIN` (shared with the zoom / - /// view-mode badges, which carry the same status-bar-height constraint). + /// Touch target: ≥ `TOUCH_MIN` wide × full bar height (see the component + /// doc for the shared status-bar-height constraint). #[props(default)] pub notice_label: String, diff --git a/appthere-ui/src/components/zoom.rs b/appthere-ui/src/components/zoom.rs new file mode 100644 index 00000000..d7fb8b63 --- /dev/null +++ b/appthere-ui/src/components/zoom.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Zoom stepping shared by the suite's status-bar zoom badges. + +/// The zoom step after `current`, for the status bar's zoom badge — cycles +/// 50 → 75 → 100 → 125 → 150 → 200 → back to 50 (unknown values snap to 100). +#[must_use] +pub fn next_zoom(current: u32) -> u32 { + match current { + 50 => 75, + 75 => 100, + 100 => 125, + 125 => 150, + 150 => 200, + 200 => 50, + _ => 100, + } +} diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index d86db451..b9a8da59 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -42,9 +42,10 @@ pub use components::ribbon::{ AtRibbon, AtRibbonGroup, AtRibbonIconButton, AtRibbonSelect, RibbonTabDesc, RibbonTabIndex, }; pub use components::{ - AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, AtHomeTab, AtHomeTabProps, AtPanelHost, - AtPanelHostProps, AtStatusBar, AtStatusBarProps, AtTabBar, AtTabBarProps, AtTitleBar, - AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform, RecentDocument, + next_zoom, AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, + AtDocumentTabProps, AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, + AtStatusBarProps, AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, + PanelPosture, Platform, RecentDocument, }; pub use responsive::{ page_fits, required_page_width, resolve_page_fit, use_breakpoint, use_provide_responsive, diff --git a/docs/adr/0009-target-architecture.md b/docs/adr/0009-target-architecture.md index 924ac5fd..fe8b8201 100644 --- a/docs/adr/0009-target-architecture.md +++ b/docs/adr/0009-target-architecture.md @@ -44,7 +44,7 @@ leaf foundation. L5 ui / appthere-ui · loki-app-shell app-shell (design system, shared runtime services: SpellService, i18n) ─────────────────────────────────────────────────────────── - L4 render loki-renderer · loki-vello · appthere-canvas · loki-render-cache + L4 render loki-renderer · loki-vello · appthere-canvas · loki-render-cache · loki-render-cpu ─────────────────────────────────────────────────────────── L3b export+ loki-pdf (exporters that REUSE layout for positioning) L3 layout loki-layout (pagination · Parley text layout) @@ -231,6 +231,7 @@ mechanical. None require a rewrite — consistent with D3: the target is reached | appthere-canvas | L4 | loki-render-cache | | loki-vello | L4 | appthere-canvas, loki-layout | | loki-renderer | L4 | appthere-canvas, loki-doc-model, loki-layout, loki-vello (A-8 `appthere-ui` edge removed) | +| loki-render-cpu | L4 | loki-layout (deterministic CPU rasterizer; conformance candidate render path) | | appthere-ui | L5 | loki-i18n | | loki-app-shell | L5 | loki-i18n, loki-spell | | loki-text | L6 | appthere-ui, loki-app-shell, loki-doc-model, loki-epub, loki-fonts, loki-i18n, loki-layout, loki-odf, loki-ooxml, loki-pdf, loki-renderer, loki-templates, loki-vello | diff --git a/docs/audit-2026-06-10.md b/docs/audit-2026-06-10.md index e20ae26f..58757c9e 100644 --- a/docs/audit-2026-06-10.md +++ b/docs/audit-2026-06-10.md @@ -15,9 +15,14 @@ crash), **High**, **Medium**, **Low**. > aggregate caps, `MAX_MATERIALIZED_*`, and checked arithmetic; **S4** (unbounded > ODT recursion) enforces `MAX_NESTING_DEPTH` (`odt/reader/document.rs`); and > **C1** (lists/tables/figures collapsing to horizontal rules on the first CRDT -> write) is fixed — `loro_bridge/write.rs` writes native `Block::Table` etc. The -> app-layer findings (F1–F7) were **not** re-verified in that pass; treat as -> likely-open. Original findings preserved below unchanged. +> write) is fixed — `loro_bridge/write.rs` writes native `Block::Table` etc. +> +> **Update (2026-07-04):** the app-layer findings F1–F7 **were re-driven** — +> per-sub-item verdicts live in the addendum of +> [`deferred-features-audit-2026-07-04.md`](deferred-features-audit-2026-07-04.md) +> (§9). Headline: F1/F2/F4 resolved, F3 largely resolved (close-without-confirm +> remains), F5 resolved by *removal* (see correction in §5 below), F6/F7 +> partially resolved. Original findings preserved below unchanged. --- @@ -270,10 +275,15 @@ The editor pipeline is `document_to_loro` on open → mutate → cache implementation. `appthere-canvas` re-exports its `PageCache`, `CacheTier`, tier policy, scroll state, key trait, and GPU texture utilities rather than carrying duplicate copies, so the renderer's existing - `appthere_canvas::*` paths resolve to it unchanged. Paired with the F5 fix + `appthere_canvas::*` paths resolve to it unchanged. ~~Paired with the F5 fix below, off-screen pages now downsample by viewport distance, bounding resident texture memory instead of keeping the whole document at full - resolution. + resolution.~~ **Correction (2026-07-04):** there is no distance-based + downsampling/retier cache — the settle/retier pipeline was *deleted*, and + texture memory is bounded by viewport-window **mounting** instead + (`loki-renderer/src/virtualize.rs` `visible_window`; off-window pages are + blank placeholders). `page_paint_source.rs` states "there is no + resolution-tiering cache." - **Empty module**: `loki-text/src/components/mod.rs` (leftover from the `document_source.rs` removal) still exported from `lib.rs:15`. The CLAUDE.md known-exceptions table is stale (references the deleted file). diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index e1831913..6ce4b82c 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -42,9 +42,9 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | Topic | file:line(s) | Defers what | |---|---|---| -| `3b-3` (partial) | `navigation.rs` (many) | Cross-page nav: up/down works; **left/right at page edges + `page_index` recompute after split/merge still `None`** | -| `loro-bridge` | `loro_bridge/inlines.rs:123,220`, `opaque.rs`, `table.rs:25` | Non-Rgb colors (Theme/Cmyk), comment/bookmark anchors, quote/span attrs, structural-table CRDT semantics | -| `loro-compaction` | `loki-bench/benches/leak_loro_history.rs`; bridge | Compact the CRDT oplog at save/undo-horizon (memory Finding 6) | +| `3b-3` (partial) | `navigation.rs` (many) | ~~Left/right at page edges + `page_index` recompute after split/merge~~ **fixed 2026-07-05** (plan 4b.1: cross-page Left/Right + `page_locate::recompute_page_index` after every mutation/move). Remaining: double-Enter list-exit heuristic (`clear_para_props`) | +| `loro-bridge` | `loro_bridge/decode.rs` (borders), `table.rs:25` | ~~Non-Rgb colors, comment/bookmark anchors, quote/span attrs~~ **fixed 2026-07-04** (plan Phase 1.4). Remaining: non-Rgb *border* colors (format migration), `Cite` metadata, structural-table CRDT semantics | +| `loro-compaction` | `loki-bench/benches/leak_loro_history.rs`; bridge | ~~Compact the CRDT oplog~~ **fixed 2026-07-04** (plan Phase 1.5): `loro_bridge::compact` + save-point wiring in loki-text; bench asserts the flattened curve. On-device validation pending (BM-14) | | `omml` | `docx/omml/mod.rs:20` | OMML↔MathML for delimiters, n-ary, matrices, accents | | `link-click` | `resolve.rs:689`, `items.rs:125`, `para.rs:203`, `scene.rs:519` | Interactive hyperlink hit-testing (only a visual hint today) | | `shadow` | `para_emit.rs:187`, `para.rs:196`, `scene.rs:93` | True soft text shadow (Vello blur) — hard grey offset copy today | @@ -61,9 +61,9 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | `pdf-rotate` | `pdf/src/page.rs:83` | Rotation transform in PDF export | | `odf-master-page` | `odf/reader/styles.rs:200` | ODF master-page transitions | | `odt-fidelity` | `editor_load.rs:84,88` | Tracked DOCX/ODT import gaps | -| `formatting` | `editor_formatting.rs:106` | Multi-block-selection formatting (clamped to focus paragraph) | -| `undo-dirty` | `editor_state.rs:118` | Saved-vs-undo-stack clean tracking (Save not implemented) | -| `nested-nav` | `navigation.rs:138,174` | Sibling path inside cell/note body | +| `formatting` | `editor_formatting.rs:106` | ~~Multi-block-selection formatting~~ **fixed 2026-07-05** (plan 4b.2: per-paragraph ranges via `editor_format_range.rs`; cross-container still clamps) | +| `undo-dirty` | `editor_state.rs:118` | ~~Saved-vs-undo-stack clean tracking~~ **fixed 2026-07-05** (plan 4b.3): `editing/saved_state.rs` clean checkpoint — undoing back to the save point clears dirty | +| `nested-nav` | `navigation.rs:138,174` | ~~Sibling path inside cell/note body~~ **fixed 2026-07-05** (plan 4b.4): paginated navigation is path-aware; Left/Right cross cell/note siblings and clamp at the container edge | | `tabs` | `shell.rs:148`, `home.rs:89` | Tab-driven (vs router) navigation; blank-doc | | `ux` | `text/home.rs:266` (+ presentation/spreadsheet) | Confirm-before-delete dialog (delete is immediate) | | `browse-templates` / `title-edit` | `text/home.rs:355`, `title_bar.rs:133` | Template browser dialog; inline-editable title | @@ -104,7 +104,7 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | `blitz-net` 0.2.1 | upstream ships rustls by default / feature flag | Gated — not met | | `dioxus-native` 0.7.9 | upstream calls `request_redraw()` after head-element/event processing | Gated — not met | | `blitz-dom` 0.2.4 | upstream: tabindex focus-on-click, scroll dispatch, absolute node-scroll API, static-canvas anim fix | Gated — not met | -| **`loki-file-access` 0.1.2** | push fixes to the (same-team) `appthere/loki-file-access` repo, publish, repoint | **Actionable locally** — same-team-owned; no evidence pushed. Also needs Gradle `FilePickerActivity.kt`. | +| ~~**`loki-file-access` 0.1.2**~~ | push fixes to the (same-team) `appthere/loki-file-access` repo, publish, repoint | **REMOVED 2026-07-05** (plan Phase 2) — upstreamed as 0.1.3 (`d2b7bc5` on `main`), patch + vendored dir deleted, build scripts repointed via cargo metadata. 5 patches remain, all upstream-gated. | | ~~`fontique`~~ | — | Already removed 2026-06-21. | --- @@ -116,7 +116,7 @@ Verified against code; none silently done-since except the Spec-02 "resolved-as- | Spec | Deferred item | Verified | |---|---|---| | 01 | `clippy::pedantic` lint set + allow-list; AST-level `no_hardcoded_layout_dims` dylint; `cargo udeps` dead-`pub` sweep; `editor_save` typed `SaveError`; Android target build verification; 300-line backlog | STILL-OPEN (deliberate residuals) | -| 02 | `vello_cpu` render path (B-1); vendored ISO 29500/ODF schemas (B-6); zero committed goldens (B-2); uncalibrated SSIM 0.98 (B-3); ΔE/worst-region/heatmap differ (B-4); pinned PDF→PNG rasterizer (B-5); shared `Fixture`/`Consumer` traits (B-8); corpus reorg + ODP/ODG/PPTX importers (B-9); **bundle Gelasio (B-10)**; CI gate wiring (B-11) | STILL-OPEN (several "resolved-as-decision" only) | +| 02 | ~~`vello_cpu` path, schemas, goldens, calibration, differ, rasterizer, Gelasio, CI wiring~~ **BUILT 2026-07-05** (plan Phase 3): B-1 (`loki-render-cpu`), B-2 (3 ODF goldens + generation script), B-3 (`CALIBRATION.md` → `Tolerance::calibrated()`; the pass quantified fidelity gap #23/kerning, pinned as a canary), B-4 (SSIM+ΔE worst-region differ + heatmaps), B-5 (`PdfRasterizer`), B-6 (ISO 29500/OPC/ODF/MathML3 vendored + real-export validation), B-10 (Gelasio + substitution suite), B-11 (axes live in CI). Remaining: B-8 `Fixture`/`Consumer` traits, B-9 corpus reorg, OOXML manual goldens, Strict XSDs. | LARGELY BUILT | | 03 | Metadata-panel label stacking <250px (R-13g); responsive doc type-scale (M4); real `Viewport.zoom`; ribbon tab-strip touch height (handed to Spec 04) | STILL-OPEN | | 04 | **M3 width-driven collapse cascade** (condensed/overflow menu, priority, hysteresis) — unbuilt; **M5 Layout/References/Review tabs + `selected_object` contextual signal** — unbuilt (only 3 non-contextual tabs); M6 touch posture; cursor-into-new-cell after insert | STILL-OPEN (Spec 04 is the least-complete "shipped" spec) | | 05 | **Page** family (`page_styles` catalog, ADR-0012); **Table** family (`TableProps` conditional/banding regions); character-style **editing form**; per-family non-paragraph `Default` sources; Compact tree breadcrumb (M7) | STILL-OPEN (model-gated) | @@ -132,7 +132,7 @@ Still-open after verification (the DONE-SINCE ones moved to §1). |---|---|---| | memory-audit F3 | Drop preserved layout for inactive tabs (`sessions.rs:39` still retains `Arc`) | STILL-OPEN | | memory-audit F5 | Share render `FontDataCache` (per-tile `page_paint_source.rs:53` vs shared `DocPageSource`) | STILL-OPEN | -| memory-audit F6 | Compact Loro oplog (`TODO(loro-compaction)`) | STILL-OPEN | +| memory-audit F6 | Compact Loro oplog (`TODO(loro-compaction)`) | ~~STILL-OPEN~~ **FIXED 2026-07-04** (plan Phase 1.5) | | audit-2026-06 Q-1 | 300-line ceiling backlog — 43→**35**, CI-ratcheted (PARTIAL; `para.rs`/`flow.rs` grew) | PARTIAL | | audit-2026-06 Q-2 | App-shell duplication (per-app `routes/`, `shell.rs`) | PARTIAL | | audit-2026-06 Q-3/Q-4 | 301 `let _ =` writer error-swallows (downgraded); ~100 `#[allow]` incl. 32 `dead_code` OOXML | STILL-OPEN (downgraded/P2) | @@ -140,8 +140,8 @@ Still-open after verification (the DONE-SINCE ones moved to §1). | audit-2026-06 T-2/T-3/T-5 tails | ODT export impl; per-case DOCX/XLSX round-trips; hard PPTX cases | STILL-OPEN | | fidelity gap #12 | External-URL images → grey placeholder (`loki-vello/src/image.rs:34`) | STILL-OPEN | | fidelity gap #19 | RTL/bidi direction not forwarded (no Parley bidi API) | STILL-OPEN | -| fidelity gaps #23,#25,#26,#27,#29,#30 | kerning, orphan/widow, `border_between`, DocxSettings, content controls, language tags | STILL-OPEN | -| fidelity-status registry | even/odd blank pages; unequal column widths; column height balancing; drop-cap editor fallback; PDF font subsetting; PDF ICC/CMYK; PDF clip/rotate paint; EPUB drops math/fields/comments; ODT `style:default-style`; macOS symbol-bullet fallback; reflow selection-delete + touch select; ACID headless raster + ODP/ODG importers + PPTX fixture; Calc/Slides squiggle rendering; personal-dictionary persistence | STILL-OPEN (registry-tracked) | +| fidelity gaps #23,#25,#26,#27,#29,#30 | ~~kerning~~ (**#23 FIXED 2026-07-05** — `StyleSpan.kerning` + reference-matching off default, found by the Spec 02 calibration pass), orphan/widow, `border_between`, DocxSettings, content controls, language tags | STILL-OPEN (except #23) | +| fidelity-status registry | even/odd blank pages; unequal column widths; column height balancing; drop-cap editor fallback; PDF font subsetting; PDF ICC/CMYK; PDF clip/rotate paint; EPUB drops math/fields/comments; ODT `style:default-style`; macOS symbol-bullet fallback; reflow touch select (selection-delete resolved 2026-07-05); ACID headless raster + ODP/ODG importers + PPTX fixture; Calc/Slides squiggle rendering; personal-dictionary persistence | STILL-OPEN (registry-tracked) | > **F1–F7** (audit-2026-06-10 app-layer: presentation tab-switch edit loss, no-op delete/copy, dead retier channels, no Save-As) were **not individually re-driven** this pass — they are app-layer and echoed in the MVP-scope doc §6; treat as likely-open pending a focused check. @@ -152,10 +152,10 @@ Still-open after verification (the DONE-SINCE ones moved to §1). | Item | Status | |---|---| | 300-line-ceiling backlog | **35** baselined files; CI-ratcheted so it can only shrink. `CLAUDE.md`'s worst-offenders sizes are stale (see S-9). | -| `tab_stops` Loro round-trip | STILL-OPEN — written as Debug string (`loro_bridge/write.rs:187`), no reader in `props_read.rs`. | -| paragraph `background_color` Loro round-trip | STILL-OPEN — Debug string (`write.rs:190`); the only reader (`props_read.rs:273`) uses `from_hex` and cannot parse it. | +| `tab_stops` Loro round-trip | ~~STILL-OPEN~~ **FIXED 2026-07-04** (plan Phase 1.1) — structured codec written + read back; `bridge_tab_stops_roundtrip`. | +| paragraph `background_color` Loro round-trip | ~~STILL-OPEN~~ **FIXED 2026-07-04** (plan Phase 1.2) — total `DocumentColor` codec (`loro_bridge/color_codec.rs`); `bridge_para_background_color_roundtrip`. | | `DocumentMeta`/DublinCore export | **DONE** (writes back to DOCX/ODT) — `CLAUDE.md` row is stale (see S-8). | -| CRDT bridge stubs | `BulletList`/`OrderedList`/`Figure`/`BlockQuote`/`Div` are debug-log-only in the bridge (export still works from the Document snapshot). | +| CRDT bridge stubs | ~~debug-log-only~~ **FIXED 2026-07-04** (plan Phase 1.3) — `BulletList`/`OrderedList`/`BlockQuote`/`Div`/`Figure` now have native mappings (`loro_bridge/containers.rs`, `table.rs` pattern: JSON metadata + live nested block lists); tested by `loro_bridge_container_tests.rs`. Legacy pre-opaque stubs still read as `HorizontalRule` (nothing recoverable); `DefinitionList` and inline fields/math stay on the opaque path. | --- @@ -179,3 +179,34 @@ Pure documentation hygiene — no functional change, but stops the docs from mis 4. Reclassify the Spec 02 **"resolved-as-decision"** items (Gelasio, schemas, goldens, SSIM, `vello_cpu`) so they read as *decided, not built*. Everything else in §2–§7 is a genuine, correctly-documented deferral. + +--- + +## 9. Addendum (2026-07-04, later the same day): F1–F7 re-driven + +The §5 closing note flagged the app-layer findings F1–F7 (audit-2026-06-10) as +"not individually re-driven; treat as likely-open pending a focused check." +That focused check has now been done (Phase 0 of +[`deferred-features-plan-2026-07-04.md`](deferred-features-plan-2026-07-04.md)); +per-sub-item verdicts against HEAD `20b05a6`: + +| # | Original claim | Verdict | Evidence / residual | +|---|---|---|---| +| F1 | Presentation editor is a hardcoded demo; no load/save | **RESOLVED** (core) | Real PPTX import (`editor_load.rs:40-54` → `PptxImport`) and export with Save/Save As (`editor_save.rs`, `editor_inner.rs:87-149`); ODP is a typed `UnsupportedFormat` (deferred by MVP scope, not faked). **Residual resolved 2026-07-05** (plan 4b.6): tab switches now stash/restore the live presentation via `sessions.rs` + `editor_path_sync.rs` (and loki-spreadsheet got the same treatment). | +| F2 | Recents Delete/Copy are silent no-ops (3 apps) | **RESOLVED** | `FileAccessToken::delete()` / `copy_bytes_to()` exist (`patches/loki-file-access/src/token.rs:116,132`) and all three `home.rs` handlers use them; failures surfaced via `pick_error` + `errors.ftl` keys. The `TODO(ux)` confirm-dialog landed 2026-07-05 (plan 4c.1, `AtConfirmDialog`). | +| F3 | Edits lost on tab switch; dirty flag never set | **LARGELY RESOLVED** | Per-tab retention via `loki-text/src/sessions.rs` `DocSession` (stash/restore in `editor_path_sync.rs:42-154`); dirty tracks a generation baseline (`editor_inner.rs:465-476`), cleared on save. **Residual (F3c) resolved 2026-07-05** (plan 4b.6): closing a dirty tab raises an `AtConfirmDialog` confirmation in all three apps' shells. | +| F4 | Untitled documents cannot be saved; no Ctrl+S | **RESOLVED** | Save As via `pick_file_to_save` (`editor_inner.rs:484-535`); Ctrl/Cmd+S bound (`editor_keydown.rs:60-67`) routing untitled → Save As. | +| F5 | Settle/retier pipeline wired to dead channels | **RESOLVED (by removal)** | The pipeline was deleted, not fixed: virtualization now bounds memory by mounting only viewport-window pages (`virtualize.rs` `visible_window`, `document_view.rs:290`). The 06-10 audit's §5 claim of "downsample by viewport distance" was wrong about the mechanism — corrected there. **Residual:** `DocumentViewProps::eq` still hardwired `false` (`document_view.rs:143-147`) — now a benign over-render, capped by `PageTile`'s own `PartialEq`. | +| F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom wired 2026-07-05 in loki-text (GPU tile scale) + loki-presentation (slide scale); spreadsheet ribbon tab-select/collapse now wired (2026-07-05) and the dead extra tabs removed; spreadsheet grid-zoom (`TODO(zoom)`) still deferred**. RESOLVED 2026-07-05: F6c-selection — typing replaces the active selection and Backspace/Delete remove it, including multi-block ranges (`loki_doc_model::delete_selection_at` composes `merge_block_at` + `delete_text_at` with pre-validation, so cross-container or table-spanning ranges are rejected untouched; wired via `editor_keydown_text.rs`; tested by `loro_selection_delete_tests.rs` + editor unit tests). RESOLVED 2026-07-05: F6a hooks-in-conditionals/loops — `recent_files.rs` + `template_gallery.rs` rows/cards are now child `#[component]`s so `use_signal` no longer lives in `for`/`if` bodies (plan 4c.5). STILL-OPEN residual:, F6c-clipboard (copy/cut/paste — partially gated on the unimplemented dioxus-native-dom clipboard converter), F6f synchronous save/load on UI thread. | +| F7 | Low grab-bag | **PARTIAL** | RESOLVED: F7d safe-area insets (RwLock + reactive version signal + resize sensor). PARTIAL → word count now live in loki-text (2026-07-05); still empty in Calc/Slides (no meaningful doc-text count yet). RESOLVED 2026-07-05: F7b (stable slide/bullet list keys + explicit `active_idx` clamp on slide delete), F7c word count (live in loki-text's status bar; `editing/word_count.rs`). PARTIAL: F7a — `AtHomeTab` now reads `use_breakpoint()` (2026-07-05); the two smaller apps still don't push a measured width so it falls back to Expanded there. STILL-OPEN: F7e debug leftovers in vendored patches, F7f `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`). | + +**Two stale in-code comments found during verification were fixed in the same +pass:** the `TODO(undo-dirty)` parenthetical ("Save not implemented" — Save now +exists; remaining work is the undo-stack clean checkpoint) and the +`editing/hit_test.rs` doc-comment claiming `scroll_offset = 0.0`. + +The confirmed-open items fold into the plan as follows: F3c + F1-residual +(close/switch protection for dirty work) join Phase 4b; F6a/F6c/F6d/F6f and +F7a/F7b/F7c join the Phase 4b/4c backlog; F7e/F7f are patch-tree fixes queued +with the next patch re-vendor (Watch list); the F5 `PartialEq` residual joins +Phase 6 (perf polish). diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md new file mode 100644 index 00000000..dc8796c7 --- /dev/null +++ b/docs/deferred-features-plan-2026-07-04.md @@ -0,0 +1,234 @@ + + +# Deferred-Features Remediation Plan (2026-07-04) + +| | | +|---|---| +| **Status** | Plan — no code changes in this document's pass. | +| **Source** | [`docs/deferred-features-audit-2026-07-04.md`](deferred-features-audit-2026-07-04.md) — every task below cites its audit section. | +| **Already done** | The audit's §8 documentation-hygiene actions (S-1…S-9 stale-doc fixes) were applied on 2026-07-04 and are **not** in this plan. Verified: no stale `TODO(editing)` / `position: absolute` COMPAT notes remain in code, and the `CLAUDE.md` rows are reconciled. | +| **What this plan covers** | The genuine, correctly-documented deferrals in audit §2–§7, sorted into: work we can and should do (Phases 0–7), upstream-gated items we can only watch (§ Watch list), and deliberate post-MVP scope we will not schedule (§ Out of scope). | + +## How to read this plan + +- **Priority**: P0 = correctness/data-integrity or unblocks other phases; P1 = committed spec work left unbuilt; P2 = quality/perf/fidelity backlog; P3 = polish. +- **Effort**: S ≤ 1 day, M ≤ 1 week, L > 1 week (single engineer, familiar with the crate). +- Phases are ordered by priority but are largely **independent workstreams** — they can proceed in parallel except where a dependency is called out. +- Every task keeps the repo conventions: root-cause fixes only, 300-line ceiling (ratcheted), `cargo fmt` + `clippy -D warnings`, and a `docs/fidelity-status.md` update for any layout/rendering/import-export change. + +--- + +## Phase 0 — Verification pass: re-drive the unverified findings (P0, S) + +The audit explicitly did **not** re-drive the app-layer findings F1–F7 from +`audit-2026-06-10` (presentation tab-switch edit loss, no-op delete/copy, dead +retier channels, no Save-As) — they are "likely-open pending a focused check" +(audit §5, closing note). Planning fixes against unverified findings risks the +exact stale-doc failure mode the audit was written to catch. + +| Task | Detail | Exit criterion | +|---|---|---| +| 0.1 | ✅ **Done 2026-07-04** — F1–F7 re-driven; verdicts in the audit's new §9 addendum. Headline: F1/F2/F4 resolved, F3 largely resolved, F5 resolved-by-removal, F6/F7 partial. | Each of F1–F7 has a verified status with `file:line` evidence. | +| 0.2 | ✅ **Done 2026-07-04** — confirmed-open subset folded into Phases 4/6 and the Watch list (see 4b.6/4b.7, 4c.5, 6.7 below). | Phase 4 backlog updated. | + +--- + +## Phase 1 — CRDT round-trip integrity (P0, M) + +Data that survives import but is silently degraded by the Loro bridge is the +closest thing to data loss in the suite. These are all code-confirmed in audit +§6 and §2 (`loro-bridge` topic). + +| Task | Source | Detail | Effort | +|---|---|---|---| +| 1.1 | §6 | ✅ **Done 2026-07-04** — structured tab-stop codec (`loro_bridge/decode.rs`) + reader; `bridge_tab_stops_roundtrip`. | S–M | +| 1.2 | §6 | ✅ **Done 2026-07-04** — total `DocumentColor` codec (`loro_bridge/color_codec.rs`, Rgb/Cmyk/Theme/Transparent) + reader; `bridge_para_background_color_roundtrip`. | S | +| 1.3 | §6 | ✅ **Done 2026-07-04** — native mappings for `BulletList`/`OrderedList`/`BlockQuote`/`Div`/`Figure` (`loro_bridge/containers.rs`, table.rs pattern); `loro_bridge_container_tests.rs`. `DefinitionList` + inline fields/math stay opaque. | M | +| 1.4 | §2 | ✅ **Done 2026-07-04** — char colors use the total `DocumentColor` codec (Theme/Cmyk/Transparent survive, mark + block-map paths); comment/bookmark anchors preserved as `OBJECT_REPLACEMENT_CHAR` snapshot marks; `Quoted` quote-type and `Span` attrs carried as range marks with recursive child writes; `loro_bridge_inline_tail_tests.rs`. Remaining TODO(loro-bridge): non-Rgb *border* colors (colon-format migration), `Cite` metadata, structural-table CRDT semantics. | M | +| 1.5 | §2, §5 | ✅ **Done 2026-07-04** — `loro_bridge::compact` (`compact_in_place` / `compact_history`) wired at the save point in loki-text (`editor_compact.rs`; ribbon Save unified into the Ctrl+S handler). Bench acceptance passed: 5 000 keystrokes = 19 208 ops uncompacted → 1 op compacted, asserted in `leak_loro_history`. On-device long-session validation still pending (BM-14). | M | + +**Exit criteria**: `metadata_round_trip.rs`-style tests pass for 1.1–1.3; the +`leak_loro_history` bench shows bounded history; the two §6 tech-debt rows and +the bridge-stubs row in `CLAUDE.md` are updated to DONE. + +--- + +## Phase 2 — The one actionable dependency patch: `loki-file-access` (P0, S) + +Audit §3: of the 6 `[patch.crates-io]` entries, five are gated on upstream +Dioxus/Blitz releases (watch list), but **`loki-file-access` 0.1.2 is +same-team-owned** (`appthere/loki-file-access`) and its removal condition is +entirely in our hands. + +| Task | Detail | +|---|---| +| 2.1 | ✅ **Done 2026-07-05** — full patch content (Android NativeActivity fixes, Java shims + dexing `build.rs`, insets/IME bridges, `token.delete()`/`copy_bytes_to()`) upstreamed to `appthere/loki-file-access` as **0.1.3**, commit `d2b7bc5` fast-forwarded to `main` (fix branch `claude/upstream-android-nativeactivity-fixes` also pushed). 43 tests + clippy clean upstream. | +| 2.2 | ✅ **Done 2026-07-05** — `[patch]` entry and `patches/loki-file-access/` removed; the `branch = "main"` git dependency resolves to 0.1.3 directly. The three Android build scripts now resolve the Java-shim directory from `cargo metadata` instead of the deleted patch path. Registry publication remains optional (the workspace dep is git, not registry) — noted in `docs/patches.md` "Removed patches". | + +**Exit criterion**: ✅ met — `cargo check --workspace` with no `loki-file-access` +patch and zero `Patch ... was not used` warnings. + +--- + +## Phase 3 — Conformance foundation: build the Spec 02 "resolved-as-decision" items (P1, L) + +Audit §1 note + §4: several Spec 02 items carry a "✅ Resolved" *decision* but +were never built — verified again 2026-07-04 (no Gelasio face in +`loki-fonts/fonts/`, no vendored schemas, zero goldens). This phase turns +decisions into artifacts, in dependency order. It also **unblocks Spec 06 +BM-3** (render-cost proxy) and the ACID headless-raster registry item. + +| Task | Spec item | Detail | Effort | +|---|---|---|---| +| 3.1 | B-10 | ✅ **Done 2026-07-05** — Gelasio ×4 faces bundled (OFL, full coverage reconstructed from fontsource subsets), Georgia→Gelasio mapping, dedicated substitution suite (`loki-layout/tests/font_substitution_suite.rs`). | S | +| 3.2 | B-6 | ✅ **Done 2026-07-05** — ISO 29500-4:2016 Transitional + mce, ECMA-376 Part-2 OPC, OASIS ODF 1.3 RNG + MathML3 vendored with PROVENANCE (sha256); real DOCX/ODT exports schema-validated incl. malformed-part negative tests. Tails: Strict XSDs, Dublin Core imports for core.xml (documented in `schemas/README.md`). | M | +| 3.3 | B-1 | ✅ **Done 2026-07-05** — new `loki-render-cpu` crate: `vello_cpu` (=0.0.9) rasterizes the same `PositionedItem` stream the GPU path paints, headless, byte-deterministic (M5 acceptance smoke tests). TODO(conformance-render): decode image items (grey placeholder today). | M | +| 3.4 | B-5 | ✅ **Done 2026-07-05** — `appthere_conformance::raster::PdfRasterizer` (pdftoppm pinned flags @ `CONFORMANCE_DPI` 144, version captured, byte-determinism tested). | S | +| 3.5 | B-2, B-3, B-4 | ✅ **Done 2026-07-05** — SSIM+CIEDE2000 worst-region differ with heatmaps (`golden/diff.rs`, Sharma reference pairs verified); 3 ODF goldens committed with GENERATION metadata (`scripts/generate-odf-goldens.sh`); calibration record `goldens/CALIBRATION.md` → `Tolerance::calibrated()` {0.60, 10.0}. **The calibration pass found and quantified fidelity gap #23 (kerning)** — pinned as the `para-carlito` expected-failure canary. OOXML goldens remain the manual Windows/Word COM procedure (§7.2). | M | +| 3.6 | B-11 | ✅ **Done 2026-07-05** — all three axes run as cargo tests in the existing `build-and-test` job (xmllint + pdftoppm installed); schema + round-trip are hard gates, visual is advisory-by-construction (known divergence pinned; hardens when kerning lands + recalibration). | S | +| 3.7 | B-8, B-9 | **Deferred** — shared `Fixture`/`Consumer` trait extraction from `loki-acid` and the 141-case corpus reorg remain open (the new fixtures/goldens live under `appthere-conformance/{fixtures,goldens}` as the seed of that layout). ODP/ODG/PPTX importers stay gated on the unbuilt ACID PPTX generator (§5.1). | M | + +**Exit criterion**: ✅ met for the built scope — CI runs schema, round-trip, +and visual-golden passes on every PR; Spec 02 rows B-1…B-6, B-10, B-11 are +*built*, B-7 was already largely done, B-8/B-9 remain the tracked tail. + +--- + +## Phase 4 — Editor completion: Spec 04/05 gaps + verified UX TODOs (P1, L) + +The audit calls Spec 04 "the least-complete 'shipped' spec" (§4). This phase +groups the user-visible editor work: spec milestones first, then the §2 UX +TODOs (merged with whatever Phase 0 confirms from F1–F7). + +### 4a. Spec milestones (model/architecture-gated first) + +| Task | Source | Detail | Effort | +|---|---|---|---| +| 4a.1 | Spec 04 M3 | Width-driven ribbon collapse cascade: condensed variant, overflow menu, per-group priority, hysteresis. | L | +| 4a.2 | Spec 04 M5 | Layout/References/Review ribbon tabs + `selected_object` contextual-tab signal (only 3 non-contextual tabs exist). | L | +| 4a.3 | Spec 05 | **Page** style family (`page_styles` catalog per ADR-0012) and **Table** family (`TableProps` conditional/banding regions); character-style editing form; per-family non-paragraph `Default` sources; Compact-tree breadcrumb (M7). | L | +| 4a.4 | Spec 03 | Metadata-panel label stacking <250 px (R-13g); responsive doc type-scale (M4); real `Viewport.zoom`. | M | +| 4a.5 | Spec 04 M6 | Touch posture + cursor-into-new-cell after insert. | M | + +### 4b. Editing-core TODOs (§2) + +| Task | Topic | Detail | Effort | +|---|---|---|---| +| 4b.1 | `3b-3` | ✅ **Done 2026-07-05** — (1) Left/Right cross page boundaries (the prev/next-entry searches walk the whole layout; entering a table from above/below lands in its first/last cell). (2) New `editing/page_locate.rs`: `recompute_page_index` re-derives a position's page from the layout, picking the page whose content band shows the byte's line for page-spanning paragraphs; wired into every mutation caret placement (`set_collapsed_cursor` — split, merge, typing, selection removal) and after every navigation move, replacing the stale-page TODOs. 4 page-locate tests (incl. a split-fragment band test) + 4 cross-page nav tests; `navigation.rs` split (`navigation_find.rs`) dropped it from the ceiling baseline (34 → 33). **Remaining `3b-3` tail:** the double-Enter list-exit heuristic (`clear_para_props`). | M | +| 4b.2 | `formatting` | ✅ **Done 2026-07-05** — the six toggles (bold/italic/underline/strikethrough/super/subscript) apply across every paragraph of a same-container multi-block selection: new `editor_format_range.rs` `resolve_format_ranges` yields per-paragraph `(BlockPath, start, end)` ranges (first-paragraph tail, full middles, last-paragraph head; text-less blocks like tables inside the range are skipped, their cells untouched); the state at the selection start decides apply-vs-clear and the whole selection is made uniform (Word behaviour). Cross-container selections keep the clamp-to-focus rule. 8 tests incl. an end-to-end double-toggle. | M | +| 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Ribbon Save disables when clean ✅ 2026-07-05:** the dirty state is now a reactive `is_dirty` signal (set by the dirty-tracking effect) threaded into `write_tab_content`; the Save button's `is_disabled = !is_dirty()` (untitled reads as dirty, so Save→Save-As stays enabled). **Remaining tail:** Spec 01's typed `SaveError` residual. | M | +| 4b.4 | `nested-nav` | ✅ **Done 2026-07-05** — paginated navigation is path-aware end-to-end: `find_para_data` matches `(block_index, path)` (index alone returned the first cell's entry for every table paragraph), the `get_text` closures take a `BlockPath` (grapheme moves inside cells previously read the root block's empty text), and Left/Right at a nested paragraph's edges cross to the sibling within the same cell / note body, clamping at the container's first/last block. Inline tests extracted to `navigation_tests.rs` (`#[path]` idiom, file ratcheted 593 → 367) + 6 nested-nav regression tests. Reflow navigation stays top-level-only (tables have no reflow editing data). | S | +| 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | +| 4b.6 | F3c + F1 residual (audit §9) | **Dirty-close protection ✅ Done 2026-07-05:** closing a dirty tab now raises a confirmation dialog in **all three apps** (new `appthere_ui::AtConfirmDialog` overlay primitive — absolute backdrop + centred card, backdrop-cancel, 44 px touch targets; shells gain `pending_close` state + an extracted `close_tab` helper). **Tab-switch retention ✅ Done 2026-07-05 (F1 residual):** loki-presentation *and* loki-spreadsheet now stash the live editing state (presentation: doc + slide index + dirty; spreadsheet: CRDT + undo manager + grid snapshot + selection) into an app-level session map on tab switch / Editor→Home unmount and restore it on return — mirroring loki-text's `sessions.rs`; closing a tab drops the session. The presentation load path also gained the `(path, result)` stale-value guard the other apps already had. | S–M | +| 4b.7 | F6c + F6f (audit §9) | **Selection editing ✅ Done 2026-07-05:** typing replaces the active selection, Backspace/Delete remove it, incl. multi-block ranges — `loki_doc_model::delete_selection_at` (merge-then-delete composition, whole range pre-validated so cross-container / table-spanning selections are rejected untouched), editor wiring in `editor_keydown_text.rs` (replace-typing is one undo entry); tests: `loro_selection_delete_tests.rs` (10) + editor unit tests (7). **Remaining:** clipboard copy/cut/paste (partially gated on the unimplemented dioxus-native-dom clipboard converter), and moving save/load I/O off the UI thread (`editor_ribbon.rs:93`, `editor_load.rs:56-101`). | M | + +### 4c. Shell/UX polish TODOs (§2) — batchable + +| Task | Topics | Detail | Effort | +|---|---|---|---| +| 4c.1 | `ux` | ✅ **Done 2026-07-05** — the recents Delete menu action now only *requests* deletion; `AtConfirmDialog` performs it on confirm (all three apps' Home; `home.rs` split `home_util.rs` out to stay under the ceiling). | S | +| 4c.2 | `a11y` | ✅ **Done 2026-07-05 (bounded by bar height)** — the status bar's three interactive controls (notice chip, view-mode toggle, zoom badge) are now transparent hit areas ≥ `TOUCH_MIN` (44 px) wide × the bar's full height, with the visual chip nested inside. **Honest constraint:** the 24 px `STATUS_BAR_HEIGHT` caps the vertical target at WCAG 2.5.8 AA's 24 px minimum, below the suite's 44 px convention — meeting it fully requires a taller bar on touch platforms (design decision, deferred; documented on `AtStatusBar`). | S | +| 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | +| 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | +| 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | ✅ **Done 2026-07-05** (spreadsheet grid-zoom is the sole tail). ✅ F6a — recent-file rows and template cards are now child `#[component]`s (`recent_row.rs`, `RecentRow`/`OpenFileButton`/`TemplateCard`/`BrowseCard`) so their hover `use_signal`s no longer live inside `for`/`if` bodies (hook count is now prop-independent; ADR-0013). ✅ F6d ribbon — loki-spreadsheet's ribbon lists only the implemented Home tab (the dead Insert/Format/Review/View entries removed) and its tab-select + collapse are live signals. ✅ F7a — `AtHomeTab` reads `use_breakpoint()` (Compact = stacked, Medium/Expanded = side-by-side) instead of the fixed `viewport_width = 375.0`. ✅ F7b — slide thumbnails/bullets use stable keys (`SlideId`, shape+para) and slide deletion clamps `active_idx` explicitly. ✅ F7c — live word count in loki-text's status bar (`editing/word_count.rs`: streaming counter, Word-matching semantics — tables counted, notes excluded; 8 tests; plural `editor-word-count` key). ✅ F6d-zoom (2 of 3 apps) — the status-bar zoom badge cycles 50–200% (`appthere_ui::next_zoom`): loki-text scales the GPU page tiles + paint transform together (`DocPageSource::zoom` → `DocumentViewProps::zoom` → `PageTile` hit-test divide-out; reflow unaffected by design), loki-presentation scales the slide box + text; **spreadsheet grid-zoom deferred** (`TODO(zoom)` — needs zoom-aware `col_px` + resize px↔pt in one pass). Ceiling wins: `document_view.rs` split (`view_types.rs`) resolved its baseline entry, plus `editor_save.rs` extracted from the spreadsheet (baseline 34 → 32). **Remaining tail:** F7a measurement plumbing for Calc/Slides (they never push a measured width, so `use_breakpoint` falls back to Expanded there) + spreadsheet grid-zoom. | M | + +--- + +## Phase 5 — Rendering & format fidelity backlog (P2, ongoing) + +Locally-actionable fidelity items from §2 and §5 (upstream-gated ones are in +the Watch list). Every task here must update `docs/fidelity-status.md`. + +| Task | Source | Detail | Effort | +|---|---|---|---| +| 5.1 | `tab-default` | Honour `DocumentSettings.default_tab_stop_pt` instead of the hardcoded 36 pt (`para.rs:648`). Pairs naturally with 1.1 (`tab_stops` round-trip). | S | +| 5.2 | `underline-style` / `strikethrough-style` | Double/Dotted/Dash/Wave underline, double strikethrough (all render Single today). Decoration geometry is ours (drawn in `loki-vello`), not Parley-gated. | M | +| 5.3 | `spell-baseline` | Tighten squiggle to the run underline offset (`para.rs:1619`). | S | +| 5.4 | `list-picture-bullet` | Picture bullets (fallback is `•`) — image plumbing already exists for block images. | M | +| 5.5 | `pdf-rotate` | Rotation transform in PDF export (`pdf/src/page.rs:83`); unlocks the "PDF clip/rotate paint" registry row. | M | +| 5.6 | gap #12 / `floating-image` | External-URL images render a grey placeholder (`loki-vello/src/image.rs:34`) + detect "floating" class for inline images (`resolve.rs:705`). | M | +| 5.7 | `odf-master-page` | ODF master-page transitions (`odf/reader/styles.rs:200`); pairs with the `style:default-style` registry row. | M | +| 5.8 | `omml` | OMML↔MathML: delimiters, n-ary, matrices, accents (`docx/omml/mod.rs:20`). | L | +| 5.9 | gaps #23–#30 tail | ~~Kerning~~ (✅ **#23 done 2026-07-05**: root-caused by the Phase 3 calibration pass — loki kerned unconditionally while Word/LO default off; `CharProps.kerning` now drives a shaper feature toggle with reference-matching default, regression-locked, all three visual goldens green), orphan/widow control, `border_between`, DocxSettings, content controls, language tags — schedule individually from the fidelity registry; orphan/widow is the highest-value (visible in any multi-page doc). | L (aggregate) | +| 5.10 | registry | Page/column geometry set: even/odd blank pages, unequal column widths, column height balancing; PDF font subsetting + ICC/CMYK; EPUB math/fields/comments. | L (aggregate) | +| 5.11 | `link-click` | Interactive hyperlink hit-testing (visual hint only today) — spans layout (`resolve.rs:689`, `items.rs:125`, `para.rs:203`) and renderer (`scene.rs:519`). | M | + +--- + +## Phase 6 — Performance & memory (P2, M–L) + +From audit §5 (memory-audit + perf tails) and §2. Re-measure with the +`loki-bench` harness before and after each item — do not fix unverified perf +findings (P-3/P-5/P-6 were "not re-driven"). + +| Task | Source | Detail | +|---|---|---| +| 6.1 | memory F3 | Drop preserved layout for inactive tabs (`sessions.rs:39` retains `Arc`); coordinate with Spec 06 BM-8 (inactive-tab layout retention policy) so one design serves both. | +| 6.2 | memory F5 | Share the render `FontDataCache` across tiles (per-tile `page_paint_source.rs:53` vs shared `DocPageSource`); same item as Spec 06 BM-9 (per-tile font-byte dedup). | +| 6.3 | `split-optimise` | Y-range item filter to avoid GPU clipping (Option B; Option A shipped) — `para.rs:409`. | +| 6.4 | `partial-render` | Viewport clipping / direct `node.scroll_offset` (`scene.rs:148`, `editor_pointer.rs:139`) — partially gated on the vendored blitz-dom scroll API; do the locally-possible part. | +| 6.5 | audit P-3/P-5/P-6 | Re-measure first (bench), then fix if confirmed: glyph-run scans, coarse cache invalidation, cold-path clones. | +| 6.6 | Spec 06 tails | BM-3 render-cost proxy executes once Phase 3.3 lands `vello_cpu`; GPU frame-time (`device` feature) and on-device RSS recalibration remain device-gated — keep deferred, tracked in Spec 06. | +| 6.7 | F5 residual (audit §9) | Replace the hardwired-`false` `DocumentViewProps::eq` (`document_view.rs:143-147`) with a real comparison — benign today (PageTile eq caps the cost) but pure over-render. | + +--- + +## Phase 7 — Code-quality debt (P2/P3, ongoing ratchet) + +| Task | Source | Detail | +|---|---|---| +| 7.1 | Q-1 / §6 | **300-line split pass**: burn down the 35-file baseline, starting with the files other phases must touch (`para.rs` 1979, `flow.rs` 1953 — both also carry Phase 4/5 TODOs) so the split happens *before* feature work grows them further. Use the two established techniques (inline-test extraction, directory split); update the baseline with `--update` per split. | +| 7.2 | Q-2 | App-shell duplication: extract the common per-app `routes/` + `shell.rs` scaffolding into `loki-app-shell` (it already hosts `android_main!` and `SpellService`). | +| 7.3 | Q-3/Q-4 | Writer error-swallows (301 `let _ =`) and the ~100 `#[allow]` (incl. 32 `dead_code` in OOXML): ratchet, don't big-bang — add a CI count-ratchet script (same pattern as the file ceiling) and reduce opportunistically when touching a file. | +| 7.4 | audit S-1b/S-2/S-3/S-5 | Security tails: nested-table drop, dimension clamp, UTF-16 odd byte, XXE comment. Small, parser-local; batch into one hardening pass with fuzz-style regression tests. | +| 7.5 | T-2/T-3/T-5 | Test tails: ODT export coverage, per-case DOCX/XLSX round-trips, hard PPTX cases — grow alongside Phase 3's conformance corpus rather than as a separate effort. | +| 7.6 | Spec 01 residuals | `clippy::pedantic` + allow-list, `no_hardcoded_layout_dims` dylint, `cargo udeps` dead-`pub` sweep, Android target build verification in CI. Deliberate residuals; schedule after 7.1 has reduced churn. | + +--- + +## Watch list — upstream-gated, not schedulable (re-check on every dependency bump) + +No local work can close these; the action is to **re-verify the removal +condition whenever the pinned dependency moves** (per the "Upgrading Dioxus" +procedure in `docs/patches.md`). + +- **The five Dioxus/Blitz patches** (`dioxus-native`, `dioxus-native-dom`, `blitz-shell`, `blitz-net`, `blitz-dom`) — each has its removal condition in `docs/patches.md`; none met as of 0.7.9 / 0.2.x. +- **Parley**: native `BaselineShift` (super/sub is already visually correct via the manual offset — S-3), bidi/RTL direction API (gap #19), inline image boxes (`inline-image-flow`). +- **Vello**: blur primitive for true soft text shadow (`shadow` — hard offset copy today). +- **Blitz CSS**: `white-space: nowrap`, `text-overflow: ellipsis`, `:hover`, `scrollbar-width`, SVG rendering, `position: fixed` (collapses to `absolute`) — runtime-verify each on every Blitz bump and update the `CLAUDE.md` confirmed/unconfirmed lists. +- **Platform quirks (permanent)**: Mali-G715 Vulkan device-loss workarounds, Android 16 double `ANativeActivity_onCreate`, Word/OOXML file quirks — documented COMPAT, never removable. +- **Patch-tree fixes queued for the next re-vendor** (audit §9 F7e/F7f): strip the `[LOKI/head]` / `println!` / `dbg!` debug leftovers from the vendored patches, and fix the `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`, should be `&= !Main`) — batch these with the next Dioxus/Blitz patch re-vendor rather than churning the vendored tree now. +- **Headless/server deferrals with in-code TODOs**: `TODO(headless-c025)` (apalis job queue), `TODO(headless-c021/c022/c023-discovery/c027/c028)`, `TODO(kms)`, `TODO(ws-membership)` — deliberate spec deferrals (ADR C021–C028); schedule when the server milestone that needs them is planned, not before. + +## Out of scope — deliberate post-MVP (do not schedule) + +Audit §7: Loki Calc / Loki Slides post-MVP items (virtualization >500×52, +richer formulas, charts, PPTX image/group export, masters/layouts, ODP import, +shape editing, etc.) remain governed by +`docs/mvp-scope-spreadsheet-presentation-2026-06-13.md`. Two exceptions worth +pulling forward when either app is next touched, because they violate suite +conventions rather than MVP scope: + +1. **i18n bypass** — both apps hardcode user-visible strings, against the "never hardcode" rule; migrate to `fl!()` opportunistically. +2. **Zero tests** — add smoke tests for whatever Phase 0 confirms from F1/F2 (edit loss, no-op delete/copy) before fixing them. + +--- + +## Suggested sequencing + +``` +Now (parallel): Phase 0 (verify F1–F7) Phase 2 (loki-file-access) Task 7.1 (split para.rs/flow.rs) +Next: Phase 1 (CRDT integrity) Phase 3 (conformance foundation) +Then: Phase 4 (editor completion, informed by Phase 0) +Ongoing ratchet: Phase 5 (fidelity), Phase 6 (perf, bench-gated), Phase 7 (quality) +Every dep bump: Watch list re-verification +``` + +Rationale for the front of the queue: Phase 0 is cheap and de-risks Phase 4; +Phase 2 is the only patch we fully control and shrinks the patch surface; +splitting `para.rs`/`flow.rs` first prevents every later phase that touches +them (5.1–5.4, 4b.5, 6.3) from fighting the ceiling ratchet; Phase 1 protects +user data; Phase 3 builds the verification infrastructure that keeps Phases 4–5 +honest. diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index fc472022..d4b743f4 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -24,7 +24,7 @@ This is the living source of truth documenting which document features, characte | **Math (equations)** | Yes | Partial | Yes | Mathematical equations round-trip through both formats. The format-neutral model stores math as a single MathML `` string in `Inline::Math` (the W3C interchange standard and ODF's native form). **DOCX** converts bidirectionally between OMML (`m:oMath` inline / `m:oMathPara` display) and MathML in `loki-ooxml`'s `docx/omml` module; the converter is mutually inverse over the common construct set — text runs (`m:r`/`m:t` ⇄ ``/``/``), fractions (`m:f`), super/subscripts (`m:sSup`/`m:sSub`/`m:sSubSup`), and radicals (`m:rad` ⇄ ``/``). **ODT** embeds the MathML as a formula sub-document (`draw:frame`/`draw:object` → `Object N/content.xml`, listed in the manifest with the `…opendocument.formula` media type) and reads it back, canonicalising on import (`loki-odf`'s `odt::math`); ODF does not distinguish display from inline math, so embedded formulas map to `MathType::InlineMath`. **Persisted through the Loro CRDT** losslessly: a block containing math is preserved as an opaque snapshot. **Rendered** by a first-pass math typesetter (`loki-layout`'s `math` module): the MathML is laid out into positioned glyph runs (tokens shaped via Parley) plus fraction-bar/radical rules, then placed inline via a Parley inline box (the same mechanism as tab stops). Covers identifiers/numbers/operators, rows, fractions (`mfrac`), scripts (`msup`/`msub`/`msubsup`), radicals (`msqrt`/`mroot`), and fenced expressions (`mfenced` / fence-wrapped rows) — reusing the standard `PositionedItem` glyph/rect types so `loki-vello` paints it with no renderer change. **The equation's baseline is aligned to the text baseline** (the inline box reserves the equation's ascent; Parley aligns box bottoms to the baseline, so the descent hangs below into the line like inline text, and the paragraph height grows to cover a deep denominator). **Radical signs and delimiters stretch to their content** via uniform glyph scaling. **Math is set in a serif/math face** (`FontFamily::Source("Cambria Math, STIX Two Math, Latin Modern Math, serif")` in the token shaper) rather than the sans-serif body default, matching Word's Cambria Math. **A paragraph that is solely an equation is centered** (display math): Word renders both `m:oMathPara` and a bare paragraph-level `m:oMath` as a centered display equation, so the DOCX mapper centers a paragraph whose only content (ignoring whitespace) is math when it has no explicit alignment. Tested by `omml_tests` + `math_round_trip.rs` (DOCX), `math_tests` + `math_round_trip.rs` (ODT), the `math::tests` typesetter unit tests (incl. stretch), and `inline_math_emits_typeset_items` / `inline_math_baseline_aligns_with_text` (layout integration). **Approximations / not yet done:** stretchy glyphs widen as they grow (uniform scaling, not true extensible glyphs); inter-atom spacing follows simple proportional gaps rather than the full TeX `mathspacing` table; matrices/n-ary operators/accents are not laid out. The OMML↔MathML converter likewise does not yet cover delimiters, n-ary operators, matrices, or accents (these pass through best-effort), so delimiter rendering currently applies to MathML that already contains fences (e.g. ODT-imported or hand-authored), not DOCX OMML round-trips. | | **Templates (DOTX / OTT)** | Yes | — | Partial | Office `.dotx`/`.dotm` and LibreOffice `.ott` open as new untitled documents (the importers key off the `officeDocument` relationship / accepted template mimetype). Export: **Save as Template** writes `.dotx` (template content type) via `DocxTemplateExport`. Five templates (Markdown, APA, MLA, Screenplay, Resume) ship as bundled `.dotx` assets (`loki-templates`) and open from the home gallery. | | **ODT export** | Yes | — | Yes | `loki-odf`'s `OdtExport` writes a full ODT package (`content.xml` / `styles.xml` / `meta.xml` + `Pictures/`). **Lossless** for: the complete character-property set (fonts incl. complex/East-Asian, size, weight, italic, underline, strike, caps, outline, shadow, super/sub, colour, letter/word spacing, kerning, scale, languages), the complete paragraph-property set (alignment, indents, spacing, line height, keep/widow/orphan/break flags, borders, padding, tab stops, bidi, background), the named style catalog, **multi-section page geometry** (each section gets its own `style:page-layout` + `style:master-page`; section breaks are emitted as `style:master-page-name` on the first paragraph of each section, the form the importer reads back), **headers/footers** (default/first/even, written per-section into the master page with their own automatic styles + images), headings, styled paragraphs, lists, tables, footnotes/endnotes, links, **bookmarks, fields, and embedded images** (decoded from data URIs and written as `Pictures/` parts), and core Dublin Core metadata. A property-level round-trip test asserts each survives. Editing an opened `.odt` and saving round-trips to ODT. **Multi-column sections** (`style:columns` with count/gap/separator), **extended Dublin Core** (publisher, contributors, rights, license, identifier, type, format, source, relation, coverage, issued, citation — carried as `meta:user-defined` entries under reserved `dcmi:` names), and **comments** (`office:annotation` / `office:annotation-end`) also round-trip. **Math** is emitted as embedded formula objects (`draw:object` → `Object N/content.xml` MathML, listed in the manifest). Still not emitted: the OTT template content type. | -| **Reflow (non-paginated) view** | — | Yes | — | `LayoutMode::Reflow` + `RenderMode::Reflow` render a continuous web-style flow through the same layout/Vello pipeline as paginated view (full font/size/alignment fidelity), sliced into zero-gap GPU band tiles (768pt ⇒ exact 1024 CSS px, so tiles stack seamlessly). Relayouts to the window width on resize (shell re-emits `onscroll` for scroll containers). **Bounded reading measure (Spec 03 M4):** the reflow tile is capped at `MAX_REFLOW_TILE_PX` (820 CSS px) and centred (`margin: auto`) on wide windows, so the line length stays comfortable instead of running edge-to-edge; narrow screens still use their full width. A single `render_layout::{reflow_tile_width_px, reflow_content_width_pt}` feeds paint, hit-test, and keyboard nav so they stay aligned (the HTML fallback references the same constant). Content wider than the viewport (e.g. a fixed-width table) widens the tiles so it is reachable by horizontal scrolling rather than clipped. No headers/footers/page chrome by design; the status-bar page indicator is hidden in reflow. **Editing:** `ContinuousLayout` carries per-paragraph editing data, so click-to-cursor, caret placement/painting, range-selection highlighting (mouse drag-select + Shift+Arrow), and reflow-native arrow / Home / End navigation all work, plus typing/undo/formatting. Still missing: typing/Backspace over a selection does not yet delete the selected range first (it inserts at the focus), and touch long-press selection is not wired for reflow. Android CPU builds (no `android_gpu`) fall back to a low-fidelity HTML flow (`reflow_view.rs`) with no caret. | +| **Reflow (non-paginated) view** | — | Yes | — | `LayoutMode::Reflow` + `RenderMode::Reflow` render a continuous web-style flow through the same layout/Vello pipeline as paginated view (full font/size/alignment fidelity), sliced into zero-gap GPU band tiles (768pt ⇒ exact 1024 CSS px, so tiles stack seamlessly). Relayouts to the window width on resize (shell re-emits `onscroll` for scroll containers). **Bounded reading measure (Spec 03 M4):** the reflow tile is capped at `MAX_REFLOW_TILE_PX` (820 CSS px) and centred (`margin: auto`) on wide windows, so the line length stays comfortable instead of running edge-to-edge; narrow screens still use their full width. A single `render_layout::{reflow_tile_width_px, reflow_content_width_pt}` feeds paint, hit-test, and keyboard nav so they stay aligned (the HTML fallback references the same constant). Content wider than the viewport (e.g. a fixed-width table) widens the tiles so it is reachable by horizontal scrolling rather than clipped. No headers/footers/page chrome by design; the status-bar page indicator is hidden in reflow. **Editing:** `ContinuousLayout` carries per-paragraph editing data, so click-to-cursor, caret placement/painting, range-selection highlighting (mouse drag-select + Shift+Arrow), and reflow-native arrow / Home / End navigation all work, plus typing/undo/formatting. Selection editing is view-independent and works here too: typing replaces the active selection and Backspace/Delete remove it, incl. multi-block ranges (`delete_selection_at` + `editor_keydown_text.rs`, 2026-07-05). Still missing: touch long-press selection is not wired for reflow. Android CPU builds (no `android_gpu`) fall back to a low-fidelity HTML flow (`reflow_view.rs`) with no caret. | --- @@ -45,7 +45,7 @@ This is the living source of truth documenting which document features, characte | **Small Caps / All Caps** | Yes | Yes | Yes | Both are **synthesized** during `flatten_paragraph` (resolve.rs), since Parley exposes no `FontVariantCaps`. **All caps:** the run text is uppercased. **Small caps:** the text is uppercased *and* the letters that were lowercase in the source are split into their own spans at a reduced size (`SMALL_CAPS_RATIO` = 0.8 of the cap size), so capitals stay full height while former-lowercase letters render as small capitals — the real small-caps look. (Previously `small_caps` only set an unused `StyleSpan.font_variant` flag, so small-caps text rendered at full size, indistinguishable from normal.) Tested by `flatten_all_caps_uppercases_text` and `flatten_small_caps_uppercases_and_shrinks_lowercase`. **DOCX export** now writes `w:caps` for all-caps direct run properties (`emit_char_props`; small-caps `w:smallCaps` was already emitted); previously all-caps was dropped on DOCX export (ODT export already round-tripped it). Surfaced by the Spec 02 conformance harness. | | **Shadow Text** | Yes | Yes | Yes | Mapped to StyleSpan properties. | | **Horizontal Scale (`w:w`)** | Yes | Yes | Yes | `CharProps.scale` reaches `StyleSpan.scale` and is applied geometrically to glyph advances/positions and highlight/decoration widths in `emit_glyph_run`, anchored at the run's left edge; later runs on the line are shifted by the added width (now returned from `emit_glyph_run`) so they do not overlap. **Applied per glyph** via each glyph's cluster→span mapping, so a scaled run that Parley shaped together with an un-scaled neighbour (same font/colour) still stretches only its own glyphs — see the baseline-shift row for the coalescing rationale. COMPAT(parley): Parley has no geometric horizontal scale, so line-breaking still measures the unscaled run — a scaled run can extend past the right margin where Word would have wrapped earlier (gap #14). Tested by `horizontal_scale_widens_glyph_advances` and `coalesced_scale_and_baseline_shift_apply_per_glyph`. **DOCX export** now writes `w:w` (integer percent) for a direct run property (`emit_char_props`); previously DOCX export dropped it (ODT export already round-tripped it). The same `emit_char_props` pass also restores DOCX export of `w:shadow`, `w:kern`, `w:szCs`, the complex/East-Asian `w:rFonts` faces, run `w:shd` background, and `w:lang` — every direct run property the importer reads is now written back, keeping DOCX export symmetric with import. Surfaced by the Spec 02 conformance harness. | -| **Kerning** | Yes | No | No | Kerning flag dropped at layout time (gap #23). | +| **Kerning** | Yes | Yes | Yes | **Resolved 2026-07-05** (gap #23). `CharProps.kerning` (OOXML `w:kern > 0`, ODF `style:letter-kerning`) reaches `StyleSpan.kerning` and toggles the shaper's GPOS `kern` feature. Default is **off**, matching the reference apps (Word's `w:kern` threshold defaults to 0; LibreOffice treats an ODT without the property as off) — the shaper (Parley 0.10/harfrust) would otherwise kern everything, which made loki's lines ~0.5 % narrower than reference renders of never-kerned documents and flipped borderline wraps (root-caused by the Spec 02 calibration pass; see `appthere-conformance/goldens/CALIBRATION.md`). Regression-locked by `loki-layout/tests/kerning_applied.rs` (kern-on advance, default-off natural advance, contextual pairs, ligatures unaffected). | | **Language Tags** | Yes | No | No | No locale-sensitive shaping or hyphenation. | --- diff --git a/docs/memory-audit-2026-06-12.md b/docs/memory-audit-2026-06-12.md index 473c4e5a..6cf59fae 100644 --- a/docs/memory-audit-2026-06-12.md +++ b/docs/memory-audit-2026-06-12.md @@ -154,22 +154,16 @@ RUST_LOG=loki_text::mem=info `loro_ops` / `loro_changes` climbing without bound while `pages` / `blocks` stay flat confirms the history (not the layout or document) is what grows. -**Proposed fix (compaction — needs on-device validation, not yet applied):** -Loro can drop history before a frontier via -`export(ExportMode::shallow_snapshot(&doc.oplog_frontiers()))` re-imported into a -fresh `LoroDoc`. Doing this at a natural checkpoint (e.g. on **save**, or after -the undo horizon of 100 steps is exceeded) caps the oplog. The cost: the swap -invalidates the `UndoManager` (recreate it), the `IncrementalReader` version -(reseed it), and every signal/`DocSession` holding the old `LoroDoc` (re-point -them), and it truncates undo history at the compaction point. Because that -touches the live editing/undo wiring and can't be validated headlessly, it is -recorded here as the next step rather than applied blind. - - -### Tech debt - -- **TODO(loro-compaction):** the Loro history (oplog + rich-text tombstones) is - never compacted, so a long single-document editing session grows resident - memory without bound (observed >3 GB). Compact via a shallow-snapshot swap at a - safe checkpoint; requires recreating the `UndoManager`/`IncrementalReader` and - re-pointing the `loro_doc` signal, plus on-device validation. +**Fix applied (2026-07-04, plan Phase 1.5):** `loro_bridge::compact` now +provides `compact_in_place` (re-encode the change store + drop checkout +caches; history and undo preserved) and `compact_history` (minimal-history +`StateOnly` snapshot re-imported into a fresh mark-configured `LoroDoc`). +loki-text wires both at the save point (`editor_compact::compact_after_save`, +called from the unified save handler): below 20k ops the in-place pass runs; +above it the doc is swapped, recreating the `UndoManager` (undo restarts at +the save point) and dropping the `IncrementalReader` seed exactly as +anticipated above. The `leak_loro_history` bench now runs a compacted phase +and asserts the curve flattens (5 000 keystrokes: 19 208 ops uncompacted → +1 op with periodic compaction). **On-device long-session validation is still +pending** — the swap path exercises real editing/undo wiring that only a +device run can confirm (tracked in Spec 06 BM-14's on-device pass). diff --git a/docs/patches.md b/docs/patches.md index 38d79048..e45546f5 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -380,107 +380,6 @@ is made synchronous (the `todo(jon)` comment in the original acknowledges this). --- -### loki-file-access — 0.1.2 - -**Source:** `patches/loki-file-access/` (local), vendored from the git source at -commit `176b590fb2da82b2ab278a15b34f0bea56ae0a7a` of -[appthere/loki-file-access](https://github.com/appthere/loki-file-access). - -**Fixes:** Two Android-specific bugs that caused a crash when tapping "Open -File" on a NativeActivity (cargo-apk) build: - -1. **Wrong activity reference for `startActivityForResult`.** android-activity - v0.6 intentionally stores the `Application` object (not the `Activity`) in - `ndk_context`, because `Application` outlives the Activity lifecycle. - `startActivityForResult` only exists on `Activity`, so calling it on the - `Application` object threw a `java.lang.NoSuchMethodError` / ART abort. The - patch adds `init_android(activity_as_ptr)` — called from `android_main` with - `AndroidApp::activity_as_ptr()` — which stores the actual NativeActivity - `GlobalRef` in an `AtomicPtr`. `start_activity_for_result` now - prefers this pointer over `ndk_context::android_context().context()`. - -2. **JNI exception not cleared on failure.** When `startActivityForResult` - failed (e.g., called on the wrong receiver type), a JNI exception was left - pending. The next `FindClass` JNI call made while an exception was pending - caused ART's checked-JNI mode to abort the process. The patch calls - `env.exception_clear()` when `call_method` returns an error. - -3. **Fail-fast for NativeActivity without Java shim.** `ANativeActivityCallbacks` - has no `onActivityResult` field — NDK NativeActivity can never receive - `startActivityForResult` results. Rather than hanging the async task - indefinitely, the patch returns `Err(PickerError::Platform)` immediately with - an explanatory message when the NativeActivity pointer is set but no Java - `FilePickerActivity` shim is registered. - -4. **Pre-wired JNI callback for future Gradle build.** The function - `Java_com_appthere_loki_FilePickerActivity_nativeOnResult` is exported from - the binary. Once a Gradle-based build includes `FilePickerActivity.kt` - (calling `nativeOnResult` from `onActivityResult`), end-to-end file picking - will work without further changes to this crate. - -**Also fixes:** `jni::errors::JniError` → `jni::errors::Error` in `jvm_err` and -`attach_err` helpers (the original used the wrong variant type for the jni -0.21.x API), and corrects a `#[no_mangle]` → `#[unsafe(no_mangle)]` attribute -for Rust 2024 edition compatibility. - -**Adds (PATCH(loki), 2026-06-13):** `query_window_insets_dp(activity_ptr)` — -orientation-aware safe-area insets `(top, bottom, left, right)` in dp from -`decorView.getRootWindowInsets().getInsets(systemBars | displayCutout)`. Unlike -the existing `query_insets_dp` (which reads the orientation-independent -`status_bar_height` / `navigation_bar_height` resources), this reflects the real -per-side insets, so landscape — where the navigation bar / cutout move to a side -— is padded correctly instead of keeping the portrait top/bottom values. Needs -the **Activity** (passed in via `AndroidApp::activity_as_ptr()`), since -`ndk_context` holds the `Application`, which has no window. Returns `None` -(caller falls back to `query_insets_dp`) before the view is attached or on -API < 30. Each Loki app (loki-text, loki-spreadsheet, loki-presentation) -re-queries it on resize via a hidden scroll-container sensor and pushes the -result into `appthere_ui::update_safe_area_insets`. - -**Extends (PATCH(loki), 2026-06-26):** the mask now also includes -`WindowInsets.Type.ime()`, i.e. -`getInsets(systemBars | displayCutout | ime)`. Because `getInsets` returns the -per-side union and `ime()` contributes only a bottom inset, top/left/right are -unchanged while the keyboard is hidden, and `bottom` grows to the soft-keyboard -height while it is visible. Combined with the blitz-shell IME-settle re-sync -(see the blitz-shell section), this is what reserves a bottom safe area for the -keyboard on a `NativeActivity` whose surface does not resize. `ime()` is API 30+ -— the same level as `getInsets(int)` — so the existing `None` fallback already -covers older API levels. - -**Adds (PATCH(loki), 2026-07-01):** `install_ime_listener(activity_ptr)` and -`set_ime_visibility_listener(cb)` — a soft-keyboard visibility signal. The former -loads the `ImeInsetsListener` Java shim (in `android/`) through the *application* -class loader (a native thread's default `FindClass` sees only framework classes), -binds its `nativeOnImeInsetsChanged` callback via `RegisterNatives` (so it does -not depend on the host `.so` name), and calls its static `install`, which sets a -decor-view `OnApplyWindowInsetsListener` on the UI thread. The listener reports -every IME visibility change — including the user-initiated dismiss/re-summon the -OS never surfaces to a `NativeActivity` — to the registered closure. loki-text -wires this to `blitz_shell::notify_ime_visibility_changed`. See the blitz-shell -"user-driven soft-keyboard collapse signal" note. The shim is dexed alongside -`FilePickerActivity` (build.rs / build-android.sh / build-aab.sh / -build-android.ps1) and, being JNI-referenced, requires minification to stay off -(already the case). - -**Root cause:** loki-file-access 0.1.2 was designed for desktop and WASM; the -Android implementation was scaffolded but never exercised on a real NativeActivity -build before this patch. - -**Upstream status:** The appthere/loki-file-access repository is maintained by -the same team. These fixes should be pushed upstream and the patch removed once -they are merged and a new version is published. - -**Removal condition:** Push these fixes to `appthere/loki-file-access`, publish -a new version, and update the workspace dependency to point at the registry -version. The `[patch."https://github.com/appthere/loki-file-access"]` entry and -the `patches/loki-file-access/` directory can then be removed. Full end-to-end -file picking additionally requires a Gradle build with `FilePickerActivity.kt`. - -**Added:** 2026-05-25 - ---- - ### blitz-dom — 0.2.4 **Source:** `patches/blitz-dom/` (local). @@ -730,6 +629,25 @@ Before removing a patch: ## Removed patches +### loki-file-access — removed 2026-07-05 (was 0.1.2) + +The `patches/loki-file-access` patch (added 2026-05-25) carried the Android +NativeActivity fixes — `init_android` Activity `GlobalRef` for +`startActivityForResult`, JNI exception clearing, the fail-fast for missing +`FilePickerActivity`, the `FilePickerActivity`/`ImeInsetsListener` Java shims ++ dexing `build.rs`, `query_window_insets_dp` / `install_ime_listener`, and +`FileAccessToken::delete()` / `copy_bytes_to()` — plus jni 0.21 error-type and +Rust-2024 `#[unsafe(no_mangle)]` fixes. + +Removed when the full patch content was upstreamed to +[appthere/loki-file-access](https://github.com/appthere/loki-file-access) as +**0.1.3** (commit `d2b7bc5`, fast-forwarded to `main`; the workspace's +`branch = "main"` git dependency now resolves to it directly). The crate is +not yet published to a registry — if it ever is, the git dependency can be +swapped for the registry version, but nothing requires that. Full end-to-end +Android file picking still requires a Gradle build that bundles the (now +upstream) `FilePickerActivity` shim. + ### fontique — removed 2026-06-21 (was 0.8.0) The `patches/fontique` patch (added 2026-04-13) worked around two issues with diff --git a/loki-bench/benches/leak_loro_history.rs b/loki-bench/benches/leak_loro_history.rs index 7e5b246e..74cbf893 100644 --- a/loki-bench/benches/leak_loro_history.rs +++ b/loki-bench/benches/leak_loro_history.rs @@ -9,8 +9,12 @@ //! tombstones grow: the op/change counters (`len_ops` / `len_changes`, the same //! signals `loki_text::mem` logs) and the live heap the session retains. This //! confirms and quantifies the known unbounded growth (memory-audit Finding 6 / -//! `TODO(loro-compaction)`); it is the yardstick a future compaction fix is -//! validated against — a fix should flatten this curve. +//! `TODO(loro-compaction)` — now addressed by `loro_bridge::compact`). +//! +//! A second phase runs the same workload with `compact_history` applied every +//! `COMPACT_EVERY` keystrokes (the editor applies it after saves) and asserts +//! the curve flattens: the compacted session's final oplog must be a small +//! fraction of the uncompacted one. //! //! Run: `cargo bench -p loki-bench --bench leak_loro_history` @@ -20,10 +24,12 @@ loki_bench::dhat_global_allocator!(); mod support; use loki_bench::residual_after; +use loki_doc_model::loro_bridge::compact_history; use loki_doc_model::{delete_text, document_to_loro, insert_text}; use std::hint::black_box; const KEYSTROKES: usize = 5_000; +const COMPACT_EVERY: usize = 1_000; fn main() { let doc = support::build_doc(20, support::WORDS_PER_PARA); @@ -55,11 +61,35 @@ fn main() { residual.curr_bytes / KEYSTROKES as u64, ); eprintln!( - " → history grows with edit count (Finding 6 / TODO(loro-compaction)); \ - a compaction fix should flatten this." + " → history grows with edit count (Finding 6); the compacted phase \ + below must flatten this." ); // Measured & reported (not a pass/fail): editing must move the oplog. assert!(ops1 > ops0, "oplog did not grow — did the edits apply?"); black_box(&loro); + + // ── Phase 2: same workload, compacting every COMPACT_EVERY keystrokes ── + let doc2 = support::build_doc(20, support::WORDS_PER_PARA); + let mut compacted = document_to_loro(&doc2).expect("document_to_loro"); + for i in 0..KEYSTROKES { + let _ = insert_text(&compacted, 0, 0, "x"); + let _ = delete_text(&compacted, 0, 0, 1); + if (i + 1) % COMPACT_EVERY == 0 { + compacted = compact_history(&compacted).expect("compact_history"); + } + } + let ops_compacted = compacted.len_ops(); + eprintln!( + "with compact_history every {COMPACT_EVERY} keystrokes:\n \ + ops after {KEYSTROKES} keystrokes: {ops_compacted} (uncompacted: {ops1})" + ); + + // The whole point of the fix: history no longer grows with session time. + // Bound = one compaction window's worth of ops plus the baseline. + assert!( + ops_compacted < ops0 + 3 * COMPACT_EVERY, + "compacted oplog did not flatten: {ops_compacted} ops" + ); + black_box(&compacted); } diff --git a/loki-doc-model/src/lib.rs b/loki-doc-model/src/lib.rs index bc649ecd..a679fa35 100644 --- a/loki-doc-model/src/lib.rs +++ b/loki-doc-model/src/lib.rs @@ -149,8 +149,8 @@ pub mod loro_bridge; pub use loro_bridge::{BridgeError, IncrementalReader, document_to_loro, loro_to_document}; pub mod loro_mutation; pub use loro_mutation::{ - BlockPath, PathStep, delete_text_at, get_block_text_at, get_mark_at_path, insert_text_at, - mark_text_at, + BlockPath, PathStep, delete_selection_at, delete_text_at, get_block_text_at, get_mark_at_path, + insert_text_at, mark_text_at, }; pub use loro_mutation::{ MutationError, delete_text, get_block_alignment, get_block_style_name, get_block_text, diff --git a/loki-doc-model/src/loro_bridge/color_codec.rs b/loki-doc-model/src/loro_bridge/color_codec.rs new file mode 100644 index 00000000..18918713 --- /dev/null +++ b/loki-doc-model/src/loro_bridge/color_codec.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Codec for [`DocumentColor`] values in the Loro CRDT schema. +//! +//! Unlike [`DocumentColor::to_hex`], this codec is total: every variant — +//! including `Cmyk`, `Theme`, and `Transparent` — has a stable string +//! encoding, so a color survives a document_to_loro → loro_to_document +//! round-trip without collapsing to Rgb or being dropped. +//! +//! Encodings: +//! - `Rgb` → `#RRGGBB` +//! - `Cmyk` → `cmyk:C:M:Y:K` (components in `[0, 1]`) +//! - `Theme` → `theme:Slot:tint` +//! - `Transparent` → `transparent` + +use loki_primitives::color::{CmykColor, DocumentColor, ThemeColorSlot}; + +/// Encode any [`DocumentColor`] as a compact schema string. +pub(super) fn encode_document_color(c: &DocumentColor) -> String { + match c { + DocumentColor::Rgb(_) => c.to_hex().unwrap_or_else(|| String::from("transparent")), + DocumentColor::Cmyk(cmyk) => format!( + "cmyk:{}:{}:{}:{}", + cmyk.cyan(), + cmyk.magenta(), + cmyk.yellow(), + cmyk.key() + ), + DocumentColor::Theme { slot, tint } => { + format!("theme:{}:{}", encode_theme_slot(*slot), tint) + } + DocumentColor::Transparent => String::from("transparent"), + // `DocumentColor` is #[non_exhaustive]; a variant this codec does not + // know yet cannot be encoded faithfully. Encode as transparent rather + // than panicking — extending the codec is the real fix when the enum + // grows. + _ => String::from("transparent"), + } +} + +/// Decode a string produced by [`encode_document_color`]. +pub(super) fn decode_document_color(s: &str) -> Option { + if s == "transparent" { + return Some(DocumentColor::Transparent); + } + if let Some(rest) = s.strip_prefix("cmyk:") { + let mut parts = rest.splitn(4, ':'); + let c: f32 = parts.next()?.parse().ok()?; + let m: f32 = parts.next()?.parse().ok()?; + let y: f32 = parts.next()?.parse().ok()?; + let k: f32 = parts.next()?.parse().ok()?; + return Some(DocumentColor::Cmyk(CmykColor::new(c, m, y, k))); + } + if let Some(rest) = s.strip_prefix("theme:") { + let mut parts = rest.splitn(2, ':'); + let slot = decode_theme_slot(parts.next()?)?; + let tint: f32 = parts.next()?.parse().ok()?; + return Some(DocumentColor::Theme { slot, tint }); + } + DocumentColor::from_hex(s).ok() +} + +fn encode_theme_slot(slot: ThemeColorSlot) -> &'static str { + match slot { + ThemeColorSlot::Dark1 => "Dark1", + ThemeColorSlot::Dark2 => "Dark2", + ThemeColorSlot::Light1 => "Light1", + ThemeColorSlot::Light2 => "Light2", + ThemeColorSlot::Accent1 => "Accent1", + ThemeColorSlot::Accent2 => "Accent2", + ThemeColorSlot::Accent3 => "Accent3", + ThemeColorSlot::Accent4 => "Accent4", + ThemeColorSlot::Accent5 => "Accent5", + ThemeColorSlot::Accent6 => "Accent6", + ThemeColorSlot::Hyperlink => "Hyperlink", + ThemeColorSlot::FollowedHyperlink => "FollowedHyperlink", + // #[non_exhaustive] guard for slots added upstream — map to Dark1 + // (text default) until the codec learns the new slot. + _ => "Dark1", + } +} + +fn decode_theme_slot(s: &str) -> Option { + match s { + "Dark1" => Some(ThemeColorSlot::Dark1), + "Dark2" => Some(ThemeColorSlot::Dark2), + "Light1" => Some(ThemeColorSlot::Light1), + "Light2" => Some(ThemeColorSlot::Light2), + "Accent1" => Some(ThemeColorSlot::Accent1), + "Accent2" => Some(ThemeColorSlot::Accent2), + "Accent3" => Some(ThemeColorSlot::Accent3), + "Accent4" => Some(ThemeColorSlot::Accent4), + "Accent5" => Some(ThemeColorSlot::Accent5), + "Accent6" => Some(ThemeColorSlot::Accent6), + "Hyperlink" => Some(ThemeColorSlot::Hyperlink), + "FollowedHyperlink" => Some(ThemeColorSlot::FollowedHyperlink), + _ => None, + } +} diff --git a/loki-doc-model/src/loro_bridge/compact.rs b/loki-doc-model/src/loro_bridge/compact.rs new file mode 100644 index 00000000..68082941 --- /dev/null +++ b/loki-doc-model/src/loro_bridge/compact.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Client-side CRDT history compaction (memory-audit Finding 6 / +//! `TODO(loro-compaction)`). +//! +//! A Loro oplog grows with every edit — with session *time*, not document +//! size — so a long editing session retains unbounded history. Two remedies, +//! in increasing strength: +//! +//! - [`compact_in_place`]: re-encode parsed ops into Loro's compact change +//! store and drop checkout caches. No history is lost, undo and +//! subscriptions keep working; only the memory *representation* shrinks. +//! Safe to call at any quiescent point (e.g. after every save). +//! - [`compact_history`]: export a minimal-history snapshot of the current +//! state and import it into a fresh, mark-configured [`LoroDoc`]. This +//! truncates the oplog itself — the durable fix — at the cost that +//! anything bound to the old doc instance (an `UndoManager`, container +//! handles, subscriptions, an `IncrementalReader` seed) must be recreated +//! by the caller, and undo history restarts at the compaction point. +//! Client-local documents only: a peer that is behind the shallow start +//! can no longer sync updates (server-relayed Tier-0/1 documents are +//! compacted by the ADR-C013 server `Compactor`; Tier-2 by the +//! `PUT /snapshot` flow). + +use super::BridgeError; +use crate::loro_schema::{CHAR_MARK_KEYS, INLINE_OBJECT_MARK_KEYS}; +use loro::{ExpandType, ExportMode, LoroDoc, StyleConfig, StyleConfigMap}; + +/// Registers every schema mark key's expand behaviour on `doc`. +/// +/// Must run on each fresh [`LoroDoc`] before it carries document text — +/// [`super::document_to_loro`] and [`compact_history`] both call it. The +/// config is per-instance runtime state; it does not travel in snapshots. +pub(super) fn configure_text_style(doc: &LoroDoc) { + let mut style_config = StyleConfigMap::new(); + // Character formatting marks expand onto text inserted at their trailing + // edge (`After`) — the single source of truth is `CHAR_MARK_KEYS`. + for key in CHAR_MARK_KEYS { + style_config.insert( + loro::InternalString::from(*key), + StyleConfig { + expand: ExpandType::After, + }, + ); + } + // Inline-object anchor marks must not expand onto adjacent text — they + // describe a single placeholder position, not a formatting span. + for key in INLINE_OBJECT_MARK_KEYS { + style_config.insert( + loro::InternalString::from(*key), + StyleConfig { + expand: ExpandType::None, + }, + ); + } + doc.config_text_style(style_config); +} + +/// In-place memory compaction: commit, re-encode the change store, and free +/// checkout caches. History (and therefore undo) is fully preserved. +pub fn compact_in_place(doc: &LoroDoc) { + doc.commit(); + doc.compact_change_store(); + doc.free_history_cache(); + doc.free_diff_calculator(); +} + +/// History truncation: returns a **fresh** [`LoroDoc`] holding the same +/// document state with a minimal-depth oplog (Loro `StateOnly` shallow +/// snapshot at the latest version). +/// +/// The caller must swap the returned doc in for the old one and recreate +/// everything bound to the old instance — see the module docs for the list +/// and the sync caveat. +pub fn compact_history(doc: &LoroDoc) -> Result { + doc.commit(); + let bytes = doc + .export(ExportMode::StateOnly(None)) + .map_err(|e| BridgeError::Loro(e.to_string()))?; + let fresh = LoroDoc::new(); + configure_text_style(&fresh); + fresh.import(&bytes)?; + Ok(fresh) +} diff --git a/loki-doc-model/src/loro_bridge/containers.rs b/loki-doc-model/src/loro_bridge/containers.rs new file mode 100644 index 00000000..70c5eccd --- /dev/null +++ b/loki-doc-model/src/loro_bridge/containers.rs @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Native CRDT mappings for container blocks: bullet/ordered lists, block +//! quotes, divs, and figures. +//! +//! Follows the `table.rs` pattern: structural metadata that rarely changes is +//! stored as a small `serde`-JSON snapshot under [`KEY_CONTAINER_META`], while +//! the *content* — list items, quote/div children, figure caption and body — +//! lives in nested movable lists written through the shared block path +//! ([`super::write::map_blocks_to_list`]). Paragraph text inside a list item +//! therefore sits in real `LoroText` containers, so concurrent edits to +//! different items merge instead of conflicting on one opaque JSON blob. +//! +//! Layout per block type: +//! - `BulletList` → [`KEY_LIST_ITEMS`] (one nested block list per item) +//! - `OrderedList` → [`KEY_CONTAINER_META`] = `ListAttributes` JSON + +//! [`KEY_LIST_ITEMS`] +//! - `BlockQuote` → [`KEY_CHILD_BLOCKS`] +//! - `Div` → [`KEY_CONTAINER_META`] = `NodeAttr` JSON + +//! [`KEY_CHILD_BLOCKS`] +//! - `Figure` → [`KEY_CONTAINER_META`] = `(NodeAttr, short caption)` +//! JSON + [`KEY_CAPTION_BLOCKS`] + [`KEY_CHILD_BLOCKS`] +//! +//! On read, a block-type tag with no content lists is a legacy stub written +//! by a bridge version that predates both this mapping and the opaque +//! snapshot scheme; it carries nothing recoverable and falls back to +//! [`Block::HorizontalRule`], matching `table.rs`. Without the `serde` +//! feature there is no metadata format, so these blocks keep taking the +//! opaque path on write. + +#[cfg(feature = "serde")] +use super::BridgeError; +use crate::content::block::Block; +use crate::loro_schema::{ + BLOCK_TYPE_BLOCKQUOTE, BLOCK_TYPE_BULLET_LIST, BLOCK_TYPE_DIV, BLOCK_TYPE_FIGURE, + BLOCK_TYPE_ORDERED_LIST, KEY_CAPTION_BLOCKS, KEY_CHILD_BLOCKS, KEY_LIST_ITEMS, +}; +#[cfg(feature = "serde")] +use crate::loro_schema::{KEY_CONTAINER_META, KEY_TYPE}; +use loro::LoroMap; +#[cfg(feature = "serde")] +use loro::LoroMovableList; + +// ── Write path ──────────────────────────────────────────────────────────────── + +/// Writes a container block (`BulletList`/`OrderedList`/`BlockQuote`/`Div`/ +/// `Figure`) into `map` as its native block type. Callers guarantee `block` +/// is one of those variants. +#[cfg(feature = "serde")] +pub(super) fn write_container(block: &Block, map: &LoroMap) -> Result<(), BridgeError> { + match block { + Block::BulletList(items) => { + map.insert(KEY_TYPE, BLOCK_TYPE_BULLET_LIST)?; + write_items(items, map) + } + Block::OrderedList(attrs, items) => { + map.insert(KEY_TYPE, BLOCK_TYPE_ORDERED_LIST)?; + write_meta(attrs, map)?; + write_items(items, map) + } + Block::BlockQuote(children) => { + map.insert(KEY_TYPE, BLOCK_TYPE_BLOCKQUOTE)?; + write_blocks(children, map, KEY_CHILD_BLOCKS) + } + Block::Div(attr, children) => { + map.insert(KEY_TYPE, BLOCK_TYPE_DIV)?; + write_meta(attr, map)?; + write_blocks(children, map, KEY_CHILD_BLOCKS) + } + Block::Figure(attr, caption, content) => { + map.insert(KEY_TYPE, BLOCK_TYPE_FIGURE)?; + write_meta(&(attr, &caption.short), map)?; + write_blocks(&caption.full, map, KEY_CAPTION_BLOCKS)?; + write_blocks(content, map, KEY_CHILD_BLOCKS) + } + // Guarded by the caller's dispatch; preserve rather than lose. + other => super::opaque::write_opaque_block(other, map), + } +} + +#[cfg(feature = "serde")] +fn write_meta(meta: &T, map: &LoroMap) -> Result<(), BridgeError> { + match serde_json::to_string(meta) { + Ok(json) => { + map.insert(KEY_CONTAINER_META, json)?; + } + Err(err) => { + // Unreachable in practice: every model type derives Serialize. + tracing::warn!("loro bridge: failed to snapshot container meta: {err}"); + } + } + Ok(()) +} + +#[cfg(feature = "serde")] +fn write_items(items: &[Vec], map: &LoroMap) -> Result<(), BridgeError> { + let items_list = map.insert_container(KEY_LIST_ITEMS, LoroMovableList::new())?; + for (i, item_blocks) in items.iter().enumerate() { + let item_list = items_list.insert_container(i, LoroMovableList::new())?; + super::write::map_blocks_to_list(item_blocks, &item_list)?; + } + Ok(()) +} + +#[cfg(feature = "serde")] +fn write_blocks(blocks: &[Block], map: &LoroMap, key: &str) -> Result<(), BridgeError> { + let list = map.insert_container(key, LoroMovableList::new())?; + super::write::map_blocks_to_list(blocks, &list) +} + +// ── Read path ──────────────────────────────────────────────────────────────── + +/// Reads a native container block back into its [`Block`] variant. `block_type` +/// is the map's [`KEY_TYPE`] tag. Falls back to [`Block::HorizontalRule`] for +/// legacy stubs (no content lists) or unknown tags. +pub(super) fn read_container(block_type: &str, map: &LoroMap) -> Block { + let block = match block_type { + BLOCK_TYPE_BULLET_LIST => read_items(map).map(Block::BulletList), + BLOCK_TYPE_ORDERED_LIST => match (read_meta(map), read_items(map)) { + (Some(attrs), Some(items)) => Some(Block::OrderedList(attrs, items)), + _ => None, + }, + BLOCK_TYPE_BLOCKQUOTE => read_blocks(map, KEY_CHILD_BLOCKS).map(Block::BlockQuote), + BLOCK_TYPE_DIV => match (read_meta(map), read_blocks(map, KEY_CHILD_BLOCKS)) { + (Some(attr), Some(children)) => Some(Block::Div(attr, children)), + _ => None, + }, + BLOCK_TYPE_FIGURE => read_figure(map), + _ => None, + }; + block.unwrap_or_else(|| { + tracing::warn!("loro bridge: unreadable native {block_type} block; dropping to rule"); + Block::HorizontalRule + }) +} + +fn read_figure(map: &LoroMap) -> Option { + let (attr, short): ( + crate::content::attr::NodeAttr, + Option>, + ) = read_meta(map)?; + let full = read_blocks(map, KEY_CAPTION_BLOCKS)?; + let content = read_blocks(map, KEY_CHILD_BLOCKS)?; + Some(Block::Figure( + attr, + crate::content::block::Caption { short, full }, + content, + )) +} + +#[cfg(feature = "serde")] +fn read_meta(map: &LoroMap) -> Option { + let json = map + .get(KEY_CONTAINER_META) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_string().ok()) + .map(|s| s.to_string())?; + serde_json::from_str(&json).ok() +} + +#[cfg(not(feature = "serde"))] +fn read_meta(_map: &LoroMap) -> Option { + None +} + +fn read_items(map: &LoroMap) -> Option>> { + let items_list = map + .get(KEY_LIST_ITEMS)? + .into_container() + .ok()? + .into_movable_list() + .ok()?; + let mut items = Vec::with_capacity(items_list.len()); + for i in 0..items_list.len() { + let item_list = items_list + .get(i)? + .into_container() + .ok()? + .into_movable_list() + .ok()?; + items.push(super::read::reconstruct_blocks_from_list(&item_list)); + } + Some(items) +} + +fn read_blocks(map: &LoroMap, key: &str) -> Option> { + let list = map + .get(key)? + .into_container() + .ok()? + .into_movable_list() + .ok()?; + Some(super::read::reconstruct_blocks_from_list(&list)) +} diff --git a/loki-doc-model/src/loro_bridge/decode.rs b/loki-doc-model/src/loro_bridge/decode.rs index 29810784..7e1d19af 100644 --- a/loki-doc-model/src/loro_bridge/decode.rs +++ b/loki-doc-model/src/loro_bridge/decode.rs @@ -12,6 +12,7 @@ use crate::style::props::char_props::{ HighlightColor, StrikethroughStyle, UnderlineStyle, VerticalAlign, }; use crate::style::props::para_props::{LineHeight, ParagraphAlignment, Spacing}; +use crate::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; @@ -139,9 +140,79 @@ pub(super) fn decode_line_height(s: &str) -> Option { } } +// ── Tab-stop codec ─────────────────────────────────────────────────────────── + +/// Encode a tab-stop list as `"position_pt:Alignment:Leader"` entries joined +/// by `';'` (e.g. `"36:Left:None;144:Decimal:Dot"`). +pub(super) fn encode_tab_stops(stops: &[TabStop]) -> String { + stops + .iter() + .map(|ts| { + let alignment = match ts.alignment { + TabAlignment::Left => "Left", + TabAlignment::Right => "Right", + TabAlignment::Center => "Center", + TabAlignment::Decimal => "Decimal", + TabAlignment::Clear => "Clear", + }; + let leader = match ts.leader { + TabLeader::None => "None", + TabLeader::Dot => "Dot", + TabLeader::Dash => "Dash", + TabLeader::Underscore => "Underscore", + TabLeader::Heavy => "Heavy", + TabLeader::MiddleDot => "MiddleDot", + }; + format!("{}:{}:{}", ts.position.value(), alignment, leader) + }) + .collect::>() + .join(";") +} + +/// Decode a tab-stop list produced by [`encode_tab_stops`]. Returns `None` +/// for unparseable input (including the pre-codec Debug-string encoding), +/// which callers treat as an absent property. +pub(super) fn decode_tab_stops(s: &str) -> Option> { + if s.is_empty() { + return Some(Vec::new()); + } + let mut stops = Vec::new(); + for entry in s.split(';') { + let mut fields = entry.splitn(3, ':'); + let position: f64 = fields.next()?.parse().ok()?; + let alignment = match fields.next()? { + "Left" => TabAlignment::Left, + "Right" => TabAlignment::Right, + "Center" => TabAlignment::Center, + "Decimal" => TabAlignment::Decimal, + "Clear" => TabAlignment::Clear, + _ => return None, + }; + let leader = match fields.next()? { + "None" => TabLeader::None, + "Dot" => TabLeader::Dot, + "Dash" => TabLeader::Dash, + "Underscore" => TabLeader::Underscore, + "Heavy" => TabLeader::Heavy, + "MiddleDot" => TabLeader::MiddleDot, + _ => return None, + }; + stops.push(TabStop { + position: Points::new(position), + alignment, + leader, + }); + } + Some(stops) +} + // ── Border codec ───────────────────────────────────────────────────────────── /// Encode a [`Border`] as a compact `"Style:width_pt:color:spacing_pt"` string. +/// +/// TODO(loro-bridge): non-Rgb border colors (Theme/Cmyk) collapse to `auto` +/// here — the colon-delimited format cannot carry the `color_codec` encodings +/// (they contain `:`), so lifting this needs a format migration. pub(super) fn encode_border(b: &Border) -> String { let style = match b.style { BorderStyle::None => "None", diff --git a/loki-doc-model/src/loro_bridge/inlines.rs b/loki-doc-model/src/loro_bridge/inlines.rs index 2369158f..ceb15ee5 100644 --- a/loki-doc-model/src/loro_bridge/inlines.rs +++ b/loki-doc-model/src/loro_bridge/inlines.rs @@ -7,7 +7,8 @@ //! stay under the 300-line ceiling. use super::BridgeError; -use crate::content::inline::Inline; +use super::color_codec::encode_document_color; +use crate::content::inline::{Inline, QuoteType}; use crate::loro_schema::*; use crate::style::props::char_props::CharProps; use loro::{LoroMap, LoroText}; @@ -105,6 +106,35 @@ pub(super) fn map_inlines( text.mark(start..end, MARK_LINK_URL, target.url.as_str())?; } } + // Quote/span wrappers write their children recursively (so nested + // marks survive) and then lay their own range mark on top. + Inline::Quoted(quote_type, inner) => { + let start = text.len_unicode(); + map_inlines(inner, text, block_map)?; + let end = text.len_unicode(); + if start < end { + let value = match quote_type { + QuoteType::SingleQuote => "Single", + QuoteType::DoubleQuote => "Double", + }; + text.mark(start..end, MARK_QUOTE_TYPE, value)?; + } + } + #[cfg(feature = "serde")] + Inline::Span(attr, inner) => { + let start = text.len_unicode(); + map_inlines(inner, text, block_map)?; + let end = text.len_unicode(); + if start < end { + match serde_json::to_string(attr) { + Ok(json) => text.mark(start..end, MARK_SPAN_ATTR, json)?, + Err(err) => { + // Unreachable in practice: NodeAttr derives Serialize. + tracing::warn!("loro bridge: failed to encode span attr: {err}"); + } + } + } + } // Inline objects anchored natively (placeholder char + data mark) so // they stay live, positioned, deletable inlines. Only *top-level* // objects reach here — one nested in a wrapper keeps its block @@ -118,9 +148,19 @@ pub(super) fn map_inlines( Inline::Note(kind, body) => { super::inline_objects::write_note(kind, body, text, block_map)?; } + // Comment/bookmark range markers carry no text; anchor them like + // images so they survive round-trips as positioned inlines. + #[cfg(feature = "serde")] + Inline::Comment(_) => { + super::inline_objects::write_inline_object(inline, text, MARK_COMMENT)?; + } + #[cfg(feature = "serde")] + Inline::Bookmark(_, _) => { + super::inline_objects::write_inline_object(inline, text, MARK_BOOKMARK)?; + } // Text-bearing wrappers without a dedicated mark: keep the text. - // Quote type / span attrs / citation metadata are not yet carried - // through the CRDT — TODO(loro-bridge). + // Citation metadata is not yet carried through the CRDT — + // TODO(loro-bridge). _ => { let text_str = extract_plain_text(std::slice::from_ref(inline)); if !text_str.is_empty() { @@ -217,11 +257,8 @@ pub(super) fn apply_char_props_marks( if let Some(v) = &props.vertical_align { text.mark(start..end, MARK_VERTICAL_ALIGN, format!("{:?}", v))?; } - // Non-Rgb variants (Theme, Cmyk, Transparent) have no hex repr and are deferred — TODO(loro-bridge) - if let Some(v) = &props.color - && let Some(hex) = v.to_hex() - { - text.mark(start..end, MARK_COLOR, hex)?; + if let Some(v) = &props.color { + text.mark(start..end, MARK_COLOR, encode_document_color(v))?; } if let Some(v) = &props.highlight_color { text.mark(start..end, MARK_HIGHLIGHT_COLOR, format!("{v:?}"))?; diff --git a/loki-doc-model/src/loro_bridge/inlines_read.rs b/loki-doc-model/src/loro_bridge/inlines_read.rs index ac688476..fef13204 100644 --- a/loki-doc-model/src/loro_bridge/inlines_read.rs +++ b/loki-doc-model/src/loro_bridge/inlines_read.rs @@ -7,15 +7,15 @@ //! under the 300-line ceiling. use super::BridgeError; +use super::color_codec::decode_document_color; use super::decode::{ decode_highlight_color, decode_strikethrough, decode_underline, decode_vertical_align, }; use crate::content::attr::NodeAttr; -use crate::content::inline::{Inline, StyledRun}; +use crate::content::inline::{Inline, QuoteType, StyledRun}; use crate::loro_schema::*; use crate::style::catalog::StyleId; use crate::style::props::char_props::CharProps; -use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; use loro::LoroValue; @@ -55,19 +55,40 @@ pub(super) fn reconstruct_inlines(map: &loro::LoroMap) -> Result, Br inlines.push(note); continue; } + if let Some(anchor) = decode_snapshot_anchor(&attrs, MARK_COMMENT, |i| { + matches!(i, Inline::Comment(_)) + }) { + inlines.push(anchor); + continue; + } + if let Some(anchor) = decode_snapshot_anchor(&attrs, MARK_BOOKMARK, |i| { + matches!(i, Inline::Bookmark(_, _)) + }) { + inlines.push(anchor); + continue; + } let props = read_char_props_from_marks(&attrs); let style_id = read_style_id_from_marks(&attrs); - if props.is_some() || style_id.is_some() { - let run = StyledRun { + let mut inline = if props.is_some() || style_id.is_some() { + Inline::StyledRun(StyledRun { style_id, direct_props: props.map(Box::new), content: vec![Inline::Str(insert.to_string())], attr: NodeAttr::default(), - }; - inlines.push(Inline::StyledRun(run)); + }) } else { - inlines.push(Inline::Str(insert.to_string())); + Inline::Str(insert.to_string()) + }; + // Re-wrap span/quote range marks (innermost first, so a + // quoted span reads back as Quoted(Span(..)) — the write + // path flattens both onto the same range). + if let Some(attr) = decode_span_attr(&attrs) { + inline = Inline::Span(attr, vec![inline]); + } + if let Some(qt) = decode_quote_type(&attrs) { + inline = Inline::Quoted(qt, vec![inline]); } + inlines.push(inline); } } } @@ -75,26 +96,47 @@ pub(super) fn reconstruct_inlines(map: &loro::LoroMap) -> Result, Br Ok(inlines) } -/// Reconstructs an [`Inline::Image`] from a [`MARK_IMAGE`] anchor's `serde`-JSON -/// snapshot, if present. Returns `None` for ordinary formatted text. +/// Reconstructs an inline from a snapshot-anchor mark (`mark_key` holding a +/// `serde`-JSON `Inline`), if present and of the expected variant (`want`). +/// Returns `None` for ordinary formatted text. #[cfg(feature = "serde")] -fn decode_image(attrs: &rustc_hash::FxHashMap) -> Option { - let Some(LoroValue::String(json)) = attrs.get(MARK_IMAGE) else { +fn decode_snapshot_anchor( + attrs: &rustc_hash::FxHashMap, + mark_key: &str, + want: fn(&Inline) -> bool, +) -> Option { + let Some(LoroValue::String(json)) = attrs.get(mark_key) else { return None; }; match serde_json::from_str::(json) { - Ok(inline @ Inline::Image(..)) => Some(inline), + Ok(inline) if want(&inline) => Some(inline), Ok(_) => { - tracing::warn!("loro bridge: MARK_IMAGE snapshot was not an image"); + tracing::warn!("loro bridge: {mark_key} snapshot was the wrong inline variant"); None } Err(err) => { - tracing::warn!("loro bridge: failed to decode inline image: {err}"); + tracing::warn!("loro bridge: failed to decode {mark_key} anchor: {err}"); None } } } +#[cfg(not(feature = "serde"))] +fn decode_snapshot_anchor( + _attrs: &rustc_hash::FxHashMap, + _mark_key: &str, + _want: fn(&Inline) -> bool, +) -> Option { + None +} + +/// Reconstructs an [`Inline::Image`] from a [`MARK_IMAGE`] anchor's `serde`-JSON +/// snapshot, if present. Returns `None` for ordinary formatted text. +#[cfg(feature = "serde")] +fn decode_image(attrs: &rustc_hash::FxHashMap) -> Option { + decode_snapshot_anchor(attrs, MARK_IMAGE, |i| matches!(i, Inline::Image(..))) +} + /// Reconstructs an [`Inline::Note`] from a [`MARK_NOTE`] anchor: its `(kind, /// idx)` mark plus the body fetched from `notes` at `idx` (a live container). #[cfg(feature = "serde")] @@ -129,6 +171,29 @@ fn decode_note( None } +/// Reads the quote style carried by [`MARK_QUOTE_TYPE`]. +fn decode_quote_type(attrs: &rustc_hash::FxHashMap) -> Option { + match attrs.get(MARK_QUOTE_TYPE) { + Some(LoroValue::String(s)) if s.as_str() == "Single" => Some(QuoteType::SingleQuote), + Some(LoroValue::String(s)) if s.as_str() == "Double" => Some(QuoteType::DoubleQuote), + _ => None, + } +} + +/// Reads the span attributes carried by [`MARK_SPAN_ATTR`]. +#[cfg(feature = "serde")] +fn decode_span_attr(attrs: &rustc_hash::FxHashMap) -> Option { + let Some(LoroValue::String(json)) = attrs.get(MARK_SPAN_ATTR) else { + return None; + }; + serde_json::from_str(json).ok() +} + +#[cfg(not(feature = "serde"))] +fn decode_span_attr(_attrs: &rustc_hash::FxHashMap) -> Option { + None +} + /// Reads the named character style carried by [`MARK_CHAR_STYLE_ID`]. fn read_style_id_from_marks(attrs: &rustc_hash::FxHashMap) -> Option { if let Some(LoroValue::String(s)) = attrs.get(MARK_CHAR_STYLE_ID) { @@ -196,7 +261,7 @@ fn read_char_props_from_marks( read_str!(underline, MARK_UNDERLINE, decode_underline); read_str!(strikethrough, MARK_STRIKETHROUGH, decode_strikethrough); read_str!(vertical_align, MARK_VERTICAL_ALIGN, decode_vertical_align); - read_str!(color, MARK_COLOR, |s: &str| DocumentColor::from_hex(s).ok()); + read_str!(color, MARK_COLOR, decode_document_color); read_str!( highlight_color, MARK_HIGHLIGHT_COLOR, diff --git a/loki-doc-model/src/loro_bridge/map_get.rs b/loki-doc-model/src/loro_bridge/map_get.rs new file mode 100644 index 00000000..ddaf870f --- /dev/null +++ b/loki-doc-model/src/loro_bridge/map_get.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Typed value accessors for [`LoroMap`] entries, shared by the bridge read +//! path (`read.rs`, `props_read.rs`). +//! +//! Split from `props_read.rs` to keep individual files under the 300-line +//! ceiling. + +use loro::LoroMap; + +pub(super) fn get_str_from_map(map: &LoroMap, key: &str) -> Option { + map.get(key) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_string().ok()) + .map(|s| s.to_string()) +} + +pub(super) fn get_f64_from_map(map: &LoroMap, key: &str) -> Option { + map.get(key) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_double().ok()) +} + +pub(super) fn get_bool_from_map(map: &LoroMap, key: &str) -> Option { + map.get(key) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_bool().ok()) +} + +pub(super) fn get_i64_from_map(map: &LoroMap, key: &str) -> Option { + map.get(key) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_i64().ok()) +} diff --git a/loki-doc-model/src/loro_bridge/mod.rs b/loki-doc-model/src/loro_bridge/mod.rs index 1722f4ee..6347c368 100644 --- a/loki-doc-model/src/loro_bridge/mod.rs +++ b/loki-doc-model/src/loro_bridge/mod.rs @@ -9,13 +9,17 @@ //! - `read` — deserialization (Loro → Loki) //! - `inlines` — inline content helpers shared by both directions +mod color_codec; mod comments; +mod compact; +mod containers; mod decode; mod incremental; #[cfg(feature = "serde")] mod inline_objects; mod inlines; mod inlines_read; +mod map_get; mod meta; mod opaque; mod props_read; @@ -25,6 +29,7 @@ mod table; mod write; pub use comments::{read_document_comments, write_document_comments}; +pub use compact::{compact_history, compact_in_place}; pub use incremental::IncrementalReader; pub use meta::{read_document_meta, write_document_meta}; pub use styles::{read_document_styles, write_document_styles}; @@ -40,7 +45,7 @@ use crate::document::Document; use crate::layout::header_footer::HeaderFooter; use crate::layout::page::{PageLayout, PageOrientation}; use crate::loro_schema::*; -use loro::{ExpandType, LoroDoc, LoroMap, LoroMovableList, StyleConfig, StyleConfigMap}; +use loro::{LoroDoc, LoroMap, LoroMovableList}; use read::{reconstruct_blocks_from_list, reconstruct_page_layout}; use write::map_blocks_to_list; @@ -127,28 +132,7 @@ pub fn document_to_loro(doc: &Document) -> Result { let loro_doc = LoroDoc::new(); // Register every mark key so Loro tracks its expand behaviour. - let mut style_config = StyleConfigMap::new(); - // Character formatting marks expand onto text inserted at their trailing - // edge (`After`) — the single source of truth is `CHAR_MARK_KEYS`. - for key in CHAR_MARK_KEYS { - style_config.insert( - loro::InternalString::from(*key), - StyleConfig { - expand: ExpandType::After, - }, - ); - } - // Inline-object anchor marks must not expand onto adjacent text — they - // describe a single placeholder position, not a formatting span. - for key in INLINE_OBJECT_MARK_KEYS { - style_config.insert( - loro::InternalString::from(*key), - StyleConfig { - expand: ExpandType::None, - }, - ); - } - loro_doc.config_text_style(style_config); + compact::configure_text_style(&loro_doc); // Metadata — full DocumentMeta (core + Dublin Core) as a lossless snapshot. let meta_map = loro_doc.get_map(KEY_METADATA); diff --git a/loki-doc-model/src/loro_bridge/opaque.rs b/loki-doc-model/src/loro_bridge/opaque.rs index b19ef809..dd3c9ebe 100644 --- a/loki-doc-model/src/loro_bridge/opaque.rs +++ b/loki-doc-model/src/loro_bridge/opaque.rs @@ -4,10 +4,12 @@ //! Opaque-snapshot preservation for blocks the Loro schema cannot represent. //! //! The CRDT schema flattens paragraph content into a [`loro::LoroText`] with -//! marks, which cannot carry most structured content (lists, figures, footnote -//! bodies, fields, math; tables have their own native mapping in `table.rs`). -//! Before this module existed, such blocks were written as empty stubs and read -//! back as `Block::HorizontalRule` — i.e. a single edit cycle destroyed them. +//! marks, which cannot carry structured content the marks cannot express +//! (definition lists, fields, math, nested notes/images). Tables have a +//! native mapping in `table.rs`; lists, block quotes, divs, and figures have +//! theirs in `containers.rs`. Before this module existed, unsupported blocks +//! were written as empty stubs and read back as `Block::HorizontalRule` — +//! i.e. a single edit cycle destroyed them. //! //! Instead, any block that cannot round-trip through the text schema is //! serialized to JSON (via the model's `serde` derives) and stored verbatim @@ -83,10 +85,12 @@ fn inline_round_trips_nested(inline: &Inline) -> bool { | Inline::Span(_, inner) | Inline::Link(_, inner, _) => inner.iter().all(inline_round_trips_nested), Inline::StyledRun(run) => run.content.iter().all(inline_round_trips_nested), - // Comment and bookmark anchors carry no body text and have always - // been dropped by the text flattening; treating them as representable - // keeps the vast majority of paragraphs editable. - // TODO(loro-bridge): preserve comment/bookmark anchors as marks. + // Comment and bookmark anchors carry no body text. A *top-level* + // anchor is now preserved natively (an OBJECT_REPLACEMENT_CHAR anchor + // with a MARK_COMMENT/MARK_BOOKMARK snapshot mark — see + // `inlines::map_inlines`); one nested inside a wrapper is still + // dropped by the text flattening, which loses no text and keeps the + // vast majority of paragraphs editable. Inline::Comment(_) | Inline::Bookmark(_, _) => true, // Nested Note/Image, Field, Math, RawInline, Cite — structured content // the flat text cannot carry (and, when nested, the native anchor path diff --git a/loki-doc-model/src/loro_bridge/props_read.rs b/loki-doc-model/src/loro_bridge/props_read.rs index 4be65914..664a5e27 100644 --- a/loki-doc-model/src/loro_bridge/props_read.rs +++ b/loki-doc-model/src/loro_bridge/props_read.rs @@ -5,46 +5,20 @@ //! //! Split from `read.rs` to keep individual files under the 300-line ceiling. +use super::color_codec::decode_document_color; use super::decode::{ decode_alignment, decode_border, decode_highlight_color, decode_line_height, decode_spacing, - decode_strikethrough, decode_underline, decode_vertical_align, + decode_strikethrough, decode_tab_stops, decode_underline, decode_vertical_align, }; +use super::map_get::{get_bool_from_map, get_f64_from_map, get_i64_from_map, get_str_from_map}; use crate::loro_schema::*; use crate::meta::language::LanguageTag; use crate::style::list_style::ListId; use crate::style::props::char_props::CharProps; use crate::style::props::para_props::ParaProps; -use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; use loro::LoroMap; -// ── Loro map value accessors ────────────────────────────────────────────────── - -pub(super) fn get_str_from_map(map: &LoroMap, key: &str) -> Option { - map.get(key) - .and_then(|v| v.into_value().ok()) - .and_then(|v| v.into_string().ok()) - .map(|s| s.to_string()) -} - -pub(super) fn get_f64_from_map(map: &LoroMap, key: &str) -> Option { - map.get(key) - .and_then(|v| v.into_value().ok()) - .and_then(|v| v.into_double().ok()) -} - -pub(super) fn get_bool_from_map(map: &LoroMap, key: &str) -> Option { - map.get(key) - .and_then(|v| v.into_value().ok()) - .and_then(|v| v.into_bool().ok()) -} - -pub(super) fn get_i64_from_map(map: &LoroMap, key: &str) -> Option { - map.get(key) - .and_then(|v| v.into_value().ok()) - .and_then(|v| v.into_i64().ok()) -} - // ── ParaProps reconstruction ────────────────────────────────────────────────── pub(super) fn reconstruct_para_props(block_map: &LoroMap) -> Option { @@ -168,6 +142,19 @@ pub(super) fn reconstruct_para_props(block_map: &LoroMap) -> Option { read_pt!(padding_left, PROP_PADDING_LEFT); read_pt!(padding_right, PROP_PADDING_RIGHT); + if let Some(s) = get_str_from_map(&props_map, PROP_TAB_STOPS) + && let Some(ts) = decode_tab_stops(&s) + { + props.tab_stops = Some(ts); + any = true; + } + if let Some(s) = get_str_from_map(&props_map, PROP_BACKGROUND_COLOR) + && let Some(c) = decode_document_color(&s) + { + props.background_color = Some(c); + any = true; + } + if any { Some(props) } else { None } } @@ -265,13 +252,13 @@ pub(super) fn reconstruct_char_props_from_map(block_map: &LoroMap) -> Option Result { // stub written before this mapping has no skeleton and falls back to a // rule inside `read_table`. BLOCK_TYPE_TABLE => Ok(super::table::read_table(map)), - // Legacy stubs: blocks written by bridge versions that predate the - // opaque-snapshot scheme carry no content and cannot be recovered. - BLOCK_TYPE_BULLET_LIST => { - tracing::debug!("TODO/stub: loro bridge bullet list"); - Ok(Block::HorizontalRule) - } - BLOCK_TYPE_ORDERED_LIST => { - tracing::debug!("TODO/stub: loro bridge ordered list"); - Ok(Block::HorizontalRule) - } - BLOCK_TYPE_FIGURE => { - tracing::debug!("TODO/stub: loro bridge figure"); - Ok(Block::HorizontalRule) - } + // Native container mappings (metadata + live nested block lists). + // Legacy stubs with no content lists fall back to a rule inside + // `read_container`. + BLOCK_TYPE_BULLET_LIST + | BLOCK_TYPE_ORDERED_LIST + | BLOCK_TYPE_BLOCKQUOTE + | BLOCK_TYPE_DIV + | BLOCK_TYPE_FIGURE => Ok(super::containers::read_container(&block_type, map)), _ => { let inlines = reconstruct_inlines(map)?; if inlines.is_empty() { diff --git a/loki-doc-model/src/loro_bridge/write.rs b/loki-doc-model/src/loro_bridge/write.rs index 4f827949..15f33708 100644 --- a/loki-doc-model/src/loro_bridge/write.rs +++ b/loki-doc-model/src/loro_bridge/write.rs @@ -4,7 +4,10 @@ //! Serialization: Loki document model → Loro CRDT containers. use super::BridgeError; -use super::decode::{encode_alignment, encode_border, encode_line_height, encode_spacing}; +use super::color_codec::encode_document_color; +use super::decode::{ + encode_alignment, encode_border, encode_line_height, encode_spacing, encode_tab_stops, +}; use super::inlines::map_inlines; use crate::content::block::Block; use crate::loro_schema::*; @@ -15,12 +18,19 @@ use loro::{LoroMap, LoroMovableList, LoroText}; // ── Block serialization ─────────────────────────────────────────────────────── pub(crate) fn map_block(block: &Block, map: &LoroMap) -> Result<(), BridgeError> { - // Tables have a native mapping: a structural skeleton plus live per-cell - // block lists (see `table.rs`). Without `serde` there is no skeleton - // format, so the table takes the opaque path below instead. + // Tables and container blocks (lists, quotes, divs, figures) have native + // mappings: structural metadata plus live nested block lists (see + // `table.rs` / `containers.rs`). Without `serde` there is no metadata + // format, so they take the opaque path below instead. #[cfg(feature = "serde")] - if let Block::Table(table) = block { - return super::table::write_table(table, map); + match block { + Block::Table(table) => return super::table::write_table(table, map), + Block::BulletList(_) + | Block::OrderedList(_, _) + | Block::BlockQuote(_) + | Block::Div(_, _) + | Block::Figure(_, _, _) => return super::containers::write_container(block, map), + _ => {} } // Blocks (or paragraphs whose inline content) the flat text schema cannot // represent are preserved verbatim as opaque JSON snapshots so that a @@ -184,10 +194,10 @@ pub(super) fn map_para_props(props: &ParaProps, map: &LoroMap) -> Result<(), Bri map.insert(PROP_PADDING_RIGHT, v.value())?; } if let Some(v) = &props.tab_stops { - map.insert(PROP_TAB_STOPS, format!("{:?}", v))?; + map.insert(PROP_TAB_STOPS, encode_tab_stops(v))?; } if let Some(v) = &props.background_color { - map.insert("background_color", format!("{:?}", v))?; + map.insert(PROP_BACKGROUND_COLOR, encode_document_color(v))?; } Ok(()) } @@ -252,11 +262,11 @@ pub(super) fn map_char_props_to_map(props: &CharProps, map: &LoroMap) -> Result< if let Some(v) = &props.hyperlink { map.insert("hyperlink", v.as_str())?; } - if let Some(hex) = props.color.as_ref().and_then(|c| c.to_hex()) { - map.insert("color", hex)?; + if let Some(v) = &props.color { + map.insert("color", encode_document_color(v))?; } - if let Some(hex) = props.background_color.as_ref().and_then(|c| c.to_hex()) { - map.insert("background_color", hex)?; + if let Some(v) = &props.background_color { + map.insert("background_color", encode_document_color(v))?; } if let Some(v) = &props.highlight_color { map.insert("highlight_color", format!("{v:?}"))?; diff --git a/loki-doc-model/src/loro_mutation/mod.rs b/loki-doc-model/src/loro_mutation/mod.rs index 2cc30a9e..23e26a58 100644 --- a/loki-doc-model/src/loro_mutation/mod.rs +++ b/loki-doc-model/src/loro_mutation/mod.rs @@ -30,6 +30,7 @@ mod block; mod nested; #[cfg(feature = "serde")] mod objects; +mod selection; mod style; mod text; @@ -42,6 +43,7 @@ pub use self::nested::{ }; #[cfg(feature = "serde")] pub use self::objects::{insert_inline_image_at, insert_inline_note_at}; +pub use self::selection::delete_selection_at; pub use self::style::{ get_block_alignment, get_block_style_name, set_block_alignment, set_block_style, set_block_type_heading, set_block_type_para, @@ -88,6 +90,11 @@ pub enum MutationError { /// range. #[error("Invalid block path: {0}")] InvalidBlockPath(String), + /// A selection operation was given endpoints in different containers + /// (body ↔ table cell, cell ↔ cell, note ↔ body, or different sections). + /// Nothing is mutated. + #[error("Selection endpoints are in different containers")] + CrossContainerSelection, } impl From for MutationError { diff --git a/loki-doc-model/src/loro_mutation/selection.rs b/loki-doc-model/src/loro_mutation/selection.rs new file mode 100644 index 00000000..ca69c550 --- /dev/null +++ b/loki-doc-model/src/loro_mutation/selection.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Selection deletion: removing a contiguous text range that may span several +//! sibling blocks (Spec/audit F6c — typing and Backspace must operate on the +//! active selection). +//! +//! The range is expressed as two `(BlockPath, byte_offset)` endpoints in +//! either order. Both endpoints must lie in the **same container** (the same +//! top-level section space, the same table cell, or the same note body) — +//! a selection that crosses a container boundary (body ↔ cell, cell ↔ cell) +//! is rejected with [`MutationError::CrossContainerSelection`], mirroring the +//! formatting layer's clamping rule. +//! +//! A multi-block deletion is composed from the existing primitives: +//! every following block in the range is [`merge_block_at`]-ed into the +//! first (the merge concatenates text and removes the merged block), and one +//! [`delete_text_at`] then removes `[start_byte, join + end_byte)` from the +//! merged text — the tail of the first block, every middle block's text, and +//! the head of the last. Word's behaviour falls out naturally: the surviving +//! paragraph keeps the *first* block's style. + +use loro::{ContainerTrait, LoroDoc}; + +use super::nested::{BlockPath, PathStep, resolve_block_list, text_for_path}; +use super::{MutationError, delete_text_at, merge_block_at}; + +/// The block index of `path`'s leaf within its container (the root index for +/// a top-level path, the leaf step's block index for a nested one). +fn leaf_index(path: &BlockPath) -> usize { + match path.steps.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => *block, + None => path.root, + } +} + +/// `path` with its leaf block index replaced by `leaf`. +fn with_leaf(path: &BlockPath, leaf: usize) -> BlockPath { + let mut p = path.clone(); + match p.steps.last_mut() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => *block = leaf, + None => p.root = leaf, + } + p +} + +/// Validates that `byte` is a UTF-8 char boundary within the text of the block +/// at `path` (rejecting a stale offset before any mutation runs). +fn validate_offset(loro: &LoroDoc, path: &BlockPath, byte: usize) -> Result<(), MutationError> { + let text = text_for_path(loro, path)?.to_string(); + if byte > text.len() || !text.is_char_boundary(byte) { + return Err(MutationError::InvalidByteOffset { offset: byte }); + } + Ok(()) +} + +/// Whether two paths address sibling blocks of one container: both top-level, +/// or nested with identical root, identical non-leaf steps, and the same leaf +/// cell / note. +fn same_container(a: &BlockPath, b: &BlockPath) -> bool { + if a.steps.len() != b.steps.len() { + return false; + } + let Some(n) = a.steps.len().checked_sub(1) else { + return true; // both top-level; cross-section is caught by list identity + }; + if a.root != b.root || a.steps[..n] != b.steps[..n] { + return false; + } + match (a.steps[n], b.steps[n]) { + (PathStep::Cell { cell: c1, .. }, PathStep::Cell { cell: c2, .. }) => c1 == c2, + (PathStep::Note { note: n1, .. }, PathStep::Note { note: n2, .. }) => n1 == n2, + _ => false, + } +} + +/// Deletes the text between two positions (in either order), collapsing any +/// blocks the range spans. Returns the collapsed cursor position — the +/// ordered start endpoint. +/// +/// # Errors +/// +/// - [`MutationError::CrossContainerSelection`] — the endpoints live in +/// different containers (or different sections); nothing is mutated. +/// - [`MutationError::TextNotFound`] — a block inside the range carries no +/// editable text (e.g. a table or horizontal rule); nothing is mutated +/// (the whole range is validated before the first mutation). +/// - [`MutationError::InvalidByteOffset`] — an endpoint offset is past the end +/// of its block's text or not on a char boundary (e.g. a stale offset from a +/// concurrent edit); nothing is mutated. +/// - Any error the underlying primitives report. +pub fn delete_selection_at( + loro: &LoroDoc, + a: (&BlockPath, usize), + b: (&BlockPath, usize), +) -> Result<(BlockPath, usize), MutationError> { + if !same_container(a.0, b.0) { + return Err(MutationError::CrossContainerSelection); + } + // Normalize the endpoints into document order within the container. + let ((start_path, start_byte), (end_path, end_byte)) = + if (leaf_index(a.0), a.1) <= (leaf_index(b.0), b.1) { + (a, b) + } else { + (b, a) + }; + let start_leaf = leaf_index(start_path); + let end_leaf = leaf_index(end_path); + + // Validate the byte offsets against the actual block text lengths BEFORE + // any mutation. Without this, a stale offset (e.g. a concurrent remote edit + // that shortened a paragraph) would only surface when the final + // `delete_text_at` runs — after every `merge_block_at` has already mutated + // the document, leaving it half-applied — and `join + end_byte - start_byte` + // could underflow `usize`. Validating start against its (full) block also + // guarantees `start_byte <= join`, so that subtraction cannot underflow. + validate_offset(loro, start_path, start_byte)?; + validate_offset(loro, end_path, end_byte)?; + + // Single-block selection: one plain text deletion. + if start_leaf == end_leaf { + if end_byte > start_byte { + delete_text_at(loro, start_path, start_byte, end_byte - start_byte)?; + } + return Ok((start_path.clone(), start_byte)); + } + + // Validate the whole range BEFORE mutating, so unsupported content inside + // the selection (a table, a rule, a section break) rejects the operation + // instead of leaving it half-applied. + let (start_list, _) = resolve_block_list(loro, start_path)?; + let (end_list, _) = resolve_block_list(loro, end_path)?; + if start_list.id() != end_list.id() { + return Err(MutationError::CrossContainerSelection); + } + for leaf in start_leaf..=end_leaf { + text_for_path(loro, &with_leaf(start_path, leaf))?; + } + + // Merge every following block in the range into the start block. Each + // merge removes the merged block, so the next one is always at + // `start_leaf + 1`. The final join offset is where the end block's text + // begins within the merged text. + let merge_path = with_leaf(start_path, start_leaf + 1); + let mut join = 0usize; + for _ in start_leaf..end_leaf { + join = merge_block_at(loro, &merge_path)?; + } + delete_text_at(loro, start_path, start_byte, join + end_byte - start_byte)?; + Ok((start_path.clone(), start_byte)) +} diff --git a/loki-doc-model/src/loro_schema/marks.rs b/loki-doc-model/src/loro_schema/marks.rs new file mode 100644 index 00000000..a22ff745 --- /dev/null +++ b/loki-doc-model/src/loro_schema/marks.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Text-formatting mark keys and inline-object anchor constants. +//! +//! Split from the parent `loro_schema` module to keep files under the +//! 300-line ceiling. + +// ----------------------------------------------------------------------------- +// CharProps Mark Keys +// ----------------------------------------------------------------------------- + +pub const MARK_BOLD: &str = "bold"; +pub const MARK_ITALIC: &str = "italic"; +pub const MARK_UNDERLINE: &str = "underline"; +pub const MARK_STRIKETHROUGH: &str = "strikethrough"; +pub const MARK_COLOR: &str = "color"; +pub const MARK_HIGHLIGHT_COLOR: &str = "highlight_color"; +pub const MARK_FONT_FAMILY: &str = "font_family"; +pub const MARK_FONT_SIZE_PT: &str = "font_size_pt"; +pub const MARK_VERTICAL_ALIGN: &str = "vertical_align"; +pub const MARK_LINK_URL: &str = "link_url"; +pub const MARK_LANGUAGE: &str = "language"; +pub const MARK_LANGUAGE_COMPLEX: &str = "language_complex"; +pub const MARK_LANGUAGE_EAST_ASIAN: &str = "language_east_asian"; +pub const MARK_LETTER_SPACING: &str = "letter_spacing"; +pub const MARK_WORD_SPACING: &str = "word_spacing"; +pub const MARK_SCALE: &str = "scale"; +pub const MARK_SMALL_CAPS: &str = "small_caps"; +pub const MARK_ALL_CAPS: &str = "all_caps"; +pub const MARK_SHADOW: &str = "shadow"; +pub const MARK_KERNING: &str = "kerning"; +/// Named character style (`StyledRun::style_id`) carried as a mark so that +/// run-level style references survive Loro round-trips. +pub const MARK_CHAR_STYLE_ID: &str = "char_style_id"; +pub const MARK_OUTLINE: &str = "outline"; +/// `Inline::Quoted`'s quote style over the quoted range: "Single" | "Double". +pub const MARK_QUOTE_TYPE: &str = "quote_type"; +/// `Inline::Span`'s `NodeAttr` as a `serde`-JSON snapshot over the span range. +pub const MARK_SPAN_ATTR: &str = "span_attr"; + +// ----------------------------------------------------------------------------- +// Inline objects (anchored by a placeholder char + a data-bearing mark) +// ----------------------------------------------------------------------------- + +/// Object-replacement character (U+FFFC) — the in-text anchor for an inline +/// object whose structured data is carried by a mark over the single anchor +/// position. The anchor occupies one Unicode scalar so the object is a +/// discrete, positioned, deletable element in the flat text stream. +pub const OBJECT_REPLACEMENT_CHAR: char = '\u{FFFC}'; + +/// String form of [`OBJECT_REPLACEMENT_CHAR`] for `LoroText::insert`. +pub const OBJECT_REPLACEMENT_STR: &str = "\u{FFFC}"; + +/// Inline image data: a `serde`-JSON snapshot of the `Inline::Image`, carried +/// as a mark over a single [`OBJECT_REPLACEMENT_CHAR`] anchor so that +/// image-bearing paragraphs round-trip *natively* (the image is a live, +/// positioned inline object) instead of as opaque block snapshots. +pub const MARK_IMAGE: &str = "image"; + +/// Inline footnote/endnote reference: carried as a mark over a single +/// [`OBJECT_REPLACEMENT_CHAR`] anchor. The value is a `serde`-JSON +/// `(NoteKind, usize)` pair — the note's kind and the index of its **body** in +/// the block's [`KEY_NOTES`] container. The `usize` index also makes each note's +/// mark unique, so adjacent notes do not merge into one rich-text delta span. +/// +/// The body itself is a **live CRDT container** (not a JSON blob in the mark), +/// so footnote text is editable and mergeable like a table cell's. +pub const MARK_NOTE: &str = "note"; + +/// Key (within a paragraph-like block's map) for the block's note bodies — a +/// movable list with one entry per [`MARK_NOTE`] anchor in the block, each a +/// movable list of the note body's blocks (written via the shared block path). +/// Indexed by the `usize` carried in each anchor's [`MARK_NOTE`] mark. +pub const KEY_NOTES: &str = "notes"; + +/// Comment anchor (`Inline::Comment`): the whole inline as a `serde`-JSON +/// snapshot carried as a mark over a single [`OBJECT_REPLACEMENT_CHAR`], so +/// comment range markers survive CRDT round-trips as positioned anchors. +pub const MARK_COMMENT: &str = "comment"; + +/// Bookmark marker (`Inline::Bookmark`): the whole inline as a `serde`-JSON +/// snapshot carried as a mark over a single [`OBJECT_REPLACEMENT_CHAR`]. +pub const MARK_BOOKMARK: &str = "bookmark"; + +/// Every inline-object anchor mark key (carried over a single +/// [`OBJECT_REPLACEMENT_CHAR`]). Registered with non-expanding behaviour and +/// shared by the write/read paths. Keep in sync as new inline objects migrate +/// off the opaque-snapshot fallback. +pub const INLINE_OBJECT_MARK_KEYS: &[&str] = &[MARK_IMAGE, MARK_NOTE, MARK_COMMENT, MARK_BOOKMARK]; + +/// Every character-level mark key (formatting that lives on a text range). +/// +/// Single source of truth shared by `document_to_loro` (which registers each +/// key's `expand` behaviour) and `replace_text` (which resets an inserted +/// range's full formatting). Keep this in sync with the read/write paths. +pub const CHAR_MARK_KEYS: &[&str] = &[ + MARK_BOLD, + MARK_ITALIC, + MARK_UNDERLINE, + MARK_STRIKETHROUGH, + MARK_COLOR, + MARK_HIGHLIGHT_COLOR, + MARK_FONT_FAMILY, + MARK_FONT_SIZE_PT, + MARK_VERTICAL_ALIGN, + MARK_LINK_URL, + MARK_LANGUAGE, + MARK_LANGUAGE_COMPLEX, + MARK_LANGUAGE_EAST_ASIAN, + MARK_LETTER_SPACING, + MARK_WORD_SPACING, + MARK_SCALE, + MARK_SMALL_CAPS, + MARK_ALL_CAPS, + MARK_SHADOW, + MARK_KERNING, + MARK_OUTLINE, + MARK_CHAR_STYLE_ID, + MARK_QUOTE_TYPE, + MARK_SPAN_ATTR, +]; diff --git a/loki-doc-model/src/loro_schema.rs b/loki-doc-model/src/loro_schema/mod.rs similarity index 64% rename from loki-doc-model/src/loro_schema.rs rename to loki-doc-model/src/loro_schema/mod.rs index 8820becd..64bb6f86 100644 --- a/loki-doc-model/src/loro_schema.rs +++ b/loki-doc-model/src/loro_schema/mod.rs @@ -3,6 +3,9 @@ //! Schema constants for mapping Loki documents to Loro CRDT structures. +mod marks; +pub use marks::*; + /// Key for the Document metadata map. pub const KEY_METADATA: &str = "metadata"; @@ -81,6 +84,7 @@ pub const BLOCK_TYPE_FIGURE: &str = "figure"; pub const BLOCK_TYPE_CODE_BLOCK: &str = "code_block"; pub const BLOCK_TYPE_HR: &str = "hr"; pub const BLOCK_TYPE_BLOCKQUOTE: &str = "blockquote"; +pub const BLOCK_TYPE_DIV: &str = "div"; pub const BLOCK_TYPE_STYLED_PARA: &str = "styled_para"; /// Block preserved as an opaque JSON snapshot (see `loro_bridge::opaque`). /// Used for block types without a native CRDT mapping so that round-trips @@ -106,105 +110,24 @@ pub const KEY_TABLE_SKELETON: &str = "table_skeleton"; /// read by the same traversal order. pub const KEY_TABLE_CELLS: &str = "table_cells"; -// ----------------------------------------------------------------------------- -// CharProps Mark Keys -// ----------------------------------------------------------------------------- +/// Key for the JSON structural metadata of a native container block (see +/// `loro_bridge::containers`): the [`ListAttributes`] of a +/// [`BLOCK_TYPE_ORDERED_LIST`], the `NodeAttr` of a [`BLOCK_TYPE_DIV`], or the +/// `(NodeAttr, short caption)` pair of a [`BLOCK_TYPE_FIGURE`]. +/// [`BLOCK_TYPE_BULLET_LIST`] and [`BLOCK_TYPE_BLOCKQUOTE`] carry no metadata. +pub const KEY_CONTAINER_META: &str = "container_meta"; -pub const MARK_BOLD: &str = "bold"; -pub const MARK_ITALIC: &str = "italic"; -pub const MARK_UNDERLINE: &str = "underline"; -pub const MARK_STRIKETHROUGH: &str = "strikethrough"; -pub const MARK_COLOR: &str = "color"; -pub const MARK_HIGHLIGHT_COLOR: &str = "highlight_color"; -pub const MARK_FONT_FAMILY: &str = "font_family"; -pub const MARK_FONT_SIZE_PT: &str = "font_size_pt"; -pub const MARK_VERTICAL_ALIGN: &str = "vertical_align"; -pub const MARK_LINK_URL: &str = "link_url"; -pub const MARK_LANGUAGE: &str = "language"; -pub const MARK_LANGUAGE_COMPLEX: &str = "language_complex"; -pub const MARK_LANGUAGE_EAST_ASIAN: &str = "language_east_asian"; -pub const MARK_LETTER_SPACING: &str = "letter_spacing"; -pub const MARK_WORD_SPACING: &str = "word_spacing"; -pub const MARK_SCALE: &str = "scale"; -pub const MARK_SMALL_CAPS: &str = "small_caps"; -pub const MARK_ALL_CAPS: &str = "all_caps"; -pub const MARK_SHADOW: &str = "shadow"; -pub const MARK_KERNING: &str = "kerning"; -/// Named character style (`StyledRun::style_id`) carried as a mark so that -/// run-level style references survive Loro round-trips. -pub const MARK_CHAR_STYLE_ID: &str = "char_style_id"; -pub const MARK_OUTLINE: &str = "outline"; +/// Key for the live item contents of a native list block — a movable list with +/// one entry per list item, each entry itself a movable list of that item's +/// blocks (written via the shared block path, like [`KEY_TABLE_CELLS`]). +pub const KEY_LIST_ITEMS: &str = "list_items"; -// ----------------------------------------------------------------------------- -// Inline objects (anchored by a placeholder char + a data-bearing mark) -// ----------------------------------------------------------------------------- +/// Key for the live child blocks of a native container block (block-quote / +/// div children, figure content) — a movable list of blocks. +pub const KEY_CHILD_BLOCKS: &str = "child_blocks"; -/// Object-replacement character (U+FFFC) — the in-text anchor for an inline -/// object whose structured data is carried by a mark over the single anchor -/// position. The anchor occupies one Unicode scalar so the object is a -/// discrete, positioned, deletable element in the flat text stream. -pub const OBJECT_REPLACEMENT_CHAR: char = '\u{FFFC}'; - -/// String form of [`OBJECT_REPLACEMENT_CHAR`] for `LoroText::insert`. -pub const OBJECT_REPLACEMENT_STR: &str = "\u{FFFC}"; - -/// Inline image data: a `serde`-JSON snapshot of the `Inline::Image`, carried -/// as a mark over a single [`OBJECT_REPLACEMENT_CHAR`] anchor so that -/// image-bearing paragraphs round-trip *natively* (the image is a live, -/// positioned inline object) instead of as opaque block snapshots. -pub const MARK_IMAGE: &str = "image"; - -/// Inline footnote/endnote reference: carried as a mark over a single -/// [`OBJECT_REPLACEMENT_CHAR`] anchor. The value is a `serde`-JSON -/// `(NoteKind, usize)` pair — the note's kind and the index of its **body** in -/// the block's [`KEY_NOTES`] container. The `usize` index also makes each note's -/// mark unique, so adjacent notes do not merge into one rich-text delta span. -/// -/// The body itself is a **live CRDT container** (not a JSON blob in the mark), -/// so footnote text is editable and mergeable like a table cell's. -pub const MARK_NOTE: &str = "note"; - -/// Key (within a paragraph-like block's map) for the block's note bodies — a -/// movable list with one entry per [`MARK_NOTE`] anchor in the block, each a -/// movable list of the note body's blocks (written via the shared block path). -/// Indexed by the `usize` carried in each anchor's [`MARK_NOTE`] mark. -pub const KEY_NOTES: &str = "notes"; - -/// Every inline-object anchor mark key (carried over a single -/// [`OBJECT_REPLACEMENT_CHAR`]). Registered with non-expanding behaviour and -/// shared by the write/read paths. Keep in sync as new inline objects migrate -/// off the opaque-snapshot fallback. -pub const INLINE_OBJECT_MARK_KEYS: &[&str] = &[MARK_IMAGE, MARK_NOTE]; - -/// Every character-level mark key (formatting that lives on a text range). -/// -/// Single source of truth shared by `document_to_loro` (which registers each -/// key's `expand` behaviour) and `replace_text` (which resets an inserted -/// range's full formatting). Keep this in sync with the read/write paths. -pub const CHAR_MARK_KEYS: &[&str] = &[ - MARK_BOLD, - MARK_ITALIC, - MARK_UNDERLINE, - MARK_STRIKETHROUGH, - MARK_COLOR, - MARK_HIGHLIGHT_COLOR, - MARK_FONT_FAMILY, - MARK_FONT_SIZE_PT, - MARK_VERTICAL_ALIGN, - MARK_LINK_URL, - MARK_LANGUAGE, - MARK_LANGUAGE_COMPLEX, - MARK_LANGUAGE_EAST_ASIAN, - MARK_LETTER_SPACING, - MARK_WORD_SPACING, - MARK_SCALE, - MARK_SMALL_CAPS, - MARK_ALL_CAPS, - MARK_SHADOW, - MARK_KERNING, - MARK_OUTLINE, - MARK_CHAR_STYLE_ID, -]; +/// Key for the live caption body blocks of a native [`BLOCK_TYPE_FIGURE`]. +pub const KEY_CAPTION_BLOCKS: &str = "caption_blocks"; // ----------------------------------------------------------------------------- // ParaProps Keys @@ -239,6 +162,7 @@ pub const PROP_PADDING_BOTTOM: &str = "padding_bottom"; pub const PROP_PADDING_LEFT: &str = "padding_left"; pub const PROP_PADDING_RIGHT: &str = "padding_right"; pub const PROP_TAB_STOPS: &str = "tab_stops"; +pub const PROP_BACKGROUND_COLOR: &str = "background_color"; // ----------------------------------------------------------------------------- // Section / PageLayout Keys diff --git a/loki-doc-model/tests/loro_bridge_compact_tests.rs b/loki-doc-model/tests/loro_bridge_compact_tests.rs new file mode 100644 index 00000000..2bc54dc3 --- /dev/null +++ b/loki-doc-model/tests/loro_bridge_compact_tests.rs @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for CRDT history compaction (`loro_bridge::compact`, memory-audit +//! Finding 6): compaction must shrink the oplog without changing the +//! document, and the compacted doc must remain fully editable. + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::{ + compact_history, compact_in_place, document_to_loro, loro_to_document, +}; +use loki_doc_model::{delete_text, insert_text}; + +/// A document with one paragraph, put through `n` insert+delete keystroke +/// pairs so the oplog grows while the content stays fixed. +fn edited_doc(n: usize) -> loro::LoroDoc { + let mut doc = Document::new(); + doc.sections[0] + .blocks + .push(Block::Para(vec![Inline::Str("stable text".into())])); + let loro = document_to_loro(&doc).expect("document_to_loro"); + for _ in 0..n { + insert_text(&loro, 0, 0, "x").expect("insert"); + delete_text(&loro, 0, 0, 1).expect("delete"); + } + loro +} + +#[test] +fn compact_history_truncates_oplog_and_preserves_content() { + let loro = edited_doc(500); + let before = loro_to_document(&loro).expect("read before"); + let ops_before = loro.len_ops(); + + let compacted = compact_history(&loro).expect("compact_history"); + + let after = loro_to_document(&compacted).expect("read after"); + assert_eq!( + before.sections[0].blocks, after.sections[0].blocks, + "compaction must not change the document content" + ); + assert!( + compacted.len_ops() < ops_before / 10, + "oplog must shrink by at least 10x (was {ops_before}, now {})", + compacted.len_ops() + ); +} + +#[test] +fn compacted_doc_remains_editable_with_marks() { + let loro = edited_doc(100); + let compacted = compact_history(&loro).expect("compact_history"); + + // Edits must apply to the compacted doc and round-trip; the text-style + // config (mark expand behaviour) must have been re-registered. + insert_text(&compacted, 0, 0, "hello ").expect("insert after compaction"); + let doc = loro_to_document(&compacted).expect("read"); + match &doc.sections[0].blocks[0] { + Block::Para(inlines) => { + let text: String = inlines + .iter() + .map(|i| match i { + Inline::Str(s) => s.clone(), + other => panic!("unexpected inline: {other:?}"), + }) + .collect(); + assert_eq!(text, "hello stable text"); + } + other => panic!("expected Para, got {other:?}"), + } +} + +#[test] +fn compact_history_is_repeatable() { + // Compact, edit, compact again — the second pass must also succeed and + // keep the state (guards against one-shot assumptions in the swap). + let loro = edited_doc(100); + let once = compact_history(&loro).expect("first compaction"); + insert_text(&once, 0, 0, "more ").expect("edit"); + let twice = compact_history(&once).expect("second compaction"); + let doc = loro_to_document(&twice).expect("read"); + match &doc.sections[0].blocks[0] { + Block::Para(inlines) => { + assert!(matches!(&inlines[0], Inline::Str(s) if s.starts_with("more "))); + } + other => panic!("expected Para, got {other:?}"), + } +} + +#[test] +fn compact_in_place_preserves_history_and_content() { + let loro = edited_doc(200); + let before = loro_to_document(&loro).expect("read before"); + let ops_before = loro.len_ops(); + + compact_in_place(&loro); + + // History is preserved (this is the memory-representation compaction), + // the document is unchanged, and the doc stays editable. + assert_eq!(loro.len_ops(), ops_before, "history must be preserved"); + let after = loro_to_document(&loro).expect("read after"); + assert_eq!(before.sections[0].blocks, after.sections[0].blocks); + insert_text(&loro, 0, 0, "y").expect("still editable"); +} diff --git a/loki-doc-model/tests/loro_bridge_container_tests.rs b/loki-doc-model/tests/loro_bridge_container_tests.rs new file mode 100644 index 00000000..a39dce9e --- /dev/null +++ b/loki-doc-model/tests/loro_bridge_container_tests.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Loro bridge round-trip tests for the native container-block mappings +//! (`loro_bridge/containers.rs`): bullet/ordered lists, block quotes, divs, +//! and figures must survive a document_to_loro → loro_to_document cycle as +//! their own variants — not as opaque snapshots and not as the pre-mapping +//! `HorizontalRule` stubs. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::{ + Block, Caption, ListAttributes, ListDelimiter, ListNumberStyle, +}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document}; + +fn round_trip_block(block: Block) -> Block { + let mut doc = Document::new(); + doc.sections[0].blocks.push(block); + let loro = document_to_loro(&doc).expect("document_to_loro must succeed"); + let recovered = loro_to_document(&loro).expect("loro_to_document must succeed"); + recovered.sections[0] + .blocks + .first() + .expect("block must survive round-trip") + .clone() +} + +fn para(text: &str) -> Block { + Block::Para(vec![Inline::Str(text.into())]) +} + +// ── Lists ───────────────────────────────────────────────────────────────────── + +#[test] +fn bullet_list_roundtrips_natively() { + let list = Block::BulletList(vec![ + vec![para("first item")], + vec![para("second item"), para("second item, second para")], + vec![], // empty item must survive as an item, not vanish + ]); + assert_eq!(round_trip_block(list.clone()), list); +} + +#[test] +fn ordered_list_roundtrips_attributes_and_items() { + let list = Block::OrderedList( + ListAttributes { + start_number: 7, + style: ListNumberStyle::LowerRoman, + delimiter: ListDelimiter::TwoParens, + }, + vec![vec![para("vii")], vec![para("viii")]], + ); + assert_eq!(round_trip_block(list.clone()), list); +} + +#[test] +fn nested_bullet_list_roundtrips() { + let inner = Block::BulletList(vec![vec![para("inner")]]); + let outer = Block::BulletList(vec![vec![para("outer"), inner]]); + assert_eq!(round_trip_block(outer.clone()), outer); +} + +// ── Block quote / div ───────────────────────────────────────────────────────── + +#[test] +fn block_quote_roundtrips_natively() { + let quote = Block::BlockQuote(vec![para("quoted"), para("still quoted")]); + assert_eq!(round_trip_block(quote.clone()), quote); +} + +#[test] +fn div_roundtrips_attr_and_children() { + let mut attr = NodeAttr::default(); + attr.id = Some("sidebar-1".into()); + attr.classes.push("sidebar".into()); + attr.kv.push(("role".into(), "note".into())); + let div = Block::Div(attr, vec![para("div body")]); + assert_eq!(round_trip_block(div.clone()), div); +} + +// ── Figure ──────────────────────────────────────────────────────────────────── + +#[test] +fn figure_roundtrips_caption_and_content() { + let mut attr = NodeAttr::default(); + attr.id = Some("fig-1".into()); + let figure = Block::Figure( + attr, + Caption { + short: Some(vec![Inline::Str("Short".into())]), + full: vec![para("Full caption body")], + }, + vec![para("figure content")], + ); + assert_eq!(round_trip_block(figure.clone()), figure); +} + +// ── Regression: the old failure mode ────────────────────────────────────────── + +/// The pre-mapping bridge collapsed these types to `HorizontalRule` after one +/// CRDT cycle. Guard the whole family against reintroducing that. +#[test] +fn no_container_collapses_to_horizontal_rule() { + let blocks = vec![ + Block::BulletList(vec![vec![para("x")]]), + Block::OrderedList(ListAttributes::default(), vec![vec![para("y")]]), + Block::BlockQuote(vec![para("z")]), + Block::Div(NodeAttr::default(), vec![para("w")]), + Block::Figure(NodeAttr::default(), Caption::default(), vec![para("v")]), + ]; + for block in blocks { + let recovered = round_trip_block(block.clone()); + assert!( + !matches!(recovered, Block::HorizontalRule), + "{block:?} must not collapse to HorizontalRule" + ); + assert_eq!(recovered, block); + } +} diff --git a/loki-doc-model/tests/loro_bridge_gap_tests.rs b/loki-doc-model/tests/loro_bridge_gap_tests.rs index 54d1459c..1eff244f 100644 --- a/loki-doc-model/tests/loro_bridge_gap_tests.rs +++ b/loki-doc-model/tests/loro_bridge_gap_tests.rs @@ -4,7 +4,8 @@ //! Loro bridge round-trip tests for L-severity gap fixes. //! //! Verifies that language, border, padding, page_break_before, orphan_control, -//! and outline_level all survive a document_to_loro → loro_to_document cycle. +//! outline_level, tab_stops, and paragraph background_color all survive a +//! document_to_loro → loro_to_document cycle. use loki_doc_model::content::attr::NodeAttr; use loki_doc_model::content::block::Block; @@ -15,7 +16,8 @@ use loki_doc_model::meta::language::LanguageTag; use loki_doc_model::style::props::border::{Border, BorderStyle}; use loki_doc_model::style::props::char_props::CharProps; use loki_doc_model::style::props::para_props::ParaProps; -use loki_primitives::color::DocumentColor; +use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; +use loki_primitives::color::{DocumentColor, ThemeColorSlot}; use loki_primitives::units::Points; fn round_trip(doc: &Document) -> Document { @@ -166,6 +168,93 @@ fn bridge_border_roundtrip() { ); } +// ── bridge_tab_stops_roundtrip ──────────────────────────────────────────────── + +fn styled_para_with_para(props: ParaProps) -> Block { + Block::StyledPara(loki_doc_model::content::block::StyledParagraph { + style_id: None, + direct_para_props: Some(Box::new(props)), + direct_char_props: None, + inlines: vec![Inline::Str("text".into())], + attr: NodeAttr::default(), + }) +} + +fn recovered_para_props(doc: &Document) -> ParaProps { + doc.sections[0] + .blocks + .iter() + .find_map(|b| { + if let Block::StyledPara(p) = b { + p.direct_para_props.as_deref().cloned() + } else { + None + } + }) + .expect("StyledPara with direct_para_props must survive round-trip") +} + +/// `ParaProps.tab_stops` must survive a Loro CRDT round-trip with position, +/// alignment, and leader intact (was written as an unreadable Debug string). +#[test] +fn bridge_tab_stops_roundtrip() { + let mut para_props = ParaProps::default(); + para_props.tab_stops = Some(vec![ + TabStop { + position: Points::new(36.0), + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + TabStop { + position: Points::new(144.5), + alignment: TabAlignment::Decimal, + leader: TabLeader::Dot, + }, + ]); + + let doc = single_block_doc(styled_para_with_para(para_props)); + let pp = recovered_para_props(&round_trip(&doc)); + + let stops = pp.tab_stops.as_ref().expect("tab_stops must survive"); + assert_eq!(stops.len(), 2, "both tab stops must survive"); + assert!((stops[0].position.value() - 36.0).abs() < 0.001); + assert_eq!(stops[0].alignment, TabAlignment::Left); + assert_eq!(stops[0].leader, TabLeader::None); + assert!((stops[1].position.value() - 144.5).abs() < 0.001); + assert_eq!(stops[1].alignment, TabAlignment::Decimal); + assert_eq!(stops[1].leader, TabLeader::Dot); +} + +// ── bridge_para_background_color_roundtrip ──────────────────────────────────── + +/// Paragraph `background_color` must survive a Loro CRDT round-trip (was +/// written as a Debug string the reader could not parse) — including non-Rgb +/// variants, which the codec must not collapse or drop. +#[test] +fn bridge_para_background_color_roundtrip() { + for color in [ + DocumentColor::from_hex("#ABCDEF").unwrap(), + DocumentColor::Cmyk(loki_primitives::color::CmykColor::new(0.1, 0.2, 0.3, 0.4)), + DocumentColor::Theme { + slot: ThemeColorSlot::Accent3, + tint: 0.25, + }, + DocumentColor::Transparent, + ] { + let mut para_props = ParaProps::default(); + para_props.background_color = Some(color.clone()); + + let doc = single_block_doc(styled_para_with_para(para_props)); + let pp = recovered_para_props(&round_trip(&doc)); + + assert_eq!( + pp.background_color, + Some(color), + "paragraph background_color must survive Loro round-trip" + ); + } +} + // ── bridge_para_fields_roundtrip ────────────────────────────────────────────── /// `ParaProps.page_break_before`, `orphan_control`, `outline_level`, and diff --git a/loki-doc-model/tests/loro_bridge_inline_tail_tests.rs b/loki-doc-model/tests/loro_bridge_inline_tail_tests.rs new file mode 100644 index 00000000..0e955346 --- /dev/null +++ b/loki-doc-model/tests/loro_bridge_inline_tail_tests.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Loro bridge round-trip tests for the inline "tail" fixes: non-Rgb character +//! colors (Theme/Cmyk), comment/bookmark anchors, and quote-type / span-attr +//! range marks — all previously dropped or collapsed by the bridge. + +use loki_doc_model::content::annotation::comment::{CommentRef, CommentRefKind}; +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{BookmarkKind, Inline, QuoteType, StyledRun}; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document}; +use loki_doc_model::style::props::char_props::CharProps; +use loki_primitives::color::{CmykColor, DocumentColor, ThemeColorSlot}; + +fn round_trip_inlines(inlines: Vec) -> Vec { + let mut doc = Document::new(); + doc.sections[0].blocks.push(Block::Para(inlines)); + let loro = document_to_loro(&doc).expect("document_to_loro must succeed"); + let recovered = loro_to_document(&loro).expect("loro_to_document must succeed"); + match &recovered.sections[0].blocks[0] { + Block::Para(inlines) => inlines.clone(), + other => panic!("expected Para, got {other:?}"), + } +} + +fn styled_run(text: &str, props: CharProps) -> Inline { + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(props)), + content: vec![Inline::Str(text.into())], + attr: NodeAttr::default(), + }) +} + +fn recovered_color(inlines: &[Inline]) -> Option { + inlines.iter().find_map(|i| { + if let Inline::StyledRun(run) = i { + run.direct_props.as_ref().and_then(|p| p.color.clone()) + } else { + None + } + }) +} + +// ── Non-Rgb character colors ────────────────────────────────────────────────── + +#[test] +fn theme_char_color_survives_roundtrip() { + let color = DocumentColor::Theme { + slot: ThemeColorSlot::Accent2, + tint: -0.5, + }; + let props = CharProps { + color: Some(color.clone()), + ..Default::default() + }; + let recovered = round_trip_inlines(vec![styled_run("themed", props)]); + assert_eq!(recovered_color(&recovered), Some(color)); +} + +#[test] +fn cmyk_char_color_survives_roundtrip() { + let color = DocumentColor::Cmyk(CmykColor::new(0.9, 0.1, 0.0, 0.25)); + let props = CharProps { + color: Some(color.clone()), + ..Default::default() + }; + let recovered = round_trip_inlines(vec![styled_run("print", props)]); + assert_eq!(recovered_color(&recovered), Some(color)); +} + +// ── Comment / bookmark anchors ──────────────────────────────────────────────── + +#[test] +fn comment_anchors_survive_roundtrip() { + let start = Inline::Comment(CommentRef::new("c1", CommentRefKind::Start)); + let end = Inline::Comment(CommentRef::new("c1", CommentRefKind::End)); + let recovered = round_trip_inlines(vec![ + Inline::Str("before ".into()), + start.clone(), + Inline::Str("commented".into()), + end.clone(), + Inline::Str(" after".into()), + ]); + let anchors: Vec<_> = recovered + .iter() + .filter(|i| matches!(i, Inline::Comment(_))) + .collect(); + assert_eq!(anchors.len(), 2, "both comment anchors must survive"); + assert_eq!(anchors[0], &start); + assert_eq!(anchors[1], &end); + // The anchors must stay positioned between the text runs. + let text: String = recovered + .iter() + .map(|i| match i { + Inline::Str(s) => s.as_str(), + _ => "|", + }) + .collect(); + assert_eq!(text, "before |commented| after"); +} + +#[test] +fn bookmark_markers_survive_roundtrip() { + let start = Inline::Bookmark(BookmarkKind::Start, "bm-1".into()); + let end = Inline::Bookmark(BookmarkKind::End, "bm-1".into()); + let recovered = round_trip_inlines(vec![ + start.clone(), + Inline::Str("marked".into()), + end.clone(), + ]); + let marks: Vec<_> = recovered + .iter() + .filter(|i| matches!(i, Inline::Bookmark(_, _))) + .collect(); + assert_eq!(marks.len(), 2, "both bookmark markers must survive"); + assert_eq!(marks[0], &start); + assert_eq!(marks[1], &end); +} + +// ── Quote type / span attrs ─────────────────────────────────────────────────── + +#[test] +fn quoted_text_keeps_quote_type() { + for quote_type in [QuoteType::SingleQuote, QuoteType::DoubleQuote] { + let quoted = Inline::Quoted(quote_type, vec![Inline::Str("quoted".into())]); + let recovered = round_trip_inlines(vec![quoted.clone()]); + assert_eq!( + recovered, + vec![quoted], + "{quote_type:?} must survive Loro round-trip" + ); + } +} + +#[test] +fn span_keeps_node_attr() { + let mut attr = NodeAttr::default(); + attr.id = Some("term-3".into()); + attr.classes.push("glossary-term".into()); + attr.kv.push(("data-ref".into(), "g3".into())); + let span = Inline::Span(attr, vec![Inline::Str("spanned".into())]); + let recovered = round_trip_inlines(vec![span.clone()]); + assert_eq!(recovered, vec![span]); +} + +#[test] +fn quoted_span_nesting_order_is_preserved() { + let mut attr = NodeAttr::default(); + attr.classes.push("inner".into()); + let quoted_span = Inline::Quoted( + QuoteType::DoubleQuote, + vec![Inline::Span(attr, vec![Inline::Str("both".into())])], + ); + let recovered = round_trip_inlines(vec![quoted_span.clone()]); + assert_eq!(recovered, vec![quoted_span]); +} diff --git a/loki-doc-model/tests/loro_bridge_tests.rs b/loki-doc-model/tests/loro_bridge_tests.rs index a5e76a6e..c458fc23 100644 --- a/loki-doc-model/tests/loro_bridge_tests.rs +++ b/loki-doc-model/tests/loro_bridge_tests.rs @@ -498,7 +498,7 @@ fn roundtrip_rgb_color_mark() { } #[test] -fn roundtrip_transparent_color_graceful_drop() { +fn roundtrip_transparent_color_survives() { let mut doc = Document::new(); let props = CharProps { color: Some(DocumentColor::Transparent), @@ -517,17 +517,18 @@ fn roundtrip_transparent_color_graceful_drop() { let loro = document_to_loro(&doc).expect("serialize"); let doc2 = loro_to_document(&loro).expect("deserialize — must not crash"); - // Transparent has no hex representation so the mark is dropped gracefully + // The total DocumentColor codec carries every variant, including + // Transparent (formerly dropped for lack of a hex representation). if let Block::Para(inlines) = &doc2.sections[0].blocks[0] { match &inlines[0] { Inline::StyledRun(run) => { let color = run.direct_props.as_ref().and_then(|p| p.color.as_ref()); - assert!( - color.is_none(), - "Transparent should not round-trip as a color value" + assert_eq!( + color, + Some(&DocumentColor::Transparent), + "Transparent must round-trip as a color value" ); } - Inline::Str(_) => {} // also acceptable — no mark means plain Str other => panic!("unexpected inline variant: {other:?}"), } } else { diff --git a/loki-doc-model/tests/loro_selection_delete_tests.rs b/loki-doc-model/tests/loro_selection_delete_tests.rs new file mode 100644 index 00000000..f61a037b --- /dev/null +++ b/loki-doc-model/tests/loro_selection_delete_tests.rs @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Selection deletion ([`delete_selection_at`]): removing ranges that span one +//! or several sibling blocks, in either endpoint order, at the top level and +//! inside table cells — plus the rejection cases (cross-container endpoints, +//! non-text blocks inside the range) proving nothing is half-applied. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::content::table::core::{Table, TableBody, TableCaption, TableFoot, TableHead}; +use loki_doc_model::content::table::row::{Cell, Row}; +use loki_doc_model::document::Document; +use loki_doc_model::layout::section::Section; +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document}; +use loki_doc_model::{BlockPath, MutationError, delete_selection_at}; + +fn para(s: &str) -> Block { + Block::Para(vec![Inline::Str(s.into())]) +} + +/// Plain text of every top-level paragraph of section `s`. +fn para_texts(doc: &Document, s: usize) -> Vec { + doc.sections[s] + .blocks + .iter() + .filter_map(|b| match b { + Block::Para(inlines) => Some( + inlines + .iter() + .filter_map(|i| match i { + Inline::Str(t) => Some(t.as_str()), + _ => None, + }) + .collect(), + ), + _ => None, + }) + .collect() +} + +fn three_para_doc() -> Document { + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("Hello world"), para("middle"), para("goodbye")]; + doc +} + +#[test] +fn deletes_within_a_single_block() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + // "Hello world" -> "Helld" (delete "lo wor", bytes 3..9). + let p = BlockPath::block(0); + let (path, byte) = delete_selection_at(&loro, (&p, 3), (&p, 9)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(0), 3)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["Helld", "middle", "goodbye"]); +} + +#[test] +fn a_collapsed_selection_deletes_nothing() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + let p = BlockPath::block(1); + let (path, byte) = delete_selection_at(&loro, (&p, 3), (&p, 3)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(1), 3)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!( + para_texts(&rebuilt, 0), + vec!["Hello world", "middle", "goodbye"] + ); +} + +#[test] +fn collapses_a_range_spanning_three_blocks() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + // From byte 6 of "Hello world" to byte 4 of "goodbye": the tail of block + // 0, all of "middle", and "good" vanish; the survivor keeps block 0's + // head and block 2's tail. + let (start, end) = (BlockPath::block(0), BlockPath::block(2)); + let (path, byte) = delete_selection_at(&loro, (&start, 6), (&end, 4)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(0), 6)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["Hello bye"]); +} + +#[test] +fn endpoints_normalize_in_either_order() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + // Same range as above, endpoints swapped (focus before anchor). + let (a, b) = (BlockPath::block(2), BlockPath::block(0)); + let (path, byte) = delete_selection_at(&loro, (&a, 4), (&b, 6)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(0), 6)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["Hello bye"]); +} + +#[test] +fn reversed_offsets_within_one_block_normalize() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + let p = BlockPath::block(0); + let (path, byte) = delete_selection_at(&loro, (&p, 9), (&p, 3)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(0), 3)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["Helld", "middle", "goodbye"]); +} + +#[test] +fn rejects_a_stale_end_offset_without_mutating() { + // A multi-block selection whose END offset is past the last block's text + // (e.g. a concurrent edit shortened "goodbye"): the pre-validation must + // reject it before any merge runs, leaving all three paragraphs intact. + let loro = document_to_loro(&three_para_doc()).unwrap(); + let (start, end) = (BlockPath::block(0), BlockPath::block(2)); + let err = delete_selection_at(&loro, (&start, 6), (&end, 99)); + assert!(matches!( + err, + Err(MutationError::InvalidByteOffset { offset: 99 }) + )); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!( + para_texts(&rebuilt, 0), + vec!["Hello world", "middle", "goodbye"] + ); +} + +#[test] +fn rejects_a_stale_start_offset_without_underflow() { + // A multi-block selection whose START offset is past the first block's text + // would make `join + end_byte - start_byte` underflow `usize` if it reached + // the delete; the pre-validation must reject it first, untouched. + let loro = document_to_loro(&three_para_doc()).unwrap(); + let (start, end) = (BlockPath::block(0), BlockPath::block(2)); + let err = delete_selection_at(&loro, (&start, 99), (&end, 4)); + assert!(matches!( + err, + Err(MutationError::InvalidByteOffset { offset: 99 }) + )); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!( + para_texts(&rebuilt, 0), + vec!["Hello world", "middle", "goodbye"] + ); +} + +// ── Nested containers ─────────────────────────────────────────────────────── + +/// `[Para("intro"), Table]` — the table (global block 1) has one body row of +/// two cells; cell 0 holds paragraphs "alpha" | "beta", cell 1 holds "z". +fn doc_with_two_block_cell() -> Document { + let cell = Cell::simple(vec![para("alpha"), para("beta")]); + let table = Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![ + cell, + Cell::simple(vec![para("z")]), + ])])], + foot: TableFoot::empty(), + }; + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("intro"), Block::Table(Box::new(table))]; + doc +} + +/// Plain text of every paragraph in the `cell`-th cell of the table at global +/// block index 1. +fn cell_block_texts(doc: &Document, cell: usize) -> Vec { + let Block::Table(t) = &doc.sections[0].blocks[1] else { + panic!("expected a table"); + }; + t.bodies[0].body_rows[0].cells[cell] + .blocks + .iter() + .filter_map(|b| match b { + Block::Para(inlines) => Some( + inlines + .iter() + .filter_map(|i| match i { + Inline::Str(s) => Some(s.as_str()), + _ => None, + }) + .collect(), + ), + _ => None, + }) + .collect() +} + +#[test] +fn collapses_a_range_inside_one_table_cell() { + let loro = document_to_loro(&doc_with_two_block_cell()).unwrap(); + // "alpha"|"beta" -> "alta": from byte 2 of "alpha" to byte 2 of "beta". + let (a, b) = (BlockPath::in_cell(1, 0, 0), BlockPath::in_cell(1, 0, 1)); + let (path, byte) = delete_selection_at(&loro, (&a, 2), (&b, 2)).unwrap(); + assert_eq!((path, byte), (BlockPath::in_cell(1, 0, 0), 2)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(cell_block_texts(&rebuilt, 0), vec!["alta"]); + assert_eq!(cell_block_texts(&rebuilt, 1), vec!["z"]); +} + +#[test] +fn rejects_endpoints_in_different_cells_without_mutating() { + let loro = document_to_loro(&doc_with_two_block_cell()).unwrap(); + let (a, b) = (BlockPath::in_cell(1, 0, 0), BlockPath::in_cell(1, 1, 0)); + let err = delete_selection_at(&loro, (&a, 1), (&b, 1)); + assert!(matches!(err, Err(MutationError::CrossContainerSelection))); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(cell_block_texts(&rebuilt, 0), vec!["alpha", "beta"]); + assert_eq!(cell_block_texts(&rebuilt, 1), vec!["z"]); +} + +#[test] +fn rejects_a_body_to_cell_selection_without_mutating() { + let loro = document_to_loro(&doc_with_two_block_cell()).unwrap(); + let (a, b) = (BlockPath::block(0), BlockPath::in_cell(1, 0, 0)); + let err = delete_selection_at(&loro, (&a, 1), (&b, 1)); + assert!(matches!(err, Err(MutationError::CrossContainerSelection))); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["intro"]); + assert_eq!(cell_block_texts(&rebuilt, 0), vec!["alpha", "beta"]); +} + +#[test] +fn rejects_a_range_containing_a_table_without_mutating() { + // [Para, Table, Para]: a top-level selection from block 0 to block 2 would + // swallow the table — the pre-validation must reject it untouched. + let mut doc = doc_with_two_block_cell(); + doc.sections[0].blocks.push(para("outro")); + let loro = document_to_loro(&doc).unwrap(); + let (a, b) = (BlockPath::block(0), BlockPath::block(2)); + let err = delete_selection_at(&loro, (&a, 1), (&b, 1)); + assert!(matches!(err, Err(MutationError::TextNotFound(_)))); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["intro", "outro"]); + assert_eq!(cell_block_texts(&rebuilt, 0), vec!["alpha", "beta"]); +} + +#[test] +fn rejects_a_cross_section_selection_without_mutating() { + // Two sections of one paragraph each: global blocks 0 and 1 are top-level + // siblings by index but live in different section lists. + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("first")]; + let mut s1 = Section::new(); + s1.blocks = vec![para("second")]; + doc.sections.push(s1); + let loro = document_to_loro(&doc).unwrap(); + let (a, b) = (BlockPath::block(0), BlockPath::block(1)); + let err = delete_selection_at(&loro, (&a, 1), (&b, 1)); + assert!(matches!(err, Err(MutationError::CrossContainerSelection))); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["first"]); + assert_eq!(para_texts(&rebuilt, 1), vec!["second"]); +} diff --git a/loki-fonts/fonts/Gelasio-Bold.ttf b/loki-fonts/fonts/Gelasio-Bold.ttf new file mode 100644 index 00000000..0f6307f8 Binary files /dev/null and b/loki-fonts/fonts/Gelasio-Bold.ttf differ diff --git a/loki-fonts/fonts/Gelasio-BoldItalic.ttf b/loki-fonts/fonts/Gelasio-BoldItalic.ttf new file mode 100644 index 00000000..7db78888 Binary files /dev/null and b/loki-fonts/fonts/Gelasio-BoldItalic.ttf differ diff --git a/loki-fonts/fonts/Gelasio-Italic.ttf b/loki-fonts/fonts/Gelasio-Italic.ttf new file mode 100644 index 00000000..9c9c732d Binary files /dev/null and b/loki-fonts/fonts/Gelasio-Italic.ttf differ diff --git a/loki-fonts/fonts/Gelasio-Regular.ttf b/loki-fonts/fonts/Gelasio-Regular.ttf new file mode 100644 index 00000000..36d51419 Binary files /dev/null and b/loki-fonts/fonts/Gelasio-Regular.ttf differ diff --git a/loki-fonts/fonts/OFL-Gelasio.txt b/loki-fonts/fonts/OFL-Gelasio.txt new file mode 100644 index 00000000..e4e73aba --- /dev/null +++ b/loki-fonts/fonts/OFL-Gelasio.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio) Gelasio-Italic[wght].ttf: Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/loki-fonts/src/lib.rs b/loki-fonts/src/lib.rs index aae4f4ed..fc2f2b97 100644 --- a/loki-fonts/src/lib.rs +++ b/loki-fonts/src/lib.rs @@ -11,10 +11,15 @@ //! | Arimo | Arial | //! | Cousine | Courier New | //! | Tinos | Times New Roman | +//! | Gelasio | Georgia | //! //! Atkinson Hyperlegible Next is licensed under SIL OFL 1.1 by the Braille //! Institute; the metric-compatible faces under SIL OFL 1.1 from -//! . +//! . The bundled Gelasio faces (added under +//! Spec 02 B-10; license in `fonts/OFL-Gelasio.txt`, no Reserved Font Name) +//! were reconstructed from the `@fontsource/gelasio` npm distribution by +//! merging its latin + latin-ext + vietnamese subsets with fonttools — the +//! full upstream coverage for this face. //! //! # Usage //! @@ -50,10 +55,10 @@ const ATKINSON_VF: &[u8] = include_bytes!("../fonts/AtkinsonHyperlegibleNext-VF. /// Raw bytes of every bundled UI/fallback face, for **synchronous** registration /// into the renderer's Parley `FontContext` at launch. /// -/// Includes the Atkinson Hyperlegible Next UI variable font followed by the five +/// Includes the Atkinson Hyperlegible Next UI variable font followed by the six /// metric-compatible fallback families (see [`fallback_font_blobs`]). Registering /// these at startup makes the family names ("Atkinson Hyperlegible Next", -/// "Carlito", "Caladea", "Arimo", "Cousine", "Tinos") resolve immediately on +/// "Carlito", "Caladea", "Arimo", "Cousine", "Tinos", "Gelasio") resolve immediately on /// every platform, without relying on the asynchronous `@font-face` `data:` URI /// fetch (which is unreliable on Android). pub fn ui_font_blobs() -> Vec> { @@ -64,8 +69,8 @@ pub fn ui_font_blobs() -> Vec> { } /// Raw bytes of every bundled metric-compatible fallback face (Carlito, Caladea, -/// Arimo, Cousine, Tinos), for direct registration into the document layout -/// engine's font collection. +/// Arimo, Cousine, Tinos, Gelasio), for direct registration into the document +/// layout engine's font collection. /// /// Available on **all** platforms. The layout engine registers these lazily, only /// when a substitute family (e.g. Carlito for Calibri) is requested but not found @@ -100,6 +105,11 @@ pub fn fallback_font_blobs() -> &'static [&'static [u8]] { include_bytes!("../fonts/Carlito-Bold.ttf"), include_bytes!("../fonts/Carlito-Italic.ttf"), include_bytes!("../fonts/Carlito-BoldItalic.ttf"), + // Gelasio — metric-compatible Georgia (Spec 02 B-10) + include_bytes!("../fonts/Gelasio-Regular.ttf"), + include_bytes!("../fonts/Gelasio-Bold.ttf"), + include_bytes!("../fonts/Gelasio-Italic.ttf"), + include_bytes!("../fonts/Gelasio-BoldItalic.ttf"), ]; FACES } diff --git a/loki-i18n/i18n/en-US/home.ftl b/loki-i18n/i18n/en-US/home.ftl index 61deafb6..0ec2f66b 100644 --- a/loki-i18n/i18n/en-US/home.ftl +++ b/loki-i18n/i18n/en-US/home.ftl @@ -59,3 +59,9 @@ home-template-portfolio = Portfolio home-template-portfolio-description = Visual design portfolio home-template-portfolio-format = PPTX home-filter-label-presentation = Presentations + +# Recents delete confirmation +home-delete-confirm-title = Delete file +home-delete-confirm-message = Permanently delete "{ $title }"? This cannot be undone. +home-delete-confirm-confirm = Delete +home-delete-confirm-cancel = Cancel diff --git a/loki-i18n/i18n/en-US/shell.ftl b/loki-i18n/i18n/en-US/shell.ftl index f7d011ff..628b3cc4 100644 --- a/loki-i18n/i18n/en-US/shell.ftl +++ b/loki-i18n/i18n/en-US/shell.ftl @@ -10,3 +10,9 @@ shell-home-tab = Home shell-tab-bar-aria = Open documents shell-new-document-aria = New document shell-close-tab-aria = Close { $title } + +# Dirty-tab close confirmation +shell-close-dirty-title = Unsaved changes +shell-close-dirty-message = "{ $title }" has unsaved changes. Close the tab and discard them? +shell-close-dirty-confirm = Discard changes +shell-close-dirty-cancel = Keep editing diff --git a/loki-layout/src/font.rs b/loki-layout/src/font.rs index 2c598157..db3dca91 100644 --- a/loki-layout/src/font.rs +++ b/loki-layout/src/font.rs @@ -163,6 +163,7 @@ impl FontResources { "times new roman" => Some("Tinos"), "calibri" => Some("Carlito"), "cambria" => Some("Caladea"), + "georgia" => Some("Gelasio"), _ => None, }; @@ -252,7 +253,7 @@ mod tests { #[test] fn substituted_family_is_actually_available() { let mut r = FontResources::new(); - for requested in ["Calibri", "Arial", "Times New Roman", "Cambria"] { + for requested in ["Calibri", "Arial", "Times New Roman", "Cambria", "Georgia"] { let resolved = r.resolve_font_name(requested); assert!( r.font_cx.collection.family_id(resolved.as_str()).is_some(), diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index fd5d38bc..f3531835 100644 --- a/loki-layout/src/lib.rs +++ b/loki-layout/src/lib.rs @@ -30,6 +30,7 @@ pub mod font; pub mod geometry; pub mod incremental; pub mod items; +mod list_marker; mod math; pub mod mode; pub mod para; diff --git a/loki-layout/src/list_marker.rs b/loki-layout/src/list_marker.rs new file mode 100644 index 00000000..e26ba2b7 --- /dev/null +++ b/loki-layout/src/list_marker.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! List-marker synthesis: bullet / numbering label formatting. +//! +//! Split from `para.rs` (300-line-ceiling backlog, Q-1) — self-contained +//! formatting helpers with no layout-state dependencies. + +use loki_doc_model::style::list_style::{BulletChar, ListLevel, ListLevelKind, NumberingScheme}; + +// ── List marker synthesis ───────────────────────────────────────────────────── + +/// Produce the display string for a list marker at `level` in `list_levels`. +/// +/// Handles bullet characters, all six [`NumberingScheme`] variants, and +/// multi-level `%N`-style format strings (OOXML `w:lvlText`, ODF +/// `text:num-format`). Picture bullets fall back to `"•"`. +/// +/// # Arguments +/// * `list_levels` – all level definitions for the list (from `ListStyle.levels`) +/// * `level` – the zero-based level being rendered +/// * `counters` – current per-level counter array (all 9 levels) +/// +/// Returns an empty string for `ListLevelKind::None`. +pub fn format_list_marker(list_levels: &[ListLevel], level: u8, counters: &[u32; 9]) -> String { + let Some(level_def) = list_levels.get(level as usize) else { + return String::new(); + }; + match &level_def.kind { + ListLevelKind::Bullet { + char: BulletChar::Char(c), + .. + } => c.to_string(), + ListLevelKind::Bullet { + char: BulletChar::Image, + .. + } => { + // TODO(list-picture-bullet): picture bullets not yet supported; render as • + "•".to_string() + } + ListLevelKind::Numbered { format, .. } => { + format_numbered_label(list_levels, format, counters) + } + ListLevelKind::None => String::new(), + // Non-exhaustive guard. + _ => String::new(), + } +} + +/// Expand a `w:lvlText`-style format string, replacing `%N` tokens with +/// the counter at 0-based level N-1 formatted by that level's scheme. +fn format_numbered_label(list_levels: &[ListLevel], format: &str, counters: &[u32; 9]) -> String { + let mut result = String::with_capacity(format.len() + 4); + let mut chars = format.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' + && let Some(&d) = chars.peek() + && d.is_ascii_digit() + && d != '0' + { + chars.next(); + let level_idx = (d as u8 - b'1') as usize; // 1-based → 0-based + let counter = counters.get(level_idx).copied().unwrap_or(1); + let scheme = list_levels + .get(level_idx) + .map(|l| match &l.kind { + ListLevelKind::Numbered { scheme, .. } => *scheme, + _ => NumberingScheme::Decimal, + }) + .unwrap_or(NumberingScheme::Decimal); + result.push_str(&format_counter(counter, scheme)); + continue; + } + result.push(c); + } + result +} + +/// Format a single counter value according to its numbering scheme. +/// +/// Shared by list-marker rendering and page-number fields (OOXML +/// `w:pgNumType @w:fmt`). +pub(crate) fn format_counter(n: u32, scheme: NumberingScheme) -> String { + match scheme { + NumberingScheme::Decimal => n.to_string(), + NumberingScheme::LowerAlpha => alpha_label(n, false), + NumberingScheme::UpperAlpha => alpha_label(n, true), + NumberingScheme::LowerRoman => roman_numeral(n, false), + NumberingScheme::UpperRoman => roman_numeral(n, true), + NumberingScheme::Ordinal => format!("{}{}", n, ordinal_suffix(n)), + NumberingScheme::None => String::new(), + _ => n.to_string(), // non-exhaustive fallback + } +} + +/// Convert `n` to an alphabetic label: 1→a, 2→b, …, 26→z, 27→aa, 28→ab, … +fn alpha_label(mut n: u32, upper: bool) -> String { + let mut buf = Vec::new(); + while n > 0 { + n -= 1; + let byte = b'a' + (n % 26) as u8; + buf.push(if upper { + byte.to_ascii_uppercase() + } else { + byte + }); + n /= 26; + } + buf.reverse(); + String::from_utf8(buf).unwrap_or_default() +} + +/// Convert `n` to a Roman numeral string. +fn roman_numeral(n: u32, upper: bool) -> String { + const TABLE: &[(u32, &str)] = &[ + (1000, "m"), + (900, "cm"), + (500, "d"), + (400, "cd"), + (100, "c"), + (90, "xc"), + (50, "l"), + (40, "xl"), + (10, "x"), + (9, "ix"), + (5, "v"), + (4, "iv"), + (1, "i"), + ]; + let mut n = n; + let mut s = String::new(); + for &(val, sym) in TABLE { + while n >= val { + s.push_str(sym); + n -= val; + } + } + if upper { s.to_uppercase() } else { s } +} + +/// Return the English ordinal suffix for `n` (1st, 2nd, 3rd, …, 11th, …). +fn ordinal_suffix(n: u32) -> &'static str { + match n % 100 { + 11..=13 => "th", + _ => match n % 10 { + 1 => "st", + 2 => "nd", + 3 => "rd", + _ => "th", + }, + } +} diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index b03dd0fe..f0fe3059 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -12,14 +12,12 @@ use std::ops::Range; use std::sync::Arc; -use loki_doc_model::style::list_style::{ - BulletChar, ListId, ListLevel, ListLevelKind, NumberingScheme, -}; +use loki_doc_model::style::list_style::ListId; use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader}; use parley::{ - Alignment, AlignmentOptions, Cursor, FontFamily, FontStyle, FontWeight, InlineBox, - InlineBoxKind, LineHeight, OverflowWrap, PositionedLayoutItem, RangedBuilder, Selection, - StyleProperty, + Alignment, AlignmentOptions, Cursor, FontFamily, FontFeatures, FontStyle, FontWeight, + InlineBox, InlineBoxKind, LineHeight, OverflowWrap, PositionedLayoutItem, RangedBuilder, + Selection, StyleProperty, }; use crate::color::LayoutColor; @@ -219,6 +217,13 @@ pub struct StyleSpan { /// margin where Word would have wrapped earlier. pub scale: Option, + /// Apply GPOS pair kerning to this run (gap #23). `Some(true)` = kern; + /// anything else = off, matching the reference apps' defaults (Word's + /// `w:kern` threshold defaults to 0 = off; LibreOffice treats an ODT + /// without `style:letter-kerning` as off). The shaper (harfrust) defaults + /// kerning ON, so the off case is an explicit feature disable. + pub kerning: Option, + /// Manual baseline shift (text rise) in points; positive raises the glyphs /// above the baseline, negative lowers them. `None` = on the baseline. /// OOXML `w:position`; ODF `style:text-position`. @@ -915,6 +920,15 @@ pub(crate) fn push_para_styles( if let Some(ws) = span.word_spacing { builder.push(StyleProperty::WordSpacing(ws), r.clone()); } + // Kerning (gap #23): the reference apps default pair kerning OFF + // (see StyleSpan::kerning); harfrust defaults it ON — disable unless + // the document explicitly enables it. + if span.kerning != Some(true) { + builder.push( + StyleProperty::FontFeatures(FontFeatures::from("\"kern\" 0")), + r.clone(), + ); + } // Caps variant (gaps #15, #16): SmallCaps stored but not applied (no Parley API). // AllCaps: text was already uppercased during flatten_paragraph. } @@ -1765,148 +1779,11 @@ fn layout_paragraph_uncached( } } -// ── List marker synthesis ───────────────────────────────────────────────────── - -/// Produce the display string for a list marker at `level` in `list_levels`. -/// -/// Handles bullet characters, all six [`NumberingScheme`] variants, and -/// multi-level `%N`-style format strings (OOXML `w:lvlText`, ODF -/// `text:num-format`). Picture bullets fall back to `"•"`. -/// -/// # Arguments -/// * `list_levels` – all level definitions for the list (from `ListStyle.levels`) -/// * `level` – the zero-based level being rendered -/// * `counters` – current per-level counter array (all 9 levels) -/// -/// Returns an empty string for `ListLevelKind::None`. -pub fn format_list_marker(list_levels: &[ListLevel], level: u8, counters: &[u32; 9]) -> String { - let Some(level_def) = list_levels.get(level as usize) else { - return String::new(); - }; - match &level_def.kind { - ListLevelKind::Bullet { - char: BulletChar::Char(c), - .. - } => c.to_string(), - ListLevelKind::Bullet { - char: BulletChar::Image, - .. - } => { - // TODO(list-picture-bullet): picture bullets not yet supported; render as • - "•".to_string() - } - ListLevelKind::Numbered { format, .. } => { - format_numbered_label(list_levels, format, counters) - } - ListLevelKind::None => String::new(), - // Non-exhaustive guard. - _ => String::new(), - } -} - -/// Expand a `w:lvlText`-style format string, replacing `%N` tokens with -/// the counter at 0-based level N-1 formatted by that level's scheme. -fn format_numbered_label(list_levels: &[ListLevel], format: &str, counters: &[u32; 9]) -> String { - let mut result = String::with_capacity(format.len() + 4); - let mut chars = format.chars().peekable(); - while let Some(c) = chars.next() { - if c == '%' - && let Some(&d) = chars.peek() - && d.is_ascii_digit() - && d != '0' - { - chars.next(); - let level_idx = (d as u8 - b'1') as usize; // 1-based → 0-based - let counter = counters.get(level_idx).copied().unwrap_or(1); - let scheme = list_levels - .get(level_idx) - .map(|l| match &l.kind { - ListLevelKind::Numbered { scheme, .. } => *scheme, - _ => NumberingScheme::Decimal, - }) - .unwrap_or(NumberingScheme::Decimal); - result.push_str(&format_counter(counter, scheme)); - continue; - } - result.push(c); - } - result -} - -/// Format a single counter value according to its numbering scheme. -/// -/// Shared by list-marker rendering and page-number fields (OOXML -/// `w:pgNumType @w:fmt`). -pub(crate) fn format_counter(n: u32, scheme: NumberingScheme) -> String { - match scheme { - NumberingScheme::Decimal => n.to_string(), - NumberingScheme::LowerAlpha => alpha_label(n, false), - NumberingScheme::UpperAlpha => alpha_label(n, true), - NumberingScheme::LowerRoman => roman_numeral(n, false), - NumberingScheme::UpperRoman => roman_numeral(n, true), - NumberingScheme::Ordinal => format!("{}{}", n, ordinal_suffix(n)), - NumberingScheme::None => String::new(), - _ => n.to_string(), // non-exhaustive fallback - } -} - -/// Convert `n` to an alphabetic label: 1→a, 2→b, …, 26→z, 27→aa, 28→ab, … -fn alpha_label(mut n: u32, upper: bool) -> String { - let mut buf = Vec::new(); - while n > 0 { - n -= 1; - let byte = b'a' + (n % 26) as u8; - buf.push(if upper { - byte.to_ascii_uppercase() - } else { - byte - }); - n /= 26; - } - buf.reverse(); - String::from_utf8(buf).unwrap_or_default() -} - -/// Convert `n` to a Roman numeral string. -fn roman_numeral(n: u32, upper: bool) -> String { - const TABLE: &[(u32, &str)] = &[ - (1000, "m"), - (900, "cm"), - (500, "d"), - (400, "cd"), - (100, "c"), - (90, "xc"), - (50, "l"), - (40, "xl"), - (10, "x"), - (9, "ix"), - (5, "v"), - (4, "iv"), - (1, "i"), - ]; - let mut n = n; - let mut s = String::new(); - for &(val, sym) in TABLE { - while n >= val { - s.push_str(sym); - n -= val; - } - } - if upper { s.to_uppercase() } else { s } -} - -/// Return the English ordinal suffix for `n` (1st, 2nd, 3rd, …, 11th, …). -fn ordinal_suffix(n: u32) -> &'static str { - match n % 100 { - 11..=13 => "th", - _ => match n % 10 { - 1 => "st", - 2 => "nd", - 3 => "rd", - _ => "th", - }, - } -} +// List-marker synthesis lives in `crate::list_marker` (split from this +// file); re-exported here so `para::format_counter` callers and the +// `para_tests.rs` suite keep their existing paths. +pub(crate) use crate::list_marker::format_counter; +pub use crate::list_marker::format_list_marker; // ── Private helpers for span → glyph-run lookups ────────────────────────────── diff --git a/loki-layout/src/para_band_tests.rs b/loki-layout/src/para_band_tests.rs index 63477e43..e2749ba4 100644 --- a/loki-layout/src/para_band_tests.rs +++ b/loki-layout/src/para_band_tests.rs @@ -37,6 +37,7 @@ fn span(text: &str) -> StyleSpan { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, } } diff --git a/loki-layout/src/para_cache.rs b/loki-layout/src/para_cache.rs index 6f2a2751..fb551486 100644 --- a/loki-layout/src/para_cache.rs +++ b/loki-layout/src/para_cache.rs @@ -160,6 +160,7 @@ mod tests { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, } } diff --git a/loki-layout/src/para_tests.rs b/loki-layout/src/para_tests.rs index 8b8ef295..6f324e3c 100644 --- a/loki-layout/src/para_tests.rs +++ b/loki-layout/src/para_tests.rs @@ -48,6 +48,7 @@ fn single_span(text: &str, font_size: f32) -> StyleSpan { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, } } @@ -668,6 +669,7 @@ fn coalesced_scale_and_baseline_shift_apply_per_glyph() { let mk = |range: std::ops::Range, scale: Option, rise: Option| StyleSpan { range, scale, + kerning: None, baseline_shift: rise, ..single_span("A", 20.0) }; diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index 5e838ddc..29d9fc7e 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -304,13 +304,16 @@ fn effective_run_char_props( // shadow → StyleSpan.shadow (gap #24, P3) // scale → StyleSpan.scale (gap #14, P2) // +// kerning → StyleSpan.kerning (gap #23, P3 — +// applied as a shaper feature toggle; default OFF to +// match Word/LO defaults) +// // Fields SILENTLY DROPPED (out of scope for Group 1): // font_name_complex — complex-script font (BiDi) // font_name_east_asian — East Asian font // font_size_complex — complex-script font size // background_color — per-run background (distinct from highlight) // outline — hollow text effect -// kerning — kerning flag (gap #23, P3) // language / language_complex / language_east_asian — locale (gap #30, P3) // hyperlink — URL (gap #11, P1 — handled at Inline level) @@ -382,6 +385,7 @@ fn char_props_to_style_span(props: &CharProps, range: Range) -> StyleSpan font_variant, word_spacing: props.word_spacing.map(pts_to_f32), // gap #22 shadow: props.shadow.unwrap_or(false), // gap #24 + kerning: props.kerning, // gap #23 link_url: None, // set by walk_inlines when inside Inline::Link (gap #11) math: None, // set by walk_inlines for Inline::Math placeholders // Horizontal text scale (gap #14): only forward a non-trivial, positive diff --git a/loki-layout/src/result_tests.rs b/loki-layout/src/result_tests.rs index 7f0982c6..fa88b72a 100644 --- a/loki-layout/src/result_tests.rs +++ b/loki-layout/src/result_tests.rs @@ -53,6 +53,7 @@ fn para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagraphData link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), diff --git a/loki-layout/tests/font_substitution_suite.rs b/loki-layout/tests/font_substitution_suite.rs new file mode 100644 index 00000000..ac37fa4e --- /dev/null +++ b/loki-layout/tests/font_substitution_suite.rs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Font-substitution suite (Spec 02 §7.3 / D4). +//! +//! Fidelity fixtures reference the metric-compatible font names directly, so +//! rendering diffs never mix with substitution behaviour. This suite is the +//! *other* half: it references the **original proprietary names** and asserts +//! the substitution engine (`FontResources::resolve_font_name`) maps each to +//! its bundled metric-compatible equivalent, records the substitution (which +//! is what drives the editor's font-substitution warning), and actually has +//! the substitute face available for layout. + +use loki_layout::FontResources; + +/// Every proprietary family with a bundled metric-compatible substitute. +const SUBSTITUTION_TABLE: &[(&str, &str)] = &[ + ("Calibri", "Carlito"), + ("Cambria", "Caladea"), + ("Arial", "Arimo"), + ("Courier New", "Cousine"), + ("Times New Roman", "Tinos"), + ("Georgia", "Gelasio"), +]; + +/// Each proprietary name must resolve to its metric-compatible substitute +/// (unless the proprietary face is genuinely installed on this machine, in +/// which case resolving to itself is correct and no substitution is recorded). +#[test] +fn proprietary_names_resolve_to_bundled_substitutes() { + let mut fonts = FontResources::new(); + for (requested, substitute) in SUBSTITUTION_TABLE { + let resolved = fonts.resolve_font_name(requested); + if resolved == *requested { + // The real face is installed here — legitimate, but then no + // substitution may be recorded for it. + assert_eq!( + fonts.substitutions.get(*requested), + None, + "{requested} resolved to itself but a substitution was recorded" + ); + } else { + assert_eq!( + &resolved, substitute, + "{requested} must substitute to {substitute}, got {resolved}" + ); + } + } +} + +/// A substitution must be *recorded* — the editor's warning banner reads +/// `FontResources::substitutions`, so a silent swap would hide the change +/// from the user. +#[test] +fn substitutions_are_recorded_for_the_warning_banner() { + let mut fonts = FontResources::new(); + for (requested, substitute) in SUBSTITUTION_TABLE { + let resolved = fonts.resolve_font_name(requested); + if resolved != *requested { + assert_eq!( + fonts.substitutions.get(*requested), + Some(&Some((*substitute).to_string())), + "{requested} → {substitute} substitution must be recorded" + ); + } + } +} + +/// The resolved family must be layout-usable: present in the Fontique +/// collection with at least one face. +#[test] +fn resolved_families_are_available_for_layout() { + let mut fonts = FontResources::new(); + for (requested, _) in SUBSTITUTION_TABLE { + let resolved = fonts.resolve_font_name(requested); + assert!( + fonts.font_cx.collection.family_id(&resolved).is_some(), + "{requested} resolved to {resolved}, which is not in the collection" + ); + } +} + +/// A family with no known substitute resolves to itself and is recorded as +/// unresolved (`None`), which the warning banner reports as "missing". +#[test] +fn unknown_family_is_recorded_as_unresolved() { + let mut fonts = FontResources::new(); + let resolved = fonts.resolve_font_name("Loki Nonexistent Face 2026"); + assert_eq!(resolved, "Loki Nonexistent Face 2026"); + assert_eq!( + fonts.substitutions.get("Loki Nonexistent Face 2026"), + Some(&None), + "an unresolvable family must be recorded as missing" + ); +} diff --git a/loki-layout/tests/kerning_applied.rs b/loki-layout/tests/kerning_applied.rs new file mode 100644 index 00000000..6a21923a --- /dev/null +++ b/loki-layout/tests/kerning_applied.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Regression locks for shaping features (fidelity gap #23). +//! +//! Kerning was listed as an open fidelity gap from the swash-shaped Parley +//! era; the Parley 0.10 upgrade (harfrust shaper) closed it silently, which +//! the 2026-07-05 conformance calibration initially mis-attributed. These +//! tests pin the *verified-current* behaviour — GPOS pair kerning and +//! standard ligatures are applied — so a future Parley upgrade or a shaping +//! feature change cannot silently regress them again. +//! +//! Ground truth (from Carlito-Regular's tables, upem 2048): +//! - `A` advance 1185 units = 13.886 pt @ 24 pt +//! - kern pair (A,V) = −89 units = −1.043 pt @ 24 pt +//! - `f`+`i` (625+470) vs the `fi` ligature (1084 units) + +use loki_layout::{ + FontResources, LayoutColor, PositionedItem, ResolvedParaProps, StyleSpan, layout_paragraph, +}; + +fn carlito_span(text: &str, font_size: f32, kerning: Option) -> StyleSpan { + StyleSpan { + range: 0..text.len(), + font_name: Some("Carlito".into()), + font_size, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning, + baseline_shift: None, + } +} + +/// Lays out `text` in Carlito and returns the first glyph run's advances. +fn advances(text: &str, font_size: f32, kerning: Option) -> Vec { + let mut resources = FontResources::new(); + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + let para = layout_paragraph( + &mut resources, + text, + &[carlito_span(text, font_size, kerning)], + &ResolvedParaProps::default(), + 1000.0, + 1.0, + false, + ); + for item in ¶.items { + if let PositionedItem::GlyphRun(run) = item { + return run.glyphs.iter().map(|g| g.advance).collect(); + } + } + panic!("no glyph run produced for {text:?}"); +} + +/// With kerning enabled, "AV"'s A carries the (A,V) kern: its effective +/// advance is the natural advance (13.886 pt @ 24 pt) minus 1.043 pt. +#[test] +fn gpos_pair_kerning_is_applied_when_enabled() { + let adv = advances("AV", 24.0, Some(true)); + assert_eq!(adv.len(), 2, "AV must shape to two glyphs"); + let natural_a = 1185.0 * 24.0 / 2048.0; // 13.886 + let kerned_a = (1185.0 - 89.0) * 24.0 / 2048.0; // 12.844 + assert!( + (adv[0] - kerned_a).abs() < 0.05, + "A before V must carry the (A,V) kern pair: expected ≈{kerned_a:.3}, got {} \ + (unkerned would be {natural_a:.3} — if you see that, shaping lost kerning)", + adv[0] + ); +} + +/// The default (no explicit kerning property) must NOT kern — matching the +/// reference apps: Word's `w:kern` threshold defaults to 0 (off) and +/// LibreOffice treats an ODT without `style:letter-kerning` as off. This is +/// what keeps loki's line widths aligned with reference renders of documents +/// that never asked for kerning (see goldens/CALIBRATION.md). +#[test] +fn kerning_defaults_off_like_the_reference_apps() { + let adv = advances("AV", 24.0, None); + let natural_a = 1185.0 * 24.0 / 2048.0; + assert!( + (adv[0] - natural_a).abs() < 0.05, + "A must keep its natural advance {natural_a:.3} when kerning is not \ + requested, got {}", + adv[0] + ); +} + +/// The kern must be contextual, not a blanket advance change: a bare "A" +/// keeps its natural advance. +#[test] +fn kerning_is_contextual() { + let adv = advances("AH", 24.0, Some(true)); // no (A,H) kern pair in Carlito + let natural_a = 1185.0 * 24.0 / 2048.0; + assert!( + (adv[0] - natural_a).abs() < 0.05, + "A before H must keep its natural advance {natural_a:.3}, got {}", + adv[0] + ); +} + +/// Standard ligatures must be applied: "five" shapes to 3 glyphs (fi + v + e). +#[test] +fn standard_ligatures_are_applied() { + let adv = advances("five", 24.0, None); + assert_eq!( + adv.len(), + 3, + "'five' must shape with the fi ligature (3 glyphs), got {} glyphs", + adv.len() + ); +} diff --git a/loki-odf/examples/gen_conformance_fixtures.rs b/loki-odf/examples/gen_conformance_fixtures.rs new file mode 100644 index 00000000..3c4266ef --- /dev/null +++ b/loki-odf/examples/gen_conformance_fixtures.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Generates the ODF conformance fixture set (Spec 02 §9 / M4) into +//! `appthere-conformance/fixtures/odt/`. +//! +//! Fidelity fixtures reference the **metric-compatible font names directly** +//! (Carlito, Tinos, Gelasio — Spec 02 D4), so reference-app and candidate +//! renders use the identical bundled faces and any diff is a rendering +//! difference, not a substitution disagreement. +//! +//! Run: `cargo run -p loki-odf --example gen_conformance_fixtures` + +use std::io::Cursor; + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::section::Section; +use loki_doc_model::style::props::char_props::CharProps; +use loki_odf::odt::export::{OdtExport, OdtExportOptions}; + +fn run(text: &str, props: CharProps) -> Inline { + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(props)), + content: vec![Inline::Str(text.into())], + attr: NodeAttr::default(), + }) +} + +fn font(name: &str) -> CharProps { + CharProps { + font_name: Some(name.into()), + ..Default::default() + } +} + +fn doc(blocks: Vec) -> Document { + let mut d = Document::default(); + let mut s = Section::new(); + s.blocks = blocks; + d.sections = vec![s]; + d +} + +/// The calibration baseline set: simple single-page documents believed +/// correct in both engines (Spec 02 §7.4). +fn fixtures() -> Vec<(&'static str, Document)> { + // Varied sentences, not one repeated pangram: repeating an identical + // sentence manufactures many near-identical wrap candidates, so a + // sub-pixel cross-renderer advance delta (the ~0.3%/line noise floor, + // see goldens/CALIBRATION.md) can flip a borderline line break and + // cascade into a whole-line diff. Visual-axis fixtures must wrap + // decisively. + let carlito_p1 = "Conformance fixtures exercise the rendering pipeline end to end. \ + Each paragraph flows through import, layout, and rasterization before \ + the perceptual differ compares it against a reference render. \ + Distinct sentence lengths keep every line break decisive."; + let carlito_p2 = "A second paragraph checks inter-paragraph spacing. \ + Short words then follow: it is so, and we go on to the end of the block."; + let lorem = "The quick brown fox jumps over the lazy dog, \ + pack my box with five dozen liquor jugs. "; + vec![ + ( + "para-carlito", + doc(vec![ + Block::Para(vec![run(carlito_p1, font("Carlito"))]), + Block::Para(vec![run(carlito_p2, font("Carlito"))]), + ]), + ), + ( + "styles-tinos", + doc(vec![ + Block::Heading( + 1, + NodeAttr::default(), + vec![run("Heading in Tinos", font("Tinos"))], + ), + Block::Para(vec![ + run("Plain, ", font("Tinos")), + run( + "bold, ", + CharProps { + bold: Some(true), + ..font("Tinos") + }, + ), + run( + "and italic", + CharProps { + italic: Some(true), + ..font("Tinos") + }, + ), + run(" text.", font("Tinos")), + ]), + ]), + ), + ( + "para-gelasio", + doc(vec![Block::Para(vec![run( + &lorem.repeat(2), + font("Gelasio"), + )])]), + ), + ] +} + +fn main() { + let out_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../appthere-conformance/fixtures/odt"); + std::fs::create_dir_all(&out_dir).expect("create fixtures dir"); + for (stem, document) in fixtures() { + let mut buf = Cursor::new(Vec::new()); + OdtExport::export(&document, &mut buf, OdtExportOptions::default()).expect("export"); + let path = out_dir.join(format!("{stem}.odt")); + std::fs::write(&path, buf.into_inner()).expect("write fixture"); + println!("wrote {}", path.display()); + } +} diff --git a/loki-odf/tests/schema_validation.rs b/loki-odf/tests/schema_validation.rs new file mode 100644 index 00000000..0f8856fb --- /dev/null +++ b/loki-odf/tests/schema_validation.rs @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Spec 02 schema axis (M2) — real ODT exports validate against the vendored +//! OASIS ODF 1.3 RELAX NG schemas. +//! +//! Every part the ODT writer emits is checked against the official schema via +//! `appthere_conformance`'s `XmllintValidator` (libxml2). A missing `xmllint` +//! fails loudly rather than skipping (Spec 02 §5); CI installs +//! `libxml2-utils`. The schemas are vendored + version-pinned under +//! `appthere-conformance/schemas/` (D6) — see its `README.md` / PROVENANCE. + +use std::io::{Cursor, Read}; +use std::path::PathBuf; + +use appthere_conformance::{SchemaKind, SchemaValidator, XmllintValidator}; +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::content::table::core::{Table, TableBody, TableCaption, TableFoot, TableHead}; +use loki_doc_model::content::table::row::{Cell, Row}; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::section::Section; +use loki_doc_model::style::props::char_props::CharProps; +use loki_odf::odt::export::{OdtExport, OdtExportOptions}; + +fn schema(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../appthere-conformance/schemas/odf") + .join(name) +} + +/// A document exercising the writer's main surfaces: heading, plain and +/// styled paragraphs, and a table. +fn sample_document() -> Document { + let bold = CharProps { + bold: Some(true), + ..Default::default() + }; + let table = Block::Table(Box::new(Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: vec![], + head: TableHead { + attr: NodeAttr::default(), + rows: vec![], + }, + bodies: vec![TableBody { + attr: NodeAttr::default(), + head_rows: vec![], + body_rows: vec![Row::new(vec![ + Cell::simple(vec![Block::Para(vec![Inline::Str("a".into())])]), + Cell::simple(vec![Block::Para(vec![Inline::Str("b".into())])]), + ])], + }], + foot: TableFoot { + attr: NodeAttr::default(), + rows: vec![], + }, + })); + let mut d = Document::default(); + let mut s = Section::new(); + s.blocks = vec![ + Block::Heading(1, NodeAttr::default(), vec![Inline::Str("Title".into())]), + Block::Para(vec![ + Inline::Str("Plain then ".into()), + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(bold)), + content: vec![Inline::Str("bold".into())], + attr: NodeAttr::default(), + }), + ]), + table, + ]; + d.sections = vec![s]; + d +} + +fn export_odt(doc: &Document) -> Vec { + let mut buf = Cursor::new(Vec::new()); + OdtExport::export(doc, &mut buf, OdtExportOptions::default()) + .expect("ODT export should succeed"); + buf.into_inner() +} + +/// Extracts a named part from the exported ODT ZIP. +fn part(odt: &[u8], name: &str) -> Vec { + let mut zip = zip::ZipArchive::new(Cursor::new(odt)).expect("exported ODT must be a ZIP"); + let mut file = zip + .by_name(name) + .unwrap_or_else(|_| panic!("exported ODT must contain {name}")); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).expect("read part"); + bytes +} + +fn assert_part_valid(part_name: &str, schema_name: &str) { + let odt = export_odt(&sample_document()); + let xml = part(&odt, part_name); + let validator = XmllintValidator::new().expect("xmllint must be installed (libxml2-utils)"); + let report = validator + .validate_bytes(&xml, &schema(schema_name), SchemaKind::RelaxNg) + .expect("validation must run"); + assert!( + report.valid, + "{part_name} must be ODF-1.3-schema-valid; violations: {:#?}", + report.violations + ); +} + +#[test] +fn odt_content_xml_is_schema_valid() { + assert_part_valid("content.xml", "OpenDocument-v1.3-schema.rng"); +} + +#[test] +fn odt_styles_xml_is_schema_valid() { + assert_part_valid("styles.xml", "OpenDocument-v1.3-schema.rng"); +} + +#[test] +fn odt_meta_xml_is_schema_valid() { + assert_part_valid("meta.xml", "OpenDocument-v1.3-schema.rng"); +} + +#[test] +fn odt_manifest_is_schema_valid() { + assert_part_valid( + "META-INF/manifest.xml", + "OpenDocument-v1.3-manifest-schema.rng", + ); +} + +/// M2 acceptance: a deliberately malformed part must FAIL the gate (guards +/// against a validator that silently passes everything). +#[test] +fn deliberately_malformed_content_fails_the_gate() { + let odt = export_odt(&sample_document()); + let xml = String::from_utf8(part(&odt, "content.xml")).expect("content.xml is UTF-8"); + let broken = xml.replacen("", "", 1); + assert_ne!(xml, broken, "the malformation must have been injected"); + let validator = XmllintValidator::new().expect("xmllint must be installed"); + let report = validator + .validate_bytes( + broken.as_bytes(), + &schema("OpenDocument-v1.3-schema.rng"), + SchemaKind::RelaxNg, + ) + .expect("validation must run"); + assert!( + !report.valid, + "an invented office:bogus-element must be rejected by the ODF schema" + ); +} diff --git a/loki-ooxml/tests/schema_validation.rs b/loki-ooxml/tests/schema_validation.rs new file mode 100644 index 00000000..06b56546 --- /dev/null +++ b/loki-ooxml/tests/schema_validation.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Spec 02 schema axis (M2) — real DOCX exports validate against the vendored +//! ECMA-376 Transitional XSDs. +//! +//! The main WordprocessingML parts the DOCX writer emits are checked against +//! the official schema via `appthere_conformance`'s `XmllintValidator` +//! (libxml2). A missing `xmllint` fails loudly rather than skipping (Spec 02 +//! §5); CI installs `libxml2-utils`. The schemas are vendored + +//! version-pinned under `appthere-conformance/schemas/` (D6). + +use std::io::{Cursor, Read}; +use std::path::PathBuf; + +use appthere_conformance::{SchemaKind, SchemaValidator, XmllintValidator}; +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::section::Section; +use loki_doc_model::style::props::char_props::CharProps; +use loki_ooxml::docx::export::DocxExport; + +fn schema_wml() -> PathBuf { + // Canonicalized so every xsd:import resolves to one canonical location — + // libxml2 treats the same file reached via two path spellings as two + // schema documents and then skips "duplicate" namespace imports. + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../appthere-conformance/schemas/ooxml/transitional/wml.xsd") + .canonicalize() + .expect("vendored wml.xsd must exist") +} + +/// A document exercising the writer's main WordprocessingML surfaces. +fn sample_document() -> Document { + let bold = CharProps { + bold: Some(true), + ..Default::default() + }; + let mut d = Document::default(); + let mut s = Section::new(); + s.blocks = vec![ + Block::Heading(1, NodeAttr::default(), vec![Inline::Str("Title".into())]), + Block::Para(vec![ + Inline::Str("Plain then ".into()), + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(bold)), + content: vec![Inline::Str("bold".into())], + attr: NodeAttr::default(), + }), + ]), + ]; + d.sections = vec![s]; + d +} + +fn export_docx(doc: &Document) -> Vec { + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(doc, &mut buf, ()).expect("DOCX export should succeed"); + buf.into_inner() +} + +fn part(docx: &[u8], name: &str) -> Vec { + let mut zip = zip::ZipArchive::new(Cursor::new(docx)).expect("exported DOCX must be a ZIP"); + let mut file = zip + .by_name(name) + .unwrap_or_else(|_| panic!("exported DOCX must contain {name}")); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).expect("read part"); + bytes +} + +fn assert_part_valid(part_name: &str) { + let docx = export_docx(&sample_document()); + let xml = part(&docx, part_name); + let validator = XmllintValidator::new().expect("xmllint must be installed (libxml2-utils)"); + let report = validator + .validate_bytes(&xml, &schema_wml(), SchemaKind::Xsd) + .expect("validation must run"); + assert!( + report.valid, + "{part_name} must be ECMA-376-Transitional-valid; violations: {:#?}", + report.violations + ); +} + +#[test] +fn docx_document_xml_is_schema_valid() { + assert_part_valid("word/document.xml"); +} + +#[test] +fn docx_styles_xml_is_schema_valid() { + assert_part_valid("word/styles.xml"); +} + +fn schema_opc(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../appthere-conformance/schemas/opc") + .join(name) + .canonicalize() + .expect("vendored OPC schema must exist") +} + +fn assert_opc_part_valid(part_name: &str, schema_name: &str) { + let docx = export_docx(&sample_document()); + let xml = part(&docx, part_name); + let validator = XmllintValidator::new().expect("xmllint must be installed"); + let report = validator + .validate_bytes(&xml, &schema_opc(schema_name), SchemaKind::Xsd) + .expect("validation must run"); + assert!( + report.valid, + "{part_name} must be OPC-schema-valid; violations: {:#?}", + report.violations + ); +} + +/// The OPC package layer (`loki-opc` output) must satisfy ECMA-376 Part 2. +#[test] +fn docx_content_types_are_opc_schema_valid() { + assert_opc_part_valid("[Content_Types].xml", "opc-contentTypes.xsd"); +} + +#[test] +fn docx_package_relationships_are_opc_schema_valid() { + assert_opc_part_valid("_rels/.rels", "opc-relationships.xsd"); +} + +// TODO(conformance-schemas): validate docProps/core.xml against +// opc-coreProperties.xsd once the Dublin Core XSDs it imports (dc.xsd, +// dcterms.xsd, dcmitype.xsd) are vendored — the schema references them by +// live dublincore.org URL, which offline validation (D6) cannot follow, and +// no in-policy source for them was reachable from this environment. + +/// M2 acceptance: a deliberately malformed part must FAIL the gate. +#[test] +fn deliberately_malformed_document_fails_the_gate() { + let docx = export_docx(&sample_document()); + let xml = String::from_utf8(part(&docx, "word/document.xml")).expect("document.xml is UTF-8"); + let broken = xml.replacen("", "", 1); + assert_ne!(xml, broken, "the malformation must have been injected"); + let validator = XmllintValidator::new().expect("xmllint must be installed"); + let report = validator + .validate_bytes(broken.as_bytes(), &schema_wml(), SchemaKind::Xsd) + .expect("validation must run"); + assert!( + !report.valid, + "an invented w:bogus-element must be rejected by the WML schema" + ); +} diff --git a/loki-presentation/src/app.rs b/loki-presentation/src/app.rs index 19e79ab1..edf77f27 100644 --- a/loki-presentation/src/app.rs +++ b/loki-presentation/src/app.rs @@ -84,9 +84,14 @@ pub fn App() -> Element { let recent_docs: Signal = use_signal(|| RecentDocuments::load(crate::recent_documents::RECENT_FILE)); + // Stashed sessions for inactive tabs — unsaved edits survive tab switches. + let doc_sessions: Signal = + use_signal(std::collections::HashMap::new); + provide_context(tabs); provide_context(active_tab); provide_context(recent_docs); + provide_context(doc_sessions); let insets = use_safe_area(); diff --git a/loki-presentation/src/lib.rs b/loki-presentation/src/lib.rs index 8f758742..28ce8696 100644 --- a/loki-presentation/src/lib.rs +++ b/loki-presentation/src/lib.rs @@ -13,6 +13,7 @@ pub mod error; pub mod new_document; pub mod recent_documents; pub mod routes; +pub mod sessions; pub mod tabs; pub mod utils; diff --git a/loki-presentation/src/routes/editor/editor_canvas.rs b/loki-presentation/src/routes/editor/editor_canvas.rs index 2678d8ad..6a47dd46 100644 --- a/loki-presentation/src/routes/editor/editor_canvas.rs +++ b/loki-presentation/src/routes/editor/editor_canvas.rs @@ -34,7 +34,7 @@ pub(super) fn SlideThumbnails( padding: 12px; gap: 12px;", for (i, v) in views.iter().enumerate() { div { - key: "{i}", + key: "{v.slide_id}", style: format!( "position: relative; width: 100%; height: 90px; background: {bg}; \ border: 2px solid {border}; border-radius: 6px; cursor: pointer; \ @@ -86,23 +86,34 @@ pub(super) fn SlideCanvas( view: SlideView, on_edit: EventHandler, on_add_bullet: EventHandler<()>, + /// Render zoom factor (1.0 = 100%): scales the slide box and its text + /// together (status-bar zoom badge; 4c.5). + zoom: f64, ) -> Element { + let z = |px: f64| px * zoom; rsx! { div { style: format!( - "width: 720px; height: 405px; background: {bg}; color: {fg}; \ + "width: {w}px; height: {h}px; background: {bg}; color: {fg}; \ border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); \ - padding: 40px; box-sizing: border-box; display: flex; \ - flex-direction: column; justify-content: flex-start; gap: 8px;", + padding: {pad}px; box-sizing: border-box; display: flex; \ + flex-direction: column; justify-content: flex-start; gap: {gap}px;", + w = z(720.0), + h = z(405.0), + pad = z(40.0), + gap = z(8.0), bg = view.bg_css, fg = view.fg_css, ), if let Some(t) = &view.title { input { - style: "font-size: 32px; font-weight: bold; background: transparent; \ - border: 1px dashed rgba(128,128,128,0.4); color: inherit; \ - width: 100%; outline: none; font-family: inherit; padding: 2px;", + style: format!( + "font-size: {fs}px; font-weight: bold; background: transparent; \ + border: 1px dashed rgba(128,128,128,0.4); color: inherit; \ + width: 100%; outline: none; font-family: inherit; padding: 2px;", + fs = z(32.0), + ), value: "{t.text}", placeholder: fl!("editor-placeholder-title"), oninput: { @@ -116,9 +127,12 @@ pub(super) fn SlideCanvas( if let Some(s) = &view.subtitle { input { - style: "font-size: 16px; font-style: italic; background: transparent; \ - border: 1px dashed rgba(128,128,128,0.4); color: inherit; \ - width: 100%; outline: none; font-family: inherit; padding: 2px; opacity: 0.85;", + style: format!( + "font-size: {fs}px; font-style: italic; background: transparent; \ + border: 1px dashed rgba(128,128,128,0.4); color: inherit; \ + width: 100%; outline: none; font-family: inherit; padding: 2px; opacity: 0.85;", + fs = z(16.0), + ), value: "{s.text}", placeholder: fl!("editor-placeholder-subtitle"), oninput: { @@ -132,14 +146,22 @@ pub(super) fn SlideCanvas( ul { style: "margin: 12px 0 0 0; padding-left: 20px; flex: 1; overflow-y: auto;", - for (i, line) in view.bullets.iter().enumerate() { + for line in view.bullets.iter() { li { - key: "{i}", - style: "margin: 6px 0; font-size: 16px; list-style-type: square;", + // Shape + paragraph is stable across bullet edits, so + // inserting a bullet re-uses the right inputs (F7b). + key: "{line.shape_id}-{line.para}", + style: format!( + "margin: 6px 0; font-size: {fs}px; list-style-type: square;", + fs = z(16.0), + ), input { - style: "background: transparent; border: 1px dashed rgba(128,128,128,0.4); \ - color: inherit; width: 92%; outline: none; font-size: 16px; \ - font-family: inherit; padding: 2px;", + style: format!( + "background: transparent; border: 1px dashed rgba(128,128,128,0.4); \ + color: inherit; width: 92%; outline: none; font-size: {fs}px; \ + font-family: inherit; padding: 2px;", + fs = z(16.0), + ), value: "{line.text}", oninput: { let id = line.shape_id.clone(); diff --git a/loki-presentation/src/routes/editor/editor_inner.rs b/loki-presentation/src/routes/editor/editor_inner.rs index ddac4f80..1692f5fc 100644 --- a/loki-presentation/src/routes/editor/editor_inner.rs +++ b/loki-presentation/src/routes/editor/editor_inner.rs @@ -13,7 +13,6 @@ use appthere_ui::AtStatusBar; use appthere_ui::tokens; use dioxus::prelude::*; -use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; use loki_i18n::fl; use loki_presentation_model::Presentation; @@ -21,22 +20,17 @@ use super::edit; use super::editor_canvas::{EditMsg, SlideCanvas, SlideThumbnails}; use super::editor_error_view::EditorErrorView; use super::editor_load::load_presentation; -use super::editor_save::export_to_token; +use super::editor_path_sync::{restore_session, stash_outgoing}; +use super::editor_save_flows::{SaveCtx, use_save_callbacks}; use super::slide_view::slide_views; -use crate::new_document::is_untitled; -use crate::recent_documents::RecentDocuments; -use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; -const PPTX_MIME: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; - /// Presentation editor inner component. #[component] pub(super) fn EditorInner(path: String) -> Element { - let navigator = use_navigator(); let mut tabs = use_context::>>(); - let recent_docs = use_context::>(); let mut path_signal = use_signal(|| path.clone()); let mut doc = use_signal(|| Option::::None); @@ -44,28 +38,61 @@ pub(super) fn EditorInner(path: String) -> Element { let mut active_idx = use_signal(|| 0usize); let mut dirty = use_signal(|| false); let mut save_message = use_signal(|| Option::::None); + // Slide render zoom, in percent (status-bar badge; 4c.5). + let mut zoom_percent = use_signal(|| 100_u32); + // Stashed sessions for inactive tabs — unsaved edits survive tab switches + // (audit F1 residual / plan 4b.6). + let doc_sessions = use_context::>(); + + // ── Session restore at mount ────────────────────────────────────────────── + // Navigating Editor → Home unmounts this component (different routes), so + // returning to a presentation tab mounts a fresh EditorInner. The matching + // stash happens in the unmount hook below. + use_hook(move || { + let initial_path = path_signal.peek().clone(); + restore_session(&initial_path, doc_sessions, doc, active_idx, dirty); + }); - // Reset per-document state when the route path changes (tab switch / Save As - // navigation reuses this component instance). + // ── Session stash at unmount ────────────────────────────────────────────── + use_drop(move || { + let old_path = path_signal.peek().clone(); + stash_outgoing(&old_path, tabs, doc_sessions, doc, active_idx, dirty); + }); + + // Per-document state handover when the route path changes (tab switch / + // Save As navigation reuses this component instance): stash the outgoing + // presentation, then restore the incoming one or reset for a fresh load. if *path_signal.peek() != path { + let old_path = path_signal.peek().clone(); + stash_outgoing(&old_path, tabs, doc_sessions, doc, active_idx, dirty); path_signal.set(path.clone()); - doc.set(None); load_error.set(None); - active_idx.set(0); - dirty.set(false); save_message.set(None); + if !restore_session(&path, doc_sessions, doc, active_idx, dirty) { + doc.set(None); + active_idx.set(0); + dirty.set(false); + } } - // Load reactively on path; populate the editable doc once resolved. - let load = use_resource(move || async move { load_presentation(path_signal()) }); + // Load reactively on path; populate the editable doc once resolved. The + // result carries the path it was loaded for, so a stale value from the + // previous tab can never clobber a restored session or the wrong document + // (same guard as loki-text / loki-spreadsheet). + let load = use_resource(move || async move { + let p = path_signal(); + (p.clone(), load_presentation(p)) + }); use_effect(move || { if doc.peek().is_some() || load_error.peek().is_some() { return; } match &*load.value().read_unchecked() { - Some(Ok(p)) => doc.set(Some(p.clone())), - Some(Err(e)) => load_error.set(Some(e.to_string())), - None => {} + Some((loaded_path, Ok(p))) if *loaded_path == path_signal() => doc.set(Some(p.clone())), + Some((loaded_path, Err(e))) if *loaded_path == path_signal() => { + load_error.set(Some(e.to_string())); + } + _ => {} } }); @@ -83,69 +110,14 @@ pub(super) fn EditorInner(path: String) -> Element { let title = use_memo(move || display_title_from_path(&path_signal())); - // ── Save As ─────────────────────────────────────────────────────────────── - let save_as = use_callback(move |_: ()| { - let Some(pres) = doc.peek().clone() else { - return; - }; - let cur_path = path_signal.peek().clone(); - let suggested = format!("{}.pptx", display_title_from_path(&cur_path)); - let mut tabs = tabs; - let mut recent = recent_docs; - let nav = navigator; - spawn(async move { - let picker = FilePicker::new(); - let opts = SaveOptions { - mime_type: Some(PPTX_MIME.to_string()), - suggested_name: Some(suggested), - }; - match picker.pick_file_to_save(opts).await { - Ok(Some(token)) => match export_to_token(&token, &pres) { - Ok(()) => { - let new_path = token.serialize(); - let new_title = display_title_from_path(&new_path); - { - let mut t = tabs.write(); - if let Some(tab) = t.iter_mut().find(|tb| tb.path == cur_path) { - tab.path = new_path.clone(); - tab.title = new_title.clone(); - tab.is_dirty = false; - } - } - recent.write().record(new_path.clone(), new_title); - recent.read().save(); - dirty.set(false); - save_message.set(Some(fl!("editor-save-success"))); - nav.push(Route::Editor { path: new_path }); - } - Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e))), - }, - Ok(None) => {} - Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))), - } - }); - }); - - // ── Save ────────────────────────────────────────────────────────────────── - let save = use_callback(move |_: ()| { - let cur = path_signal.peek().clone(); - if is_untitled(&cur) { - save_as.call(()); - return; - } - let Some(pres) = doc.peek().clone() else { - return; - }; - match FileAccessToken::deserialize(&cur) { - Ok(token) => match export_to_token(&token, &pres) { - Ok(()) => { - dirty.set(false); - save_message.set(Some(fl!("editor-save-success"))); - } - Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e))), - }, - Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))), - } + // ── Save / Save As (extracted flows) ────────────────────────────────────── + // Only `save` is dispatched from the toolbar; it routes untitled + // documents to the Save As flow internally. + let (save, _save_as) = use_save_callbacks(SaveCtx { + doc, + path_signal, + dirty, + save_message, }); // ── Render states ────────────────────────────────────────────────────────── @@ -201,7 +173,15 @@ pub(super) fn EditorInner(path: String) -> Element { button { style: toolbar_btn_style(), onclick: move |_| { - if let Some(p) = doc.write().as_mut() { edit::delete_slide(p, idx); } + let new_len = { + let mut d = doc.write(); + let Some(p) = d.as_mut() else { return }; + edit::delete_slide(p, idx); + p.slide_count() + }; + // Keep the selection on the same position, clamped to + // the shrunken deck (F7b). + active_idx.set(idx.min(new_len.saturating_sub(1))); dirty.set(true); }, {fl!("editor-action-delete-slide")} @@ -244,6 +224,7 @@ pub(super) fn EditorInner(path: String) -> Element { if let Some(v) = active { SlideCanvas { view: v, + zoom: zoom_percent() as f64 / 100.0, on_edit: move |msg: EditMsg| { if let Some(p) = doc.write().as_mut() { edit::set_shape_text(p, idx, &msg.shape_id, msg.para, &msg.text); @@ -273,11 +254,14 @@ pub(super) fn EditorInner(path: String) -> Element { ), word_count_label: String::new(), language_label: fl!("editor-language"), - zoom_percent: 100, + zoom_percent: zoom_percent(), collaborator_count: 0, collaborator_label: String::new(), zoom_aria_label: fl!("editor-zoom-aria"), - on_zoom_click: |_| {}, + on_zoom_click: move |_| { + let next = appthere_ui::next_zoom(*zoom_percent.peek()); + zoom_percent.set(next); + }, } } } diff --git a/loki-presentation/src/routes/editor/editor_path_sync.rs b/loki-presentation/src/routes/editor/editor_path_sync.rs new file mode 100644 index 00000000..db54d266 --- /dev/null +++ b/loki-presentation/src/routes/editor/editor_path_sync.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Per-document session stash/restore for tab switches (audit F1 residual / +//! plan 4b.6) — see [`crate::sessions`] for the session map itself. +//! +//! Extracted from `editor_inner.rs` to keep that file under the 300-line +//! ceiling. + +use dioxus::prelude::*; +use loki_presentation_model::Presentation; + +use crate::sessions::{DocSession, DocSessions}; +use crate::tabs::OpenTab; + +/// Moves the live editable state into the session map. No-op when nothing is +/// loaded, or when no tab points at `old_path` any more — a closed (or +/// Save-As-repointed) tab must not resurrect its old state on reopen. +pub(super) fn stash_outgoing( + old_path: &str, + tabs: Signal>, + mut doc_sessions: Signal, + mut doc: Signal>, + active_idx: Signal, + dirty: Signal, +) { + let Some(pres) = doc.write().take() else { + return; + }; + if !tabs.peek().iter().any(|t| t.path == old_path) { + return; + } + doc_sessions.write().insert( + old_path.to_owned(), + DocSession { + doc: pres, + active_idx: *active_idx.peek(), + dirty: *dirty.peek(), + }, + ); +} + +/// Writes a stashed session back into the live editor state. Returns whether +/// a session existed (`false` → the caller resets for a fresh disk load). +pub(super) fn restore_session( + new_path: &str, + mut doc_sessions: Signal, + mut doc: Signal>, + mut active_idx: Signal, + mut dirty: Signal, +) -> bool { + let Some(session) = doc_sessions.write().remove(new_path) else { + return false; + }; + doc.set(Some(session.doc)); + active_idx.set(session.active_idx); + dirty.set(session.dirty); + true +} diff --git a/loki-presentation/src/routes/editor/editor_save_flows.rs b/loki-presentation/src/routes/editor/editor_save_flows.rs new file mode 100644 index 00000000..9d929500 --- /dev/null +++ b/loki-presentation/src/routes/editor/editor_save_flows.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Save and Save As flows for the presentation editor. +//! +//! Extracted from `editor_inner.rs` to keep that file under the 300-line +//! ceiling. Save As picks a destination, exports PPTX, repoints the tab, +//! records recents, and navigates; Save exports to the current token (or +//! routes untitled documents to Save As). + +use dioxus::prelude::*; +use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; +use loki_i18n::fl; +use loki_presentation_model::Presentation; + +use super::editor_save::export_to_token; +use crate::new_document::is_untitled; +use crate::recent_documents::RecentDocuments; +use crate::routes::Route; +use crate::tabs::OpenTab; +use crate::utils::display_title_from_path; + +const PPTX_MIME: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + +/// Signals the save flows read and write, grouped for the hook call. +pub(super) struct SaveCtx { + pub doc: Signal>, + pub path_signal: Signal, + pub dirty: Signal, + pub save_message: Signal>, +} + +/// Builds the `(save, save_as)` callbacks. Must be called unconditionally in +/// the component body (it registers hooks). +pub(super) fn use_save_callbacks(ctx: SaveCtx) -> (Callback<()>, Callback<()>) { + let SaveCtx { + doc, + path_signal, + mut dirty, + mut save_message, + } = ctx; + let navigator = use_navigator(); + let tabs = use_context::>>(); + let recent_docs = use_context::>(); + + // ── Save As ─────────────────────────────────────────────────────────────── + let save_as = use_callback(move |_: ()| { + let Some(pres) = doc.peek().clone() else { + return; + }; + let cur_path = path_signal.peek().clone(); + let suggested = format!("{}.pptx", display_title_from_path(&cur_path)); + let mut tabs = tabs; + let mut recent = recent_docs; + let nav = navigator; + spawn(async move { + let picker = FilePicker::new(); + let opts = SaveOptions { + mime_type: Some(PPTX_MIME.to_string()), + suggested_name: Some(suggested), + }; + match picker.pick_file_to_save(opts).await { + Ok(Some(token)) => match export_to_token(&token, &pres) { + Ok(()) => { + let new_path = token.serialize(); + let new_title = display_title_from_path(&new_path); + { + let mut t = tabs.write(); + if let Some(tab) = t.iter_mut().find(|tb| tb.path == cur_path) { + tab.path = new_path.clone(); + tab.title = new_title.clone(); + tab.is_dirty = false; + } + } + recent.write().record(new_path.clone(), new_title); + recent.read().save(); + dirty.set(false); + save_message.set(Some(fl!("editor-save-success"))); + nav.push(Route::Editor { path: new_path }); + } + Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e))), + }, + Ok(None) => {} + Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))), + } + }); + }); + + // ── Save ────────────────────────────────────────────────────────────────── + let save = use_callback(move |_: ()| { + let cur = path_signal.peek().clone(); + if is_untitled(&cur) { + save_as.call(()); + return; + } + let Some(pres) = doc.peek().clone() else { + return; + }; + match FileAccessToken::deserialize(&cur) { + Ok(token) => match export_to_token(&token, &pres) { + Ok(()) => { + dirty.set(false); + save_message.set(Some(fl!("editor-save-success"))); + } + Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e))), + }, + Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))), + } + }); + + (save, save_as) +} diff --git a/loki-presentation/src/routes/editor/mod.rs b/loki-presentation/src/routes/editor/mod.rs index cf46469d..32a5b47d 100644 --- a/loki-presentation/src/routes/editor/mod.rs +++ b/loki-presentation/src/routes/editor/mod.rs @@ -8,7 +8,9 @@ mod editor_canvas; mod editor_error_view; mod editor_inner; mod editor_load; +mod editor_path_sync; mod editor_save; +mod editor_save_flows; mod slide_view; use dioxus::prelude::*; diff --git a/loki-presentation/src/routes/editor/slide_view.rs b/loki-presentation/src/routes/editor/slide_view.rs index 9c737dcf..1673d9a2 100644 --- a/loki-presentation/src/routes/editor/slide_view.rs +++ b/loki-presentation/src/routes/editor/slide_view.rs @@ -36,6 +36,9 @@ pub(super) struct EditableLine { /// A flattened, edit-aware view of one slide. #[derive(Debug, Clone, PartialEq)] pub(super) struct SlideView { + /// The slide's stable identifier — the Dioxus list key for thumbnails, so + /// inserting/deleting slides re-uses the right DOM nodes (F7b). + pub slide_id: String, /// Title field, if the slide has a title placeholder. pub title: Option, /// Subtitle field, if present. @@ -76,6 +79,7 @@ fn slide_to_view(slide: &Slide) -> SlideView { }; SlideView { + slide_id: slide.id.as_str().to_string(), title, subtitle, bullets, diff --git a/loki-presentation/src/routes/home.rs b/loki-presentation/src/routes/home.rs index 2d4b61f9..784985e8 100644 --- a/loki-presentation/src/routes/home.rs +++ b/loki-presentation/src/routes/home.rs @@ -2,14 +2,17 @@ //! Home screen route component for loki-presentation. -use appthere_ui::{AtHomeTab, BuiltinTemplate, RecentDocument}; +use appthere_ui::{AtConfirmDialog, AtHomeTab, BuiltinTemplate, RecentDocument}; use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, PickOptions, PickerError, SaveOptions}; use loki_i18n::fl; +use super::home_util::{close_tab_for_path, push_or_switch_tab, suggested_copy_name}; + use crate::new_document::new_blank_tab; use crate::recent_documents::RecentDocuments; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -42,47 +45,6 @@ fn make_templates() -> Vec { ] } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn push_or_switch_tab(mut tabs: Signal>, mut active_tab: Signal, path: String) { - let title = display_title_from_path(&path); - let existing = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = existing { - *active_tab.write() = idx + 1; - } else { - tabs.write().push(OpenTab { - title, - path, - is_dirty: false, - is_discarded: false, - }); - *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home - } -} - -/// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. -fn close_tab_for_path(mut tabs: Signal>, mut active_tab: Signal, path: &str) { - let removed = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = removed { - tabs.write().remove(idx); - // active_tab is 1-based (index 0 = Home). Reset to Home if the active - // selection pointed at or past the removed tab to avoid a stale index. - if *active_tab.read() > idx { - *active_tab.write() = 0; - } - } -} - -/// Build a " Copy." filename from a token's display name. -fn suggested_copy_name(token: &FileAccessToken) -> String { - let name = token.display_name(); - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), - _ => format!("{name} Copy"), - } -} - // ── Home ────────────────────────────────────────────────────────────────────── /// Home screen component. @@ -92,6 +54,7 @@ pub fn Home() -> Element { let tabs = use_context::>>(); let active_tab = use_context::>(); + let doc_sessions = use_context::>(); let mut recent_docs = use_context::>(); let pick_error: Signal> = use_signal(|| None); @@ -169,15 +132,25 @@ pub fn Home() -> Element { // ── on_recent_delete ────────────────────────────────────────────────────── // - // `path` is a serialised FileAccessToken, not a filesystem path. Decode it, - // delete the underlying file via the capability token, close any open tab - // for it, then drop the recents entry. + // Deleting a file is destructive, so the menu action only *requests* it: + // the confirmation dialog below performs the deletion on confirm (4c.1). + let mut pending_delete: Signal> = use_signal(|| None); let on_recent_delete = move |idx: usize| { - let mut err_sig = pick_error; - let Some(path) = recent_docs.read().entries.get(idx).map(|e| e.path.clone()) else { - return; - }; + let entry = recent_docs + .read() + .entries + .get(idx) + .map(|e| (e.path.clone(), e.title.clone())); + if let Some(path_and_title) = entry { + pending_delete.set(Some(path_and_title)); + } + }; + // The confirmed deletion. `path` is a serialised FileAccessToken, not a + // filesystem path: decode it, delete the underlying file via the + // capability token, close any open tab for it, then drop the recents entry. + let mut delete_recent = move |path: String| { + let mut err_sig = pick_error; match FileAccessToken::deserialize(&path) { Ok(token) => { if let Err(e) = token.delete() { @@ -189,8 +162,7 @@ pub fn Home() -> Element { } } - close_tab_for_path(tabs, active_tab, &path); - // TODO(ux): Add a confirmation dialog before destructive deletion. + close_tab_for_path(tabs, active_tab, doc_sessions, &path); recent_docs.write().remove(&path); recent_docs.read().save(); }; @@ -263,26 +235,46 @@ pub fn Home() -> Element { .collect(); rsx! { - AtHomeTab { - templates: make_templates(), - recent_documents: recent_list, - templates_label: fl!("home-templates-heading"), - recent_label: fl!("home-recent-heading"), - browse_label: String::new(), - open_file_label: fl!("home-open-file"), - empty_recent_label: fl!("home-no-recent"), - recent_menu_aria_label: fl!("home-recent-menu-aria"), - recent_remove_label: fl!("home-recent-menu-remove"), - recent_delete_label: fl!("home-recent-menu-delete"), - recent_open_copy_label: fl!("home-recent-menu-open-copy"), - pick_error: pick_error, - on_template_select: on_template_select, - on_browse_templates: |_| {}, - on_recent_open: on_recent_open, - on_open_file: on_open_file, - on_recent_remove: on_recent_remove, - on_recent_delete: on_recent_delete, - on_recent_open_copy: on_recent_open_copy, + // position: relative anchors the AtConfirmDialog overlay over the + // home area (AtHomeTab sizes itself to the viewport minus tab bar). + div { + style: "position: relative;", + AtHomeTab { + templates: make_templates(), + recent_documents: recent_list, + templates_label: fl!("home-templates-heading"), + recent_label: fl!("home-recent-heading"), + browse_label: String::new(), + open_file_label: fl!("home-open-file"), + empty_recent_label: fl!("home-no-recent"), + recent_menu_aria_label: fl!("home-recent-menu-aria"), + recent_remove_label: fl!("home-recent-menu-remove"), + recent_delete_label: fl!("home-recent-menu-delete"), + recent_open_copy_label: fl!("home-recent-menu-open-copy"), + pick_error: pick_error, + on_template_select: on_template_select, + on_browse_templates: |_| {}, + on_recent_open: on_recent_open, + on_open_file: on_open_file, + on_recent_remove: on_recent_remove, + on_recent_delete: on_recent_delete, + on_recent_open_copy: on_recent_open_copy, + } + + // ── Delete confirmation (ADR-0013 boundary mount) ───────────────── + {pending_delete.read().clone().map(|(path, title)| rsx! { + AtConfirmDialog { + title: fl!("home-delete-confirm-title"), + message: fl!("home-delete-confirm-message", title = title), + confirm_label: fl!("home-delete-confirm-confirm"), + cancel_label: fl!("home-delete-confirm-cancel"), + on_confirm: move |_| { + pending_delete.set(None); + delete_recent(path.clone()); + }, + on_cancel: move |_| pending_delete.set(None), + } + })} } } } diff --git a/loki-presentation/src/routes/home_util.rs b/loki-presentation/src/routes/home_util.rs new file mode 100644 index 00000000..e4b78808 --- /dev/null +++ b/loki-presentation/src/routes/home_util.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tab and file-token helpers for the Home route. +//! +//! Extracted from `home.rs` to keep that file under the 300-line ceiling. + +use dioxus::prelude::*; +use loki_file_access::FileAccessToken; + +use crate::sessions::DocSessions; +use crate::tabs::OpenTab; +use crate::utils::display_title_from_path; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +pub(super) fn push_or_switch_tab( + mut tabs: Signal>, + mut active_tab: Signal, + path: String, +) { + let title = display_title_from_path(&path); + let existing = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = existing { + *active_tab.write() = idx + 1; + } else { + tabs.write().push(OpenTab { + title, + path, + is_dirty: false, + is_discarded: false, + }); + *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home + } +} + +/// Close any open tab whose `path` matches `path`, resetting the active tab to +/// Home when the closed (or a now-shifted) tab was selected, and drop any +/// stashed editing session for that path. +/// +/// The session must be dropped here (not only in the shell's tab-close button): +/// deleting a presentation from the recents list while it is open, or with a +/// session stashed from an earlier tab switch, would otherwise leak the whole +/// `LoroDoc`/deck in the map — and a later file created at the same token key +/// would restore the deleted deck's content instead of loading fresh. +pub(super) fn close_tab_for_path( + mut tabs: Signal>, + mut active_tab: Signal, + mut sessions: Signal, + path: &str, +) { + let removed = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = removed { + tabs.write().remove(idx); + // active_tab is 1-based (index 0 = Home). Reset to Home if the active + // selection pointed at or past the removed tab to avoid a stale index. + if *active_tab.read() > idx { + *active_tab.write() = 0; + } + } + // Drop the stashed session regardless of whether a tab was open — a session + // can outlive its tab (stashed on tab switch, then the tab closed). + sessions.write().remove(path); +} + +/// Build a " Copy." filename from a token's display name. +pub(super) fn suggested_copy_name(token: &FileAccessToken) -> String { + let name = token.display_name(); + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), + _ => format!("{name} Copy"), + } +} diff --git a/loki-presentation/src/routes/mod.rs b/loki-presentation/src/routes/mod.rs index 12a43763..1a785708 100644 --- a/loki-presentation/src/routes/mod.rs +++ b/loki-presentation/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod editor; pub mod home; +mod home_util; pub mod shell; use dioxus::prelude::*; diff --git a/loki-presentation/src/routes/shell.rs b/loki-presentation/src/routes/shell.rs index c1d3035c..06949e34 100644 --- a/loki-presentation/src/routes/shell.rs +++ b/loki-presentation/src/routes/shell.rs @@ -3,24 +3,75 @@ //! Persistent application shell wrapping all routes for loki-presentation. use appthere_ui::tokens; -use appthere_ui::{AtDocumentTabData, AtTabBar}; +use appthere_ui::{AtConfirmDialog, AtDocumentTabData, AtTabBar}; use dioxus::prelude::*; +use dioxus_router::Navigator; use loki_i18n::fl; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; +/// Closes the tab at 1-based tab-bar index `idx`: drops its stashed editing +/// session (so a later reopen loads fresh from disk instead of resurrecting +/// discarded unsaved edits) and fixes up the active tab / route. +fn close_tab( + idx: usize, + mut tabs: Signal>, + mut active_tab: Signal, + mut doc_sessions: Signal, + navigator: Navigator, +) { + let vec_idx = idx - 1; + // Guard: idx is captured at event time; a rapid second close (or a close + // confirmed after the list changed) must not index out of bounds. + if vec_idx >= tabs.read().len() { + return; + } + let current_active = *active_tab.read(); + + let closed_path = tabs.read().get(vec_idx).map(|t| t.path.clone()); + if let Some(p) = closed_path { + doc_sessions.write().remove(&p); + } + + tabs.write().remove(vec_idx); + let new_len = tabs.read().len(); + + if new_len == 0 { + *active_tab.write() = 0; + navigator.push(Route::Home {}); + } else if idx == current_active { + let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; + *active_tab.write() = new_active; + if let Some(tab) = tabs.read().get(new_active - 1) { + navigator.push(Route::Editor { + path: tab.path.clone(), + }); + } + } else if idx < current_active { + *active_tab.write() = current_active - 1; + } +} + /// Persistent application shell. #[component] pub fn Shell() -> Element { - let mut tabs = use_context::>>(); + let tabs = use_context::>>(); let mut active_tab = use_context::>(); + let doc_sessions = use_context::>(); let navigator = use_navigator(); + // A dirty tab awaiting close confirmation: `(tab-bar index, title)`. + // While `Some`, the confirmation dialog overlays the shell (plan 4b.6). + let mut pending_close: Signal> = use_signal(|| None); rsx! { div { + // position: relative anchors the AtConfirmDialog overlay (its + // absolute backdrop resolves against this shell-sized box). style: format!( - "height: 100vh; display: flex; flex-direction: column; \ + "height: 100vh; position: relative; \ + display: flex; flex-direction: column; \ overflow: hidden; background: {bg};", bg = tokens::COLOR_SURFACE_BASE, ), @@ -49,29 +100,18 @@ pub fn Shell() -> Element { return; // Home tab cannot be closed. } let vec_idx = idx - 1; - // Guard: idx is captured at render time; a rapid second close event - // before the deferred DOM re-render produces a stale vec_idx that is - // already out of bounds after the first removal. - if vec_idx >= tabs.read().len() { + // A dirty tab gets a confirmation dialog instead of an + // immediate close — closing discards its unsaved edits + // together with the stashed session (plan 4b.6). + let dirty_title = tabs + .read() + .get(vec_idx) + .and_then(|t| t.is_dirty.then(|| t.title.clone())); + if let Some(title) = dirty_title { + pending_close.set(Some((idx, title))); return; } - let current_active = *active_tab.read(); - - tabs.write().remove(vec_idx); - let new_len = tabs.read().len(); - - if new_len == 0 { - *active_tab.write() = 0; - navigator.push(Route::Home {}); - } else if idx == current_active { - let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; - *active_tab.write() = new_active; - if let Some(tab) = tabs.read().get(new_active - 1) { - navigator.push(Route::Editor { path: tab.path.clone() }); - } - } else if idx < current_active { - *active_tab.write() = current_active - 1; - } + close_tab(idx, tabs, active_tab, doc_sessions, navigator); }, on_new_tab: move |_| { *active_tab.write() = 0; @@ -89,6 +129,21 @@ pub fn Shell() -> Element { ), Outlet:: {} } + + // ── Dirty-tab close confirmation (ADR-0013 boundary mount) ──────── + {pending_close.read().clone().map(|(idx, title)| rsx! { + AtConfirmDialog { + title: fl!("shell-close-dirty-title"), + message: fl!("shell-close-dirty-message", title = title), + confirm_label: fl!("shell-close-dirty-confirm"), + cancel_label: fl!("shell-close-dirty-cancel"), + on_confirm: move |_| { + pending_close.set(None); + close_tab(idx, tabs, active_tab, doc_sessions, navigator); + }, + on_cancel: move |_| pending_close.set(None), + } + })} } } } diff --git a/loki-presentation/src/sessions.rs b/loki-presentation/src/sessions.rs new file mode 100644 index 00000000..67a6ba56 --- /dev/null +++ b/loki-presentation/src/sessions.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! In-memory editing sessions for inactive presentation tabs. +//! +//! When the user switches away from a presentation tab, `EditorInner` stashes +//! the live editable state here instead of discarding it; switching back +//! restores the session so unsaved edits survive tab switches (audit F1 +//! residual / plan 4b.6). Closing a tab drops its session (see +//! `routes/shell.rs`). +//! +//! The map is provided as `Signal` in Dioxus context at the +//! [`crate::app::App`] root. Sessions exist only for *inactive* tabs — the +//! active tab's state lives in the editor signals. + +use std::collections::HashMap; + +use loki_presentation_model::Presentation; + +/// Live editing state for one open-but-inactive presentation. +pub struct DocSession { + /// The editable presentation — holds all unsaved edits. + pub doc: Presentation, + /// Index of the slide that was active at stash time. + pub active_idx: usize, + /// Whether the presentation had unsaved edits at stash time. + pub dirty: bool, +} + +/// Open-but-inactive presentation sessions, keyed by the serialised file token. +pub type DocSessions = HashMap; diff --git a/loki-render-cpu/Cargo.toml b/loki-render-cpu/Cargo.toml new file mode 100644 index 00000000..7cdae55f --- /dev/null +++ b/loki-render-cpu/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "loki-render-cpu" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Deterministic in-process CPU rasterizer for loki-layout output (Spec 02 D2 conformance candidate render path)" +repository = "https://github.com/appthere/loki" + +[dependencies] +loki-layout = { path = "../loki-layout" } +# Pinned exactly: the conformance candidate render must be reproducible +# (Spec 02 D2 — fixed DPI, pinned rasterizer settings). Bump deliberately, +# together with a golden/calibration re-check. +vello_cpu = { version = "=0.0.9", features = ["text"] } +image = { version = "0.25", default-features = false, features = ["png"] } +thiserror = { workspace = true } + +[dev-dependencies] +loki-doc-model = { path = "../loki-doc-model" } +loki-fonts = { path = "../loki-fonts" } +# Calibration example: import the ODF fixtures and diff against goldens. +loki-odf = { path = "../loki-odf" } +appthere-conformance = { path = "../appthere-conformance" } diff --git a/loki-render-cpu/examples/calibrate_odf.rs b/loki-render-cpu/examples/calibrate_odf.rs new file mode 100644 index 00000000..dc045570 --- /dev/null +++ b/loki-render-cpu/examples/calibrate_odf.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The Spec 02 §7.4 / D5 calibration pass: measures the natural +//! cross-renderer noise floor between the committed LibreOffice goldens and +//! Loki's `vello_cpu` candidate renders, over the baseline fixture set +//! believed correct in both engines. +//! +//! Prints per-fixture and aggregate region-score distributions (SSIM and +//! CIEDE2000 ΔE) — the data the committed calibration record +//! (`appthere-conformance/goldens/CALIBRATION.md`) and the calibrated +//! `Tolerance` derive from. Also emits heatmaps for visual inspection. +//! +//! Run: `cargo run -p loki-render-cpu --example calibrate_odf` + +use std::io::Cursor; +use std::path::Path; + +use appthere_conformance::CONFORMANCE_DPI; +use appthere_conformance::golden::{Tolerance, compare_pages, emit_heatmap}; +use loki_doc_model::io::DocumentImport; +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; +use loki_odf::odt::import::{OdtImport, OdtImportOptions}; +use loki_render_cpu::render_page; + +fn percentile(sorted: &[f64], p: f64) -> f64 { + if sorted.is_empty() { + return f64::NAN; + } + let idx = ((sorted.len() - 1) as f64 * p).round() as usize; + sorted[idx] +} + +fn main() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let fixtures = root.join("appthere-conformance/fixtures/odt"); + let goldens = root.join("appthere-conformance/goldens/odt"); + let scratch = std::env::temp_dir().join("loki-calibration"); + std::fs::create_dir_all(&scratch).expect("scratch dir"); + + let mut all_ssim: Vec = Vec::new(); + let mut all_de: Vec = Vec::new(); + + for entry in std::fs::read_dir(&fixtures).expect("fixtures dir") { + let path = entry.expect("entry").path(); + if path.extension().is_none_or(|e| e != "odt") { + continue; + } + let stem = path.file_stem().unwrap().to_string_lossy().to_string(); + let golden_png = goldens.join(&stem).join("page-1.png"); + if !golden_png.exists() { + eprintln!("skipping {stem}: no golden committed"); + continue; + } + + // Candidate: import → layout (bundled faces registered) → CPU render. + let bytes = std::fs::read(&path).expect("read fixture"); + let doc = OdtImport::import(Cursor::new(bytes), OdtImportOptions::default()) + .expect("fixture must import"); + let mut resources = FontResources::new(); + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + let layout = match layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("expected paginated layout, got {other:?}"), + }; + let candidate = render_page(&layout, 0, CONFORMANCE_DPI).expect("candidate render"); + let golden = image::open(&golden_png).expect("golden decodes").to_rgba8(); + + // The two sides may differ by a rounding pixel; crop to the common + // area (recorded in the calibration doc). + let w = golden.width().min(candidate.width()); + let h = golden.height().min(candidate.height()); + let golden = image::imageops::crop_imm(&golden, 0, 0, w, h).to_image(); + let candidate_c = image::imageops::crop_imm(&candidate, 0, 0, w, h).to_image(); + + // Permissive tolerance: this pass MEASURES; it does not judge. + let report = compare_pages( + &golden, + &candidate_c, + Tolerance { + min_ssim: 0.0, + max_delta_e: f64::MAX, + }, + ) + .expect("compare"); + + let mut ssim: Vec = report.regions.iter().map(|r| r.ssim).collect(); + let mut de: Vec = report.regions.iter().map(|r| r.delta_e).collect(); + ssim.sort_by(f64::total_cmp); + de.sort_by(f64::total_cmp); + println!( + "{stem}: {} regions ({}x{} px)\n ssim min={:.4} p1={:.4} p5={:.4} median={:.4}\n ΔE max={:.3} p99={:.3} p95={:.3} median={:.3}", + ssim.len(), + w, + h, + ssim.first().unwrap(), + percentile(&ssim, 0.01), + percentile(&ssim, 0.05), + percentile(&ssim, 0.50), + de.last().unwrap(), + percentile(&de, 0.99), + percentile(&de, 0.95), + percentile(&de, 0.50), + ); + let heatmap = scratch.join(format!("{stem}-heatmap.png")); + emit_heatmap(&golden, &candidate_c, &heatmap).expect("heatmap"); + println!(" heatmap: {}", heatmap.display()); + // Optional side-by-side dump for offline inspection of a divergence. + if let Ok(dir) = std::env::var("CALIBRATE_DUMP_DIR") { + let dir = Path::new(&dir); + std::fs::create_dir_all(dir).expect("dump dir"); + golden.save(dir.join(format!("{stem}-golden.png"))).ok(); + candidate_c + .save(dir.join(format!("{stem}-candidate.png"))) + .ok(); + } + + all_ssim.extend(ssim); + all_de.extend(de); + } + + all_ssim.sort_by(f64::total_cmp); + all_de.sort_by(f64::total_cmp); + println!( + "\nAGGREGATE over {} regions:\n ssim min={:.4} p1={:.4} p5={:.4}\n ΔE max={:.3} p99={:.3} p95={:.3}", + all_ssim.len(), + all_ssim.first().unwrap(), + percentile(&all_ssim, 0.01), + percentile(&all_ssim, 0.05), + all_de.last().unwrap(), + percentile(&all_de, 0.99), + percentile(&all_de, 0.95), + ); +} diff --git a/loki-render-cpu/src/lib.rs b/loki-render-cpu/src/lib.rs new file mode 100644 index 00000000..bed72b31 --- /dev/null +++ b/loki-render-cpu/src/lib.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Deterministic in-process CPU rasterization of [`loki_layout`] output — +//! the Spec 02 **D2** conformance *candidate* render path. +//! +//! Loki's production render path is Vello on wgpu (GPU): fast, but not +//! bit-reproducible across drivers, and unusable in the GPU-less CI/agent +//! environment. This crate renders the **same positioned items the GPU path +//! paints** (`PositionedItem` from `loki-layout`) through `vello_cpu`, a pure +//! software rasterizer needing no graphics adapter — so committed goldens +//! compare against a deterministic candidate. +//! +//! Divergence containment (Spec 02 §7.1): both paths consume the identical +//! renderer-agnostic layout output; each `PositionedItem` arm here mirrors +//! its `loki-vello` twin (same origin/offset/scale math, same skip rules). +//! The differences are confined to the rasterizer itself — which is fine, +//! because fidelity is judged against the *reference application's* golden, +//! never against Loki's GPU output. +//! +//! Deliberately not rendered (editor chrome, not document content): page +//! drop shadows, cursors, selection highlights, spell squiggles' hover +//! state. TODO(conformance-render): image items paint the same grey +//! placeholder as `loki-vello`'s unresolved-image path; decoding embedded +//! images is a follow-up. + +#![forbid(unsafe_code)] + +mod paint; + +use image::RgbaImage; +use loki_layout::{LayoutRect, PaginatedLayout, PositionedItem, PositionedRect}; +use vello_cpu::{Pixmap, RenderContext, Resources}; + +/// Errors from the CPU candidate render. +#[derive(Debug, thiserror::Error)] +pub enum RenderCpuError { + /// The requested page does not exist in the layout. + #[error("page {index} out of range ({pages} pages)")] + PageOutOfRange { + /// Requested page index. + index: usize, + /// Number of pages in the layout. + pages: usize, + }, + /// The page dimensions overflow the rasterizer's u16 pixel space. + #[error("page pixel size {0}x{1} exceeds the rasterizer limit (65535)")] + PageTooLarge(u32, u32), + /// The rasterizer's pixel buffer did not match the page dimensions. + #[error("pixel buffer ({len} bytes) does not match {width}x{height} RGBA")] + BufferMismatch { + /// Buffer length in bytes. + len: usize, + /// Page width in pixels. + width: u32, + /// Page height in pixels. + height: u32, + }, +} + +/// White paper, matching the production painter's page background. +const PAGE_BG: loki_layout::LayoutColor = loki_layout::LayoutColor::WHITE; + +/// Renders one page of a paginated layout to an RGBA image at `dpi`. +/// +/// The geometry mirrors `loki_vello::paint_single_page`: content items are +/// content-area-local (translated by the page margins), header/footer and +/// comment items are page-local. No editor chrome (shadow/cursor) is drawn. +pub fn render_page( + layout: &PaginatedLayout, + page_index: usize, + dpi: u32, +) -> Result { + let page = layout + .pages + .get(page_index) + .ok_or(RenderCpuError::PageOutOfRange { + index: page_index, + pages: layout.pages.len(), + })?; + + let scale = dpi as f32 / 72.0; + let (page_w, page_h) = (page.page_size.width, page.page_size.height); + let px_w = (page_w * scale).ceil() as u32; + let px_h = (page_h * scale).ceil() as u32; + if px_w > u32::from(u16::MAX) || px_h > u32::from(u16::MAX) || px_w == 0 || px_h == 0 { + return Err(RenderCpuError::PageTooLarge(px_w, px_h)); + } + + let mut ctx = RenderContext::new(px_w as u16, px_h as u16); + let mut resources = Resources::new(); + + // Paper background across the full pixmap. + paint::paint_filled_rect( + &mut ctx, + &PositionedRect { + rect: LayoutRect::new(0.0, 0.0, page_w, page_h), + color: PAGE_BG, + }, + scale, + (0.0, 0.0), + ); + + // Mirrors paint_single_page's coordinate spaces. + let page_origin = (0.0, 0.0); + let content_origin = (page.margins.left, page.margins.top); + paint::paint_items( + &mut ctx, + &mut resources, + &page.content_items, + scale, + content_origin, + ); + paint::paint_items( + &mut ctx, + &mut resources, + &page.header_items, + scale, + page_origin, + ); + paint::paint_items( + &mut ctx, + &mut resources, + &page.footer_items, + scale, + page_origin, + ); + paint::paint_items( + &mut ctx, + &mut resources, + &page.comment_items, + scale, + page_origin, + ); + + ctx.flush(); + let mut pixmap = Pixmap::new(px_w as u16, px_h as u16); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + + let data = pixmap.data_as_u8_slice().to_vec(); + let len = data.len(); + // `from_raw` only returns `None` if the buffer length ≠ width*height*4; + // it matches by construction here, but surface a typed error rather than + // panic (no `.expect()` in library code — CLAUDE.md). + RgbaImage::from_raw(px_w, px_h, data).ok_or(RenderCpuError::BufferMismatch { + len, + width: px_w, + height: px_h, + }) +} + +/// Renders every page of the layout, in page order. +pub fn render_document( + layout: &PaginatedLayout, + dpi: u32, +) -> Result, RenderCpuError> { + (0..layout.pages.len()) + .map(|i| render_page(layout, i, dpi)) + .collect() +} + +/// Reachable item count across groups — used by tests/diagnostics to assert a +/// layout actually produced paintable content. +#[must_use] +pub fn paintable_item_count(items: &[PositionedItem]) -> usize { + items + .iter() + .map(|i| match i { + PositionedItem::ClippedGroup { items, .. } => paintable_item_count(items), + PositionedItem::RotatedGroup { items, .. } => paintable_item_count(items), + _ => 1, + }) + .sum() +} diff --git a/loki-render-cpu/src/paint.rs b/loki-render-cpu/src/paint.rs new file mode 100644 index 00000000..27924912 --- /dev/null +++ b/loki-render-cpu/src/paint.rs @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Per-item painting: each arm mirrors its `loki-vello` twin (same +//! origin/offset/scale math, same skip rules), targeting +//! `vello_cpu::RenderContext` instead of `vello::Scene`. + +use loki_layout::{ + BorderEdge, DecorationKind, LayoutColor, PositionedBorderRect, PositionedDecoration, + PositionedGlyphRun, PositionedImage, PositionedItem, PositionedRect, +}; +use vello_cpu::kurbo::{Affine, BezPath, Line, Rect, Shape, Stroke}; +use vello_cpu::{RenderContext, Resources, color::AlphaColor, peniko}; + +/// The grey placeholder `loki-vello` paints for unresolved images. +const IMAGE_PLACEHOLDER: LayoutColor = LayoutColor { + r: 0.8, + g: 0.8, + b: 0.8, + a: 1.0, +}; + +fn to_color(c: &LayoutColor) -> AlphaColor { + AlphaColor::new([c.r, c.g, c.b, c.a]) +} + +/// Paints a slice of items at `(offset, scale)` — the CPU twin of +/// `loki_vello::paint_items`. +pub(crate) fn paint_items( + ctx: &mut RenderContext, + resources: &mut Resources, + items: &[PositionedItem], + scale: f32, + offset: (f32, f32), +) { + for item in items { + match item { + PositionedItem::GlyphRun(run) => paint_glyph_run(ctx, resources, run, scale, offset), + PositionedItem::FilledRect(r) | PositionedItem::HorizontalRule(r) => { + paint_filled_rect(ctx, r, scale, offset); + } + PositionedItem::BorderRect(b) => paint_border_rect(ctx, b, scale, offset), + PositionedItem::Decoration(d) => paint_decoration(ctx, d, scale, offset), + PositionedItem::Image(img) => paint_image_placeholder(ctx, img, scale, offset), + PositionedItem::ClippedGroup { clip_rect, items } => { + let mut path = BezPath::new(); + path.extend( + Rect::new( + f64::from((clip_rect.x() + offset.0) * scale), + f64::from((clip_rect.y() + offset.1) * scale), + f64::from((clip_rect.max_x() + offset.0) * scale), + f64::from((clip_rect.max_y() + offset.1) * scale), + ) + .path_elements(0.1), + ); + ctx.push_clip_layer(&path); + paint_items(ctx, resources, items, scale, offset); + ctx.pop_layer(); + } + PositionedItem::RotatedGroup { + origin, + degrees, + content_width, + content_height, + items, + } => { + // Mirrors loki-vello's rotated-cell math: rotate the locally + // painted children about the physical cell centre. + let ox = origin.x + offset.0; + let oy = origin.y + offset.1; + let cx_local = content_width / 2.0; + let cy_local = content_height / 2.0; + let (cx_physical, cy_physical) = match *degrees as i32 { + 90 | 270 => (ox + cy_local, oy + cx_local), + _ => (ox + cx_local, oy + cy_local), + }; + let transform = Affine::translate(( + f64::from(cx_physical * scale), + f64::from(cy_physical * scale), + )) * Affine::rotate(f64::from(*degrees).to_radians()) + * Affine::translate(( + f64::from(-cx_local * scale), + f64::from(-cy_local * scale), + )); + let saved = *ctx.transform(); + ctx.set_transform(transform * saved); + paint_items(ctx, resources, items, scale, (0.0, 0.0)); + ctx.set_transform(saved); + } + // `PositionedItem` is #[non_exhaustive]; ignore unknown variants + // exactly as the GPU painter does. + _ => {} + } + } +} + +/// CPU twin of `loki_vello::glyph::paint_glyph_run`: baseline-origin +/// translate, per-glyph offsets scaled, `.notdef` skipped, hinting off. +fn paint_glyph_run( + ctx: &mut RenderContext, + resources: &mut Resources, + run: &PositionedGlyphRun, + scale: f32, + offset: (f32, f32), +) { + if run.glyphs.is_empty() || run.font_data.is_empty() { + return; + } + let blob = peniko::Blob::new(run.font_data.clone()); + let font = peniko::FontData::new(blob, run.font_index); + + let saved = *ctx.transform(); + ctx.set_transform( + saved + * Affine::translate(( + f64::from((run.origin.x + offset.0) * scale), + f64::from((run.origin.y + offset.1) * scale), + )), + ); + ctx.set_paint(to_color(&run.color)); + + let glyphs = run + .glyphs + .iter() + .filter(|g| g.id != 0) + .map(|g| vello_cpu::Glyph { + id: u32::from(g.id), + x: g.x * scale, + y: g.y * scale, + }) + .collect::>(); + + // Variable fonts render at their default instance, matching the GPU + // path (its FontDataCache passes all-zero coords — the default master). + ctx.glyph_run(resources, &font) + .font_size(run.font_size * scale) + .hint(false) + .fill_glyphs(glyphs.into_iter()); + + ctx.set_transform(saved); +} + +/// CPU twin of `loki_vello::rect::paint_filled_rect`. +pub(crate) fn paint_filled_rect( + ctx: &mut RenderContext, + item: &PositionedRect, + scale: f32, + offset: (f32, f32), +) { + ctx.set_paint(to_color(&item.color)); + ctx.fill_rect(&Rect::new( + f64::from((item.rect.x() + offset.0) * scale), + f64::from((item.rect.y() + offset.1) * scale), + f64::from((item.rect.max_x() + offset.0) * scale), + f64::from((item.rect.max_y() + offset.1) * scale), + )); +} + +/// CPU twin of `loki_vello::rect::paint_border_rect`: each present edge is a +/// centred stroke along its side; `width <= 0` edges skipped. +fn paint_border_rect( + ctx: &mut RenderContext, + item: &PositionedBorderRect, + scale: f32, + offset: (f32, f32), +) { + let x0 = f64::from((item.rect.x() + offset.0) * scale); + let y0 = f64::from((item.rect.y() + offset.1) * scale); + let x1 = f64::from((item.rect.max_x() + offset.0) * scale); + let y1 = f64::from((item.rect.max_y() + offset.1) * scale); + paint_edge(ctx, item.top.as_ref(), (x0, y0), (x1, y0), scale); + paint_edge(ctx, item.right.as_ref(), (x1, y0), (x1, y1), scale); + paint_edge(ctx, item.bottom.as_ref(), (x0, y1), (x1, y1), scale); + paint_edge(ctx, item.left.as_ref(), (x0, y0), (x0, y1), scale); +} + +fn paint_edge( + ctx: &mut RenderContext, + edge: Option<&BorderEdge>, + from: (f64, f64), + to: (f64, f64), + scale: f32, +) { + let Some(edge) = edge else { return }; + if edge.width <= 0.0 { + return; + } + ctx.set_paint(to_color(&edge.color)); + ctx.set_stroke(Stroke::new(f64::from(edge.width * scale))); + let mut path = BezPath::new(); + path.extend(Line::new(from, to).path_elements(0.1)); + ctx.stroke_path(&path); +} + +/// CPU twin of `loki_vello::decor::paint_decoration`: the stroke is centred +/// on the middle of the decoration stripe; the spelling squiggle uses the +/// same wave geometry. +fn paint_decoration( + ctx: &mut RenderContext, + item: &PositionedDecoration, + scale: f32, + offset: (f32, f32), +) { + if item.width <= 0.0 || item.thickness <= 0.0 { + return; + } + ctx.set_paint(to_color(&item.color)); + ctx.set_stroke(Stroke::new(f64::from(item.thickness * scale))); + + let x = item.x + offset.0; + let y = item.y + offset.1; + let mut path = BezPath::new(); + if item.kind == DecorationKind::Spelling { + // Squiggle: half-wave quads with a 2pt period, matching loki-vello. + let amplitude = f64::from(item.thickness * scale); + let period = f64::from(2.0 * scale); + let x0 = f64::from(x * scale); + let x1 = f64::from((x + item.width) * scale); + let yc = f64::from((y + item.thickness) * scale); + path.move_to((x0, yc)); + let mut cx = x0; + let mut up = true; + while cx < x1 { + let next = (cx + period).min(x1); + let ctrl_y = if up { yc - amplitude } else { yc + amplitude }; + path.quad_to(((cx + next) / 2.0, ctrl_y), (next, yc)); + up = !up; + cx = next; + } + } else { + let yc = f64::from((y + item.thickness / 2.0) * scale); + path.extend( + Line::new( + (f64::from(x * scale), yc), + (f64::from((x + item.width) * scale), yc), + ) + .path_elements(0.1), + ); + } + ctx.stroke_path(&path); +} + +/// TODO(conformance-render): decode and draw the actual image; today this is +/// the same grey placeholder `loki-vello` paints for unresolved images, so +/// image-bearing fixtures diff on the placeholder box, not garbage. +fn paint_image_placeholder( + ctx: &mut RenderContext, + img: &PositionedImage, + scale: f32, + offset: (f32, f32), +) { + paint_filled_rect( + ctx, + &PositionedRect { + rect: img.rect, + color: IMAGE_PLACEHOLDER, + }, + scale, + offset, + ); +} diff --git a/loki-render-cpu/tests/render_smoke.rs b/loki-render-cpu/tests/render_smoke.rs new file mode 100644 index 00000000..00fb90d0 --- /dev/null +++ b/loki-render-cpu/tests/render_smoke.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Spec 02 M5 acceptance smoke tests: the `vello_cpu` candidate path renders +//! a real laid-out document **headless, with no graphics adapter**, produces +//! actual ink, and is byte-deterministic across runs. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::document::Document; +use loki_doc_model::layout::section::Section; +use loki_doc_model::style::props::char_props::CharProps; +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; +use loki_render_cpu::{paintable_item_count, render_document, render_page}; + +/// Conformance DPI (matches `appthere_conformance::CONFORMANCE_DPI`; kept +/// literal here to avoid a dependency direction from renderer to harness). +const DPI: u32 = 144; + +fn sample_document() -> Document { + let props = CharProps { + bold: Some(true), + font_name: Some("Carlito".into()), + ..Default::default() + }; + let mut d = Document::default(); + let mut s = Section::new(); + s.blocks = vec![ + Block::Heading( + 1, + NodeAttr::default(), + vec![Inline::Str("Conformance render".into())], + ), + Block::Para(vec![ + Inline::Str("Deterministic CPU rasterization via ".into()), + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(props)), + content: vec![Inline::Str("vello_cpu".into())], + attr: NodeAttr::default(), + }), + Inline::Str(".".into()), + ]), + ]; + d.sections = vec![s]; + d +} + +fn paginated(doc: &Document) -> loki_layout::PaginatedLayout { + let mut resources = FontResources::new(); + // Embed the metric-compatible faces so shaping is machine-independent. + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + match layout_document( + &mut resources, + doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("expected paginated layout, got {other:?}"), + } +} + +#[test] +fn renders_a_page_headless_with_ink() { + let layout = paginated(&sample_document()); + assert!(!layout.pages.is_empty(), "layout must produce pages"); + assert!( + paintable_item_count(&layout.pages[0].content_items) > 0, + "layout must produce paintable items" + ); + + let page = render_page(&layout, 0, DPI).expect("render must succeed with no GPU"); + // A4 at 144 dpi ≈ 1190×1684; just assert the scale relation. + let expected_w = (layout.page_size.width * DPI as f32 / 72.0).ceil() as u32; + assert_eq!(page.width(), expected_w); + + // There must be real ink: some pixels darker than the white paper. + let dark = page + .pixels() + .filter(|p| u32::from(p.0[0]) + u32::from(p.0[1]) + u32::from(p.0[2]) < 300) + .count(); + assert!( + dark > 100, + "rendered page must contain glyph ink (found {dark} dark pixels)" + ); +} + +#[test] +fn rendering_is_deterministic() { + let layout = paginated(&sample_document()); + let a = render_page(&layout, 0, DPI).expect("render A"); + let b = render_page(&layout, 0, DPI).expect("render B"); + assert_eq!( + a.as_raw(), + b.as_raw(), + "the candidate render must be byte-deterministic (Spec 02 D2)" + ); +} + +#[test] +fn out_of_range_page_errors() { + let layout = paginated(&sample_document()); + assert!(render_page(&layout, 99, DPI).is_err()); + assert_eq!( + render_document(&layout, DPI).unwrap().len(), + layout.pages.len() + ); +} diff --git a/loki-render-cpu/tests/visual_golden.rs b/loki-render-cpu/tests/visual_golden.rs new file mode 100644 index 00000000..a52bfb9d --- /dev/null +++ b/loki-render-cpu/tests/visual_golden.rs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The visual-goldens axis over the committed ODF golden set, at the +//! **calibrated** tolerance (Spec 02 M5 acceptance: a correct fixture +//! passes, a mis-rendered one fails, and the threshold traces to the +//! calibration record, not a literal). +//! +//! All three committed fixtures pass at the calibrated tolerance. The +//! original `para-carlito` divergence was root-caused (2026-07-05) to loki +//! kerning unconditionally while the reference apps default kerning OFF; +//! `StyleSpan::kerning` now honours the document property with a +//! reference-matching default (see `loki-layout/tests/kerning_applied.rs` +//! and goldens/CALIBRATION.md). + +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +use appthere_conformance::CONFORMANCE_DPI; +use appthere_conformance::golden::{Tolerance, compare_pages}; +use loki_doc_model::io::DocumentImport; +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; +use loki_odf::odt::import::{OdtImport, OdtImportOptions}; +use loki_render_cpu::render_page; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("..") +} + +/// Renders the fixture's first page and compares against its committed +/// golden at the calibrated tolerance. Returns the pass verdict and the +/// worst region for diagnostics. +fn compare(stem: &str) -> (bool, String) { + let fixture = root().join(format!("appthere-conformance/fixtures/odt/{stem}.odt")); + let golden_png = root().join(format!( + "appthere-conformance/goldens/odt/{stem}/page-1.png" + )); + + let bytes = std::fs::read(&fixture).expect("fixture exists (committed)"); + let doc = OdtImport::import(Cursor::new(bytes), OdtImportOptions::default()) + .expect("fixture imports"); + let mut resources = FontResources::new(); + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + let layout = match layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("expected paginated layout, got {other:?}"), + }; + let candidate = render_page(&layout, 0, CONFORMANCE_DPI).expect("candidate render"); + let golden = image::open(&golden_png) + .expect("golden decodes (committed)") + .to_rgba8(); + + // ≤1 px DPI-rounding difference between the two pipelines (recorded in + // CALIBRATION.md); crop to the common area. + let w = golden.width().min(candidate.width()); + let h = golden.height().min(candidate.height()); + let golden = image::imageops::crop_imm(&golden, 0, 0, w, h).to_image(); + let candidate = image::imageops::crop_imm(&candidate, 0, 0, w, h).to_image(); + + let report = compare_pages(&golden, &candidate, Tolerance::calibrated()).expect("compare"); + let worst = report + .worst + .map(|r| { + format!( + "worst region {:?}: ssim={:.4} delta_e={:.3}", + r.region, r.ssim, r.delta_e + ) + }) + .unwrap_or_default(); + (report.passed, worst) +} + +#[test] +fn styles_tinos_matches_its_golden() { + let (passed, worst) = compare("styles-tinos"); + assert!( + passed, + "styles-tinos must pass calibrated tolerance; {worst}" + ); +} + +#[test] +fn para_gelasio_matches_its_golden() { + let (passed, worst) = compare("para-gelasio"); + assert!( + passed, + "para-gelasio must pass calibrated tolerance; {worst}" + ); +} + +/// Formerly the kerning-gap canary: this fixture diverged while loki kerned +/// text the reference apps leave unkerned (gap #23, resolved 2026-07-05). +/// Now a full member of the passing golden set. +#[test] +fn para_carlito_matches_its_golden() { + let (passed, worst) = compare("para-carlito"); + assert!( + passed, + "para-carlito must pass calibrated tolerance; {worst}" + ); +} diff --git a/loki-renderer/src/doc_page_source.rs b/loki-renderer/src/doc_page_source.rs index 8de3187d..a9d2a8d3 100644 --- a/loki-renderer/src/doc_page_source.rs +++ b/loki-renderer/src/doc_page_source.rs @@ -31,9 +31,7 @@ use loki_doc_model::document::Document; use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, PaginatedLayout}; use loki_vello::FontDataCache; -use crate::render_layout::{ - MIN_REFLOW_CONTENT_PT, REFLOW_PADDING_PT, REFLOW_TILE_HEIGHT_PT, RenderLayout, RenderMode, -}; +use crate::render_layout::{MIN_REFLOW_CONTENT_PT, REFLOW_PADDING_PT, RenderLayout, RenderMode}; // ── A4 page size at 96 dpi ──────────────────────────────────────────────────── @@ -80,6 +78,11 @@ pub struct DocPageSource { /// (whose `texture_generation` initialises to 0) always renders on its /// first frame. generation: Arc, + /// Render zoom factor (1.0 = 100%). Paginated mode only: it scales the + /// tile CSS size and the paint transform together, leaving the layout — + /// which stays in points — untouched. Reflow keeps 1.0 (its "zoom" is the + /// layout width). See `DocumentView` / `LokiPageSource::render`. + zoom: Mutex, } impl DocPageSource { @@ -93,9 +96,23 @@ impl DocPageSource { layout_resources: Mutex::new(None), renderer: Mutex::new(None), generation: Arc::new(AtomicU64::new(1)), + zoom: Mutex::new(1.0), } } + /// Sets the paginated render zoom factor (clamped to a sane range). The + /// next paint picks it up; the tile resize that accompanies a zoom change + /// forces the repaint (texture-size mismatch), so no generation bump is + /// needed. + pub fn set_zoom(&self, zoom: f32) { + *self.zoom.lock().unwrap_or_else(|e| e.into_inner()) = zoom.clamp(0.25, 4.0); + } + + /// The current paginated render zoom factor. + pub fn zoom(&self) -> f32 { + *self.zoom.lock().unwrap_or_else(|e| e.into_inner()) + } + /// Returns the current document. pub fn document(&self) -> Arc { self.doc.lock().unwrap_or_else(|e| e.into_inner()).clone() @@ -106,38 +123,6 @@ impl DocPageSource { self.generation.load(Ordering::Acquire) } - /// Hit-test a tile-local click in the reflow layout, returning - /// `(block_index, byte_offset)`. - /// - /// `tile_index` is the band tile clicked; `tile_x_pt` / `tile_y_pt` are the - /// tile-local position in layout points. Returns `None` in paginated mode or - /// when there is no editing data at the point. - pub fn reflow_hit_test( - &self, - tile_index: usize, - tile_x_pt: f32, - tile_y_pt: f32, - ) -> Option<(usize, usize)> { - let guard = self.layout_for_generation(self.current_generation()); - let (_, layout) = guard.as_ref()?; - // Tile-local → canvas: undo the band's x inset and y offset. - let canvas_x = tile_x_pt - REFLOW_PADDING_PT; - let canvas_y = tile_y_pt + tile_index as f32 * REFLOW_TILE_HEIGHT_PT; - layout.reflow_hit_test(canvas_x, canvas_y) - } - - /// The reflow band (tile) index containing the caret for `(block_index, - /// byte_offset)`, or `None` in paginated mode / when not found. - /// - /// The view uses this as the caret's `page_index` so the correct tile is - /// invalidated (and repainted) as the caret moves between bands. - pub fn reflow_cursor_band(&self, block_index: usize, byte_offset: usize) -> Option { - let guard = self.layout_for_generation(self.current_generation()); - let (_, layout) = guard.as_ref()?; - let cr = layout.reflow_cursor_canvas(block_index, byte_offset)?; - Some((cr.y / REFLOW_TILE_HEIGHT_PT).floor().max(0.0) as usize) - } - /// Increments the generation counter. /// /// Call this after applying a document mutation so that [`LokiPageSource`] diff --git a/loki-renderer/src/doc_page_source_reflow.rs b/loki-renderer/src/doc_page_source_reflow.rs new file mode 100644 index 00000000..9b439bc3 --- /dev/null +++ b/loki-renderer/src/doc_page_source_reflow.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Reflow-mode hit-test helpers for [`DocPageSource`]. Split from +//! `doc_page_source.rs` to keep it under the file-size ceiling. + +use crate::doc_page_source::DocPageSource; +use crate::render_layout::{REFLOW_PADDING_PT, REFLOW_TILE_HEIGHT_PT}; + +impl DocPageSource { + /// Hit-test a tile-local click in the reflow layout, returning + /// `(block_index, byte_offset)`. + /// + /// `tile_index` is the band tile clicked; `tile_x_pt` / `tile_y_pt` are the + /// tile-local position in layout points. Returns `None` in paginated mode or + /// when there is no editing data at the point. + pub fn reflow_hit_test( + &self, + tile_index: usize, + tile_x_pt: f32, + tile_y_pt: f32, + ) -> Option<(usize, usize)> { + let guard = self.layout_for_generation(self.current_generation()); + let (_, layout) = guard.as_ref()?; + // Tile-local → canvas: undo the band's x inset and y offset. + let canvas_x = tile_x_pt - REFLOW_PADDING_PT; + let canvas_y = tile_y_pt + tile_index as f32 * REFLOW_TILE_HEIGHT_PT; + layout.reflow_hit_test(canvas_x, canvas_y) + } + + /// The reflow band (tile) index containing the caret for `(block_index, + /// byte_offset)`, or `None` in paginated mode / when not found. + /// + /// The view uses this as the caret's `page_index` so the correct tile is + /// invalidated (and repainted) as the caret moves between bands. + pub fn reflow_cursor_band(&self, block_index: usize, byte_offset: usize) -> Option { + let guard = self.layout_for_generation(self.current_generation()); + let (_, layout) = guard.as_ref()?; + let cr = layout.reflow_cursor_canvas(block_index, byte_offset)?; + Some((cr.y / REFLOW_TILE_HEIGHT_PT).floor().max(0.0) as usize) + } +} diff --git a/loki-renderer/src/document_view.rs b/loki-renderer/src/document_view.rs index 384704a0..80568f54 100644 --- a/loki-renderer/src/document_view.rs +++ b/loki-renderer/src/document_view.rs @@ -7,8 +7,6 @@ use std::sync::{Arc, Mutex}; #[cfg(any(not(target_os = "android"), android_gpu))] use dioxus::prelude::*; -use loki_doc_model::document::Document; -use loki_layout::PaginatedLayout; // PageTile (and the wgpu paint path under it) is enabled on: desktop, and // Android devices built with RUSTFLAGS='--cfg android_gpu' (Vulkan-capable @@ -25,126 +23,9 @@ use crate::renderer_state::RendererState; #[cfg(all(target_os = "android", not(android_gpu)))] use crate::reflow_view::ReflowDocView; -// ── ViewMode ────────────────────────────────────────────────────────────────── - -/// How the document is laid out in the canvas. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub enum ViewMode { - /// Fixed print layout — one fixed-size page tile per page (needs the GPU - /// paint path). This is the default on large viewports. - #[default] - Paginated, - /// Reflowable, web-page-style continuous layout that wraps to the viewport - /// width. The default on small viewports, and the only mode available on - /// the Android CPU path. - Reflow, -} - -// ── RendererCursorPos ───────────────────────────────────────────────────────── - -/// Minimal cursor position for GPU painting. No Loro dependency. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct RendererCursorPos { - pub page_index: usize, - pub paragraph_index: usize, - pub byte_offset: usize, -} - -/// Caret + optional range selection for GPU painting. `anchor == focus` (by -/// paragraph/byte) means a collapsed caret with no selection. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct RendererSelection { - pub focus: RendererCursorPos, - pub anchor: RendererCursorPos, -} - -impl RendererSelection { - /// True when there is no range selection (anchor and focus coincide). - pub fn is_collapsed(&self) -> bool { - self.anchor.paragraph_index == self.focus.paragraph_index - && self.anchor.byte_offset == self.focus.byte_offset - } -} - -/// A right-click on a page tile, carrying both the tile-local layout-point -/// coordinates (for an accurate hit test) and the window-relative client -/// coordinates (to anchor a floating menu at the cursor). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct TileContext { - /// Index of the page tile that was right-clicked. - pub page_index: usize, - /// X within the tile, in layout points (from `element_coordinates`). - pub x_pt: f32, - /// Y within the tile, in layout points. - pub y_pt: f32, - /// Window-relative X of the cursor, in CSS pixels. - pub client_x: f32, - /// Window-relative Y of the cursor, in CSS pixels. - pub client_y: f32, -} - -// ── DocumentViewProps ───────────────────────────────────────────────────────── - -/// Props for the DocumentView component. -#[derive(Props, Clone)] -pub struct DocumentViewProps { - pub doc: Arc, - /// Paginated layout already computed by the editor for `doc`, reused in - /// paginated mode so the renderer does not lay the document out a second - /// time (the single canonical layout). `None` falls back to computing it - /// on the render path (e.g. before the first editor layout, or on the - /// Android CPU reflow path). - pub paginated_layout: Option>, - pub viewport_height_px: f64, - /// Current vertical scroll offset of the editor's scroll container, in CSS - /// px. Drives tile virtualization: only pages within ~one screen of this - /// offset are GPU-rendered. The editor owns the scroll container (this - /// component is laid out inside it), so the scroll position must be passed - /// in — the renderer's own scroll signal is not updated by the real scroll. - pub viewport_top_px: f64, - /// The caret / selection focus position. - pub cursor_pos: Option, - /// The selection anchor. When it differs from `cursor_pos`, a range - /// selection is highlighted between them (reflow mode). - pub selection_anchor: Option, - /// Current layout mode. Ignored on the Android CPU path, which only supports - /// [`ViewMode::Reflow`]. - pub view_mode: ViewMode, - /// Available viewport width in CSS pixels for [`ViewMode::Reflow`]. - /// `<= 0` means "not yet measured" — the view falls back to paginated - /// rendering until a real width arrives. - pub reflow_width_px: f64, - /// Called with `(page_index, x_pt, y_pt)` in layout points when the user - /// clicks a page tile in **paginated** mode. The caller performs the hit test - /// and updates cursor state. - pub on_tile_click: EventHandler<(usize, f32, f32)>, - /// Called with `(block_index, byte_offset)` when the user clicks in - /// **reflow** mode. This component owns the reflow layout, so it hit-tests - /// the click itself and reports the resolved document position. - pub on_reflow_click: EventHandler<(usize, usize)>, - /// Called with `(block_index, byte_offset)` while drag-selecting in - /// **reflow** mode (mouse moved with a button held). The caller extends the - /// selection focus to this position. - pub on_reflow_drag: EventHandler<(usize, usize)>, - /// Called when a page tile is right-clicked (paginated mode), carrying the - /// accurate tile-local + client coordinates. Drives the spelling context menu. - pub on_tile_context: EventHandler, - /// Vertical gap between page tiles in paginated mode, in CSS px. Injected by - /// the app (from `appthere_ui::tokens::PAGE_GAP_PX`) rather than imported - /// here, so the render layer does not depend on the UI layer — Spec 01 audit - /// A-8. Zeroed automatically in reflow mode. - pub page_gap_px: f64, - /// Bottom padding below the last page, in CSS px (from - /// `appthere_ui::tokens::SPACE_6`). Injected for the same reason as - /// `page_gap_px`. - pub content_padding_bottom_px: f32, -} - -impl PartialEq for DocumentViewProps { - fn eq(&self, _other: &Self) -> bool { - false // Conservatively always re-render - } -} +pub use crate::view_types::{ + DocumentViewProps, RendererCursorPos, RendererSelection, TileContext, ViewMode, +}; // ── DocumentView ────────────────────────────────────────────────────────────── @@ -199,6 +80,14 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { RenderMode::Paginated }; renderer.source.set_render_mode(render_mode); + // Zoom applies to paginated tiles only; reflow "zoom" would be a + // layout-width change, so it stays at 1.0 there. + let zoom = if render_mode == RenderMode::Paginated { + props.zoom.max(0.25) + } else { + 1.0 + }; + renderer.source.set_zoom(zoom as f32); // Single canonical layout: in paginated mode reuse the layout the editor // already computed for this document instead of laying it out again. // Provided after set_render_mode so it is keyed to the current @@ -223,9 +112,13 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { let pages: Vec<(usize, f64, f64)> = if let Some((_, layout)) = layout_guard.as_ref() { (0..layout.page_count()) .filter_map(|i| { - layout - .page_size_pts(i) - .map(|(w, h)| (i, w as f64 * PTS_TO_CSS_PX, h as f64 * PTS_TO_CSS_PX)) + layout.page_size_pts(i).map(|(w, h)| { + ( + i, + w as f64 * PTS_TO_CSS_PX * zoom, + h as f64 * PTS_TO_CSS_PX * zoom, + ) + }) }) .collect() } else { @@ -331,6 +224,7 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { page_index: idx, w, h, + zoom, shared_renderer: renderer.shared_renderer.clone(), cursor_holder: cursor_holder.clone(), selection, diff --git a/loki-renderer/src/lib.rs b/loki-renderer/src/lib.rs index 4c1cc0ff..e14d0510 100644 --- a/loki-renderer/src/lib.rs +++ b/loki-renderer/src/lib.rs @@ -13,12 +13,14 @@ #![forbid(unsafe_code)] pub mod doc_page_source; +mod doc_page_source_reflow; pub mod document_view; #[cfg(any(not(target_os = "android"), android_gpu))] pub mod page_paint_source; pub(crate) mod page_source_impl; #[cfg(any(not(target_os = "android"), android_gpu))] pub(crate) mod page_tile; +mod view_types; // The HTML-flow fallback view is only compiled on the Android CPU path; GPU // targets render reflow mode through the layout engine (RenderMode::Reflow). #[cfg(all(target_os = "android", not(android_gpu)))] diff --git a/loki-renderer/src/page_paint_source.rs b/loki-renderer/src/page_paint_source.rs index 5f6a56b1..40fc5196 100644 --- a/loki-renderer/src/page_paint_source.rs +++ b/loki-renderer/src/page_paint_source.rs @@ -198,7 +198,7 @@ impl CustomPaintSource for LokiPageSource { let view = texture.create_view(&TextureViewDescriptor::default()); // Step 6: build Vello scene for this page. - let render_scale = scale as f32 * (96.0 / 72.0); + let render_scale = scale as f32 * (96.0 / 72.0) * self.source.zoom(); // Compute cursor paint data in a scoped block so the layout guard is // dropped before the second layout_for_generation call below. diff --git a/loki-renderer/src/page_tile.rs b/loki-renderer/src/page_tile.rs index 08547f00..41f1a8cc 100644 --- a/loki-renderer/src/page_tile.rs +++ b/loki-renderer/src/page_tile.rs @@ -21,6 +21,9 @@ pub(crate) struct PageTileProps { pub(crate) page_index: usize, pub(crate) w: f64, pub(crate) h: f64, + /// Paginated render zoom factor — tile-local CSS px are + /// `pt × 96/72 × zoom`, so hit-test conversions divide it back out. + pub(crate) zoom: f64, pub(crate) shared_renderer: Arc>>, pub(crate) cursor_holder: Arc>>, pub(crate) selection: Option, @@ -47,6 +50,7 @@ impl PartialEq for PageTileProps { && self.page_index == other.page_index && self.w == other.w && self.h == other.h + && self.zoom == other.zoom && Arc::ptr_eq(&self.cursor_holder, &other.cursor_holder) && self.selection == other.selection && self.doc_gen == other.doc_gen @@ -118,8 +122,8 @@ pub(crate) fn PageTile(props: PageTileProps) -> Element { // which exactly equals page-local CSS pixels — no origin math needed. onmousedown: move |evt: MouseEvent| { let e = evt.element_coordinates(); - let x_pt = e.x as f32 * (72.0 / 96.0); - let y_pt = e.y as f32 * (72.0 / 96.0); + let x_pt = (e.x / props.zoom) as f32 * (72.0 / 96.0); + let y_pt = (e.y / props.zoom) as f32 * (72.0 / 96.0); // Right-click → context menu (carry client coords to anchor it); // any other button → cursor placement / drag origin. if evt.trigger_button() == Some(MouseButton::Secondary) { @@ -142,8 +146,8 @@ pub(crate) fn PageTile(props: PageTileProps) -> Element { return; } let e = evt.element_coordinates(); - let x_pt = e.x as f32 * (72.0 / 96.0); - let y_pt = e.y as f32 * (72.0 / 96.0); + let x_pt = (e.x / props.zoom) as f32 * (72.0 / 96.0); + let y_pt = (e.y / props.zoom) as f32 * (72.0 / 96.0); on_tile_drag.call((page_index, x_pt, y_pt)); }, canvas { diff --git a/loki-renderer/src/render_layout_tests.rs b/loki-renderer/src/render_layout_tests.rs index e25569cb..7c7a119b 100644 --- a/loki-renderer/src/render_layout_tests.rs +++ b/loki-renderer/src/render_layout_tests.rs @@ -67,6 +67,7 @@ fn one_para_reflow(text: &str, origin: (f32, f32)) -> RenderLayout { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), diff --git a/loki-renderer/src/view_types.rs b/loki-renderer/src/view_types.rs new file mode 100644 index 00000000..3137ae49 --- /dev/null +++ b/loki-renderer/src/view_types.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! View-facing types for [`super::document_view`]: the view mode, cursor and +//! selection positions, tile context, and [`DocumentViewProps`]. Extracted to +//! keep `document_view.rs` under the file-size ceiling. + +use std::sync::Arc; + +use dioxus::prelude::*; +use loki_doc_model::document::Document; +use loki_layout::PaginatedLayout; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum ViewMode { + /// Fixed print layout — one fixed-size page tile per page (needs the GPU + /// paint path). This is the default on large viewports. + #[default] + Paginated, + /// Reflowable, web-page-style continuous layout that wraps to the viewport + /// width. The default on small viewports, and the only mode available on + /// the Android CPU path. + Reflow, +} + +// ── RendererCursorPos ───────────────────────────────────────────────────────── + +/// Minimal cursor position for GPU painting. No Loro dependency. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RendererCursorPos { + pub page_index: usize, + pub paragraph_index: usize, + pub byte_offset: usize, +} + +/// Caret + optional range selection for GPU painting. `anchor == focus` (by +/// paragraph/byte) means a collapsed caret with no selection. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RendererSelection { + pub focus: RendererCursorPos, + pub anchor: RendererCursorPos, +} + +impl RendererSelection { + /// True when there is no range selection (anchor and focus coincide). + pub fn is_collapsed(&self) -> bool { + self.anchor.paragraph_index == self.focus.paragraph_index + && self.anchor.byte_offset == self.focus.byte_offset + } +} + +/// A right-click on a page tile, carrying both the tile-local layout-point +/// coordinates (for an accurate hit test) and the window-relative client +/// coordinates (to anchor a floating menu at the cursor). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TileContext { + /// Index of the page tile that was right-clicked. + pub page_index: usize, + /// X within the tile, in layout points (from `element_coordinates`). + pub x_pt: f32, + /// Y within the tile, in layout points. + pub y_pt: f32, + /// Window-relative X of the cursor, in CSS pixels. + pub client_x: f32, + /// Window-relative Y of the cursor, in CSS pixels. + pub client_y: f32, +} + +// ── DocumentViewProps ───────────────────────────────────────────────────────── + +/// Props for the DocumentView component. +#[derive(Props, Clone)] +pub struct DocumentViewProps { + pub doc: Arc, + /// Paginated layout already computed by the editor for `doc`, reused in + /// paginated mode so the renderer does not lay the document out a second + /// time (the single canonical layout). `None` falls back to computing it + /// on the render path (e.g. before the first editor layout, or on the + /// Android CPU reflow path). + pub paginated_layout: Option>, + pub viewport_height_px: f64, + /// Current vertical scroll offset of the editor's scroll container, in CSS + /// px. Drives tile virtualization: only pages within ~one screen of this + /// offset are GPU-rendered. The editor owns the scroll container (this + /// component is laid out inside it), so the scroll position must be passed + /// in — the renderer's own scroll signal is not updated by the real scroll. + pub viewport_top_px: f64, + /// The caret / selection focus position. + pub cursor_pos: Option, + /// The selection anchor. When it differs from `cursor_pos`, a range + /// selection is highlighted between them (reflow mode). + pub selection_anchor: Option, + /// Current layout mode. Ignored on the Android CPU path, which only supports + /// [`ViewMode::Reflow`]. + pub view_mode: ViewMode, + /// Available viewport width in CSS pixels for [`ViewMode::Reflow`]. + /// `<= 0` means "not yet measured" — the view falls back to paginated + /// rendering until a real width arrives. + pub reflow_width_px: f64, + /// Paginated render zoom factor (1.0 = 100%). Scales the page tiles' CSS + /// size and paint transform together; the layout (in points) and reflow + /// mode are unaffected. + #[props(default = 1.0)] + pub zoom: f64, + /// Called with `(page_index, x_pt, y_pt)` in layout points when the user + /// clicks a page tile in **paginated** mode. The caller performs the hit test + /// and updates cursor state. + pub on_tile_click: EventHandler<(usize, f32, f32)>, + /// Called with `(block_index, byte_offset)` when the user clicks in + /// **reflow** mode. This component owns the reflow layout, so it hit-tests + /// the click itself and reports the resolved document position. + pub on_reflow_click: EventHandler<(usize, usize)>, + /// Called with `(block_index, byte_offset)` while drag-selecting in + /// **reflow** mode (mouse moved with a button held). The caller extends the + /// selection focus to this position. + pub on_reflow_drag: EventHandler<(usize, usize)>, + /// Called when a page tile is right-clicked (paginated mode), carrying the + /// accurate tile-local + client coordinates. Drives the spelling context menu. + pub on_tile_context: EventHandler, + /// Vertical gap between page tiles in paginated mode, in CSS px. Injected by + /// the app (from `appthere_ui::tokens::PAGE_GAP_PX`) rather than imported + /// here, so the render layer does not depend on the UI layer — Spec 01 audit + /// A-8. Zeroed automatically in reflow mode. + pub page_gap_px: f64, + /// Bottom padding below the last page, in CSS px (from + /// `appthere_ui::tokens::SPACE_6`). Injected for the same reason as + /// `page_gap_px`. + pub content_padding_bottom_px: f32, +} + +impl PartialEq for DocumentViewProps { + fn eq(&self, _other: &Self) -> bool { + false // Conservatively always re-render + } +} diff --git a/loki-spreadsheet/src/app.rs b/loki-spreadsheet/src/app.rs index 573b5d8d..0c859a29 100644 --- a/loki-spreadsheet/src/app.rs +++ b/loki-spreadsheet/src/app.rs @@ -84,9 +84,14 @@ pub fn App() -> Element { let recent_docs: Signal = use_signal(|| RecentDocuments::load(crate::recent_documents::RECENT_FILE)); + // Stashed sessions for inactive tabs — unsaved edits survive tab switches. + let doc_sessions: Signal = + use_signal(std::collections::HashMap::new); + provide_context(tabs); provide_context(active_tab); provide_context(recent_docs); + provide_context(doc_sessions); let insets = use_safe_area(); diff --git a/loki-spreadsheet/src/lib.rs b/loki-spreadsheet/src/lib.rs index 0d22917b..4c9d14ba 100644 --- a/loki-spreadsheet/src/lib.rs +++ b/loki-spreadsheet/src/lib.rs @@ -18,6 +18,7 @@ pub mod error; pub mod new_document; pub mod recent_documents; pub mod routes; +pub mod sessions; pub mod tabs; pub mod utils; diff --git a/loki-spreadsheet/src/routes/editor/editor_inner.rs b/loki-spreadsheet/src/routes/editor/editor_inner.rs index 80ad667d..dac5cb8e 100644 --- a/loki-spreadsheet/src/routes/editor/editor_inner.rs +++ b/loki-spreadsheet/src/routes/editor/editor_inner.rs @@ -3,70 +3,25 @@ //! Spreadsheet editor inner view. -use appthere_ui::tokens; use appthere_ui::{ AtIcon, AtRibbon, AtRibbonGroup, AtRibbonIconButton, AtStatusBar, LUCIDE_BOLD, LUCIDE_ITALIC, - LUCIDE_REDO, LUCIDE_UNDERLINE, LUCIDE_UNDO, RibbonTabDesc, + LUCIDE_REDO, LUCIDE_UNDERLINE, LUCIDE_UNDO, RibbonTabDesc, tokens, }; use dioxus::prelude::*; -use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; use loki_i18n::fl; use std::collections::HashSet; use super::cell_ref::{col_to_label, grid_dimensions}; -use super::editor_load::{DocumentFormat, detect_format, load_document}; +use super::editor_load::load_document; +use super::editor_mutate::{mutate_cell, mutate_cell_style, mutate_column_width}; +use super::editor_path_sync::{ + PathSyncSignals, restore_session, stash_outgoing, sync_path_and_reset, +}; +use super::editor_save::save_document; use super::editor_state::{EditorState, use_editor_state}; use super::formula::{evaluate_cell, format_evaluated_value}; -use crate::routes::Route; -use crate::routes::dioxus_router::Navigator; use crate::utils::display_title_from_path; -/// Helper to mutate Loro cells in-place -fn mutate_cell( - ldoc: &loro::LoroDoc, - sheet_idx: usize, - row: u32, - col: u32, - val: String, - formula: Option, -) -> Result<(), loro::LoroError> { - let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); - let sheet_val = sheets_list - .get(sheet_idx) - .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; - let sheet_map = sheet_val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; - let cells_map = match sheet_map.get("cells") { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Cells container is not a map"))?, - None => sheet_map.insert_container("cells", loro::LoroMap::new())?, - }; - - let key = format!("{},{}", row, col); - let cell_map = match cells_map.get(&key) { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Cell container is not a map"))?, - None => cells_map.insert_container(&key, loro::LoroMap::new())?, - }; - - cell_map.insert("value", val.as_str())?; - if let Some(f) = formula { - cell_map.insert("formula", f.as_str())?; - } else { - let _ = cell_map.delete("formula"); - } - Ok(()) -} - /// Default rendered column width in CSS px (when the document specifies none). const DEFAULT_COL_PX: f64 = 100.0; /// Resize clamps (CSS px). @@ -90,94 +45,6 @@ struct ColResize { current_px: f64, } -/// Writes a column width (points) into the Loro sheet's `columns` map. -fn mutate_column_width( - ldoc: &loro::LoroDoc, - sheet_idx: usize, - col: u32, - width_pt: f64, -) -> Result<(), loro::LoroError> { - let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); - let sheet_val = sheets_list - .get(sheet_idx) - .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; - let sheet_map = sheet_val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; - let cols_map = match sheet_map.get("columns") { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Columns container is not a map"))?, - None => sheet_map.insert_container("columns", loro::LoroMap::new())?, - }; - cols_map.insert(col.to_string().as_str(), width_pt)?; - Ok(()) -} - -/// Helper to mutate cell style properties in-place -fn mutate_cell_style( - ldoc: &loro::LoroDoc, - sheet_idx: usize, - row: u32, - col: u32, - style_fn: F, -) -> Result<(), loro::LoroError> -where - F: FnOnce(&loro::LoroMap) -> Result<(), loro::LoroError>, -{ - let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); - let sheet_val = sheets_list - .get(sheet_idx) - .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; - let sheet_map = sheet_val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; - let cells_map = match sheet_map.get("cells") { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Cells container is not a map"))?, - None => sheet_map.insert_container("cells", loro::LoroMap::new())?, - }; - - let key = format!("{},{}", row, col); - let cell_map = match cells_map.get(&key) { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Cell container is not a map"))?, - None => cells_map.insert_container(&key, loro::LoroMap::new())?, - }; - - let style_map = match cell_map.get("style") { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Style container is not a map"))?, - None => { - let m = cell_map.insert_container("style", loro::LoroMap::new())?; - m.insert("bold", false)?; - m.insert("italic", false)?; - m.insert("underline", false)?; - m.insert("align", "left")?; - m.insert("num_format", "general")?; - m - } - }; - - style_fn(&style_map)?; - Ok(()) -} - /// Dispatches changes to Loro, commits, deserializes back, and marks the active tab as dirty fn apply_change( loro_doc: Signal>, @@ -234,124 +101,6 @@ fn sync_undo_redo( } } -/// Saves the workbook snapshot to the file target (or picks a target first if untitled) -fn save_document( - _path_prop: String, - mut path_signal: Signal, - workbook_snap: Signal, - mut tabs: Signal>, - active_tab: Signal, - navigator: Navigator, - mut save_message: Signal>, -) { - let active_tab_idx = *active_tab.peek(); - let current_path = path_signal.peek().clone(); - let wb = workbook_snap.peek().clone(); - - spawn(async move { - save_message.set(None); // clear any previous status - let token = if crate::new_document::is_untitled(¤t_path) { - let picker = FilePicker::new(); - let opts = SaveOptions { - mime_type: Some( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string(), - ), - suggested_name: Some("Workbook.xlsx".to_string()), - }; - match picker.pick_file_to_save(opts).await { - Ok(Some(t)) => t, - Ok(None) => return, // user cancelled — not an error - Err(e) => { - save_message.set(Some(fl!("error-file-picker", err = e.to_string()))); - return; - } - } - } else { - match FileAccessToken::deserialize(¤t_path) { - Ok(t) => t, - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); - return; - } - } - }; - - let format = detect_format(&token); - let mut writer = match token.open_write() { - Ok(w) => w, - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); - return; - } - }; - - let res = match format { - DocumentFormat::Xlsx => loki_ooxml::xlsx::export::XlsxExport::export(&wb, &mut *writer) - .map_err(|e| e.to_string()), - DocumentFormat::Ods => { - loki_odf::OdsExport::export(&wb, &mut *writer).map_err(|e| e.to_string()) - } - DocumentFormat::Unsupported(ext) => Err(format!("Unsupported format: .{ext}")), - }; - - match res { - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e))); - } - Ok(()) => { - let new_path = token.serialize(); - let new_title = display_title_from_path(&new_path); - - path_signal.set(new_path.clone()); - - if active_tab_idx > 0 { - let mut tabs_mut = tabs.write(); - if let Some(tab) = tabs_mut.get_mut(active_tab_idx - 1) { - tab.path = new_path.clone(); - tab.title = new_title; - tab.is_dirty = false; - } - } - - save_message.set(Some(fl!("editor-save-success"))); - navigator.push(Route::Editor { path: new_path }); - } - } - }); -} - -/// Reset per-document state when switching paths reactively -#[allow(clippy::too_many_arguments)] -fn sync_path_and_reset( - path: &str, - path_signal: &mut Signal, - workbook_snap: &mut Signal, - loro_doc: &mut Signal>, - undo_manager: &mut Signal>, - can_undo: &mut Signal, - can_redo: &mut Signal, - selected_cell: &mut Signal>, - editing_cell: &mut Signal>, -) { - let current = path_signal.peek().clone(); - if current == path { - return; - } - tracing::debug!( - "EditorInner: path changed from {} to {} -> resetting state", - current, - path - ); - path_signal.set(path.to_owned()); - workbook_snap.set(loki_sheet_model::Workbook::new()); - loro_doc.set(None); - undo_manager.set(None); - can_undo.set(false); - can_redo.set(false); - selected_cell.set(Some((0, 0))); - editing_cell.set(None); -} - /// Spreadsheet editor inner component. #[component] pub(super) fn EditorInner(path: String) -> Element { @@ -362,31 +111,82 @@ pub(super) fn EditorInner(path: String) -> Element { let tabs = use_context::>>(); let active_tab = use_context::>(); let active_tab_idx = *active_tab.read(); + // Stashed sessions for inactive tabs — unsaved edits survive tab switches. + let doc_sessions = use_context::>(); let EditorState { mut workbook_snap, mut loro_doc, mut undo_manager, - mut can_undo, - mut can_redo, + can_undo, + can_redo, mut selected_cell, mut editing_cell, } = use_editor_state(); // Transient save status (success or error) shown as a dismissible banner. let mut save_message = use_signal(|| Option::::None); + // Ribbon chrome state (F6d): active tab + collapse are live signals. + let mut ribbon_tab = use_signal(|| 0_usize); + let mut ribbon_collapsed = use_signal(|| false); + + // ── Session restore at mount ───────────────────────────────────────────── + // Navigating Editor → Home unmounts this component (different routes), so + // returning to a workbook tab mounts a fresh EditorInner. The matching + // stash happens in the unmount hook below. + { + let mut sessions_at_mount = doc_sessions; + use_hook(move || { + let initial_path = path_signal.peek().clone(); + if let Some(session) = sessions_at_mount.write().remove(&initial_path) { + let mut sig = PathSyncSignals { + workbook_snap, + loro_doc, + undo_manager, + can_undo, + can_redo, + selected_cell, + editing_cell, + }; + restore_session(session, &mut sig); + } + }); + } - // ── Synchronous Path Sync & State Reset ────────────────────────────────── + // ── Session stash at unmount ───────────────────────────────────────────── + { + let tabs_at_drop = tabs; + let sessions_at_drop = doc_sessions; + use_drop(move || { + let old_path = path_signal.peek().clone(); + let mut sig = PathSyncSignals { + workbook_snap, + loro_doc, + undo_manager, + can_undo, + can_redo, + selected_cell, + editing_cell, + }; + stash_outgoing(&old_path, tabs_at_drop, sessions_at_drop, &mut sig); + }); + } + + // ── Synchronous Path Sync & Session Handover ───────────────────────────── sync_path_and_reset( &path, &mut path_signal, - &mut workbook_snap, - &mut loro_doc, - &mut undo_manager, - &mut can_undo, - &mut can_redo, - &mut selected_cell, - &mut editing_cell, + tabs, + doc_sessions, + &mut PathSyncSignals { + workbook_snap, + loro_doc, + undo_manager, + can_undo, + can_redo, + selected_cell, + editing_cell, + }, ); // ── Document load — reactive on path_signal ─────────────────────────────── @@ -1212,19 +1012,21 @@ pub(super) fn EditorInner(path: String) -> Element { {main_content} // ── Ribbon (formatting controls) ────────────────────────────────── + // Only tabs with real content are listed (the loki-text + // convention) — the earlier Insert/Format/Review/View entries were + // dead affordances (audit F6d). Collapse is wired for real. AtRibbon { tabs: vec![ - RibbonTabDesc { label: fl!("ribbon-tab-home"), is_contextual: false, aria_label: None }, - RibbonTabDesc { label: fl!("ribbon-tab-insert"), is_contextual: false, aria_label: None }, - RibbonTabDesc { label: fl!("ribbon-tab-format"), is_contextual: false, aria_label: None }, - RibbonTabDesc { label: fl!("ribbon-tab-review"), is_contextual: false, aria_label: None }, - RibbonTabDesc { label: fl!("ribbon-tab-view"), is_contextual: false, aria_label: None }, + RibbonTabDesc { label: fl!("ribbon-tab-home"), is_contextual: false, aria_label: None }, ], - active_tab: 0, - collapsed: false, - on_toggle_collapse: move |_| {}, + active_tab: ribbon_tab(), + collapsed: ribbon_collapsed(), + on_toggle_collapse: move |_| { + let next = !*ribbon_collapsed.peek(); + ribbon_collapsed.set(next); + }, toggle_aria_label: fl!("ribbon-collapse-aria"), - on_tab_select: move |_idx| {}, + on_tab_select: move |idx| ribbon_tab.set(idx), tab_content: home_tab } @@ -1237,6 +1039,7 @@ pub(super) fn EditorInner(path: String) -> Element { collaborator_count: 0, collaborator_label: String::new(), zoom_aria_label: fl!("editor-zoom-aria"), + // TODO(zoom): needs zoom-aware col_px + resize px↔pt (plan 4c.5). on_zoom_click: |_| {}, } } diff --git a/loki-spreadsheet/src/routes/editor/editor_mutate.rs b/loki-spreadsheet/src/routes/editor/editor_mutate.rs new file mode 100644 index 00000000..0cea04c4 --- /dev/null +++ b/loki-spreadsheet/src/routes/editor/editor_mutate.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! In-place Loro mutations for the spreadsheet CRDT: cell value/formula, +//! column width, and cell style writes. +//! +//! Extracted from `editor_inner.rs` (an oversized file) — see +//! [`super::editor_inner`] for the dispatch (`apply_change`) that commits and +//! re-snapshots after each of these mutations. + +/// Helper to mutate Loro cells in-place +pub(super) fn mutate_cell( + ldoc: &loro::LoroDoc, + sheet_idx: usize, + row: u32, + col: u32, + val: String, + formula: Option, +) -> Result<(), loro::LoroError> { + let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); + let sheet_val = sheets_list + .get(sheet_idx) + .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; + let sheet_map = sheet_val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; + let cells_map = match sheet_map.get("cells") { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Cells container is not a map"))?, + None => sheet_map.insert_container("cells", loro::LoroMap::new())?, + }; + + let key = format!("{},{}", row, col); + let cell_map = match cells_map.get(&key) { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Cell container is not a map"))?, + None => cells_map.insert_container(&key, loro::LoroMap::new())?, + }; + + cell_map.insert("value", val.as_str())?; + if let Some(f) = formula { + cell_map.insert("formula", f.as_str())?; + } else { + let _ = cell_map.delete("formula"); + } + Ok(()) +} + +/// Writes a column width (points) into the Loro sheet's `columns` map. +pub(super) fn mutate_column_width( + ldoc: &loro::LoroDoc, + sheet_idx: usize, + col: u32, + width_pt: f64, +) -> Result<(), loro::LoroError> { + let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); + let sheet_val = sheets_list + .get(sheet_idx) + .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; + let sheet_map = sheet_val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; + let cols_map = match sheet_map.get("columns") { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Columns container is not a map"))?, + None => sheet_map.insert_container("columns", loro::LoroMap::new())?, + }; + cols_map.insert(col.to_string().as_str(), width_pt)?; + Ok(()) +} + +/// Helper to mutate cell style properties in-place +pub(super) fn mutate_cell_style( + ldoc: &loro::LoroDoc, + sheet_idx: usize, + row: u32, + col: u32, + style_fn: F, +) -> Result<(), loro::LoroError> +where + F: FnOnce(&loro::LoroMap) -> Result<(), loro::LoroError>, +{ + let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); + let sheet_val = sheets_list + .get(sheet_idx) + .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; + let sheet_map = sheet_val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; + let cells_map = match sheet_map.get("cells") { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Cells container is not a map"))?, + None => sheet_map.insert_container("cells", loro::LoroMap::new())?, + }; + + let key = format!("{},{}", row, col); + let cell_map = match cells_map.get(&key) { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Cell container is not a map"))?, + None => cells_map.insert_container(&key, loro::LoroMap::new())?, + }; + + let style_map = match cell_map.get("style") { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Style container is not a map"))?, + None => { + let m = cell_map.insert_container("style", loro::LoroMap::new())?; + m.insert("bold", false)?; + m.insert("italic", false)?; + m.insert("underline", false)?; + m.insert("align", "left")?; + m.insert("num_format", "general")?; + m + } + }; + + style_fn(&style_map)?; + Ok(()) +} diff --git a/loki-spreadsheet/src/routes/editor/editor_path_sync.rs b/loki-spreadsheet/src/routes/editor/editor_path_sync.rs new file mode 100644 index 00000000..05aba4ed --- /dev/null +++ b/loki-spreadsheet/src/routes/editor/editor_path_sync.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Path-change detection and per-document state handover for +//! [`super::editor_inner::EditorInner`]. +//! +//! On tab switch the outgoing workbook's live state (CRDT, undo history, grid +//! snapshot, selection) is stashed into the app-level +//! [`DocSessions`] map instead of being discarded, and the +//! incoming workbook's session is restored if one exists — unsaved edits +//! survive tab switches (plan 4b.6, mirrors `loki-text`). Only documents with +//! no stashed session fall through to the reset path (and are then loaded +//! from disk by `use_resource`). + +use dioxus::prelude::*; + +use crate::sessions::{DocSession, DocSessions}; +use crate::tabs::OpenTab; + +/// All per-document signals reset or restored on tab switch, grouped to keep +/// the [`sync_path_and_reset`] signature manageable. +pub(super) struct PathSyncSignals { + pub workbook_snap: Signal, + pub loro_doc: Signal>, + pub undo_manager: Signal>, + pub can_undo: Signal, + pub can_redo: Signal, + pub selected_cell: Signal>, + pub editing_cell: Signal>, +} + +/// Synchronises `path_signal` with the `path` prop. On change, stashes the +/// outgoing document's session, then either restores the incoming document's +/// stashed session or resets all per-document signals for a fresh disk load. +pub(super) fn sync_path_and_reset( + path: &str, + path_signal: &mut Signal, + tabs: Signal>, + mut sessions: Signal, + sig: &mut PathSyncSignals, +) { + let current = path_signal.peek().clone(); + if current == path { + return; + } + tracing::debug!( + "EditorInner: path changed from {} to {} → stashing outgoing session", + current, + path + ); + path_signal.set(path.to_owned()); + + stash_outgoing(¤t, tabs, sessions, sig); + + // Transient UI state never carries across documents. + sig.editing_cell.set(None); + + let restored = sessions.write().remove(path); + match restored { + Some(session) => restore_session(session, sig), + None => reset_for_fresh_load(sig), + } +} + +/// Move the outgoing document's live state into the session map. No-op when +/// no document ever finished loading, or when no tab points at `old_path` any +/// more — a closed (or Save-As-repointed) tab must not resurrect its old +/// state on reopen. +/// +/// Called on path change (doc → doc tab switch) and from the unmount hook in +/// `EditorInner` (doc → Home navigation unmounts the editor route). +pub(super) fn stash_outgoing( + old_path: &str, + tabs: Signal>, + mut sessions: Signal, + sig: &mut PathSyncSignals, +) { + let Some(loro_doc) = sig.loro_doc.write().take() else { + return; // nothing loaded — nothing to stash + }; + let undo_manager = sig.undo_manager.write().take(); + if !tabs.peek().iter().any(|t| t.path == old_path) { + return; + } + sessions.write().insert( + old_path.to_owned(), + DocSession { + loro_doc, + undo_manager, + workbook: sig.workbook_snap.peek().clone(), + can_undo: *sig.can_undo.peek(), + can_redo: *sig.can_redo.peek(), + selected_cell: *sig.selected_cell.peek(), + }, + ); +} + +/// Write a stashed session back into the live editor state. +/// +/// Called on path change and from the mount hook in `EditorInner` (returning +/// to a workbook tab after the editor route was unmounted by Home). +pub(super) fn restore_session(session: DocSession, sig: &mut PathSyncSignals) { + sig.workbook_snap.set(session.workbook); + sig.loro_doc.set(Some(session.loro_doc)); + sig.undo_manager.set(session.undo_manager); + sig.can_undo.set(session.can_undo); + sig.can_redo.set(session.can_redo); + sig.selected_cell.set(session.selected_cell); +} + +/// Reset all per-document state ahead of a fresh `load_document` pass. +fn reset_for_fresh_load(sig: &mut PathSyncSignals) { + sig.workbook_snap.set(loki_sheet_model::Workbook::new()); + sig.loro_doc.set(None); + sig.undo_manager.set(None); + sig.can_undo.set(false); + sig.can_redo.set(false); + sig.selected_cell.set(Some((0, 0))); +} diff --git a/loki-spreadsheet/src/routes/editor/editor_save.rs b/loki-spreadsheet/src/routes/editor/editor_save.rs new file mode 100644 index 00000000..bbe784ca --- /dev/null +++ b/loki-spreadsheet/src/routes/editor/editor_save.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Workbook save flow (XLSX/ODS export to the current or a picked token). +//! +//! Extracted from `editor_inner.rs` (an oversized file). + +use dioxus::prelude::*; +use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; +use loki_i18n::fl; + +use super::editor_load::{DocumentFormat, detect_format}; +use crate::routes::Route; +use crate::routes::dioxus_router::Navigator; +use crate::utils::display_title_from_path; + +/// Saves the workbook snapshot to the file target (or picks a target first if untitled) +pub(super) fn save_document( + _path_prop: String, + mut path_signal: Signal, + workbook_snap: Signal, + mut tabs: Signal>, + active_tab: Signal, + navigator: Navigator, + mut save_message: Signal>, +) { + let active_tab_idx = *active_tab.peek(); + let current_path = path_signal.peek().clone(); + let wb = workbook_snap.peek().clone(); + + spawn(async move { + save_message.set(None); // clear any previous status + let token = if crate::new_document::is_untitled(¤t_path) { + let picker = FilePicker::new(); + let opts = SaveOptions { + mime_type: Some( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string(), + ), + suggested_name: Some("Workbook.xlsx".to_string()), + }; + match picker.pick_file_to_save(opts).await { + Ok(Some(t)) => t, + Ok(None) => return, // user cancelled — not an error + Err(e) => { + save_message.set(Some(fl!("error-file-picker", err = e.to_string()))); + return; + } + } + } else { + match FileAccessToken::deserialize(¤t_path) { + Ok(t) => t, + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + return; + } + } + }; + + let format = detect_format(&token); + let mut writer = match token.open_write() { + Ok(w) => w, + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + return; + } + }; + + let res = match format { + DocumentFormat::Xlsx => loki_ooxml::xlsx::export::XlsxExport::export(&wb, &mut *writer) + .map_err(|e| e.to_string()), + DocumentFormat::Ods => { + loki_odf::OdsExport::export(&wb, &mut *writer).map_err(|e| e.to_string()) + } + DocumentFormat::Unsupported(ext) => Err(format!("Unsupported format: .{ext}")), + }; + + match res { + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e))); + } + Ok(()) => { + let new_path = token.serialize(); + let new_title = display_title_from_path(&new_path); + + path_signal.set(new_path.clone()); + + if active_tab_idx > 0 { + let mut tabs_mut = tabs.write(); + if let Some(tab) = tabs_mut.get_mut(active_tab_idx - 1) { + tab.path = new_path.clone(); + tab.title = new_title; + tab.is_dirty = false; + } + } + + save_message.set(Some(fl!("editor-save-success"))); + navigator.push(Route::Editor { path: new_path }); + } + } + }); +} diff --git a/loki-spreadsheet/src/routes/editor/mod.rs b/loki-spreadsheet/src/routes/editor/mod.rs index 11a0b419..f1e0a984 100644 --- a/loki-spreadsheet/src/routes/editor/mod.rs +++ b/loki-spreadsheet/src/routes/editor/mod.rs @@ -7,6 +7,9 @@ mod cell_ref; mod editor_error_view; mod editor_inner; mod editor_load; +mod editor_mutate; +mod editor_path_sync; +mod editor_save; mod editor_state; mod formula; #[cfg(test)] diff --git a/loki-spreadsheet/src/routes/home.rs b/loki-spreadsheet/src/routes/home.rs index 48042a34..f0195cb2 100644 --- a/loki-spreadsheet/src/routes/home.rs +++ b/loki-spreadsheet/src/routes/home.rs @@ -2,14 +2,17 @@ //! Home screen route component for loki-spreadsheet. -use appthere_ui::{AtHomeTab, BuiltinTemplate, RecentDocument}; +use appthere_ui::{AtConfirmDialog, AtHomeTab, BuiltinTemplate, RecentDocument}; use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, PickOptions, PickerError, SaveOptions}; use loki_i18n::fl; +use super::home_util::{close_tab_for_path, push_or_switch_tab, suggested_copy_name}; + use crate::new_document::new_blank_tab; use crate::recent_documents::RecentDocuments; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -42,48 +45,6 @@ fn make_templates() -> Vec { ] } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Push `path` as a new open tab, or switch to its existing tab if already open. -fn push_or_switch_tab(mut tabs: Signal>, mut active_tab: Signal, path: String) { - let title = display_title_from_path(&path); - let existing = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = existing { - *active_tab.write() = idx + 1; - } else { - tabs.write().push(OpenTab { - title, - path, - is_dirty: false, - is_discarded: false, - }); - *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home - } -} - -/// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. -fn close_tab_for_path(mut tabs: Signal>, mut active_tab: Signal, path: &str) { - let removed = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = removed { - tabs.write().remove(idx); - // active_tab is 1-based (index 0 = Home). Reset to Home if the active - // selection pointed at or past the removed tab to avoid a stale index. - if *active_tab.read() > idx { - *active_tab.write() = 0; - } - } -} - -/// Build a " Copy." filename from a token's display name. -fn suggested_copy_name(token: &FileAccessToken) -> String { - let name = token.display_name(); - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), - _ => format!("{name} Copy"), - } -} - // ── Home ────────────────────────────────────────────────────────────────────── /// Home screen component. @@ -93,6 +54,7 @@ pub fn Home() -> Element { let tabs = use_context::>>(); let active_tab = use_context::>(); + let doc_sessions = use_context::>(); let mut recent_docs = use_context::>(); // Holds the last file-picker error message, if any. @@ -171,15 +133,25 @@ pub fn Home() -> Element { // ── on_recent_delete ────────────────────────────────────────────────────── // - // `path` is a serialised FileAccessToken, not a filesystem path. Decode it, - // delete the underlying file via the capability token, close any open tab - // for it, then drop the recents entry. + // Deleting a file is destructive, so the menu action only *requests* it: + // the confirmation dialog below performs the deletion on confirm (4c.1). + let mut pending_delete: Signal> = use_signal(|| None); let on_recent_delete = move |idx: usize| { - let mut err_sig = pick_error; - let Some(path) = recent_docs.read().entries.get(idx).map(|e| e.path.clone()) else { - return; - }; + let entry = recent_docs + .read() + .entries + .get(idx) + .map(|e| (e.path.clone(), e.title.clone())); + if let Some(path_and_title) = entry { + pending_delete.set(Some(path_and_title)); + } + }; + // The confirmed deletion. `path` is a serialised FileAccessToken, not a + // filesystem path: decode it, delete the underlying file via the + // capability token, close any open tab for it, then drop the recents entry. + let mut delete_recent = move |path: String| { + let mut err_sig = pick_error; match FileAccessToken::deserialize(&path) { Ok(token) => { if let Err(e) = token.delete() { @@ -191,8 +163,7 @@ pub fn Home() -> Element { } } - close_tab_for_path(tabs, active_tab, &path); - // TODO(ux): Add a confirmation dialog before destructive deletion. + close_tab_for_path(tabs, active_tab, doc_sessions, &path); recent_docs.write().remove(&path); recent_docs.read().save(); }; @@ -266,26 +237,46 @@ pub fn Home() -> Element { .collect(); rsx! { - AtHomeTab { - templates: make_templates(), - recent_documents: recent_list, - templates_label: fl!("home-templates-heading"), - recent_label: fl!("home-recent-heading"), - browse_label: String::new(), - open_file_label: fl!("home-open-file"), - empty_recent_label: fl!("home-no-recent"), - recent_menu_aria_label: fl!("home-recent-menu-aria"), - recent_remove_label: fl!("home-recent-menu-remove"), - recent_delete_label: fl!("home-recent-menu-delete"), - recent_open_copy_label: fl!("home-recent-menu-open-copy"), - pick_error: pick_error, - on_template_select: on_template_select, - on_browse_templates: |_| {}, - on_recent_open: on_recent_open, - on_open_file: on_open_file, - on_recent_remove: on_recent_remove, - on_recent_delete: on_recent_delete, - on_recent_open_copy: on_recent_open_copy, + // position: relative anchors the AtConfirmDialog overlay over the + // home area (AtHomeTab sizes itself to the viewport minus tab bar). + div { + style: "position: relative;", + AtHomeTab { + templates: make_templates(), + recent_documents: recent_list, + templates_label: fl!("home-templates-heading"), + recent_label: fl!("home-recent-heading"), + browse_label: String::new(), + open_file_label: fl!("home-open-file"), + empty_recent_label: fl!("home-no-recent"), + recent_menu_aria_label: fl!("home-recent-menu-aria"), + recent_remove_label: fl!("home-recent-menu-remove"), + recent_delete_label: fl!("home-recent-menu-delete"), + recent_open_copy_label: fl!("home-recent-menu-open-copy"), + pick_error: pick_error, + on_template_select: on_template_select, + on_browse_templates: |_| {}, + on_recent_open: on_recent_open, + on_open_file: on_open_file, + on_recent_remove: on_recent_remove, + on_recent_delete: on_recent_delete, + on_recent_open_copy: on_recent_open_copy, + } + + // ── Delete confirmation (ADR-0013 boundary mount) ───────────────── + {pending_delete.read().clone().map(|(path, title)| rsx! { + AtConfirmDialog { + title: fl!("home-delete-confirm-title"), + message: fl!("home-delete-confirm-message", title = title), + confirm_label: fl!("home-delete-confirm-confirm"), + cancel_label: fl!("home-delete-confirm-cancel"), + on_confirm: move |_| { + pending_delete.set(None); + delete_recent(path.clone()); + }, + on_cancel: move |_| pending_delete.set(None), + } + })} } } } diff --git a/loki-spreadsheet/src/routes/home_util.rs b/loki-spreadsheet/src/routes/home_util.rs new file mode 100644 index 00000000..e8e1e6fe --- /dev/null +++ b/loki-spreadsheet/src/routes/home_util.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tab and file-token helpers for the Home route. +//! +//! Extracted from `home.rs` to keep that file under the 300-line ceiling. + +use dioxus::prelude::*; +use loki_file_access::FileAccessToken; + +use crate::sessions::DocSessions; +use crate::tabs::OpenTab; +use crate::utils::display_title_from_path; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Push `path` as a new open tab, or switch to its existing tab if already open. +pub(super) fn push_or_switch_tab( + mut tabs: Signal>, + mut active_tab: Signal, + path: String, +) { + let title = display_title_from_path(&path); + let existing = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = existing { + *active_tab.write() = idx + 1; + } else { + tabs.write().push(OpenTab { + title, + path, + is_dirty: false, + is_discarded: false, + }); + *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home + } +} + +/// Close any open tab whose `path` matches `path`, resetting the active tab to +/// Home when the closed (or a now-shifted) tab was selected, and drop any +/// stashed editing session for that path. +/// +/// The session must be dropped here (not only in the shell's tab-close button): +/// deleting a workbook from the recents list while it is open, or with a session +/// stashed from an earlier tab switch, would otherwise leak the whole +/// `LoroDoc`/workbook in the map — and a later file created at the same token +/// key would restore the deleted workbook's content instead of loading fresh. +pub(super) fn close_tab_for_path( + mut tabs: Signal>, + mut active_tab: Signal, + mut sessions: Signal, + path: &str, +) { + let removed = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = removed { + tabs.write().remove(idx); + // active_tab is 1-based (index 0 = Home). Reset to Home if the active + // selection pointed at or past the removed tab to avoid a stale index. + if *active_tab.read() > idx { + *active_tab.write() = 0; + } + } + // Drop the stashed session regardless of whether a tab was open — a session + // can outlive its tab (stashed on tab switch, then the tab closed). + sessions.write().remove(path); +} + +/// Build a " Copy." filename from a token's display name. +pub(super) fn suggested_copy_name(token: &FileAccessToken) -> String { + let name = token.display_name(); + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), + _ => format!("{name} Copy"), + } +} diff --git a/loki-spreadsheet/src/routes/mod.rs b/loki-spreadsheet/src/routes/mod.rs index c1fce9fa..2dd09491 100644 --- a/loki-spreadsheet/src/routes/mod.rs +++ b/loki-spreadsheet/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod editor; pub mod home; +mod home_util; pub mod shell; use dioxus::prelude::*; diff --git a/loki-spreadsheet/src/routes/shell.rs b/loki-spreadsheet/src/routes/shell.rs index 4b0b9e12..a05c18cf 100644 --- a/loki-spreadsheet/src/routes/shell.rs +++ b/loki-spreadsheet/src/routes/shell.rs @@ -3,24 +3,75 @@ //! Persistent application shell wrapping all routes for loki-spreadsheet. use appthere_ui::tokens; -use appthere_ui::{AtDocumentTabData, AtTabBar}; +use appthere_ui::{AtConfirmDialog, AtDocumentTabData, AtTabBar}; use dioxus::prelude::*; +use dioxus_router::Navigator; use loki_i18n::fl; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; +/// Closes the tab at 1-based tab-bar index `idx`: drops its stashed editing +/// session (so a later reopen loads fresh from disk instead of resurrecting +/// discarded unsaved edits) and fixes up the active tab / route. +fn close_tab( + idx: usize, + mut tabs: Signal>, + mut active_tab: Signal, + mut doc_sessions: Signal, + navigator: Navigator, +) { + let vec_idx = idx - 1; + // Guard: idx is captured at event time; a rapid second close (or a close + // confirmed after the list changed) must not index out of bounds. + if vec_idx >= tabs.read().len() { + return; + } + let current_active = *active_tab.read(); + + let closed_path = tabs.read().get(vec_idx).map(|t| t.path.clone()); + if let Some(p) = closed_path { + doc_sessions.write().remove(&p); + } + + tabs.write().remove(vec_idx); + let new_len = tabs.read().len(); + + if new_len == 0 { + *active_tab.write() = 0; + navigator.push(Route::Home {}); + } else if idx == current_active { + let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; + *active_tab.write() = new_active; + if let Some(tab) = tabs.read().get(new_active - 1) { + navigator.push(Route::Editor { + path: tab.path.clone(), + }); + } + } else if idx < current_active { + *active_tab.write() = current_active - 1; + } +} + /// Persistent application shell. #[component] pub fn Shell() -> Element { - let mut tabs = use_context::>>(); + let tabs = use_context::>>(); let mut active_tab = use_context::>(); + let doc_sessions = use_context::>(); let navigator = use_navigator(); + // A dirty tab awaiting close confirmation: `(tab-bar index, title)`. + // While `Some`, the confirmation dialog overlays the shell (plan 4b.6). + let mut pending_close: Signal> = use_signal(|| None); rsx! { div { + // position: relative anchors the AtConfirmDialog overlay (its + // absolute backdrop resolves against this shell-sized box). style: format!( - "height: 100vh; display: flex; flex-direction: column; \ + "height: 100vh; position: relative; \ + display: flex; flex-direction: column; \ overflow: hidden; background: {bg};", bg = tokens::COLOR_SURFACE_BASE, ), @@ -49,29 +100,18 @@ pub fn Shell() -> Element { return; // Home tab cannot be closed. } let vec_idx = idx - 1; - // Guard: idx is captured at render time; a rapid second close event - // before the deferred DOM re-render produces a stale vec_idx that is - // already out of bounds after the first removal. - if vec_idx >= tabs.read().len() { + // A dirty tab gets a confirmation dialog instead of an + // immediate close — closing discards its unsaved edits + // together with the stashed session (plan 4b.6). + let dirty_title = tabs + .read() + .get(vec_idx) + .and_then(|t| t.is_dirty.then(|| t.title.clone())); + if let Some(title) = dirty_title { + pending_close.set(Some((idx, title))); return; } - let current_active = *active_tab.read(); - - tabs.write().remove(vec_idx); - let new_len = tabs.read().len(); - - if new_len == 0 { - *active_tab.write() = 0; - navigator.push(Route::Home {}); - } else if idx == current_active { - let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; - *active_tab.write() = new_active; - if let Some(tab) = tabs.read().get(new_active - 1) { - navigator.push(Route::Editor { path: tab.path.clone() }); - } - } else if idx < current_active { - *active_tab.write() = current_active - 1; - } + close_tab(idx, tabs, active_tab, doc_sessions, navigator); }, on_new_tab: move |_| { *active_tab.write() = 0; @@ -89,6 +129,21 @@ pub fn Shell() -> Element { ), Outlet:: {} } + + // ── Dirty-tab close confirmation (ADR-0013 boundary mount) ──────── + {pending_close.read().clone().map(|(idx, title)| rsx! { + AtConfirmDialog { + title: fl!("shell-close-dirty-title"), + message: fl!("shell-close-dirty-message", title = title), + confirm_label: fl!("shell-close-dirty-confirm"), + cancel_label: fl!("shell-close-dirty-cancel"), + on_confirm: move |_| { + pending_close.set(None); + close_tab(idx, tabs, active_tab, doc_sessions, navigator); + }, + on_cancel: move |_| pending_close.set(None), + } + })} } } } diff --git a/loki-spreadsheet/src/sessions.rs b/loki-spreadsheet/src/sessions.rs new file mode 100644 index 00000000..23f2fd84 --- /dev/null +++ b/loki-spreadsheet/src/sessions.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! In-memory editing sessions for inactive workbook tabs. +//! +//! When the user switches away from a workbook tab, `EditorInner` stashes the +//! live CRDT and grid state here instead of discarding it; switching back +//! restores the session so unsaved edits survive tab switches (plan 4b.6, +//! mirrors `loki-text/src/sessions.rs`). Closing a tab drops its session (see +//! `routes/shell.rs`). +//! +//! The map is provided as `Signal` in Dioxus context at the +//! [`crate::app::App`] root. Sessions exist only for *inactive* tabs — the +//! active tab's state lives in the editor signals. The dirty flag needs no +//! stashing: it lives on the tab entry itself (`OpenTab::is_dirty`). + +use std::collections::HashMap; + +use loki_sheet_model::Workbook; + +/// Live editing state for one open-but-inactive workbook. +pub struct DocSession { + /// The live CRDT — holds all unsaved edits. + pub loro_doc: loro::LoroDoc, + /// Undo history paired with `loro_doc`. + pub undo_manager: Option, + /// Post-mutation workbook snapshot the grid renders from. + pub workbook: Workbook, + /// Ribbon/keyboard undo/redo state at stash time. + pub can_undo: bool, + /// Ribbon/keyboard undo/redo state at stash time. + pub can_redo: bool, + /// The selected cell at stash time (restored so the user resumes in place). + pub selected_cell: Option<(usize, usize)>, +} + +/// Open-but-inactive workbook sessions, keyed by the serialised file token. +pub type DocSessions = HashMap; diff --git a/loki-text/src/editing/cursor.rs b/loki-text/src/editing/cursor.rs index 9c6600bb..3432239c 100644 --- a/loki-text/src/editing/cursor.rs +++ b/loki-text/src/editing/cursor.rs @@ -41,6 +41,22 @@ impl DocumentPosition { } } + /// Whether two positions address the same caret location, ignoring + /// `page_index`. + /// + /// `page_index` is a display-only artifact: a paragraph flowing across a + /// page break has an entry on every page it touches, so the same logical + /// caret `(paragraph_index, byte_offset, path)` can carry different + /// `page_index` values depending on which fragment produced it. Selection + /// logic must compare on the logical caret so a "phantom" zero-width + /// selection differing only in `page_index` is not treated as a range. + #[must_use] + pub fn same_caret(&self, other: &DocumentPosition) -> bool { + self.paragraph_index == other.paragraph_index + && self.byte_offset == other.byte_offset + && self.path == other.path + } + /// The [`BlockPath`] addressing this position's paragraph for mutation. #[must_use] pub fn block_path(&self) -> BlockPath { @@ -126,9 +142,18 @@ impl CursorState { } } - /// Returns `true` when a range selection exists (anchor differs from focus). + /// Returns `true` when a range selection exists — the anchor and focus + /// address different caret locations. + /// + /// Compares on the logical caret (via [`DocumentPosition::same_caret`]), so + /// two positions that differ only in `page_index` (a page-spanning + /// paragraph fragment) do not register as a phantom zero-width selection + /// that would swallow the next Backspace/Delete. pub fn has_selection(&self) -> bool { - self.anchor.is_some() && self.focus.is_some() && self.anchor != self.focus + match (self.anchor.as_ref(), self.focus.as_ref()) { + (Some(a), Some(f)) => !a.same_caret(f), + _ => false, + } } /// The [`BlockPath`] of the focus position, if a cursor is placed. @@ -193,107 +218,5 @@ pub fn next_grapheme_boundary(text: &str, offset: usize) -> usize { // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn prev_grapheme_in_ascii_mid() { - // "hello": h(0) e(1) l(2) l(3) o(4) - assert_eq!(prev_grapheme_boundary("hello", 3), 2); - } - - #[test] - fn prev_grapheme_at_start() { - assert_eq!(prev_grapheme_boundary("hello", 0), 0); - } - - #[test] - fn next_grapheme_in_ascii_mid() { - assert_eq!(next_grapheme_boundary("hello", 2), 3); - } - - #[test] - fn next_grapheme_at_end() { - assert_eq!(next_grapheme_boundary("hello", 5), 5); - } - - #[test] - fn prev_grapheme_multibyte() { - // "héllo": h(0) é(1..3) l(3) l(4) o(5) - // é is U+00E9, encoded as 0xC3 0xA9 — 2 bytes. - // byte 3 is the start of 'l'; prev boundary should be 1 (start of é). - let s = "héllo"; - assert_eq!(prev_grapheme_boundary(s, 3), 1); - } - - #[test] - fn next_grapheme_emoji() { - // "a😀b": a(0) 😀(1..5) b(5) - // 😀 is U+1F600, encoded as 4 bytes. - // next boundary after offset 1 should be 5 (end of emoji / start of 'b'). - let s = "a\u{1F600}b"; - assert_eq!(next_grapheme_boundary(s, 1), 5); - } - - #[test] - fn top_level_position_block_path_is_flat() { - let pos = DocumentPosition::top_level(0, 3, 7); - assert_eq!(pos.block_path(), BlockPath::block(3)); - assert!(pos.path.is_empty()); - } - - #[test] - fn nested_position_block_path_carries_steps() { - let pos = DocumentPosition { - page_index: 0, - paragraph_index: 2, - byte_offset: 0, - path: vec![PathStep::Cell { cell: 1, block: 0 }], - }; - assert_eq!(pos.block_path(), BlockPath::in_cell(2, 1, 0)); - } - - #[test] - fn sibling_block_shifts_top_level_paragraph() { - let pos = DocumentPosition::top_level(0, 3, 7); - let next = pos.sibling_block(1, 0); - assert_eq!(next.paragraph_index, 4); - assert_eq!(next.byte_offset, 0); - assert!(next.path.is_empty()); - // Saturates at 0 rather than underflowing. - assert_eq!( - DocumentPosition::top_level(0, 0, 0) - .sibling_block(-1, 5) - .paragraph_index, - 0 - ); - } - - #[test] - fn sibling_block_shifts_nested_leaf_block_only() { - let pos = DocumentPosition { - page_index: 0, - paragraph_index: 2, - byte_offset: 9, - path: vec![PathStep::Cell { cell: 1, block: 0 }], - }; - let next = pos.sibling_block(1, 0); - // Root paragraph_index is untouched; the leaf block index advances. - assert_eq!(next.paragraph_index, 2); - assert_eq!(next.byte_offset, 0); - assert_eq!(next.path, vec![PathStep::Cell { cell: 1, block: 1 }]); - } - - #[test] - fn cursor_block_path_follows_focus() { - let mut cs = CursorState::new(); - assert_eq!(cs.block_path(), None); - cs.focus = Some(DocumentPosition { - page_index: 0, - paragraph_index: 5, - byte_offset: 0, - path: vec![PathStep::Note { note: 0, block: 0 }], - }); - assert_eq!(cs.block_path(), Some(BlockPath::in_note(5, 0, 0))); - } -} +#[path = "cursor_tests.rs"] +mod tests; diff --git a/loki-text/src/editing/cursor_tests.rs b/loki-text/src/editing/cursor_tests.rs new file mode 100644 index 00000000..6bd67d46 --- /dev/null +++ b/loki-text/src/editing/cursor_tests.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use super::*; + +#[test] +fn prev_grapheme_in_ascii_mid() { + // "hello": h(0) e(1) l(2) l(3) o(4) + assert_eq!(prev_grapheme_boundary("hello", 3), 2); +} + +#[test] +fn prev_grapheme_at_start() { + assert_eq!(prev_grapheme_boundary("hello", 0), 0); +} + +#[test] +fn next_grapheme_in_ascii_mid() { + assert_eq!(next_grapheme_boundary("hello", 2), 3); +} + +#[test] +fn next_grapheme_at_end() { + assert_eq!(next_grapheme_boundary("hello", 5), 5); +} + +#[test] +fn prev_grapheme_multibyte() { + // "héllo": h(0) é(1..3) l(3) l(4) o(5) + // é is U+00E9, encoded as 0xC3 0xA9 — 2 bytes. + // byte 3 is the start of 'l'; prev boundary should be 1 (start of é). + let s = "héllo"; + assert_eq!(prev_grapheme_boundary(s, 3), 1); +} + +#[test] +fn next_grapheme_emoji() { + // "a😀b": a(0) 😀(1..5) b(5) + // 😀 is U+1F600, encoded as 4 bytes. + // next boundary after offset 1 should be 5 (end of emoji / start of 'b'). + let s = "a\u{1F600}b"; + assert_eq!(next_grapheme_boundary(s, 1), 5); +} + +#[test] +fn top_level_position_block_path_is_flat() { + let pos = DocumentPosition::top_level(0, 3, 7); + assert_eq!(pos.block_path(), BlockPath::block(3)); + assert!(pos.path.is_empty()); +} + +#[test] +fn nested_position_block_path_carries_steps() { + let pos = DocumentPosition { + page_index: 0, + paragraph_index: 2, + byte_offset: 0, + path: vec![PathStep::Cell { cell: 1, block: 0 }], + }; + assert_eq!(pos.block_path(), BlockPath::in_cell(2, 1, 0)); +} + +#[test] +fn sibling_block_shifts_top_level_paragraph() { + let pos = DocumentPosition::top_level(0, 3, 7); + let next = pos.sibling_block(1, 0); + assert_eq!(next.paragraph_index, 4); + assert_eq!(next.byte_offset, 0); + assert!(next.path.is_empty()); + // Saturates at 0 rather than underflowing. + assert_eq!( + DocumentPosition::top_level(0, 0, 0) + .sibling_block(-1, 5) + .paragraph_index, + 0 + ); +} + +#[test] +fn sibling_block_shifts_nested_leaf_block_only() { + let pos = DocumentPosition { + page_index: 0, + paragraph_index: 2, + byte_offset: 9, + path: vec![PathStep::Cell { cell: 1, block: 0 }], + }; + let next = pos.sibling_block(1, 0); + // Root paragraph_index is untouched; the leaf block index advances. + assert_eq!(next.paragraph_index, 2); + assert_eq!(next.byte_offset, 0); + assert_eq!(next.path, vec![PathStep::Cell { cell: 1, block: 1 }]); +} + +#[test] +fn has_selection_ignores_page_index_only_differences() { + // A page-spanning paragraph fragment can give the same logical caret a + // different page_index on each endpoint; that is NOT a range selection. + let mut cs = CursorState::new(); + cs.anchor = Some(DocumentPosition::top_level(0, 4, 7)); + cs.focus = Some(DocumentPosition::top_level(1, 4, 7)); + assert!( + !cs.has_selection(), + "endpoints differing only in page_index must not count as a selection" + ); + // A genuine byte difference is still a selection. + cs.focus = Some(DocumentPosition::top_level(1, 4, 8)); + assert!( + cs.has_selection(), + "a real byte-range must count as a selection" + ); +} + +#[test] +fn cursor_block_path_follows_focus() { + let mut cs = CursorState::new(); + assert_eq!(cs.block_path(), None); + cs.focus = Some(DocumentPosition { + page_index: 0, + paragraph_index: 5, + byte_offset: 0, + path: vec![PathStep::Note { note: 0, block: 0 }], + }); + assert_eq!(cs.block_path(), Some(BlockPath::in_note(5, 0, 0))); +} diff --git a/loki-text/src/editing/hit_test.rs b/loki-text/src/editing/hit_test.rs index ce6bbb7d..6be1923a 100644 --- a/loki-text/src/editing/hit_test.rs +++ b/loki-text/src/editing/hit_test.rs @@ -21,8 +21,9 @@ //! Spec 01 audit A-1.) //! - `canvas_origin.y` = `TOOLBAR_HEIGHT_TOP + SPACE_6` (exact from tokens). //! -//! - `scroll_offset` = 0.0 (Blitz does not expose `node.scroll_offset` to -//! Dioxus components; wired as a TODO once the API is available). +//! - `scroll_offset` = the live scroll position mirrored from the canvas +//! `onscroll` handler (`editor_canvas.rs` → `scroll_metrics`), threaded in +//! by the pointer handlers (`editor_pointer.rs`). //! //! All geometry inside this function works in layout **points** (1 pt = 1/72 in). //! The conversion from CSS logical pixels is applied once at entry: @@ -87,8 +88,16 @@ pub fn reflow_hit_test_window( /// offset to Dioxus components. /// * `layout` — paginated layout produced with /// `LayoutOptions { preserve_for_editing: true }`. -/// * `page_width_px` / `page_height_px` — page canvas dimensions in CSS px. -/// * `page_gap_px` — vertical gap between page canvases in CSS px. +/// * `page_width_px` / `page_height_px` — **unzoomed** page canvas dimensions in +/// CSS px (as stored in `DocumentState`). The painted tiles are these scaled +/// by `zoom`; this function reapplies the scale internally. +/// * `page_gap_px` — vertical gap between page canvases in CSS px. This is a +/// fixed CSS margin that is **not** scaled by zoom (see +/// `loki_renderer::document_view`). +/// * `zoom` — the paginated render zoom (1.0 = 1:1). The caller must also pass a +/// `canvas_origin.0` centred on the **scaled** page width +/// (`centred_origin_x(page_width_px * zoom)`), since the tiles are centred at +/// their painted width. // All arguments are semantically distinct; grouping into a struct would reduce clarity at call sites. #[allow(clippy::too_many_arguments)] pub fn hit_test_document( @@ -100,7 +109,10 @@ pub fn hit_test_document( _page_width_px: f32, page_height_px: f32, page_gap_px: f32, + zoom: f32, ) -> Option { + let zoom = if zoom > 0.0 { zoom } else { 1.0 }; + // ── 1. Canvas-local coordinates in CSS pixels ───────────────────────────── let canvas_x_px = client_x - canvas_origin.0; let canvas_y_px = client_y - canvas_origin.1 + scroll_offset; @@ -109,26 +121,28 @@ pub fn hit_test_document( return None; } - // ── 2. Convert to layout points (1 pt = 96/72 CSS px) ──────────────────── - let canvas_x = canvas_x_px * PX_TO_PT; - let canvas_y = canvas_y_px * PX_TO_PT; - - let page_height_pt = page_height_px * PX_TO_PT; - let page_gap_pt = page_gap_px * PX_TO_PT; - - // ── 3. Determine which page was clicked ─────────────────────────────────── - let page_and_gap = page_height_pt + page_gap_pt; - if page_and_gap <= 0.0 { + // ── 2. Locate the page in scaled CSS px ────────────────────────────────── + // Tiles are painted at `zoom` scale; the inter-page gap is a fixed CSS + // margin that is not scaled. Work the page stride in CSS px, then convert + // the in-page offset to layout points, dividing the zoom back out. + let page_h_scaled = page_height_px * zoom; + let slot_px = page_h_scaled + page_gap_px; + if slot_px <= 0.0 { return None; } - let page_index = (canvas_y / page_and_gap) as usize; - let y_in_page = canvas_y - (page_index as f32 * page_and_gap); + let page_index = (canvas_y_px / slot_px) as usize; + let y_in_page_px = canvas_y_px - (page_index as f32 * slot_px); // Reject clicks in the gap between pages. - if y_in_page > page_height_pt || y_in_page < 0.0 { + if y_in_page_px > page_h_scaled || y_in_page_px < 0.0 { return None; } + // ── 3. In-page CSS px → layout points (1 pt = 96/72 CSS px, ÷ zoom) ─────── + let px_to_pt = PX_TO_PT / zoom; + let canvas_x = canvas_x_px * px_to_pt; + let y_in_page = y_in_page_px * px_to_pt; + hit_test_page(page_index, canvas_x, y_in_page, layout) } diff --git a/loki-text/src/editing/hit_test_tests.rs b/loki-text/src/editing/hit_test_tests.rs index 949a6bc8..aca199ad 100644 --- a/loki-text/src/editing/hit_test_tests.rs +++ b/loki-text/src/editing/hit_test_tests.rs @@ -37,6 +37,7 @@ fn make_test_layout() -> PaginatedLayout { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), @@ -107,6 +108,7 @@ fn click_at_content_origin_returns_page0_para0_offset0() { page_w_px, page_h_px, pt_to_px(24.0), // page_gap_px + 1.0, // zoom ); let pos = result.expect("click at content origin should hit para 0"); assert_eq!(pos.page_index, 0); @@ -131,6 +133,7 @@ fn click_below_all_content_returns_none() { page_w_px, page_h_px, pt_to_px(24.0), + 1.0, // zoom ); assert!( result.is_none(), @@ -169,6 +172,7 @@ fn click_on_page2_returns_page_index_1() { page_w_px, page_h_px, page_gap_px, + 1.0, // zoom ); let pos = result.expect("click on page 2 should succeed"); assert_eq!(pos.page_index, 1, "should land on page 1 (0-based)"); @@ -228,6 +232,7 @@ fn scroll_offset_corrects_page2_click() { page_w_px, page_h_px, page_gap_px, + 1.0, // zoom ); let pos = result.expect("correct scroll_offset must resolve page 2 click"); assert_eq!( @@ -271,6 +276,7 @@ fn missing_scroll_offset_misses_page2_click() { page_w_px, page_h_px, page_gap_px, + 1.0, // zoom ); // Without scroll_offset, click_y maps to page 0 content (near top), // so result is either page 0 or None — never page 1. @@ -282,6 +288,80 @@ fn missing_scroll_offset_misses_page2_click() { } } +/// At zoom ≠ 1.0 the painted tiles are scaled, so a window-relative click must +/// be de-scaled before mapping to layout points. A click at the content origin +/// scaled by the zoom must still resolve to para 0, byte 0. +#[test] +fn zoomed_click_at_content_origin_resolves_para0() { + let layout = make_test_layout(); + let page = &layout.pages[0]; + let page_w_px = pt_to_px(page.page_size.width); + let page_h_px = pt_to_px(page.page_size.height); + let zoom = 2.0; + // The painted content origin is the (unzoomed) margin offset scaled by zoom. + let click_x = pt_to_px(page.margins.left) * zoom; + let click_y = pt_to_px(page.margins.top) * zoom; + + let result = hit_test_document( + click_x, + click_y, + canvas_origin_for_test(), + 0.0, + &layout, + page_w_px, + page_h_px, + pt_to_px(24.0), + zoom, + ); + let pos = result.expect("zoomed click at content origin should hit para 0"); + assert_eq!(pos.page_index, 0); + assert_eq!(pos.paragraph_index, 0); + assert_eq!(pos.byte_offset, 0, "top-left click should land at byte 0"); +} + +/// The page stride is `page_height × zoom + gap` (the gap is a fixed CSS margin, +/// unscaled). A zoomed click at page 2's content origin must resolve to page +/// index 1 — the pre-fix code divided by an unzoomed stride and over-counted. +#[test] +fn zoomed_click_on_page2_resolves_page_index_1() { + let layout = { + let single = make_test_layout(); + let page0 = single.pages[0].clone(); + let mut page1 = (*page0).clone(); + page1.page_number = 2; + PaginatedLayout { + page_size: single.page_size, + pages: vec![page0, Arc::new(page1)], + } + }; + let page = &layout.pages[1]; + let page_h_px = pt_to_px(layout.page_size.height); + let page_w_px = pt_to_px(layout.page_size.width); + let page_gap_px = pt_to_px(24.0); + let zoom = 2.0; + // Page 1's top is one scaled page height + one (unscaled) gap down; its + // content origin adds the scaled top margin. + let click_x = pt_to_px(page.margins.left) * zoom; + let click_y = page_h_px * zoom + page_gap_px + pt_to_px(page.margins.top) * zoom; + + let result = hit_test_document( + click_x, + click_y, + canvas_origin_for_test(), + 0.0, + &layout, + page_w_px, + page_h_px, + page_gap_px, + zoom, + ); + let pos = result.expect("zoomed click on page 2 should succeed"); + assert_eq!( + pos.page_index, 1, + "should land on page 1 (0-based) at zoom 2×" + ); +} + // ── Reflow (continuous) hit-testing ─────────────────────────────────────── /// One reflow paragraph laid out at the given canvas origin. @@ -310,6 +390,7 @@ fn reflow_para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagr link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), diff --git a/loki-text/src/editing/mod.rs b/loki-text/src/editing/mod.rs index a7bf3f03..a9fae788 100644 --- a/loki-text/src/editing/mod.rs +++ b/loki-text/src/editing/mod.rs @@ -12,9 +12,13 @@ pub mod cursor; pub mod hit_test; pub mod navigation; +mod navigation_find; +pub mod page_locate; pub mod reflow_nav; pub mod relayout; +pub mod saved_state; pub mod spell; pub mod state; pub mod touch; pub mod viewport; +pub mod word_count; diff --git a/loki-text/src/editing/navigation.rs b/loki-text/src/editing/navigation.rs index f4a46e91..57f41599 100644 --- a/loki-text/src/editing/navigation.rs +++ b/loki-text/src/editing/navigation.rs @@ -9,10 +9,10 @@ //! //! # Cross-page navigation //! -//! All six navigation functions clamp at the current page boundary and return -//! `None` when the target falls off the page. The caller leaves the cursor -//! unchanged on `None`. Cross-page traversal is tracked as -//! `TODO(3b-3)` throughout this file. +//! Left/Right cross page boundaries (the prev/next-entry searches walk the +//! whole layout); Up/Down cross via their previous/next-paragraph fallback. +//! `None` means there is nowhere to go (document start/end) and the caller +//! leaves the cursor unchanged. //! //! # Double-Enter to exit a list //! @@ -22,103 +22,37 @@ //! the list properties from the trailing empty block. //! `TODO(3b-3)`: implement `clear_para_props` / list-exit heuristic. -use loki_layout::{PageParagraphData, PaginatedLayout}; +use loki_doc_model::BlockPath; +use loki_layout::PaginatedLayout; use super::cursor::{DocumentPosition, next_grapheme_boundary, prev_grapheme_boundary}; use super::hit_test::hit_test_page; - -// ── Internal helpers ────────────────────────────────────────────────────────── - -/// Look up the [`PageParagraphData`] for a block on a specific page. -/// -/// `block_index` is the flat document block index stored in -/// [`DocumentPosition::paragraph_index`]. -fn find_para_data( - layout: &PaginatedLayout, - page_index: usize, - block_index: usize, -) -> Option<&PageParagraphData> { - layout - .pages - .get(page_index)? - .editing_data - .as_ref()? - .paragraphs - .iter() - .find(|p| p.block_index == block_index) -} - -/// Find the paragraph entry immediately preceding `block_index` in document order. -/// -/// Searches the current page first (for `block_index - 1`), then walks backward -/// through previous pages. Returns `(page_index, para_data)`. -/// -/// Returns `None` when `block_index` is 0 (no predecessor exists). -fn find_prev_para_data( - layout: &PaginatedLayout, - page_index: usize, - block_index: usize, -) -> Option<(usize, &PageParagraphData)> { - if block_index == 0 { - return None; - } - let prev_block = block_index - 1; - // Search this page and previous pages for the entry with prev_block. - for pi in (0..=page_index).rev() { - if let Some(ed) = layout.pages[pi].editing_data.as_ref() - && let Some(para) = ed.paragraphs.iter().find(|p| p.block_index == prev_block) - { - return Some((pi, para)); - } - } - None -} - -/// Find the paragraph entry immediately following `block_index` in document order. -/// -/// Searches the current page first (for `block_index + 1`), then walks forward -/// through subsequent pages. Returns `(page_index, para_data)`. -/// -/// Returns `None` when no following block exists in the layout. -fn find_next_para_data( - layout: &PaginatedLayout, - page_index: usize, - block_index: usize, -) -> Option<(usize, &PageParagraphData)> { - let next_block = block_index + 1; - for pi in page_index..layout.pages.len() { - if let Some(ed) = layout.pages[pi].editing_data.as_ref() - && let Some(para) = ed.paragraphs.iter().find(|p| p.block_index == next_block) - { - return Some((pi, para)); - } - } - None -} +use super::navigation_find::{ + find_next_para_data, find_para_data, find_prev_para_data, nested_sibling, +}; // ── Public navigation functions ─────────────────────────────────────────────── /// Move one grapheme cluster to the left. /// /// - Within a paragraph: moves to the previous grapheme boundary. -/// - At offset 0: moves to the end of the previous paragraph **on the same -/// page** if one exists. +/// - At offset 0: moves to the end of the previous paragraph, crossing page +/// boundaries — or, for a nested position (table cell / note body), to the +/// previous sibling within the same container, clamping at the container's +/// first block. /// /// Returns `None` when already at the very start (first block on first page) /// or when no layout data is available. /// -/// `get_text(block_index)` is called to retrieve the plain text of a block. -/// Pass a closure over `loki_doc_model::get_block_text`. -/// -/// `TODO(3b-3)`: when at offset 0 of the first block on a page, navigate to -/// the last character of the last block on the previous page. +/// `get_text(path)` is called to retrieve the plain text of the addressed +/// paragraph. Pass a closure over [`loki_doc_model::get_block_text_at`]. pub fn navigate_left( focus: &DocumentPosition, layout: &PaginatedLayout, - get_text: impl Fn(usize) -> String, + get_text: impl Fn(&BlockPath) -> String, ) -> Option { if focus.byte_offset > 0 { - let text = get_text(focus.paragraph_index); + let text = get_text(&focus.block_path()); let new_offset = prev_grapheme_boundary(&text, focus.byte_offset); return Some(DocumentPosition { byte_offset: new_offset, @@ -126,38 +60,53 @@ pub fn navigate_left( }); } - // At start of paragraph — move to end of previous block on the same page. - if focus.paragraph_index == 0 { - // TODO(3b-3): navigate to last char of last block on previous page - return None; + // At start of a nested paragraph — move to the end of the previous sibling + // within the same cell / note body. When there is no previous sibling (the + // container's first block), fall through to the top-level prev-block search: + // the container is addressed by its root `paragraph_index`, so that search + // finds the block preceding it and the caret escapes the cell / note body. + if !focus.path.is_empty() + && let Some(sibling) = nested_sibling(focus, layout, -1) + { + let prev_text = get_text(&sibling.block_path()); + return Some(DocumentPosition { + byte_offset: prev_text.len(), + ..sibling + }); } - let prev_index = focus.paragraph_index - 1; - // Verify the previous block is actually on this page before crossing. - find_para_data(layout, focus.page_index, prev_index)?; - let prev_text = get_text(prev_index); - // TODO(nested-nav): a sibling inside a cell/note body must carry its path. - Some(DocumentPosition::top_level( - focus.page_index, - prev_index, - prev_text.len(), - )) + + // At start of paragraph — move to the end of the previous block, + // crossing page boundaries (the entry search walks backward through the + // layout, so the nearest fragment page wins for split paragraphs). + let (prev_pi, prev_para) = + find_prev_para_data(layout, focus.page_index, focus.paragraph_index)?; + let prev_pos = DocumentPosition { + page_index: prev_pi, + paragraph_index: prev_para.block_index, + byte_offset: 0, + path: prev_para.path.clone(), + }; + let prev_text = get_text(&prev_pos.block_path()); + Some(DocumentPosition { + byte_offset: prev_text.len(), + ..prev_pos + }) } /// Move one grapheme cluster to the right. /// /// - Within a paragraph: moves to the next grapheme boundary. -/// - At the end: moves to the start of the next paragraph **on the same page** -/// if one exists. +/// - At the end: moves to the start of the next paragraph, crossing page +/// boundaries — or, for a nested position, to the next sibling within the +/// same container, clamping at the container's last block. /// /// Returns `None` when already at the very end or no layout data is available. -/// -/// `TODO(3b-3)`: cross-page rightward navigation. pub fn navigate_right( focus: &DocumentPosition, layout: &PaginatedLayout, - get_text: impl Fn(usize) -> String, + get_text: impl Fn(&BlockPath) -> String, ) -> Option { - let text = get_text(focus.paragraph_index); + let text = get_text(&focus.block_path()); if focus.byte_offset < text.len() { let new_offset = next_grapheme_boundary(&text, focus.byte_offset); return Some(DocumentPosition { @@ -166,13 +115,27 @@ pub fn navigate_right( }); } - // At end of paragraph — move to start of next block on the same page. - let next_index = focus.paragraph_index + 1; - // Verify the next block is actually on this page. - find_para_data(layout, focus.page_index, next_index)?; - // TODO(3b-3): cross-page rightward navigation - // TODO(nested-nav): see navigate_left. - Some(DocumentPosition::top_level(focus.page_index, next_index, 0)) + // At the end of a nested paragraph — move to the start of the next sibling + // within the same cell / note body. When there is no next sibling (the + // container's last block), fall through to the top-level next-block search: + // the container is addressed by its root `paragraph_index`, so that search + // finds the block following it and the caret escapes the cell / note body. + if !focus.path.is_empty() + && let Some(sibling) = nested_sibling(focus, layout, 1) + { + return Some(sibling); + } + + // At end of paragraph — move to the start of the next block, crossing + // page boundaries (the entry search walks forward through the layout). + let (next_pi, next_para) = + find_next_para_data(layout, focus.page_index, focus.paragraph_index)?; + Some(DocumentPosition { + page_index: next_pi, + paragraph_index: next_para.block_index, + byte_offset: 0, + path: next_para.path.clone(), + }) } /// Move the cursor one line up, preserving the horizontal screen position. @@ -184,7 +147,7 @@ pub fn navigate_right( /// /// Returns `None` when already at the very first position of the document. pub fn navigate_up(focus: &DocumentPosition, layout: &PaginatedLayout) -> Option { - let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index)?; + let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index, &focus.path)?; let rect = para_data.layout.cursor_rect(focus.byte_offset)?; let margins = &layout.pages.get(focus.page_index)?.margins; @@ -229,7 +192,7 @@ pub fn navigate_down( focus: &DocumentPosition, layout: &PaginatedLayout, ) -> Option { - let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index)?; + let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index, &focus.path)?; let rect = para_data.layout.cursor_rect(focus.byte_offset)?; let margins = &layout.pages.get(focus.page_index)?.margins; let page = layout.pages.get(focus.page_index)?; @@ -271,7 +234,7 @@ pub fn navigate_home( focus: &DocumentPosition, layout: &PaginatedLayout, ) -> Option { - let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index)?; + let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index, &focus.path)?; let rect = para_data.layout.cursor_rect(focus.byte_offset)?; // Centre-y of the current line in paragraph-local coordinates. @@ -289,15 +252,15 @@ pub fn navigate_home( /// line, trimming any trailing hard-break character so the cursor stays after /// the last visible glyph. /// -/// `get_text(block_index)` is called to retrieve the paragraph text needed by +/// `get_text(path)` is called to retrieve the paragraph text needed by /// `line_end_offset` to check for a trailing `\n`. pub fn navigate_end( focus: &DocumentPosition, layout: &PaginatedLayout, - get_text: impl Fn(usize) -> String, + get_text: impl Fn(&BlockPath) -> String, ) -> Option { - let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index)?; - let text = get_text(focus.paragraph_index); + let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index, &focus.path)?; + let text = get_text(&focus.block_path()); let end_offset = para_data.layout.line_end_offset(focus.byte_offset, &text)?; Some(DocumentPosition { byte_offset: end_offset, @@ -308,284 +271,5 @@ pub fn navigate_end( // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use std::sync::Arc; - - use loki_layout::{ - FontResources, LayoutColor, LayoutInsets, LayoutPage, LayoutSize, PageEditingData, - PageParagraphData, PaginatedLayout, ResolvedParaProps, StyleSpan, layout_paragraph, - }; - - use super::*; - - fn make_layout_with_text(text: &str) -> PaginatedLayout { - let mut resources = FontResources::new(); - let para = layout_paragraph( - &mut resources, - text, - &[StyleSpan { - range: 0..text.len(), - font_name: None, - font_size: 12.0, - bold: false, - weight: 400, - italic: false, - color: LayoutColor::BLACK, - underline: None, - strikethrough: None, - line_height: None, - vertical_align: None, - highlight_color: None, - letter_spacing: None, - font_variant: None, - word_spacing: None, - shadow: false, - link_url: None, - math: None, - scale: None, - baseline_shift: None, - }], - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - let editing_data = PageEditingData { - paragraphs: vec![PageParagraphData { - block_index: 0, - path: Vec::new(), - layout: Arc::new(para), - origin: (0.0, 0.0), - }], - }; - let page_size = LayoutSize::new(595.0, 842.0); - let margins = LayoutInsets { - top: 72.0, - right: 72.0, - bottom: 72.0, - left: 72.0, - }; - let page = LayoutPage { - page_number: 1, - page_size, - margins, - content_items: vec![], - header_items: vec![], - footer_items: vec![], - comment_items: vec![], - header_height: 0.0, - footer_height: 0.0, - editing_data: Some(editing_data), - }; - PaginatedLayout { - page_size, - pages: vec![Arc::new(page)], - } - } - - fn focus_at(byte_offset: usize) -> DocumentPosition { - DocumentPosition::top_level(0, 0, byte_offset) - } - - #[test] - fn navigate_left_moves_to_prev_grapheme() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(3); - let result = navigate_left(&focus, &layout, |_| "hello".to_string()); - assert_eq!(result.unwrap().byte_offset, 2); - } - - #[test] - fn navigate_left_at_start_returns_none_for_first_block() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(0); - let result = navigate_left(&focus, &layout, |_| "hello".to_string()); - assert!(result.is_none(), "at start of block 0 should return None"); - } - - #[test] - fn navigate_right_moves_to_next_grapheme() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(2); - let result = navigate_right(&focus, &layout, |_| "hello".to_string()); - assert_eq!(result.unwrap().byte_offset, 3); - } - - #[test] - fn navigate_right_at_end_of_last_block_returns_none() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(5); // end of "hello" - let result = navigate_right(&focus, &layout, |_| "hello".to_string()); - assert!(result.is_none(), "at end of last block should return None"); - } - - #[test] - fn navigate_home_returns_position_on_same_paragraph() { - let layout = make_layout_with_text("hello world"); - let focus = focus_at(6); - let result = navigate_home(&focus, &layout); - // Home from mid-paragraph — offset ≤ 6 (start of line or paragraph). - let pos = result.expect("navigate_home should return Some"); - assert_eq!(pos.page_index, 0); - assert_eq!(pos.paragraph_index, 0); - assert!( - pos.byte_offset <= 6, - "Home should move to start of line (byte ≤ 6)" - ); - } - - #[test] - fn navigate_end_returns_position_on_same_paragraph() { - let layout = make_layout_with_text("hello world"); - let focus = focus_at(0); - let result = navigate_end(&focus, &layout, |_| "hello world".to_string()); - let pos = result.expect("navigate_end should return Some"); - assert_eq!(pos.page_index, 0); - assert_eq!(pos.paragraph_index, 0); - // End from start — offset should be > 0. - assert!(pos.byte_offset > 0, "End should move past the start"); - } - - #[test] - fn navigate_up_at_first_line_returns_none() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(0); - // Single-line paragraph at top of page — no line above. - let result = navigate_up(&focus, &layout); - assert!( - result.is_none(), - "no line above first line should return None" - ); - } - - #[test] - fn navigate_down_at_last_line_returns_none() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(0); - // Single-line paragraph — no line below. - let result = navigate_down(&focus, &layout); - assert!( - result.is_none(), - "no line below last line should return None" - ); - } - - // ── Cross-boundary helper tests ─────────────────────────────────────────── - - fn make_two_para_layout(text0: &str, text1: &str) -> PaginatedLayout { - let mut resources = FontResources::new(); - let make_span = |text: &str| StyleSpan { - range: 0..text.len(), - font_name: None, - font_size: 12.0, - bold: false, - weight: 400, - italic: false, - color: LayoutColor::BLACK, - underline: None, - strikethrough: None, - line_height: None, - vertical_align: None, - highlight_color: None, - letter_spacing: None, - font_variant: None, - word_spacing: None, - shadow: false, - link_url: None, - math: None, - scale: None, - baseline_shift: None, - }; - let para0 = layout_paragraph( - &mut resources, - text0, - &[make_span(text0)], - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - let h0 = para0.height; - let para1 = layout_paragraph( - &mut resources, - text1, - &[make_span(text1)], - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - let editing_data = PageEditingData { - paragraphs: vec![ - PageParagraphData { - block_index: 0, - path: Vec::new(), - layout: Arc::new(para0), - origin: (0.0, 0.0), - }, - PageParagraphData { - block_index: 1, - path: Vec::new(), - layout: Arc::new(para1), - origin: (0.0, h0), - }, - ], - }; - let page_size = LayoutSize::new(595.0, 842.0); - let margins = LayoutInsets { - top: 72.0, - right: 72.0, - bottom: 72.0, - left: 72.0, - }; - let page = LayoutPage { - page_number: 1, - page_size, - margins, - content_items: vec![], - header_items: vec![], - footer_items: vec![], - comment_items: vec![], - header_height: 0.0, - footer_height: 0.0, - editing_data: Some(editing_data), - }; - PaginatedLayout { - page_size, - pages: vec![Arc::new(page)], - } - } - - #[test] - fn find_prev_para_data_at_block_0_returns_none() { - let layout = make_two_para_layout("first", "second"); - let result = find_prev_para_data(&layout, 0, 0); - assert!(result.is_none(), "block 0 has no predecessor"); - } - - #[test] - fn find_prev_para_data_at_block_1_returns_block_0() { - let layout = make_two_para_layout("first", "second"); - let result = find_prev_para_data(&layout, 0, 1); - let (page_idx, para) = result.expect("block 1 should have a predecessor"); - assert_eq!(page_idx, 0); - assert_eq!(para.block_index, 0); - } - - #[test] - fn find_next_para_data_at_last_block_returns_none() { - let layout = make_two_para_layout("first", "second"); - // block_index 1 is the last block; no successor. - let result = find_next_para_data(&layout, 0, 1); - assert!(result.is_none(), "last block has no successor"); - } - - #[test] - fn find_next_para_data_at_block_0_returns_block_1() { - let layout = make_two_para_layout("first", "second"); - let result = find_next_para_data(&layout, 0, 0); - let (page_idx, para) = result.expect("block 0 should have a successor"); - assert_eq!(page_idx, 0); - assert_eq!(para.block_index, 1); - } -} +#[path = "navigation_tests.rs"] +mod tests; diff --git a/loki-text/src/editing/navigation_find.rs b/loki-text/src/editing/navigation_find.rs new file mode 100644 index 00000000..23eb0adb --- /dev/null +++ b/loki-text/src/editing/navigation_find.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Paragraph-entry lookup helpers for [`super::navigation`]: path-aware +//! entry search, nested-sibling resolution, and the cross-page prev/next +//! walks. Extracted to keep `navigation.rs` under the 300-line ceiling. + +use loki_doc_model::PathStep; +use loki_layout::{PageParagraphData, PaginatedLayout}; + +use super::cursor::DocumentPosition; + +/// Look up the [`PageParagraphData`] for a paragraph on a specific page. +/// +/// `block_index` is the flat document block index stored in +/// [`DocumentPosition::paragraph_index`]; `path` is the nested descent +/// ([`DocumentPosition::path`]). Nested paragraphs (table cells, note bodies) +/// share their root's `block_index`, so both must match — matching on the +/// index alone would return the first cell's entry for *every* paragraph of +/// a table. +pub(super) fn find_para_data<'a>( + layout: &'a PaginatedLayout, + page_index: usize, + block_index: usize, + path: &[PathStep], +) -> Option<&'a PageParagraphData> { + layout + .pages + .get(page_index)? + .editing_data + .as_ref()? + .paragraphs + .iter() + .find(|p| p.block_index == block_index && p.path == path) +} + +/// The leaf block index of a nested position within its container (the leaf +/// step's block index; positions with an empty path are not nested). +pub(super) fn nested_leaf(focus: &DocumentPosition) -> Option { + match focus.path.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => Some(*block), + None => None, + } +} + +/// The page index of a nested paragraph entry, searching **every** page. +/// +/// A table cell's blocks can flow across a page break, so the sibling of the +/// focus need not live on the focus's page; a page-local search would miss it +/// and strand the caret. Returns the first page whose editing data holds the +/// `(block_index, path)` entry. +fn nested_para_page( + layout: &PaginatedLayout, + block_index: usize, + path: &[PathStep], +) -> Option { + layout.pages.iter().position(|page| { + page.editing_data.as_ref().is_some_and(|ed| { + ed.paragraphs + .iter() + .any(|p| p.block_index == block_index && p.path == path) + }) + }) +} + +/// The sibling of a nested `focus` shifted by `delta` blocks within its +/// container, if it exists in the layout. Clamps at the container's first/last +/// block by returning `None` — crossing out of a cell or note body into the +/// surrounding document is handled by the caller's top-level fallback. +/// +/// The sibling may have flowed onto a different page than the focus (a cell +/// split across a page break), so its `page_index` is re-derived from wherever +/// its entry actually appears rather than inherited from the focus. +pub(super) fn nested_sibling( + focus: &DocumentPosition, + layout: &PaginatedLayout, + delta: isize, +) -> Option { + let leaf = nested_leaf(focus)?; + leaf.checked_add_signed(delta)?; // clamp at the container start + let sibling = focus.sibling_block(delta, 0); + // Only cross when the sibling paragraph actually exists somewhere in the + // layout, and adopt the page it was laid out on. + let page = nested_para_page(layout, sibling.paragraph_index, &sibling.path)?; + Some(DocumentPosition { + page_index: page, + ..sibling + }) +} + +/// Find the paragraph entry immediately preceding `block_index` in document order. +/// +/// Searches the current page first (for `block_index - 1`), then walks backward +/// through previous pages. When the preceding block is a container (a table), +/// its **last** paragraph entry on the page is returned, so entering it from +/// below lands in its final cell. Returns `(page_index, para_data)`. +/// +/// Returns `None` when `block_index` is 0 (no predecessor exists). +pub(super) fn find_prev_para_data( + layout: &PaginatedLayout, + page_index: usize, + block_index: usize, +) -> Option<(usize, &PageParagraphData)> { + if block_index == 0 { + return None; + } + let prev_block = block_index - 1; + // Search this page and previous pages for the entry with prev_block. + for pi in (0..=page_index.min(layout.pages.len().saturating_sub(1))).rev() { + if let Some(ed) = layout.pages[pi].editing_data.as_ref() + && let Some(para) = ed + .paragraphs + .iter() + .rev() + .find(|p| p.block_index == prev_block) + { + return Some((pi, para)); + } + } + None +} + +/// Find the paragraph entry immediately following `block_index` in document order. +/// +/// Searches the current page first (for `block_index + 1`), then walks forward +/// through subsequent pages. When the following block is a container (a +/// table), its **first** paragraph entry is returned, so entering it from +/// above lands in its first cell. Returns `(page_index, para_data)`. +/// +/// Returns `None` when no following block exists in the layout. +pub(super) fn find_next_para_data( + layout: &PaginatedLayout, + page_index: usize, + block_index: usize, +) -> Option<(usize, &PageParagraphData)> { + let next_block = block_index + 1; + for pi in page_index..layout.pages.len() { + if let Some(ed) = layout.pages[pi].editing_data.as_ref() + && let Some(para) = ed.paragraphs.iter().find(|p| p.block_index == next_block) + { + return Some((pi, para)); + } + } + None +} diff --git a/loki-text/src/editing/navigation_tests.rs b/loki-text/src/editing/navigation_tests.rs new file mode 100644 index 00000000..ee114fad --- /dev/null +++ b/loki-text/src/editing/navigation_tests.rs @@ -0,0 +1,562 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use std::sync::Arc; + +use loki_doc_model::PathStep; + +use loki_layout::{ + FontResources, LayoutColor, LayoutInsets, LayoutPage, LayoutSize, PageEditingData, + PageParagraphData, PaginatedLayout, ResolvedParaProps, StyleSpan, layout_paragraph, +}; + +use super::*; + +fn layout_para( + resources: &mut FontResources, + text: &str, +) -> std::sync::Arc { + Arc::new(layout_paragraph( + resources, + text, + &[StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + }], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + )) +} + +/// One page whose editing data holds the given `(block_index, path, text)` +/// paragraphs, stacked vertically. +fn make_layout_with_paras(paras: &[(usize, Vec, &str)]) -> PaginatedLayout { + let mut resources = FontResources::new(); + let mut y = 0.0f32; + let mut entries = Vec::new(); + for (block_index, path, text) in paras { + let layout = layout_para(&mut resources, text); + let h = layout.height; + entries.push(PageParagraphData { + block_index: *block_index, + path: path.clone(), + layout, + origin: (0.0, y), + }); + y += h; + } + let editing_data = PageEditingData { + paragraphs: entries, + }; + let page_size = LayoutSize::new(595.0, 842.0); + let margins = LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }; + let page = LayoutPage { + page_number: 1, + page_size, + margins, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(editing_data), + }; + PaginatedLayout { + page_size, + pages: vec![Arc::new(page)], + } +} + +fn make_layout_with_text(text: &str) -> PaginatedLayout { + make_layout_with_paras(&[(0, Vec::new(), text)]) +} + +fn focus_at(byte_offset: usize) -> DocumentPosition { + DocumentPosition::top_level(0, 0, byte_offset) +} + +#[test] +fn navigate_left_moves_to_prev_grapheme() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(3); + let result = navigate_left(&focus, &layout, |_| "hello".to_string()); + assert_eq!(result.unwrap().byte_offset, 2); +} + +#[test] +fn navigate_left_at_start_returns_none_for_first_block() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(0); + let result = navigate_left(&focus, &layout, |_| "hello".to_string()); + assert!(result.is_none(), "at start of block 0 should return None"); +} + +#[test] +fn navigate_right_moves_to_next_grapheme() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(2); + let result = navigate_right(&focus, &layout, |_| "hello".to_string()); + assert_eq!(result.unwrap().byte_offset, 3); +} + +#[test] +fn navigate_right_at_end_of_last_block_returns_none() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(5); // end of "hello" + let result = navigate_right(&focus, &layout, |_| "hello".to_string()); + assert!(result.is_none(), "at end of last block should return None"); +} + +#[test] +fn navigate_home_returns_position_on_same_paragraph() { + let layout = make_layout_with_text("hello world"); + let focus = focus_at(6); + let result = navigate_home(&focus, &layout); + // Home from mid-paragraph — offset ≤ 6 (start of line or paragraph). + let pos = result.expect("navigate_home should return Some"); + assert_eq!(pos.page_index, 0); + assert_eq!(pos.paragraph_index, 0); + assert!( + pos.byte_offset <= 6, + "Home should move to start of line (byte ≤ 6)" + ); +} + +#[test] +fn navigate_end_returns_position_on_same_paragraph() { + let layout = make_layout_with_text("hello world"); + let focus = focus_at(0); + let result = navigate_end(&focus, &layout, |_| "hello world".to_string()); + let pos = result.expect("navigate_end should return Some"); + assert_eq!(pos.page_index, 0); + assert_eq!(pos.paragraph_index, 0); + // End from start — offset should be > 0. + assert!(pos.byte_offset > 0, "End should move past the start"); +} + +#[test] +fn navigate_up_at_first_line_returns_none() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(0); + // Single-line paragraph at top of page — no line above. + let result = navigate_up(&focus, &layout); + assert!( + result.is_none(), + "no line above first line should return None" + ); +} + +#[test] +fn navigate_down_at_last_line_returns_none() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(0); + // Single-line paragraph — no line below. + let result = navigate_down(&focus, &layout); + assert!( + result.is_none(), + "no line below last line should return None" + ); +} + +// ── Cross-boundary helper tests ─────────────────────────────────────────── + +fn make_two_para_layout(text0: &str, text1: &str) -> PaginatedLayout { + let mut resources = FontResources::new(); + let make_span = |text: &str| StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + }; + let para0 = layout_paragraph( + &mut resources, + text0, + &[make_span(text0)], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + ); + let h0 = para0.height; + let para1 = layout_paragraph( + &mut resources, + text1, + &[make_span(text1)], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + ); + let editing_data = PageEditingData { + paragraphs: vec![ + PageParagraphData { + block_index: 0, + path: Vec::new(), + layout: Arc::new(para0), + origin: (0.0, 0.0), + }, + PageParagraphData { + block_index: 1, + path: Vec::new(), + layout: Arc::new(para1), + origin: (0.0, h0), + }, + ], + }; + let page_size = LayoutSize::new(595.0, 842.0); + let margins = LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }; + let page = LayoutPage { + page_number: 1, + page_size, + margins, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(editing_data), + }; + PaginatedLayout { + page_size, + pages: vec![Arc::new(page)], + } +} + +#[test] +fn find_prev_para_data_at_block_0_returns_none() { + let layout = make_two_para_layout("first", "second"); + let result = find_prev_para_data(&layout, 0, 0); + assert!(result.is_none(), "block 0 has no predecessor"); +} + +#[test] +fn find_prev_para_data_at_block_1_returns_block_0() { + let layout = make_two_para_layout("first", "second"); + let result = find_prev_para_data(&layout, 0, 1); + let (page_idx, para) = result.expect("block 1 should have a predecessor"); + assert_eq!(page_idx, 0); + assert_eq!(para.block_index, 0); +} + +#[test] +fn find_next_para_data_at_last_block_returns_none() { + let layout = make_two_para_layout("first", "second"); + // block_index 1 is the last block; no successor. + let result = find_next_para_data(&layout, 0, 1); + assert!(result.is_none(), "last block has no successor"); +} + +#[test] +fn find_next_para_data_at_block_0_returns_block_1() { + let layout = make_two_para_layout("first", "second"); + let result = find_next_para_data(&layout, 0, 0); + let (page_idx, para) = result.expect("block 0 should have a successor"); + assert_eq!(page_idx, 0); + assert_eq!(para.block_index, 1); +} + +// ── Nested (cell / note body) sibling navigation (4b.4) ─────────────────────── + +/// `[Para "intro", Table]` where the table (block 1) has one cell holding the +/// paragraphs "alpha" | "beta", followed by a top-level "outro" (block 2). +fn nested_cell_layout() -> PaginatedLayout { + make_layout_with_paras(&[ + (0, Vec::new(), "intro"), + (1, vec![PathStep::Cell { cell: 0, block: 0 }], "alpha"), + (1, vec![PathStep::Cell { cell: 0, block: 1 }], "beta"), + (2, Vec::new(), "outro"), + ]) +} + +fn nested_text(bp: &BlockPath) -> String { + match (bp.root, bp.steps.as_slice()) { + (0, []) => "intro", + (1, [PathStep::Cell { cell: 0, block: 0 }]) => "alpha", + (1, [PathStep::Cell { cell: 0, block: 1 }]) => "beta", + (2, []) => "outro", + _ => "", + } + .to_string() +} + +fn cell_pos(block: usize, byte_offset: usize) -> DocumentPosition { + DocumentPosition { + page_index: 0, + paragraph_index: 1, + byte_offset, + path: vec![PathStep::Cell { cell: 0, block }], + } +} + +#[test] +fn navigate_right_crosses_to_the_next_cell_sibling() { + let layout = nested_cell_layout(); + // End of "alpha" → start of "beta", keeping the cell path. + let result = navigate_right(&cell_pos(0, 5), &layout, nested_text); + assert_eq!(result, Some(cell_pos(1, 0))); +} + +#[test] +fn navigate_left_crosses_to_the_previous_cell_sibling() { + let layout = nested_cell_layout(); + // Start of "beta" → end of "alpha" (byte 5), keeping the cell path. + let result = navigate_left(&cell_pos(1, 0), &layout, nested_text); + assert_eq!(result, Some(cell_pos(0, 5))); +} + +#[test] +fn navigate_left_escapes_a_cell_at_its_first_block() { + let layout = nested_cell_layout(); + // Start of "alpha" (first block of the cell): escape the table to the end + // of the preceding top-level "intro" paragraph (block 0). Arrow keys must + // be able to leave a table, not just enter it. + let result = navigate_left(&cell_pos(0, 0), &layout, nested_text); + assert_eq!(result, Some(DocumentPosition::top_level(0, 0, 5))); +} + +#[test] +fn navigate_right_escapes_a_cell_at_its_last_block() { + let layout = nested_cell_layout(); + // End of "beta" (last block of the cell): escape the table to the start of + // the following top-level "outro" paragraph (block 2). + let result = navigate_right(&cell_pos(1, 4), &layout, nested_text); + assert_eq!(result, Some(DocumentPosition::top_level(0, 2, 0))); +} + +#[test] +fn grapheme_navigation_inside_a_cell_uses_the_cell_text() { + let layout = nested_cell_layout(); + // Mid-"alpha" moves within the nested paragraph's own text (previously + // the flat getter returned the root table block's empty text). + let result = navigate_right(&cell_pos(0, 2), &layout, nested_text); + assert_eq!(result, Some(cell_pos(0, 3))); + let result = navigate_left(&cell_pos(0, 2), &layout, nested_text); + assert_eq!(result, Some(cell_pos(0, 1))); +} + +#[test] +fn navigate_end_inside_a_cell_uses_the_cell_line() { + let layout = nested_cell_layout(); + let result = navigate_end(&cell_pos(0, 1), &layout, nested_text); + assert_eq!(result, Some(cell_pos(0, 5))); +} + +// ── Cross-page Left/Right (4b.1 / 3b-3) ─────────────────────────────────────── + +/// Two pages: block 0 ("first page") on page 0, block 1 ("second") on page 1. +fn two_page_layout() -> PaginatedLayout { + let mut resources = FontResources::new(); + let page_size = LayoutSize::new(595.0, 842.0); + let margins = LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }; + let mk_page = |number: usize, paras: Vec| LayoutPage { + page_number: number, + page_size, + margins, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(PageEditingData { paragraphs: paras }), + }; + let p0 = layout_para(&mut resources, "first page"); + let p1 = layout_para(&mut resources, "second"); + PaginatedLayout { + page_size, + pages: vec![ + Arc::new(mk_page( + 1, + vec![PageParagraphData { + block_index: 0, + path: Vec::new(), + layout: p0, + origin: (0.0, 0.0), + }], + )), + Arc::new(mk_page( + 2, + vec![PageParagraphData { + block_index: 1, + path: Vec::new(), + layout: p1, + origin: (0.0, 0.0), + }], + )), + ], + } +} + +fn two_page_text(bp: &BlockPath) -> String { + match bp.root { + 0 => "first page", + 1 => "second", + _ => "", + } + .to_string() +} + +#[test] +fn navigate_right_crosses_to_the_next_page() { + let layout = two_page_layout(); + // End of block 0 (page 0) → start of block 1 on page 1. + let focus = DocumentPosition::top_level(0, 0, "first page".len()); + let result = navigate_right(&focus, &layout, two_page_text); + assert_eq!(result, Some(DocumentPosition::top_level(1, 1, 0))); +} + +#[test] +fn navigate_left_crosses_to_the_previous_page() { + let layout = two_page_layout(); + // Start of block 1 (page 1) → end of block 0 on page 0. + let focus = DocumentPosition::top_level(1, 1, 0); + let result = navigate_left(&focus, &layout, two_page_text); + assert_eq!( + result, + Some(DocumentPosition::top_level(0, 0, "first page".len())) + ); +} + +#[test] +fn navigate_left_at_document_start_still_returns_none() { + let layout = two_page_layout(); + let focus = DocumentPosition::top_level(0, 0, 0); + assert_eq!(navigate_left(&focus, &layout, two_page_text), None); +} + +#[test] +fn navigate_right_at_document_end_still_returns_none() { + let layout = two_page_layout(); + let focus = DocumentPosition::top_level(1, 1, "second".len()); + assert_eq!(navigate_right(&focus, &layout, two_page_text), None); +} + +// ── Cross-page nested sibling (cell split across a page break) ──────────────── + +/// A table (block 1) whose single cell has two paragraphs that flow across a +/// page break: "alpha" on page 0, "beta" on page 1. +fn cell_split_across_pages() -> PaginatedLayout { + let mut resources = FontResources::new(); + let page_size = LayoutSize::new(595.0, 842.0); + let margins = LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }; + let mk_page = |number: usize, paras: Vec| LayoutPage { + page_number: number, + page_size, + margins, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(PageEditingData { paragraphs: paras }), + }; + let alpha = layout_para(&mut resources, "alpha"); + let beta = layout_para(&mut resources, "beta"); + PaginatedLayout { + page_size, + pages: vec![ + Arc::new(mk_page( + 1, + vec![PageParagraphData { + block_index: 1, + path: vec![PathStep::Cell { cell: 0, block: 0 }], + layout: alpha, + origin: (0.0, 0.0), + }], + )), + Arc::new(mk_page( + 2, + vec![PageParagraphData { + block_index: 1, + path: vec![PathStep::Cell { cell: 0, block: 1 }], + layout: beta, + origin: (0.0, 0.0), + }], + )), + ], + } +} + +#[test] +fn navigate_right_crosses_to_a_cell_sibling_on_the_next_page() { + let layout = cell_split_across_pages(); + // End of "alpha" (page 0, cell block 0) → start of "beta", which was laid + // out on page 1. The result must adopt page_index 1, not stay on page 0. + let focus = DocumentPosition { + page_index: 0, + paragraph_index: 1, + byte_offset: 5, + path: vec![PathStep::Cell { cell: 0, block: 0 }], + }; + let result = navigate_right(&focus, &layout, |_| "alpha".to_string()); + assert_eq!( + result, + Some(DocumentPosition { + page_index: 1, + paragraph_index: 1, + byte_offset: 0, + path: vec![PathStep::Cell { cell: 0, block: 1 }], + }) + ); +} diff --git a/loki-text/src/editing/page_locate.rs b/loki-text/src/editing/page_locate.rs new file mode 100644 index 00000000..2f7abd54 --- /dev/null +++ b/loki-text/src/editing/page_locate.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Re-deriving a cursor position's `page_index` from the paginated layout +//! (plan 4b.1 / `3b-3`). +//! +//! A [`DocumentPosition`]'s `page_index` must name the page whose editing +//! data contains the caret's paragraph — hit-testing, cursor painting, and +//! navigation all resolve the paragraph through it. Two things silently +//! invalidate it: +//! +//! - **mutations**: a split/merge or typing near a page boundary relays the +//! document out, and the caret's paragraph can move to a different page; +//! - **split paragraphs**: a paragraph flowing across a page break has an +//! entry on *every* page it touches (one shared [`ParagraphLayout`] with +//! shifted origins), so the right page depends on the byte offset's line. +//! +//! [`recompute_page_index`] handles both: it scans the layout for the pages +//! holding the position's `(block_index, path)` entry and, when the +//! paragraph spans several pages, picks the page whose content band contains +//! the byte's line. +//! +//! [`ParagraphLayout`]: loki_layout::ParagraphLayout + +use loki_layout::PaginatedLayout; + +use super::cursor::DocumentPosition; + +#[cfg(test)] +#[path = "page_locate_tests.rs"] +mod tests; + +/// Returns `pos` with its `page_index` re-derived from `layout`. +/// +/// When the position's paragraph is found on exactly one page, that page +/// wins. When it spans several pages (a split paragraph), the page whose +/// content band contains the byte offset's line-centre wins; if no band +/// matches (degenerate geometry), the first page holding the paragraph is +/// used. When the paragraph is not in the layout at all (e.g. the layout is +/// momentarily stale), `pos` is returned unchanged. +#[must_use] +pub fn recompute_page_index(layout: &PaginatedLayout, pos: &DocumentPosition) -> DocumentPosition { + let mut first_holder: Option = None; + let mut visible: Option = None; + + for (pi, page) in layout.pages.iter().enumerate() { + let Some(ed) = page.editing_data.as_ref() else { + continue; + }; + let Some(para) = ed + .paragraphs + .iter() + .find(|p| p.block_index == pos.paragraph_index && p.path == pos.path) + else { + continue; + }; + first_holder.get_or_insert(pi); + if visible.is_none() + && let Some(rect) = para.layout.cursor_rect(pos.byte_offset) + { + // Content-band check: the line's centre, in page-content + // coordinates (origin is already content-relative). + let y_center = rect.y + rect.height / 2.0 + para.origin.1; + let content_h = page.page_size.height - page.margins.top - page.margins.bottom; + if y_center >= 0.0 && y_center < content_h { + visible = Some(pi); + } + } + if visible.is_some() { + break; + } + } + + let new_page = match (visible, first_holder) { + (Some(pi), _) => pi, + (None, Some(pi)) => pi, + (None, None) => pos.page_index, + }; + if new_page == pos.page_index { + return pos.clone(); + } + DocumentPosition { + page_index: new_page, + ..pos.clone() + } +} diff --git a/loki-text/src/editing/page_locate_tests.rs b/loki-text/src/editing/page_locate_tests.rs new file mode 100644 index 00000000..d78971a9 --- /dev/null +++ b/loki-text/src/editing/page_locate_tests.rs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use std::sync::Arc; + +use loki_layout::{ + FontResources, LayoutColor, LayoutInsets, LayoutPage, LayoutSize, PageEditingData, + PageParagraphData, PaginatedLayout, ParagraphLayout, ResolvedParaProps, StyleSpan, + layout_paragraph, +}; + +use super::*; + +const PAGE_H: f32 = 842.0; +const MARGIN: f32 = 72.0; +const CONTENT_H: f32 = PAGE_H - 2.0 * MARGIN; + +fn para(text: &str, width: f32) -> Arc { + let mut resources = FontResources::new(); + Arc::new(layout_paragraph( + &mut resources, + text, + &[StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + word_spacing: None, + font_variant: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + }], + &ResolvedParaProps::default(), + width, + 1.0, + true, + )) +} + +fn page(paragraphs: Vec, number: usize) -> Arc { + Arc::new(LayoutPage { + page_number: number, + page_size: LayoutSize::new(595.0, PAGE_H), + margins: LayoutInsets { + top: MARGIN, + right: MARGIN, + bottom: MARGIN, + left: MARGIN, + }, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(PageEditingData { paragraphs }), + }) +} + +fn entry(block_index: usize, layout: Arc, y: f32) -> PageParagraphData { + PageParagraphData { + block_index, + path: Vec::new(), + layout, + origin: (0.0, y), + } +} + +#[test] +fn moves_the_page_index_to_the_paragraphs_page() { + // Block 0 on page 0, block 1 on page 1 — a position for block 1 that + // still carries page 0 (e.g. after a split pushed it over) is corrected. + let p0 = para("first", 400.0); + let p1 = para("second", 400.0); + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![ + page(vec![entry(0, p0, 0.0)], 1), + page(vec![entry(1, p1, 0.0)], 2), + ], + }; + let stale = DocumentPosition::top_level(0, 1, 0); + let fixed = recompute_page_index(&layout, &stale); + assert_eq!(fixed.page_index, 1); + assert_eq!(fixed.paragraph_index, 1); +} + +#[test] +fn keeps_the_page_index_when_already_correct() { + let p0 = para("only", 400.0); + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![page(vec![entry(0, p0, 0.0)], 1)], + }; + let pos = DocumentPosition::top_level(0, 0, 2); + assert_eq!(recompute_page_index(&layout, &pos).page_index, 0); +} + +#[test] +fn unknown_paragraph_leaves_the_position_unchanged() { + let p0 = para("only", 400.0); + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![page(vec![entry(0, p0, 0.0)], 1)], + }; + let pos = DocumentPosition::top_level(0, 9, 0); + assert_eq!(recompute_page_index(&layout, &pos), pos); +} + +#[test] +fn split_paragraph_picks_the_page_showing_the_bytes_line() { + // One long paragraph wrapped to many lines at a narrow measure, split + // across two pages the way the flow engine does it: the same layout on + // both pages, page 1's fragment shifted up so its visible band starts + // where page 0's ended. + let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; + let p = para(text, 60.0); // narrow → many lines + let total_h = p.height; + assert!( + total_h > 40.0, + "test premise: the paragraph wraps to several lines (height {total_h})" + ); + // Page 0 shows the first half, page 1 the rest. + let cut = total_h / 2.0; + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![ + // Fragment on page 0 ends its visible band at `cut`: place it so + // the paragraph starts at the bottom of the content area minus cut. + page(vec![entry(0, Arc::clone(&p), CONTENT_H - cut)], 1), + // Fragment on page 1 starts `cut` into the paragraph. + page(vec![entry(0, Arc::clone(&p), -cut)], 2), + ], + }; + + // Byte 0 (first line) is visible on page 0 only. + let first = recompute_page_index(&layout, &DocumentPosition::top_level(1, 0, 0)); + assert_eq!(first.page_index, 0); + + // The last byte (last line) is visible on page 1 only. + let last = recompute_page_index(&layout, &DocumentPosition::top_level(0, 0, text.len())); + assert_eq!(last.page_index, 1); +} diff --git a/loki-text/src/editing/reflow_nav.rs b/loki-text/src/editing/reflow_nav.rs index c3f81e30..e01c136b 100644 --- a/loki-text/src/editing/reflow_nav.rs +++ b/loki-text/src/editing/reflow_nav.rs @@ -180,6 +180,7 @@ mod tests { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), diff --git a/loki-text/src/editing/saved_state.rs b/loki-text/src/editing/saved_state.rs new file mode 100644 index 00000000..02d201a2 --- /dev/null +++ b/loki-text/src/editing/saved_state.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Saved-state tracking over the undo stack (plan 4b.3, `undo-dirty`). +//! +//! The tab dirty indicator needs to answer "does the live document match the +//! file on disk?". A generation counter cannot: it only moves forward, so +//! undoing back to the saved state still reads as dirty. This module tracks +//! the *undo-stack depth* of the last save instead, giving the classic +//! clean-index semantics (Qt `QUndoStack::setClean` and friends): +//! +//! - saving records the current depth as the clean point; +//! - undo/redo moving the stack back to that depth means the document again +//! equals the file — **clean**; +//! - a *fresh edit* made below the clean depth truncates the redo stack that +//! led back to it, so the saved state becomes permanently **unreachable** +//! (dirty until the next save). +//! +//! The tracker mirrors the stack depth via the [`loro::UndoManager`] +//! `on_push`/`on_pop` hooks. The discriminator for "fresh edit vs. redo +//! replay" (both push onto the undo stack) is loro's third `on_push` +//! argument: a fresh local edit passes `Some(DiffEvent)`, while the pushes +//! performed *inside* `undo()`/`redo()` pass `None`. +//! +//! Two usage invariants keep that discriminator sound (both already hold in +//! this crate — see `post_mutation_sync`): +//! +//! 1. every mutation is committed before `undo()`/`redo()`/save runs, so +//! loro's internal `record_new_checkpoint` calls never coalesce pending +//! ops into an item (which would also arrive with `None`); +//! 2. no merge interval or undo grouping is configured, so every edit pushes +//! its own item and the clean depth can never end up *inside* an item. + +use std::sync::{Arc, Mutex}; + +use loro::{UndoItemMeta, UndoOrRedo}; + +#[cfg(test)] +#[path = "saved_state_tests.rs"] +mod tests; + +/// Depth bookkeeping shared between the editor and the hooks installed on +/// the paired [`loro::UndoManager`]. +#[derive(Debug)] +struct Tracker { + /// Undo-stack depth at the last save; `None` = the saved state is no + /// longer reachable by undo/redo. + saved_depth: Option, + /// Mirror of the undo-stack depth. + depth: usize, +} + +impl Tracker { + /// A fresh local edit pushed a new undo item. An edit at or below the + /// clean depth cleared the redo path back to the saved state. + fn on_fresh_edit(&mut self) { + self.depth += 1; + if let Some(d) = self.saved_depth + && self.depth <= d + { + self.saved_depth = None; + } + } +} + +/// Cloneable handle to one document's saved-state tracker. +/// +/// [`attach`](Self::attach) installs the mirroring hooks on the document's +/// `UndoManager`; the handle then travels with the editor signals (and the +/// tab's stashed `DocSession`) so the dirty indicator can query +/// [`is_clean`](Self::is_clean) at any time. +#[derive(Clone, Debug)] +pub struct SavedStateHandle(Arc>); + +impl SavedStateHandle { + /// A tracker for a freshly loaded document: depth 0 is the clean point + /// (the document matches what was read from disk). + #[must_use] + pub fn new() -> Self { + Self(Arc::new(Mutex::new(Tracker { + saved_depth: Some(0), + depth: 0, + }))) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Tracker> { + self.0.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Installs the `on_push`/`on_pop` mirroring hooks. Call exactly once per + /// `UndoManager` (a replacement manager needs a fresh handle). + pub fn attach(&self, um: &mut loro::UndoManager) { + let push_state = Arc::clone(&self.0); + um.set_on_push(Some(Box::new(move |kind, _span, event| { + if kind == UndoOrRedo::Undo { + let mut t = push_state.lock().unwrap_or_else(|e| e.into_inner()); + if event.is_some() { + t.on_fresh_edit(); + } else { + // redo() replaying an item back onto the undo stack — + // the path to the saved state is preserved. + t.depth += 1; + } + } + UndoItemMeta::default() + }))); + let pop_state = Arc::clone(&self.0); + um.set_on_pop(Some(Box::new(move |kind, _span, _meta| { + if kind == UndoOrRedo::Undo { + // undo() popping the undo stack (including no-op items it + // pops while searching for an effective one). + let mut t = pop_state.lock().unwrap_or_else(|e| e.into_inner()); + t.depth = t.depth.saturating_sub(1); + } + }))); + } + + /// Records the current depth as the clean point (call on save success). + pub fn mark_saved(&self) { + let mut t = self.lock(); + t.saved_depth = Some(t.depth); + } + + /// Whether the document currently equals the last-saved state. + #[must_use] + pub fn is_clean(&self) -> bool { + let t = self.lock(); + t.saved_depth == Some(t.depth) + } +} + +impl Default for SavedStateHandle { + fn default() -> Self { + Self::new() + } +} diff --git a/loki-text/src/editing/saved_state_tests.rs b/loki-text/src/editing/saved_state_tests.rs new file mode 100644 index 00000000..67e26dda --- /dev/null +++ b/loki-text/src/editing/saved_state_tests.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Integration tests against a real `loro::UndoManager`, locking the loro +//! behaviours the tracker relies on (fresh edits arrive with `Some(event)`, +//! undo/redo internal pushes with `None`). + +use loro::{LoroDoc, UndoManager}; + +use super::SavedStateHandle; + +fn doc_and_manager() -> (LoroDoc, UndoManager, SavedStateHandle) { + let doc = LoroDoc::new(); + let mut um = UndoManager::new(&doc); + let tracker = SavedStateHandle::new(); + tracker.attach(&mut um); + (doc, um, tracker) +} + +fn type_text(doc: &LoroDoc, s: &str) { + let text = doc.get_text("t"); + let end = text.len_unicode(); + text.insert(end, s).unwrap(); + doc.commit(); +} + +#[test] +fn freshly_loaded_document_is_clean() { + let (_doc, _um, tracker) = doc_and_manager(); + assert!(tracker.is_clean()); +} + +#[test] +fn an_edit_dirties_and_undo_restores_clean() { + let (doc, mut um, tracker) = doc_and_manager(); + type_text(&doc, "a"); + assert!(!tracker.is_clean()); + um.undo().unwrap(); + assert!(tracker.is_clean(), "undo back to the loaded state is clean"); + um.redo().unwrap(); + assert!(!tracker.is_clean(), "redoing the edit is dirty again"); + um.undo().unwrap(); + assert!(tracker.is_clean()); +} + +#[test] +fn save_moves_the_clean_point() { + let (doc, mut um, tracker) = doc_and_manager(); + type_text(&doc, "a"); + type_text(&doc, "b"); + tracker.mark_saved(); + assert!(tracker.is_clean()); + type_text(&doc, "c"); + assert!(!tracker.is_clean()); + um.undo().unwrap(); + assert!(tracker.is_clean(), "undo back to the save point is clean"); + um.undo().unwrap(); + assert!(!tracker.is_clean(), "undoing past the save point is dirty"); + um.redo().unwrap(); + assert!( + tracker.is_clean(), + "redo forward to the save point is clean" + ); +} + +#[test] +fn saving_while_undone_makes_that_depth_the_clean_point() { + let (doc, mut um, tracker) = doc_and_manager(); + type_text(&doc, "a"); + type_text(&doc, "b"); + um.undo().unwrap(); + tracker.mark_saved(); // save the "a" state with a live redo stack + assert!(tracker.is_clean()); + um.redo().unwrap(); // "ab" differs from the file + assert!(!tracker.is_clean()); + um.undo().unwrap(); + assert!(tracker.is_clean()); +} + +#[test] +fn editing_below_the_save_point_makes_it_unreachable() { + let (doc, mut um, tracker) = doc_and_manager(); + type_text(&doc, "a"); + type_text(&doc, "b"); + tracker.mark_saved(); // clean at depth 2 ("ab") + um.undo().unwrap(); // depth 1 ("a") + type_text(&doc, "X"); // truncates the redo path back to "ab" + assert!( + !tracker.is_clean(), + "\"aX\" at the saved depth is not the saved state" + ); + // No amount of undo/redo can reach the saved state now. + um.undo().unwrap(); + assert!(!tracker.is_clean()); + um.redo().unwrap(); + assert!(!tracker.is_clean()); + um.undo().unwrap(); + um.undo().unwrap(); + assert!(!tracker.is_clean()); + // Only a new save re-establishes a clean point. + tracker.mark_saved(); + assert!(tracker.is_clean()); +} + +#[test] +fn a_replacement_manager_starts_clean_with_a_fresh_handle() { + // Mirrors the post-save compaction swap: new doc, new manager, new + // tracker — the save point is depth 0 of the fresh stack. + let (doc, _um, _old_tracker) = doc_and_manager(); + type_text(&doc, "history"); + let fresh = LoroDoc::new(); + let mut um2 = UndoManager::new(&fresh); + let tracker2 = SavedStateHandle::new(); + tracker2.attach(&mut um2); + assert!(tracker2.is_clean()); + type_text(&fresh, "z"); + assert!(!tracker2.is_clean()); + um2.undo().unwrap(); + assert!(tracker2.is_clean()); +} diff --git a/loki-text/src/editing/word_count.rs b/loki-text/src/editing/word_count.rs new file mode 100644 index 00000000..2110f764 --- /dev/null +++ b/loki-text/src/editing/word_count.rs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Live word count for the status bar (audit F7c / plan 4c.5). +//! +//! [`count_words`] streams over the document's display text without +//! allocating: a word is a maximal run of non-whitespace characters, and +//! adjacent inline runs continue the same word (`"Hel"` + bold `"lo"` is one +//! word), while block boundaries, spaces, and line breaks end it. Matching +//! Word's status-bar semantics, table cells and figure captions are counted; +//! footnote/endnote bodies, comments, and generated content (TOC, index) are +//! not. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_i18n::fl; + +use super::cursor::CursorState; +use super::state::DocumentState; + +#[cfg(test)] +#[path = "word_count_tests.rs"] +mod tests; + +/// Streaming word-count state: `in_word` carries across adjacent text runs so +/// styling boundaries inside a word don't split it. +#[derive(Default)] +struct Counter { + words: usize, + in_word: bool, +} + +impl Counter { + fn text(&mut self, s: &str) { + for c in s.chars() { + if c.is_whitespace() { + self.in_word = false; + } else if !self.in_word { + self.words += 1; + self.in_word = true; + } + } + } + + fn separator(&mut self) { + self.in_word = false; + } +} + +/// Counts the words in `doc`'s display text. See the module docs for what is +/// and is not counted. +#[must_use] +pub fn count_words(doc: &Document) -> usize { + let mut counter = Counter::default(); + for section in &doc.sections { + count_blocks(§ion.blocks, &mut counter); + } + counter.words +} + +fn count_blocks(blocks: &[Block], counter: &mut Counter) { + for block in blocks { + counter.separator(); + match block { + Block::Plain(inlines) | Block::Para(inlines) | Block::Heading(_, _, inlines) => { + count_inlines(inlines, counter); + } + Block::StyledPara(p) => count_inlines(&p.inlines, counter), + Block::LineBlock(lines) => { + for line in lines { + counter.separator(); + count_inlines(line, counter); + } + } + Block::CodeBlock(_, code) => counter.text(code), + Block::BlockQuote(inner) | Block::Div(_, inner) => count_blocks(inner, counter), + Block::OrderedList(_, items) | Block::BulletList(items) => { + for item in items { + count_blocks(item, counter); + } + } + Block::DefinitionList(defs) => { + for (term, definitions) in defs { + counter.separator(); + count_inlines(term, counter); + for def in definitions { + count_blocks(def, counter); + } + } + } + Block::Table(table) => { + for row in table + .head + .rows + .iter() + .chain(table.bodies.iter().flat_map(|b| b.body_rows.iter())) + .chain(table.foot.rows.iter()) + { + for cell in &row.cells { + count_blocks(&cell.blocks, counter); + } + } + } + Block::Figure(_, caption, content) => { + // The module contract counts figure captions; `caption.full` + // holds the caption's block content. + count_blocks(&caption.full, counter); + count_blocks(content, counter); + } + // Generated or non-text content (and, `#[non_exhaustive]`, any + // future block kind until it is classified) contributes nothing. + _ => {} + } + } +} + +fn count_inlines(inlines: &[Inline], counter: &mut Counter) { + for inline in inlines { + match inline { + Inline::Str(s) => counter.text(s), + Inline::Emph(inner) + | Inline::Underline(inner) + | Inline::Strong(inner) + | Inline::Strikeout(inner) + | Inline::Superscript(inner) + | Inline::Subscript(inner) + | Inline::SmallCaps(inner) + | Inline::Quoted(_, inner) + | Inline::Cite(_, inner) + | Inline::Span(_, inner) + | Inline::Link(_, inner, _) => count_inlines(inner, counter), + Inline::StyledRun(run) => count_inlines(&run.content, counter), + Inline::Code(_, code) => counter.text(code), + Inline::Space | Inline::SoftBreak | Inline::LineBreak => counter.separator(), + // Word's status-bar count excludes footnote/endnote bodies; image + // alt text, raw passthrough, math, fields, comments, and bookmark + // markers are not display words either. (`#[non_exhaustive]` + // future inline kinds land here too, as separators.) + _ => counter.separator(), + } + } +} + +/// Memoised, localised status-bar word-count label (`editor-word-count`), +/// recomputed after every document mutation (the cursor's mirrored +/// `document_generation` is the change signal). +pub fn use_word_count_label( + doc_state: Arc>, + cursor_state: Signal, +) -> Memo { + // Narrow the change signal. Reading `cursor_state` directly in the count + // memo would subscribe it to *every* CursorState write — each cursor move, + // click, and drag update — re-running the full-document walk on the UI path + // even when the document did not change. This cheap intermediate memo reads + // the generation on every write, but its `u64` output only changes on a real + // mutation, so the expensive count below is gated on that. + let generation = use_memo(move || cursor_state.read().document_generation); + use_memo(move || { + let _generation = generation(); + let count = doc_state + .lock() + .ok() + .and_then(|s| s.document.as_ref().map(|d| count_words(d))) + .unwrap_or(0); + fl!("editor-word-count", count = count as i64) + }) +} diff --git a/loki-text/src/editing/word_count_tests.rs b/loki-text/src/editing/word_count_tests.rs new file mode 100644 index 00000000..0043c0b7 --- /dev/null +++ b/loki-text/src/editing/word_count_tests.rs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::{Block, Caption}; +use loki_doc_model::content::inline::{Inline, NoteKind}; +use loki_doc_model::content::table::core::{Table, TableBody, TableCaption, TableFoot, TableHead}; +use loki_doc_model::content::table::row::{Cell, Row}; +use loki_doc_model::document::Document; + +use super::count_words; + +fn doc_with(blocks: Vec) -> Document { + let mut doc = Document::new(); + doc.sections[0].blocks = blocks; + doc +} + +#[test] +fn counts_whitespace_separated_words() { + let doc = doc_with(vec![Block::Para(vec![Inline::Str( + "the quick brown fox".into(), + )])]); + assert_eq!(count_words(&doc), 4); +} + +#[test] +fn empty_document_counts_zero() { + assert_eq!(count_words(&doc_with(vec![])), 0); + assert_eq!( + count_words(&doc_with(vec![Block::Para(vec![Inline::Str( + " ".into() + )])])), + 0 + ); +} + +#[test] +fn styling_inside_a_word_does_not_split_it() { + // "Hel" + bold "lo" + " world" = 2 words, not 3. + let doc = doc_with(vec![Block::Para(vec![ + Inline::Str("Hel".into()), + Inline::Strong(vec![Inline::Str("lo".into())]), + Inline::Str(" world".into()), + ])]); + assert_eq!(count_words(&doc), 2); +} + +#[test] +fn explicit_space_and_break_inlines_separate_words() { + let doc = doc_with(vec![Block::Para(vec![ + Inline::Str("one".into()), + Inline::Space, + Inline::Str("two".into()), + Inline::LineBreak, + Inline::Str("three".into()), + ])]); + assert_eq!(count_words(&doc), 3); +} + +#[test] +fn block_boundaries_separate_words() { + // "…end" | "start…" must not merge into one word across paragraphs. + let doc = doc_with(vec![ + Block::Para(vec![Inline::Str("end".into())]), + Block::Para(vec![Inline::Str("start".into())]), + ]); + assert_eq!(count_words(&doc), 2); +} + +#[test] +fn table_cells_are_counted() { + let table = Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![ + Cell::simple(vec![Block::Para(vec![Inline::Str("alpha beta".into())])]), + Cell::simple(vec![Block::Para(vec![Inline::Str("gamma".into())])]), + ])])], + foot: TableFoot::empty(), + }; + let doc = doc_with(vec![ + Block::Para(vec![Inline::Str("intro".into())]), + Block::Table(Box::new(table)), + ]); + assert_eq!(count_words(&doc), 4); +} + +#[test] +fn footnote_bodies_are_excluded() { + // Matches Word's status-bar semantics (footnotes not included). + let doc = doc_with(vec![Block::Para(vec![ + Inline::Str("body".into()), + Inline::Note( + NoteKind::Footnote, + vec![Block::Para(vec![Inline::Str("hidden note words".into())])], + ), + Inline::Str("more".into()), + ])]); + assert_eq!(count_words(&doc), 2); +} + +#[test] +fn figure_caption_and_content_are_both_counted() { + // The module contract counts figure captions as well as figure content. + let caption = Caption { + short: None, + full: vec![Block::Para(vec![Inline::Str("caption words here".into())])], + }; + let doc = doc_with(vec![Block::Figure( + NodeAttr::default(), + caption, + vec![Block::Para(vec![Inline::Str("figure body".into())])], + )]); + // "caption words here" (3) + "figure body" (2) = 5. + assert_eq!(count_words(&doc), 5); +} + +#[test] +fn lists_and_headings_are_counted() { + let doc = doc_with(vec![ + Block::Heading(1, NodeAttr::default(), vec![Inline::Str("Title".into())]), + Block::BulletList(vec![ + vec![Block::Para(vec![Inline::Str("first item".into())])], + vec![Block::Para(vec![Inline::Str("second".into())])], + ]), + ]); + assert_eq!(count_words(&doc), 4); +} diff --git a/loki-text/src/routes/editor/editor_canvas.rs b/loki-text/src/routes/editor/editor_canvas.rs index bd25bdf5..94228bbd 100644 --- a/loki-text/src/routes/editor/editor_canvas.rs +++ b/loki-text/src/routes/editor/editor_canvas.rs @@ -31,10 +31,14 @@ use std::sync::Arc; use appthere_ui::tokens; +use dioxus::html::input_data::MouseButton; use dioxus::prelude::*; +use loki_app_shell::spell::SpellService; use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::derive_loro_cursor; use loki_renderer::{DocumentView, RendererCursorPos, TileContext, ViewMode}; +use super::editor_canvas_loading::loading_view; use super::editor_error_view::EditorErrorView; use super::editor_keydown::make_keydown_handler; use super::editor_pointer::{ @@ -43,17 +47,10 @@ use super::editor_pointer::{ use super::editor_scrollbar::{ CanvasMounted, ScrollMetrics, ThumbDrag, horizontal_scrollbar, vertical_scrollbar, }; -use crate::editing::cursor::{CursorState, DocumentPosition}; -use crate::editing::hit_test::hit_test_page; -use crate::editing::state::DocumentState; -use crate::editing::touch::TouchInteractionState; -use dioxus::html::input_data::MouseButton; -use loki_app_shell::spell::SpellService; - use super::editor_spell::{SpellMenu, resolve_spell_menu}; +use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::editing::{hit_test::hit_test_page, state::DocumentState, touch::TouchInteractionState}; use crate::error::LoadError; -use loki_doc_model::loro_bridge::derive_loro_cursor; -use loki_i18n::fl; /// Fallback viewport height (CSS px) for tile virtualization before the scroll /// container is first measured. A named default — not a hardcoded screen @@ -62,43 +59,6 @@ use loki_i18n::fl; /// the real height. const DEFAULT_VIEWPORT_HEIGHT_PX: f64 = 800.0; -/// Blank page placeholder shown while a document is being opened. -/// -/// Renders immediately when the editor tab mounts (before the async load -/// resolves), so the user sees a page-shaped surface with an "opening" label -/// instead of an empty canvas while the file is read, imported, and laid out. -fn loading_view() -> Element { - rsx! { - div { - style: format!( - "display: flex; flex: 1; align-items: flex-start; \ - justify-content: center; width: 100%; padding-top: {gap}px;", - gap = tokens::SPACE_6, - ), - div { - style: format!( - "width: {w}px; height: {h}px; flex-shrink: 0; background: {page}; \ - border: 1px solid {border}; border-radius: 2px; display: flex; \ - align-items: center; justify-content: center;", - w = tokens::PAGE_WIDTH_PX, - h = tokens::PAGE_HEIGHT_PX, - page = tokens::CANVAS_PAGE_BG, - border = tokens::COLOR_BORDER_CHROME, - ), - span { - style: format!( - "font-family: {ff}; font-size: {fs}px; color: {fg};", - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_BODY, - fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, - ), - { fl!("editor-document-loading") } - } - } - } - } -} - /// Right-click handler body: resolves the word under the tile-local coordinates /// in `ctx` (accurate, via `element_coordinates` — no window-centring math), /// selects it, and opens the spelling menu anchored at the cursor. A no-op when @@ -177,6 +137,7 @@ pub(super) fn render_canvas_area( service: SpellService, spell_menu: Signal>, doc_state_context: Arc>, + zoom_percent: Signal, ) -> Element { rsx! { // Outer wrapper occupies the editor column's flex:1 slot and lays out @@ -258,7 +219,11 @@ pub(super) fn render_canvas_area( Ok(s) => (s.page_height_px, s.page_count), Err(_) => return, }; - let slot = page_h + page_gap_px; + // Tiles are painted at `zoom` scale (the inter-page gap is a + // fixed, unscaled CSS margin), so the page stride the scroll + // offset measures against is `page_h × zoom + gap`. + let zoom = zoom_percent() as f32 / 100.0; + let slot = page_h * zoom + page_gap_px; if slot <= 0.0 || count == 0 { return; } @@ -290,6 +255,7 @@ pub(super) fn render_canvas_area( cursor_state, page_gap_px, view_mode, + zoom_percent, ), onmouseup: move |_| { @@ -316,6 +282,7 @@ pub(super) fn render_canvas_area( page_gap_px, view_mode, scroll_metrics, + zoom_percent, ), ontouchend: make_touchend_handler( @@ -327,6 +294,7 @@ pub(super) fn render_canvas_area( page_gap_px, view_mode, scroll_metrics, + zoom_percent, ), onkeydown: make_keydown_handler( @@ -376,6 +344,7 @@ pub(super) fn render_canvas_area( DocumentView { doc: arc_doc, paginated_layout, + zoom: zoom_percent() as f64 / 100.0, // Real measured viewport height (falls back to a // sensible default before the first measure). Drives // tile virtualization: only pages within ~one screen diff --git a/loki-text/src/routes/editor/editor_canvas_loading.rs b/loki-text/src/routes/editor/editor_canvas_loading.rs new file mode 100644 index 00000000..c6a1b41c --- /dev/null +++ b/loki-text/src/routes/editor/editor_canvas_loading.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Loading placeholder for the document canvas. +//! +//! Extracted from `editor_canvas.rs` to keep that file under the 300-line +//! ceiling. + +use appthere_ui::tokens; +use dioxus::prelude::*; +use loki_i18n::fl; + +/// Blank page placeholder shown while a document is being opened. +/// +/// Renders immediately when the editor tab mounts (before the async load +/// resolves), so the user sees a page-shaped surface with an "opening" label +/// instead of an empty canvas while the file is read, imported, and laid out. +pub(super) fn loading_view() -> Element { + rsx! { + div { + style: format!( + "display: flex; flex: 1; align-items: flex-start; \ + justify-content: center; width: 100%; padding-top: {gap}px;", + gap = tokens::SPACE_6, + ), + div { + style: format!( + "width: {w}px; height: {h}px; flex-shrink: 0; background: {page}; \ + border: 1px solid {border}; border-radius: 2px; display: flex; \ + align-items: center; justify-content: center;", + w = tokens::PAGE_WIDTH_PX, + h = tokens::PAGE_HEIGHT_PX, + page = tokens::CANVAS_PAGE_BG, + border = tokens::COLOR_BORDER_CHROME, + ), + span { + style: format!( + "font-family: {ff}; font-size: {fs}px; color: {fg};", + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_BODY, + fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + ), + { fl!("editor-document-loading") } + } + } + } + } +} diff --git a/loki-text/src/routes/editor/editor_compact.rs b/loki-text/src/routes/editor/editor_compact.rs new file mode 100644 index 00000000..ea4ec56d --- /dev/null +++ b/loki-text/src/routes/editor/editor_compact.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Post-save CRDT history compaction (memory-audit Finding 6). +//! +//! A Loro oplog grows with every keystroke, so a long session retains +//! unbounded history. The save point is the natural horizon: the file is now +//! the durable state, so history behind it only serves undo. After each +//! successful save this module either +//! +//! - **truncates** the history (`compact_history`) when the oplog has grown +//! past [`COMPACT_THRESHOLD_OPS`] — swapping in a fresh doc and resetting +//! the undo stack to the save point, or +//! - **re-encodes** it in place (`compact_in_place`) below the threshold — +//! free memory savings with undo fully preserved. +//! +//! The threshold keeps the undo-reset tradeoff rare: routine saves keep +//! their undo history; only marathon sessions pay with a truncated stack. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use loro::LoroDoc; + +use crate::editing::state::DocumentState; + +/// Oplog size (in ops) above which a save triggers full history truncation. +/// ~2 ops per keystroke → roughly 10k keystrokes since the last truncation. +const COMPACT_THRESHOLD_OPS: usize = 20_000; + +/// Compacts the CRDT history after a successful save. See the module docs +/// for the threshold behaviour. +pub(super) fn compact_after_save( + mut loro_doc: Signal>, + mut undo_manager: Signal>, + mut saved_state: Signal, + mut can_undo: Signal, + mut can_redo: Signal, + doc_state: &Arc>, +) { + // Clone the handle (cheap, shared) so the read guard drops before set(). + let Some(doc) = loro_doc.peek().clone() else { + return; + }; + + if doc.len_ops() < COMPACT_THRESHOLD_OPS { + loki_doc_model::loro_bridge::compact_in_place(&doc); + return; + } + + match loki_doc_model::loro_bridge::compact_history(&doc) { + Ok(fresh) => { + // Everything bound to the old doc instance is recreated: the + // undo manager restarts at the save point, the clean-checkpoint + // tracker restarts at depth 0 (= the just-saved state), and the + // incremental reader re-seeds from the new doc on the next edit. + let mut um = loro::UndoManager::new(&fresh); + let tracker = crate::editing::saved_state::SavedStateHandle::new(); + tracker.attach(&mut um); + saved_state.set(tracker); + loro_doc.set(Some(fresh)); + undo_manager.set(Some(um)); + can_undo.set(false); + can_redo.set(false); + let mut state = doc_state.lock().unwrap_or_else(|e| e.into_inner()); + state.incremental = None; + } + Err(err) => { + // The save itself succeeded; a failed compaction only means the + // memory win is skipped this time. Keep the working doc. + tracing::warn!("post-save history compaction failed: {err}"); + loki_doc_model::loro_bridge::compact_in_place(&doc); + } + } +} diff --git a/loki-text/src/routes/editor/editor_dirty.rs b/loki-text/src/routes/editor/editor_dirty.rs new file mode 100644 index 00000000..465c043c --- /dev/null +++ b/loki-text/src/routes/editor/editor_dirty.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Unsaved-changes (dirty) tracking effect for the document editor. +//! +//! Extracted from `editor_inner.rs` to keep that file under the 300-line +//! ceiling. + +use dioxus::prelude::*; + +use crate::editing::cursor::CursorState; +use crate::editing::saved_state::SavedStateHandle; +use crate::new_document::is_untitled; +use crate::tabs::OpenTab; + +/// Wires the effect that keeps the `is_dirty` signal and the active tab's dirty +/// indicator in sync with the document's edit state. +/// +/// Dirty = the live generation differs from the clean baseline AND the +/// undo-stack clean checkpoint disagrees (undoing to the save point clears +/// dirty; plan 4b.3); untitled docs are always dirty until the first Save As. +pub(super) fn use_dirty_tracking( + cursor_state: Signal, + path_signal: Signal, + baseline_gen: Signal, + saved_state: Signal, + mut is_dirty: Signal, + mut tabs: Signal>, +) { + use_effect(move || { + let live_gen = cursor_state.read().document_generation; + let path = path_signal(); + let base = baseline_gen(); + let undo_clean = saved_state.read().is_clean(); + let dirty = is_untitled(&path) || (live_gen != base && !undo_clean); + if *is_dirty.peek() != dirty { + is_dirty.set(dirty); // guard avoids a needless ribbon re-render + } + // Only take a write guard when a tab's flag actually changes. A bare + // `tabs.write()` marks the shared tabs signal dirty and re-renders the + // whole tab bar on every keystroke, even when nothing changed — peek + // first and write only on a real transition. + let needs_update = tabs + .peek() + .iter() + .any(|tab| tab.path == path && tab.is_dirty != dirty); + if needs_update { + let mut t = tabs.write(); + if let Some(tab) = t.iter_mut().find(|tab| tab.path == path) { + tab.is_dirty = dirty; + } + } + }); +} diff --git a/loki-text/src/routes/editor/editor_format_range.rs b/loki-text/src/routes/editor/editor_format_range.rs new file mode 100644 index 00000000..a00a8497 --- /dev/null +++ b/loki-text/src/routes/editor/editor_format_range.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Format-range resolution: maps the cursor/selection to the per-paragraph +//! byte ranges a character-formatting action applies to (plan 4b.2). +//! +//! Extracted from `editor_formatting.rs` to keep that file under the +//! 300-line ceiling. +//! +//! # Byte offset coordinate space +//! +//! All byte offsets are UTF-8 byte positions, matching `CursorState` and the +//! `mark_utf8` API used by `mark_text_at`. + +use loki_doc_model::{BlockPath, PathStep, get_block_text_at}; +use loro::LoroDoc; + +use crate::editing::cursor::{CursorState, DocumentPosition}; + +#[cfg(test)] +#[path = "editor_format_range_tests.rs"] +mod tests; + +/// Resolves the format ranges from cursor state, one `(BlockPath, byte_start, +/// byte_end)` per paragraph the action applies to, in document order. +/// +/// - Selection within one paragraph → that byte range. +/// - Selection spanning sibling paragraphs of one container (top-level +/// blocks, or blocks of the same table cell / note body) → the tail of the +/// first paragraph, every middle paragraph in full, and the head of the +/// last. Non-text blocks inside the range (e.g. a table between two +/// top-level paragraphs) contribute an empty range and are skipped, so +/// their nested content is left untouched. +/// - Selection crossing containers (body ↔ cell, cell ↔ cell) → clamped to +/// the focus paragraph, mirroring the model layer's cross-container +/// rejection rule. +/// - Point cursor → the word at the cursor. +/// +/// Empty ranges are never returned; an empty `Vec` means there is nothing to +/// format. +pub fn resolve_format_ranges( + loro: &LoroDoc, + cursor: &CursorState, +) -> Vec<(BlockPath, usize, usize)> { + let Some(focus) = cursor.focus.as_ref() else { + return Vec::new(); + }; + let path = focus.block_path(); + + if cursor.has_selection() { + let Some(anchor) = cursor.anchor.as_ref() else { + return Vec::new(); + }; + // Same paragraph requires the same index *and* the same nesting path + // (two cells of one table share the root index but differ by path). + if anchor.paragraph_index == focus.paragraph_index && anchor.path == focus.path { + let (start, end) = if anchor.byte_offset <= focus.byte_offset { + (anchor.byte_offset, focus.byte_offset) + } else { + (focus.byte_offset, anchor.byte_offset) + }; + if start < end { + return vec![(path, start, end)]; + } + return Vec::new(); + } + if same_container(anchor, focus) { + return sibling_ranges(loro, anchor, focus); + } + if focus.byte_offset > 0 { + // Cross-container: clamp to the focus paragraph as a best-effort. + return vec![(path, 0, focus.byte_offset)]; + } + return Vec::new(); + } + + // No selection — expand to the word at cursor. + let text = get_block_text_at(loro, &path); + let (word_start, word_end) = word_bounds_at(&text, focus.byte_offset); + if word_start < word_end { + vec![(path, word_start, word_end)] + } else { + Vec::new() + } +} + +/// The leaf block index of a position within its container (the leaf step's +/// block index for nested positions, the paragraph index for top-level ones). +fn leaf_index(pos: &DocumentPosition) -> usize { + match pos.path.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => *block, + None => pos.paragraph_index, + } +} + +/// Whether two positions address sibling blocks of one container: both +/// top-level, or nested with the same root, the same non-leaf steps, and the +/// same leaf cell / note (mirrors `loro_mutation::selection`). +fn same_container(a: &DocumentPosition, b: &DocumentPosition) -> bool { + if a.path.len() != b.path.len() { + return false; + } + let Some(n) = a.path.len().checked_sub(1) else { + return true; // both top-level + }; + if a.paragraph_index != b.paragraph_index || a.path[..n] != b.path[..n] { + return false; + } + match (a.path[n], b.path[n]) { + (PathStep::Cell { cell: c1, .. }, PathStep::Cell { cell: c2, .. }) => c1 == c2, + (PathStep::Note { note: n1, .. }, PathStep::Note { note: n2, .. }) => n1 == n2, + _ => false, + } +} + +/// Per-paragraph ranges for a selection spanning sibling blocks of one +/// container: `[start_byte..len]` of the first, `[0..len]` of every middle +/// block, `[0..end_byte]` of the last. Empty ranges (empty paragraphs, +/// text-less blocks like tables) are skipped. +fn sibling_ranges( + loro: &LoroDoc, + a: &DocumentPosition, + b: &DocumentPosition, +) -> Vec<(BlockPath, usize, usize)> { + let (start, end) = if (leaf_index(a), a.byte_offset) <= (leaf_index(b), b.byte_offset) { + (a, b) + } else { + (b, a) + }; + let (start_leaf, end_leaf) = (leaf_index(start), leaf_index(end)); + + let mut ranges = Vec::with_capacity(end_leaf - start_leaf + 1); + for leaf in start_leaf..=end_leaf { + let pos = start.sibling_block(leaf as isize - start_leaf as isize, 0); + let bp = pos.block_path(); + let text_len = get_block_text_at(loro, &bp).len(); + let s = if leaf == start_leaf { + start.byte_offset + } else { + 0 + }; + let e = if leaf == end_leaf { + end.byte_offset.min(text_len) + } else { + text_len + }; + if s < e { + ranges.push((bp, s, e)); + } + } + ranges +} + +/// Returns the word boundary around `byte_offset` in `text` as +/// `(word_start_byte, word_end_byte)`. +/// +/// A "word character" is alphanumeric or underscore. If the cursor is +/// on whitespace or punctuation, `word_start == word_end` (empty word). +fn word_bounds_at(text: &str, byte_offset: usize) -> (usize, usize) { + let clamped = byte_offset.min(text.len()); + let before = &text[..clamped]; + + // Walk backward to find the last non-word character, then word starts one + // character after it. + let word_start = match before + .char_indices() + .rev() + .find(|(_, c)| !c.is_alphanumeric() && *c != '_') + { + Some((i, c)) => i + c.len_utf8(), + None => 0, + }; + + // Walk forward from clamped to find the first non-word character. + let after = &text[clamped..]; + let word_end = clamped + + match after + .char_indices() + .find(|(_, c)| !c.is_alphanumeric() && *c != '_') + { + Some((i, _)) => i, + None => after.len(), + }; + + (word_start, word_end) +} diff --git a/loki-text/src/routes/editor/editor_format_range_tests.rs b/loki-text/src/routes/editor/editor_format_range_tests.rs new file mode 100644 index 00000000..ad88549b --- /dev/null +++ b/loki-text/src/routes/editor/editor_format_range_tests.rs @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::document_to_loro; +use loki_doc_model::{BlockPath, PathStep}; + +use super::resolve_format_ranges; +use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::routes::editor::editor_formatting::toggle_bold; + +fn para(s: &str) -> Block { + Block::Para(vec![Inline::Str(s.into())]) +} + +fn loro_with_paras(texts: &[&str]) -> loro::LoroDoc { + let mut doc = Document::new(); + doc.sections[0].blocks = texts.iter().map(|t| para(t)).collect(); + document_to_loro(&doc).unwrap() +} + +fn selection(anchor: DocumentPosition, focus: DocumentPosition) -> CursorState { + let mut cs = CursorState::new(); + cs.anchor = Some(anchor); + cs.focus = Some(focus); + cs +} + +#[test] +fn point_cursor_expands_to_the_word() { + let loro = loro_with_paras(&["hello world"]); + let mut cs = CursorState::new(); + cs.anchor = Some(DocumentPosition::top_level(0, 0, 7)); + cs.focus = cs.anchor.clone(); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!(ranges, vec![(BlockPath::block(0), 6, 11)]); +} + +#[test] +fn single_paragraph_selection_is_one_range() { + let loro = loro_with_paras(&["hello world"]); + let cs = selection( + DocumentPosition::top_level(0, 0, 8), + DocumentPosition::top_level(0, 0, 2), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!(ranges, vec![(BlockPath::block(0), 2, 8)]); +} + +#[test] +fn multi_paragraph_selection_covers_every_block() { + let loro = loro_with_paras(&["Hello world", "middle", "goodbye"]); + // From byte 6 of block 0 to byte 4 of block 2, endpoints reversed. + let cs = selection( + DocumentPosition::top_level(0, 2, 4), + DocumentPosition::top_level(0, 0, 6), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!( + ranges, + vec![ + (BlockPath::block(0), 6, 11), // tail of "Hello world" + (BlockPath::block(1), 0, 6), // all of "middle" + (BlockPath::block(2), 0, 4), // head of "goodbye" + ] + ); +} + +#[test] +fn selection_ending_at_offset_zero_skips_the_empty_last_range() { + let loro = loro_with_paras(&["first", "second"]); + let cs = selection( + DocumentPosition::top_level(0, 0, 2), + DocumentPosition::top_level(0, 1, 0), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!(ranges, vec![(BlockPath::block(0), 2, 5)]); +} + +#[test] +fn selection_within_one_table_cell_covers_its_blocks() { + use loki_doc_model::content::attr::NodeAttr; + use loki_doc_model::content::table::core::{ + Table, TableBody, TableCaption, TableFoot, TableHead, + }; + use loki_doc_model::content::table::row::{Cell, Row}; + + let cell = Cell::simple(vec![para("alpha"), para("beta")]); + let table = Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![cell])])], + foot: TableFoot::empty(), + }; + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("intro"), Block::Table(Box::new(table))]; + let loro = document_to_loro(&doc).unwrap(); + + let pos = |block: usize, byte: usize| DocumentPosition { + page_index: 0, + paragraph_index: 1, + byte_offset: byte, + path: vec![PathStep::Cell { cell: 0, block }], + }; + let cs = selection(pos(0, 2), pos(1, 3)); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!( + ranges, + vec![ + (BlockPath::in_cell(1, 0, 0), 2, 5), // tail of "alpha" + (BlockPath::in_cell(1, 0, 1), 0, 3), // head of "beta" + ] + ); +} + +#[test] +fn cross_container_selection_clamps_to_the_focus_paragraph() { + let loro = loro_with_paras(&["top level"]); + let cs = selection( + DocumentPosition { + page_index: 0, + paragraph_index: 0, + byte_offset: 1, + path: vec![PathStep::Cell { cell: 0, block: 0 }], + }, + DocumentPosition::top_level(0, 0, 3), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!(ranges, vec![(BlockPath::block(0), 0, 3)]); +} + +#[test] +fn a_table_between_selected_paragraphs_is_skipped() { + use loki_doc_model::content::attr::NodeAttr; + use loki_doc_model::content::table::core::{ + Table, TableBody, TableCaption, TableFoot, TableHead, + }; + use loki_doc_model::content::table::row::{Cell, Row}; + + let table = Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![Cell::simple( + vec![para("cell")], + )])])], + foot: TableFoot::empty(), + }; + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("before"), Block::Table(Box::new(table)), para("after")]; + let loro = document_to_loro(&doc).unwrap(); + + // Top-level selection across the table: block 0 tail + block 2 head; the + // table (no text container) contributes nothing and its cell is untouched. + let cs = selection( + DocumentPosition::top_level(0, 0, 3), + DocumentPosition::top_level(0, 2, 3), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!( + ranges, + vec![(BlockPath::block(0), 3, 6), (BlockPath::block(2), 0, 3)] + ); +} + +#[test] +fn toggle_bold_marks_every_paragraph_of_a_multi_block_selection() { + use loki_doc_model::loro_bridge::loro_to_document; + + let loro = loro_with_paras(&["Hello world", "middle", "goodbye"]); + let cs = selection( + DocumentPosition::top_level(0, 0, 6), + DocumentPosition::top_level(0, 2, 4), + ); + assert!( + toggle_bold(&loro, &cs).unwrap(), + "first toggle applies bold" + ); + + let rebuilt = loro_to_document(&loro).unwrap(); + let bold_text = |block: usize| -> String { + let Block::Para(inlines) = &rebuilt.sections[0].blocks[block] else { + panic!("para"); + }; + inlines + .iter() + .filter_map(|i| match i { + Inline::StyledRun(r) + if r.direct_props + .as_ref() + .is_some_and(|p| p.bold == Some(true)) => + { + Some(r.content.iter().filter_map(|c| match c { + Inline::Str(s) => Some(s.as_str()), + _ => None, + })) + } + _ => None, + }) + .flatten() + .collect() + }; + assert_eq!(bold_text(0), "world"); + assert_eq!(bold_text(1), "middle"); + assert_eq!(bold_text(2), "good"); + + // Second toggle (state read at the selection start) removes it everywhere. + assert!( + !toggle_bold(&loro, &cs).unwrap(), + "second toggle clears bold" + ); + let rebuilt = loro_to_document(&loro).unwrap(); + let all_unbold = (0..3).all(|b| { + let Block::Para(inlines) = &rebuilt.sections[0].blocks[b] else { + panic!("para"); + }; + inlines.iter().all(|i| { + !matches!(i, Inline::StyledRun(r) + if r.direct_props.as_ref().is_some_and(|p| p.bold == Some(true))) + }) + }); + assert!(all_unbold, "bold cleared across the whole selection"); +} diff --git a/loki-text/src/routes/editor/editor_formatting.rs b/loki-text/src/routes/editor/editor_formatting.rs index 7c05c51e..0b25a27c 100644 --- a/loki-text/src/routes/editor/editor_formatting.rs +++ b/loki-text/src/routes/editor/editor_formatting.rs @@ -9,29 +9,30 @@ //! //! # Toggle semantics //! -//! Each `toggle_*` function reads the mark at the start of the resolved -//! format range. If the mark is already active, it is cleared (`LoroValue::Null`); -//! otherwise it is applied. This matches Word/LibreOffice toggle behaviour. +//! Each `toggle_*` function reads the mark at the start of the **first** +//! resolved range (the document-order start of the selection). If the mark +//! is already active there, it is cleared (`LoroValue::Null`) across every +//! range; otherwise it is applied across every range. This matches +//! Word/LibreOffice toggle behaviour, extended to multi-paragraph selections +//! (plan 4b.2): the state at the selection start decides, and the whole +//! selection is made uniform. //! //! # Range resolution //! -//! [`resolve_format_range`] maps `CursorState` to `(block_index, byte_start, byte_end)`. -//! With an active selection in a single block, the selection is used directly. -//! With a point cursor (no selection), the word at the cursor is expanded. -//! Cross-block selections are clamped to the focus block — a future pass can -//! extend this. -//! -//! # Byte offset coordinate space -//! -//! All byte offsets are UTF-8 byte positions, matching `CursorState` and the -//! `mark_utf8` API used by `mark_text`. +//! [`resolve_format_ranges`](super::editor_format_range::resolve_format_ranges) +//! maps `CursorState` to one `(BlockPath, byte_start, byte_end)` per +//! paragraph: the selection's ranges for single- and multi-paragraph +//! selections within one container, the word at the cursor for a point +//! cursor, and a clamp to the focus paragraph for cross-container +//! selections. See `editor_format_range.rs`. use loki_doc_model::loro_schema::{ MARK_BOLD, MARK_ITALIC, MARK_STRIKETHROUGH, MARK_UNDERLINE, MARK_VERTICAL_ALIGN, }; -use loki_doc_model::{BlockPath, MutationError, get_block_text_at, get_mark_at_path, mark_text_at}; +use loki_doc_model::{BlockPath, MutationError, get_mark_at_path, mark_text_at}; use loro::{LoroDoc, LoroValue}; +use super::editor_format_range::resolve_format_ranges; use crate::editing::cursor::CursorState; /// Whether the mark was applied (`true`) or removed (`false`). @@ -91,69 +92,40 @@ pub fn toggle_subscript( toggle_vertical_align(loro, cursor, "Subscript") } -// ── Range resolution ────────────────────────────────────────────────────────── +// ── Private helpers ─────────────────────────────────────────────────────────── -/// Resolves the format range from cursor state: `(BlockPath, byte_start, byte_end)`. -/// -/// The [`BlockPath`] addresses the focus paragraph — top-level or nested inside -/// a table cell / note body — so formatting applies to the right container. -/// With a selection within a single paragraph, the selection range is returned; -/// with a point cursor, the word at the cursor is expanded; a cross-paragraph -/// selection is clamped to the focus paragraph. -/// -/// Returns `None` when there is no valid cursor position. -/// -/// // TODO(formatting): extend to multi-block selections. -pub fn resolve_format_range( +/// Applies `new_value` for `mark_key` across every resolved range. +fn mark_ranges( loro: &LoroDoc, - cursor: &CursorState, -) -> Option<(BlockPath, usize, usize)> { - let focus = cursor.focus.as_ref()?; - let path = focus.block_path(); - - if cursor.has_selection() { - let anchor = cursor.anchor.as_ref()?; - // Same paragraph requires the same index *and* the same nesting path - // (two cells of one table share the root index but differ by path). - if anchor.paragraph_index == focus.paragraph_index && anchor.path == focus.path { - let (start, end) = if anchor.byte_offset <= focus.byte_offset { - (anchor.byte_offset, focus.byte_offset) - } else { - (focus.byte_offset, anchor.byte_offset) - }; - if start < end { - return Some((path, start, end)); - } - } else if focus.byte_offset > 0 { - // Cross-paragraph: clamp to the focus paragraph as a best-effort. - return Some((path, 0, focus.byte_offset)); - } - } - - // No selection — expand to the word at cursor. - let text = get_block_text_at(loro, &path); - let (word_start, word_end) = word_bounds_at(&text, focus.byte_offset); - if word_start < word_end { - Some((path, word_start, word_end)) - } else { - None + ranges: &[(BlockPath, usize, usize)], + mark_key: &str, + new_value: &LoroValue, +) -> Result<(), MutationError> { + for (path, byte_start, byte_end) in ranges { + mark_text_at( + loro, + path, + *byte_start, + *byte_end, + mark_key, + new_value.clone(), + )?; } + Ok(()) } -// ── Private helpers ─────────────────────────────────────────────────────────── - fn toggle_string_mark( loro: &LoroDoc, cursor: &CursorState, mark_key: &str, enable_value: &str, ) -> Result { - let (path, byte_start, byte_end) = match resolve_format_range(loro, cursor) { - Some(r) => r, - None => return Ok(false), + let ranges = resolve_format_ranges(loro, cursor); + let Some((first_path, first_start, _)) = ranges.first() else { + return Ok(false); }; let active = matches!( - get_mark_at_path(loro, &path, byte_start, mark_key)?, + get_mark_at_path(loro, first_path, *first_start, mark_key)?, Some(LoroValue::String(_)) ); let new_value = if active { @@ -161,7 +133,7 @@ fn toggle_string_mark( } else { LoroValue::from(enable_value.to_string()) }; - mark_text_at(loro, &path, byte_start, byte_end, mark_key, new_value)?; + mark_ranges(loro, &ranges, mark_key, &new_value)?; Ok(!active) } @@ -170,13 +142,13 @@ fn toggle_bool_mark( cursor: &CursorState, mark_key: &str, ) -> Result { - let (path, byte_start, byte_end) = match resolve_format_range(loro, cursor) { - Some(r) => r, - None => return Ok(false), + let ranges = resolve_format_ranges(loro, cursor); + let Some((first_path, first_start, _)) = ranges.first() else { + return Ok(false); }; let active = matches!( - get_mark_at_path(loro, &path, byte_start, mark_key)?, + get_mark_at_path(loro, first_path, *first_start, mark_key)?, Some(LoroValue::Bool(true)) ); @@ -185,7 +157,7 @@ fn toggle_bool_mark( } else { LoroValue::Bool(true) }; - mark_text_at(loro, &path, byte_start, byte_end, mark_key, new_value)?; + mark_ranges(loro, &ranges, mark_key, &new_value)?; Ok(!active) } @@ -194,13 +166,13 @@ fn toggle_vertical_align( cursor: &CursorState, target_str: &str, ) -> Result { - let (path, byte_start, byte_end) = match resolve_format_range(loro, cursor) { - Some(r) => r, - None => return Ok(false), + let ranges = resolve_format_ranges(loro, cursor); + let Some((first_path, first_start, _)) = ranges.first() else { + return Ok(false); }; let already_active = matches!( - get_mark_at_path(loro, &path, byte_start, MARK_VERTICAL_ALIGN)?, + get_mark_at_path(loro, first_path, *first_start, MARK_VERTICAL_ALIGN)?, Some(LoroValue::String(ref s)) if s.as_str() == target_str ); @@ -211,47 +183,6 @@ fn toggle_vertical_align( } else { LoroValue::from(target_str.to_string()) }; - mark_text_at( - loro, - &path, - byte_start, - byte_end, - MARK_VERTICAL_ALIGN, - new_value, - )?; + mark_ranges(loro, &ranges, MARK_VERTICAL_ALIGN, &new_value)?; Ok(!already_active) } - -/// Returns the word boundary around `byte_offset` in `text` as -/// `(word_start_byte, word_end_byte)`. -/// -/// A "word character" is alphanumeric or underscore. If the cursor is -/// on whitespace or punctuation, `word_start == word_end` (empty word). -fn word_bounds_at(text: &str, byte_offset: usize) -> (usize, usize) { - let clamped = byte_offset.min(text.len()); - let before = &text[..clamped]; - - // Walk backward to find the last non-word character, then word starts one - // character after it. - let word_start = match before - .char_indices() - .rev() - .find(|(_, c)| !c.is_alphanumeric() && *c != '_') - { - Some((i, c)) => i + c.len_utf8(), - None => 0, - }; - - // Walk forward from clamped to find the first non-word character. - let after = &text[clamped..]; - let word_end = clamped - + match after - .char_indices() - .find(|(_, c)| !c.is_alphanumeric() && *c != '_') - { - Some((i, _)) => i, - None => after.len(), - }; - - (word_start, word_end) -} diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index b3bbeda0..7547b0b7 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -22,8 +22,7 @@ use std::rc::Rc; use std::sync::Arc; -use appthere_ui::tokens; -use appthere_ui::{AtRibbon, AtStatusBar, RibbonTabDesc, use_breakpoint}; +use appthere_ui::{AtRibbon, AtStatusBar, RibbonTabDesc, tokens, use_breakpoint}; use dioxus::prelude::*; use loki_doc_model::document::Document; use loki_doc_model::get_mark_at; @@ -45,7 +44,6 @@ use super::editor_path_sync::{ use super::editor_publish::{publish_panel, publish_tab_content}; use super::editor_ribbon::write_tab_content; use super::editor_ribbon_insert::insert_tab_content; -use super::editor_save::{export_document_to_token, save_document_to_path}; use super::editor_save_banner::save_banner; use super::editor_spell::SpellMenu; use super::editor_state::{EditorState, use_editor_state}; @@ -53,17 +51,9 @@ use super::editor_style::style_picker_panel; use super::editor_style_catalog::available_font_families; use super::editor_style_editor::style_editor_panel; use crate::error::LoadError; -use crate::new_document::is_untitled; -use crate::recent_documents::RecentDocuments; -use crate::routes::Route; use crate::sessions::DocSessions; use crate::tabs::OpenTab; -use crate::utils::display_title_from_path; use loki_app_shell::spell::SpellService; -use loki_file_access::{FilePicker, SaveOptions}; - -/// MIME type used when saving documents (DOCX is the only writable format). -const DOCX_MIME: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; // EditorMode removed — the editor is always in edit mode when a document is // open. Distraction-free reading is handled by the View ribbon tab (future @@ -110,11 +100,14 @@ pub(super) fn EditorInner(path: String) -> Element { mut superscript_active, mut subscript_active, mut undo_manager, + mut saved_state, can_undo, can_redo, is_style_picker_open, editing_style_draft, - mut save_message, + mut zoom_percent, + is_dirty, + save_message, save_request, mut active_ribbon_tab, is_publish_panel_open, @@ -123,8 +116,7 @@ pub(super) fn EditorInner(path: String) -> Element { } = use_editor_state(); // ── Tab/recents context for Save As and the unsaved-changes indicator ──── - let mut tabs = use_context::>>(); - let recent_docs = use_context::>(); + let tabs = use_context::>>(); // Spell-check service (provided at the app root). Drives the right-click // suggestions panel and the language picker. let spell_service = use_context::(); @@ -178,6 +170,7 @@ pub(super) fn EditorInner(path: String) -> Element { editing_style_draft, save_message, baseline_gen, + saved_state, }; restore_session(session, &doc_state_restore, &mut sig); } @@ -186,17 +179,15 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Session stash at unmount ───────────────────────────────────────────── // - // Skipped when the tab was closed (Shell already dropped the session and - // re-stashing would resurrect discarded edits on reopen). + // `stash_outgoing` itself skips the stash when no tab still points at the + // path (the tab was closed, and Shell already dropped the session — re- + // stashing would resurrect discarded edits on reopen). { let doc_state_drop = Arc::clone(&doc_state); let tabs_at_drop = tabs; let mut sessions_at_drop = doc_sessions; use_drop(move || { let path = path_signal.peek().clone(); - if !tabs_at_drop.peek().iter().any(|t| t.path == path) { - return; - } let mut sig = PathSyncSignals { cursor_state, loro_doc, @@ -210,8 +201,15 @@ pub(super) fn EditorInner(path: String) -> Element { editing_style_draft, save_message, baseline_gen, + saved_state, }; - stash_outgoing(&path, &doc_state_drop, &mut sessions_at_drop, &mut sig); + stash_outgoing( + &path, + &doc_state_drop, + tabs_at_drop, + &mut sessions_at_drop, + &mut sig, + ); }); } @@ -224,6 +222,7 @@ pub(super) fn EditorInner(path: String) -> Element { &path, &mut path_signal, &doc_state, + tabs, doc_sessions, &mut PathSyncSignals { cursor_state, @@ -238,6 +237,7 @@ pub(super) fn EditorInner(path: String) -> Element { editing_style_draft, save_message, baseline_gen, + saved_state, }, ); @@ -290,8 +290,6 @@ pub(super) fn EditorInner(path: String) -> Element { } }); - let navigator = use_navigator(); - // ── Loro bridge: initialise CRDT once the document is loaded ───────────── // // The first paginated layout is a CPU-heavy pass (tens of ms on a multi-page @@ -352,7 +350,12 @@ pub(super) fn EditorInner(path: String) -> Element { match document_to_loro(&doc) { Ok(l_doc) => { - let um = loro::UndoManager::new(&l_doc); + let mut um = loro::UndoManager::new(&l_doc); + // Pair a fresh clean-checkpoint tracker with the fresh + // undo manager (depth 0 = the on-disk state). + let tracker = crate::editing::saved_state::SavedStateHandle::new(); + tracker.attach(&mut um); + saved_state.set(tracker); loro_doc.set(Some(l_doc)); undo_manager.set(Some(um)); @@ -456,84 +459,27 @@ pub(super) fn EditorInner(path: String) -> Element { // blitz-dom / dioxus-native-dom) whenever a wheel or touch gesture changes // the scroll container's offset. - // ── Unsaved-changes (dirty) tracking ───────────────────────────────────── - // - // The tab shows a dirty indicator when the live document generation differs - // from the clean baseline captured at load/save. Untitled documents are - // always dirty until the first Save As. Runs whenever the cursor's mirrored - // generation, the path, or the baseline changes. - use_effect(move || { - let live_gen = cursor_state.read().document_generation; - let path = path_signal(); - let base = baseline_gen(); - let dirty = is_untitled(&path) || live_gen != base; - let mut t = tabs.write(); - if let Some(tab) = t.iter_mut().find(|tab| tab.path == path) - && tab.is_dirty != dirty - { - tab.is_dirty = dirty; - } - }); + // Live status-bar word count, recomputed per mutation (F7c / 4c.5). + let word_count_label = + crate::editing::word_count::use_word_count_label(Arc::clone(&doc_state), cursor_state); - // ── Save As ────────────────────────────────────────────────────────────── - // - // Picks a destination via the platform save dialog, exports DOCX to it, then - // repoints the current tab at the new file and records it in recents. This - // is the only way to persist an untitled document. - let doc_state_saveas = Arc::clone(&doc_state); - let save_as = use_callback(move |_: ()| { - let doc_state = Arc::clone(&doc_state_saveas); - let mut tabs = tabs; - let mut recent = recent_docs; - let mut save_message = save_message; - let mut baseline_gen = baseline_gen; - let nav = navigator; - let cur_path = path_signal.peek().clone(); - let suggested = { - let stem = display_title_from_path(&cur_path); - format!("{stem}.docx") - }; - spawn(async move { - let picker = FilePicker::new(); - let opts = SaveOptions { - mime_type: Some(DOCX_MIME.to_string()), - suggested_name: Some(suggested), - }; - match picker.pick_file_to_save(opts).await { - Ok(Some(token)) => match export_document_to_token(&token, &doc_state) { - Ok(()) => { - let new_path = token.serialize(); - let new_title = display_title_from_path(&new_path); - { - let mut t = tabs.write(); - if let Some(tab) = t.iter_mut().find(|tab| tab.path == cur_path) { - tab.path = new_path.clone(); - tab.title = new_title.clone(); - tab.is_dirty = false; - } - } - recent.write().record(new_path.clone(), new_title); - recent.read().save(); - save_message.set(Some(fl!("editor-save-success"))); - // Navigate to the saved file; the editor reloads it and - // re-establishes a clean baseline. - baseline_gen.set(0); - nav.push(Route::Editor { path: new_path }); - } - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); - } - }, - Ok(None) => { /* user cancelled — no-op */ } - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); - } - } - }); - }); + // Unsaved-changes (dirty) tracking → tab indicator + ribbon Save state. + super::editor_dirty::use_dirty_tracking( + cursor_state, + path_signal, + baseline_gen, + saved_state, + is_dirty, + tabs, + ); - // ── Save as Template (.dotx) ─────────────────────────────────────────────── - // Self-contained save flow — extracted to keep this file under its ceiling. + // ── Save As / Save as Template (extracted flows: editor_save_callbacks) ── + let save_as = super::editor_save_callbacks::use_save_as_callback( + Arc::clone(&doc_state), + save_message, + baseline_gen, + path_signal, + ); let save_as_template = super::editor_save_callbacks::use_save_as_template_callback( Arc::clone(&doc_state), save_message, @@ -551,29 +497,20 @@ pub(super) fn EditorInner(path: String) -> Element { save_message, }; - // ── Ctrl+S handler ─────────────────────────────────────────────────────── - // - // The keydown handler bumps `save_request`; perform the save here, where the - // tab/recents context is reachable. Untitled documents route to Save As. - let doc_state_savereq = Arc::clone(&doc_state); - use_effect(move || { - let n = save_request(); // subscribe — fires on each Ctrl+S - if n == 0 { - return; // initial value — nothing requested yet - } - let path = path_signal.peek().clone(); - if is_untitled(&path) { - save_as.call(()); - return; - } - let msg = match save_document_to_path(&path, &doc_state_savereq) { - Ok(()) => { - baseline_gen.set(cursor_state.peek().document_generation); - fl!("editor-save-success") - } - Err(e) => fl!("editor-save-error", reason = e.to_string()), - }; - save_message.set(Some(msg)); + // ── Ctrl+S handler (extracted flow — see editor_save_callbacks) ───────── + super::editor_save_callbacks::use_ctrl_s_save(super::editor_save_callbacks::CtrlSCtx { + doc_state: Arc::clone(&doc_state), + path_signal, + save_request, + save_as, + baseline_gen, + cursor_state, + loro_doc, + undo_manager, + saved_state, + can_undo, + can_redo, + save_message, }); // ── Viewport-driven effects (Spec 03 M1/M2) ────────────────────────────── @@ -663,6 +600,7 @@ pub(super) fn EditorInner(path: String) -> Element { spell_service.clone(), spell_menu, doc_state_spell_ctx, + zoom_percent, )} // ── Font-substitution warning (Spec 03 M3) ──────────────────────── @@ -813,12 +751,11 @@ pub(super) fn EditorInner(path: String) -> Element { subscript_active, current_style_name, is_style_picker_open, - path_signal, - save_message, + save_request, + is_dirty, editing_style_draft, save_as, save_as_template, - baseline_gen, ), }, } @@ -826,13 +763,16 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Status bar ──────────────────────────────────────────────────── AtStatusBar { page_label: page_label, - word_count_label: "".to_string(), + word_count_label: word_count_label(), language_label: fl!("editor-language"), - zoom_percent: 100, + zoom_percent: zoom_percent(), collaborator_count: 0, collaborator_label: String::new(), zoom_aria_label: fl!("editor-zoom-aria"), - on_zoom_click: |_| {}, + on_zoom_click: move |_| { + let next = appthere_ui::next_zoom(*zoom_percent.peek()); + zoom_percent.set(next); + }, view_mode_label: if view_mode() == ViewMode::Reflow { fl!("editor-view-reflowed") } else { diff --git a/loki-text/src/routes/editor/editor_insert.rs b/loki-text/src/routes/editor/editor_insert.rs index a71601b6..511af126 100644 --- a/loki-text/src/routes/editor/editor_insert.rs +++ b/loki-text/src/routes/editor/editor_insert.rs @@ -31,7 +31,7 @@ use loki_doc_model::{ }; use loro::{LoroDoc, LoroValue}; -use super::editor_formatting::resolve_format_range; +use super::editor_format_range::resolve_format_ranges; use crate::editing::cursor::CursorState; /// EMU per CSS pixel at 96 DPI (1 inch = 914 400 EMU = 96 px). Inserted images @@ -40,25 +40,37 @@ const EMU_PER_PX_96: u64 = 9525; /// Applies (or clears) a hyperlink over the selection or the word at the cursor. /// -/// An empty/whitespace-only `url` clears any existing link; otherwise the -/// resolved range is marked with [`MARK_LINK_URL`]. Returns `true` when a link -/// was applied, `false` when it was cleared or there was no resolvable range -/// (e.g. the cursor sits on whitespace with no selection). +/// An empty/whitespace-only `url` clears any existing link; otherwise every +/// resolved range is marked with [`MARK_LINK_URL`]. A multi-paragraph selection +/// resolves to one range per paragraph (like the character-formatting toggles), +/// so the whole selection is linked — not just its first paragraph. Returns +/// `true` when a link was applied, `false` when it was cleared or there was no +/// resolvable range (e.g. the cursor sits on whitespace with no selection). pub fn set_hyperlink( loro: &LoroDoc, cursor: &CursorState, url: &str, ) -> Result { - let Some((path, byte_start, byte_end)) = resolve_format_range(loro, cursor) else { + let ranges = resolve_format_ranges(loro, cursor); + if ranges.is_empty() { return Ok(false); - }; + } let trimmed = url.trim(); let value = if trimmed.is_empty() { LoroValue::Null } else { LoroValue::from(trimmed.to_string()) }; - mark_text_at(loro, &path, byte_start, byte_end, MARK_LINK_URL, value)?; + for (path, byte_start, byte_end) in &ranges { + mark_text_at( + loro, + path, + *byte_start, + *byte_end, + MARK_LINK_URL, + value.clone(), + )?; + } Ok(!trimmed.is_empty()) } diff --git a/loki-text/src/routes/editor/editor_insert_tests.rs b/loki-text/src/routes/editor/editor_insert_tests.rs index 6a6eccce..b907dd04 100644 --- a/loki-text/src/routes/editor/editor_insert_tests.rs +++ b/loki-text/src/routes/editor/editor_insert_tests.rs @@ -69,6 +69,37 @@ fn url_is_trimmed() { assert_eq!(link_at(&loro, 0).as_deref(), Some("https://trim.example")); } +#[test] +fn applies_link_across_a_multi_paragraph_selection() { + // Two paragraphs; a selection spanning both must link the tail of the + // first AND the head of the second (the bug linked only the first). + let mut doc = Document::new(); + doc.sections[0].blocks = vec![ + Block::Para(vec![Inline::Str("hello".into())]), + Block::Para(vec![Inline::Str("world".into())]), + ]; + let loro = document_to_loro(&doc).expect("document_to_loro"); + let cursor = CursorState { + loro_cursor: None, + anchor: Some(DocumentPosition::top_level(0, 0, 2)), + focus: Some(DocumentPosition::top_level(0, 1, 3)), + document_generation: 0, + }; + assert!(set_hyperlink(&loro, &cursor, "https://multi.example").unwrap()); + // First paragraph: linked from byte 2 onward. + let p0 = get_mark_at(&loro, 0, 3, MARK_LINK_URL).expect("get_mark_at p0"); + assert!( + matches!(p0, Some(LoroValue::String(_))), + "first para linked" + ); + // Second paragraph: also linked (byte 1 lies inside 0..3). + let p1 = get_mark_at(&loro, 1, 1, MARK_LINK_URL).expect("get_mark_at p1"); + assert!( + matches!(p1, Some(LoroValue::String(_))), + "second paragraph of the selection must be linked too" + ); +} + /// Encodes a `w`×`h` RGBA PNG into bytes for the image-insert tests. fn png_bytes(w: u32, h: u32) -> Vec { let img = image::RgbaImage::new(w, h); diff --git a/loki-text/src/routes/editor/editor_keydown.rs b/loki-text/src/routes/editor/editor_keydown.rs index 105cba67..4fa38efc 100644 --- a/loki-text/src/routes/editor/editor_keydown.rs +++ b/loki-text/src/routes/editor/editor_keydown.rs @@ -6,20 +6,19 @@ use std::sync::{Arc, Mutex}; use dioxus::prelude::*; use keyboard_types::Modifiers; -use loki_doc_model::loro_mutation::{ - delete_text_at, get_block_text, get_block_text_at, insert_text_at, -}; -use loki_doc_model::merge_block_at; +use loki_doc_model::loro_mutation::{get_block_text, get_block_text_at}; use loki_renderer::ViewMode; use loki_renderer::render_layout::reflow_content_width_pt; -use super::editor_keydown_ctrl::{ - handle_ctrl_keys, handle_delete_key, handle_enter_key, post_mutation_sync, +use super::editor_keydown_ctrl::{handle_ctrl_keys, handle_delete_key}; +use super::editor_keydown_enter::handle_enter_key; +use super::editor_keydown_text::{ + SelectionRemoval, handle_backspace_key, handle_character_key, remove_selection, }; use super::editor_scrollbar::ScrollMetrics; -use crate::editing::cursor::{CursorState, DocumentPosition, prev_grapheme_boundary}; +use crate::editing::cursor::CursorState; use crate::editing::navigation::{ navigate_down, navigate_end, navigate_home, navigate_left, navigate_right, navigate_up, }; @@ -27,7 +26,7 @@ use crate::editing::reflow_nav::{ reflow_navigate_down, reflow_navigate_end, reflow_navigate_home, reflow_navigate_left, reflow_navigate_right, reflow_navigate_up, }; -use crate::editing::state::{DocumentState, apply_mutation_and_relayout, ensure_reflow_layout}; +use crate::editing::state::{DocumentState, ensure_reflow_layout}; // EditorMode removed — the editor is always in edit mode when a document is // open. Distraction-free reading is handled by the View ribbon tab (future @@ -82,128 +81,58 @@ pub(super) fn make_keydown_handler( let Some(focus) = focus else { return }; match &key { - // ── Printable characters ────────────────────────────────────────── + // ── Printable characters (replace the selection if one is active) ─ Key::Character(ch) => { - let ch = ch.clone(); - { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - if insert_text_at(ldoc, &focus.block_path(), focus.byte_offset, &ch).is_err() { - return; - } - } - { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - apply_mutation_and_relayout(&doc_state, ldoc); - } - post_mutation_sync( - &doc_state, + handle_character_key( + ch.clone(), + focus, loro_doc, + &doc_state, cursor_state, undo_manager, can_undo, can_redo, ); - let new_offset = focus.byte_offset + ch.len(); - let new_pos = DocumentPosition { - byte_offset: new_offset, - ..focus - }; - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); } - // ── Backspace ───────────────────────────────────────────────────── + // ── Backspace (selection removal / block merge / grapheme) ──────── Key::Backspace => { - if focus.byte_offset == 0 { - // Backspace-at-start merges this block into its previous - // sibling within the same container. `merge_block_at` returns - // `NoPreviousBlock` at the first block of a container (a - // top-level paragraph 0 or the first block of a cell / note - // body), making this a no-op there. - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - let Ok(merged_offset) = merge_block_at(ldoc, &focus.block_path()) else { - return; - }; - apply_mutation_and_relayout(&doc_state, ldoc); - post_mutation_sync( - &doc_state, - loro_doc, - cursor_state, - undo_manager, - can_undo, - can_redo, - ); - // TODO(3b-3): recompute page_index from layout after merge. - // Caret lands at the join point in the previous sibling block. - let new_pos = focus.sibling_block(-1, merged_offset); - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); - return; - } - let text = { - let ldoc_guard = loro_doc.read(); - ldoc_guard - .as_ref() - .map(|l| get_block_text_at(l, &focus.block_path())) - .unwrap_or_default() - }; - let prev = prev_grapheme_boundary(&text, focus.byte_offset); - let len = focus.byte_offset - prev; - { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - if delete_text_at(ldoc, &focus.block_path(), prev, len).is_err() { - return; - } - } - { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - apply_mutation_and_relayout(&doc_state, ldoc); - } - post_mutation_sync( - &doc_state, + handle_backspace_key( + focus, loro_doc, + &doc_state, cursor_state, undo_manager, can_undo, can_redo, ); - let new_pos = DocumentPosition { - byte_offset: prev, - ..focus - }; - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); } // ── Forward delete ──────────────────────────────────────────────── Key::Delete => { - handle_delete_key( - focus, - loro_doc, - &doc_state, - cursor_state, - undo_manager, - can_undo, - can_redo, - ); + // An active selection is removed instead of the next grapheme; + // a rejected (cross-container) selection swallows the key. + if matches!( + remove_selection( + loro_doc, + &doc_state, + cursor_state, + undo_manager, + can_undo, + can_redo, + ), + SelectionRemoval::NoSelection + ) { + handle_delete_key( + focus, + loro_doc, + &doc_state, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + } } // ── Arrow / Home / End navigation (mode-aware) ──────────────────── @@ -218,12 +147,21 @@ pub(super) fn make_keydown_handler( | Key::End => { let shift_held = modifiers.shift(); let ldoc_guard = loro_doc.read(); + // Reflow navigation addresses top-level blocks by flat index; + // the paginated path is path-aware so navigation works inside + // table cells and note bodies too (4b.4). let get_text = |idx: usize| { ldoc_guard .as_ref() .map(|l| get_block_text(l, idx)) .unwrap_or_default() }; + let get_text_at = |bp: &loki_doc_model::BlockPath| { + ldoc_guard + .as_ref() + .map(|l| get_block_text_at(l, bp)) + .unwrap_or_default() + }; let new_pos = if view_mode() == ViewMode::Reflow { let width_px = scroll_metrics.peek().client_width; @@ -250,14 +188,17 @@ pub(super) fn make_keydown_handler( }; let Some(layout) = layout_opt else { return }; match &key { - Key::ArrowLeft => navigate_left(&focus, &layout, get_text), - Key::ArrowRight => navigate_right(&focus, &layout, get_text), + Key::ArrowLeft => navigate_left(&focus, &layout, get_text_at), + Key::ArrowRight => navigate_right(&focus, &layout, get_text_at), Key::ArrowUp => navigate_up(&focus, &layout), Key::ArrowDown => navigate_down(&focus, &layout), Key::Home => navigate_home(&focus, &layout), - Key::End => navigate_end(&focus, &layout, get_text), + Key::End => navigate_end(&focus, &layout, get_text_at), _ => None, } + // A move inside a page-spanning paragraph can land on a + // line shown on a different page — re-derive the page. + .map(|np| crate::editing::page_locate::recompute_page_index(&layout, &np)) }; if let Some(np) = new_pos { diff --git a/loki-text/src/routes/editor/editor_keydown_ctrl.rs b/loki-text/src/routes/editor/editor_keydown_ctrl.rs index e0e786bc..778c86d6 100644 --- a/loki-text/src/routes/editor/editor_keydown_ctrl.rs +++ b/loki-text/src/routes/editor/editor_keydown_ctrl.rs @@ -10,12 +10,39 @@ use std::sync::{Arc, Mutex}; use dioxus::prelude::*; use keyboard_types::{Key, Modifiers}; use loki_doc_model::loro_mutation::{delete_text_at, get_block_text, get_block_text_at}; -use loki_doc_model::{StyleId, get_block_style_name, set_block_style, split_block_at}; use super::editor_formatting; use crate::editing::cursor::{CursorState, DocumentPosition, next_grapheme_boundary}; use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; +/// Re-derives the `page_index` of the cursor's anchor and focus from the +/// current paginated layout. Used after undo/redo, which can move (or remove) +/// the caret's paragraph across a page boundary; `recompute_page_index` leaves +/// a position unchanged when its paragraph is no longer in the layout. +fn recompute_cursor_pages( + doc_state: &Arc>, + mut cursor_state: Signal, +) { + let Some(layout) = doc_state + .lock() + .ok() + .and_then(|s| s.paginated_layout.clone()) + else { + return; + }; + let mut cs = cursor_state.write(); + if let Some(f) = cs.focus.clone() { + cs.focus = Some(crate::editing::page_locate::recompute_page_index( + &layout, &f, + )); + } + if let Some(a) = cs.anchor.clone() { + cs.anchor = Some(crate::editing::page_locate::recompute_page_index( + &layout, &a, + )); + } +} + /// Dispatches Ctrl/Meta/Super+key shortcuts. /// /// Handles select-all (`a`), bold (`b`), italic (`i`), underline (`u`), @@ -106,6 +133,8 @@ pub(super) fn handle_ctrl_keys( if let Some(ldoc) = ldoc_guard.as_ref() { apply_mutation_and_relayout(doc_state, ldoc); } + drop(ldoc_guard); + recompute_cursor_pages(doc_state, cursor_state); } "y" => { { @@ -118,6 +147,8 @@ pub(super) fn handle_ctrl_keys( if let Some(ldoc) = ldoc_guard.as_ref() { apply_mutation_and_relayout(doc_state, ldoc); } + drop(ldoc_guard); + recompute_cursor_pages(doc_state, cursor_state); } _ => {} } @@ -169,62 +200,6 @@ pub(super) fn handle_delete_key( }; apply_mutation_and_relayout(doc_state, ldoc); } - // Cursor stays at the same offset after forward delete. - post_mutation_sync( - doc_state, - loro_doc, - cursor_state, - undo_manager, - can_undo, - can_redo, - ); -} - -/// Handles the Enter key: splits the current paragraph at the cursor position. -pub(super) fn handle_enter_key( - focus: DocumentPosition, - loro_doc: Signal>, - doc_state: &Arc>, - mut cursor_state: Signal, - undo_manager: Signal>, - can_undo: Signal, - can_redo: Signal, -) { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - - let nested = !focus.path.is_empty(); - - // Resolve next_style_id for the current block's style before splitting. - // Style inheritance via next_style_id is a top-level concern (named styles - // address top-level paragraphs); nested splits keep the source block's type. - let next_style: Option = if nested { - None - } else { - let style_name = get_block_style_name(ldoc, focus.paragraph_index); - doc_state.lock().ok().and_then(|state| { - state - .document - .as_ref()? - .styles - .paragraph_styles - .get(&StyleId::new(&style_name)) - .and_then(|s| s.next_style_id.clone()) - }) - }; - - if split_block_at(ldoc, &focus.block_path(), focus.byte_offset).is_err() { - return; - } - - // Apply the next_style to the newly created block if one is defined. - if let Some(ref nstyle) = next_style { - let _ = set_block_style(ldoc, focus.paragraph_index + 1, nstyle); - } - - apply_mutation_and_relayout(doc_state, ldoc); post_mutation_sync( doc_state, loro_doc, @@ -233,13 +208,10 @@ pub(super) fn handle_enter_key( can_undo, can_redo, ); - // TODO(3b-3): recompute page_index from layout after split. - // The split inserts the new block right after the source within the same - // container, so the caret moves to the next sibling block at offset 0. - let new_pos = focus.sibling_block(1, 0); - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); + // The caret byte is unchanged, but forward-deleting text from a + // page-spanning paragraph can pull its later lines back a page — re-derive + // the caret's page_index from the fresh layout (plan 4b.1). + super::editor_keydown_text::set_collapsed_cursor(doc_state, cursor_state, focus); } /// Syncs cursor generation, `can_undo`, and `can_redo` after any document mutation. diff --git a/loki-text/src/routes/editor/editor_keydown_enter.rs b/loki-text/src/routes/editor/editor_keydown_enter.rs new file mode 100644 index 00000000..adab3b8f --- /dev/null +++ b/loki-text/src/routes/editor/editor_keydown_enter.rs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Enter-key handling for the document canvas: replace the active selection (if +//! any) with a paragraph break, then split the paragraph at the caret. +//! +//! Extracted from `editor_keydown_ctrl.rs` to keep that file under the 300-line +//! ceiling. Called by [`super::editor_keydown::make_keydown_handler`]. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use loki_doc_model::{StyleId, get_block_style_name, set_block_style, split_block_at}; + +use super::editor_keydown_ctrl::post_mutation_sync; +use super::editor_keydown_text::{delete_selection_in_doc, set_collapsed_cursor}; +use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; + +/// Handles the Enter key: replaces the active selection (if any) with a +/// paragraph break — splitting the current paragraph at the cursor position. +/// +/// Like replace-typing (`handle_character_key`), an active selection is deleted +/// first and the split happens at the collapsed start, all in one relayout + +/// undo entry. A range the model rejects swallows the key. +pub(super) fn handle_enter_key( + focus: DocumentPosition, + loro_doc: Signal>, + doc_state: &Arc>, + cursor_state: Signal, + undo_manager: Signal>, + can_undo: Signal, + can_redo: Signal, +) { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + + // Replace the active selection: delete it in the CRDT first (batched into + // this same relayout/commit), then split at the collapsed start. + let focus = if cursor_state.read().has_selection() { + let Some(pos) = delete_selection_in_doc(ldoc, &cursor_state.read()) else { + return; // rejected range — swallow the key, do not split + }; + pos + } else { + focus + }; + + let nested = !focus.path.is_empty(); + + // Resolve next_style_id for the current block's style before splitting. + // Style inheritance via next_style_id is a top-level concern (named styles + // address top-level paragraphs); nested splits keep the source block's type. + let next_style: Option = if nested { + None + } else { + let style_name = get_block_style_name(ldoc, focus.paragraph_index); + doc_state.lock().ok().and_then(|state| { + state + .document + .as_ref()? + .styles + .paragraph_styles + .get(&StyleId::new(&style_name)) + .and_then(|s| s.next_style_id.clone()) + }) + }; + + if split_block_at(ldoc, &focus.block_path(), focus.byte_offset).is_err() { + return; + } + + // Apply the next_style to the newly created block if one is defined. + if let Some(ref nstyle) = next_style { + let _ = set_block_style(ldoc, focus.paragraph_index + 1, nstyle); + } + + apply_mutation_and_relayout(doc_state, ldoc); + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + // The split inserts the new block right after the source within the same + // container, so the caret moves to the next sibling block at offset 0 + // (its page_index is re-derived from the fresh layout — the new + // paragraph may start on the next page). + let new_pos = focus.sibling_block(1, 0); + set_collapsed_cursor(doc_state, cursor_state, new_pos); +} diff --git a/loki-text/src/routes/editor/editor_keydown_text.rs b/loki-text/src/routes/editor/editor_keydown_text.rs new file mode 100644 index 00000000..fed8d0e6 --- /dev/null +++ b/loki-text/src/routes/editor/editor_keydown_text.rs @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Printable-character and Backspace handling for the document canvas, +//! including selection-aware replacement and removal (audit F6c): typing +//! replaces the active selection, Backspace/Delete remove it. +//! +//! Extracted from `editor_keydown.rs` to keep that file under the 300-line +//! ceiling. Called by [`super::editor_keydown::make_keydown_handler`]. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use loki_doc_model::loro_mutation::{delete_text_at, get_block_text_at, insert_text_at}; +use loki_doc_model::{PathStep, delete_selection_at, merge_block_at}; + +use super::editor_keydown_ctrl::post_mutation_sync; +use crate::editing::cursor::{CursorState, DocumentPosition, prev_grapheme_boundary}; +use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; + +#[cfg(test)] +#[path = "editor_keydown_text_tests.rs"] +mod tests; + +/// Collapses the cursor to `pos` after a mutation, re-deriving its +/// `page_index` from the freshly relaid-out layout — a split, merge, or +/// keystroke near a page boundary can move the caret's paragraph to a +/// different page (plan 4b.1). +pub(super) fn set_collapsed_cursor( + doc_state: &Arc>, + mut cursor_state: Signal, + pos: DocumentPosition, +) { + let layout = doc_state + .lock() + .ok() + .and_then(|s| s.paginated_layout.clone()); + let pos = match layout { + Some(l) => crate::editing::page_locate::recompute_page_index(&l, &pos), + None => pos, + }; + let mut cs = cursor_state.write(); + cs.focus = Some(pos.clone()); + cs.anchor = Some(pos); +} + +/// What [`remove_selection`] did with the active selection. +pub(super) enum SelectionRemoval { + /// No range selection was active — the caller performs its normal + /// single-cursor action. + NoSelection, + /// The selected range was deleted and the cursor collapsed to its start. + Removed, + /// A selection was active but the model rejected the range (endpoints in + /// different containers, or a non-text block inside it). Nothing was + /// mutated; the caller must NOT fall through to a single-cursor edit — + /// swallowing the key beats surprising the user with a stray deletion. + Rejected, +} + +/// The selection endpoint that comes first in document order, using the same +/// `(leaf block index, byte offset)` normalization as +/// [`delete_selection_at`] — so the collapsed cursor keeps the right +/// `page_index`. +fn selection_start(a: &DocumentPosition, b: &DocumentPosition) -> DocumentPosition { + fn leaf(p: &DocumentPosition) -> usize { + match p.path.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => *block, + None => p.paragraph_index, + } + } + if (leaf(a), a.byte_offset) <= (leaf(b), b.byte_offset) { + a.clone() + } else { + b.clone() + } +} + +/// Deletes the active selection in the CRDT only — no relayout or undo +/// commit, so a caller can batch a follow-up insert (typing) or split (Enter) +/// into the same undo entry. +/// +/// Returns the collapsed cursor position, or `None` when there is no active +/// selection or the model rejected the range (nothing mutated either way). +pub(super) fn delete_selection_in_doc( + ldoc: &loro::LoroDoc, + cursor: &CursorState, +) -> Option { + if !cursor.has_selection() { + return None; + } + let (anchor, focus) = (cursor.anchor.clone()?, cursor.focus.clone()?); + let (_, byte) = delete_selection_at( + ldoc, + (&anchor.block_path(), anchor.byte_offset), + (&focus.block_path(), focus.byte_offset), + ) + .ok()?; + Some(DocumentPosition { + byte_offset: byte, + ..selection_start(&anchor, &focus) + }) +} + +/// Removes the active selection (Backspace/Delete over a range): mutates, +/// relayouts, syncs, and collapses the cursor to the range start. +#[allow(clippy::too_many_arguments)] // mirrors the other keydown helpers' signals +pub(super) fn remove_selection( + loro_doc: Signal>, + doc_state: &Arc>, + cursor_state: Signal, + undo_manager: Signal>, + can_undo: Signal, + can_redo: Signal, +) -> SelectionRemoval { + if !cursor_state.read().has_selection() { + return SelectionRemoval::NoSelection; + } + let collapsed = { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return SelectionRemoval::Rejected; + }; + let Some(pos) = delete_selection_in_doc(ldoc, &cursor_state.read()) else { + return SelectionRemoval::Rejected; + }; + apply_mutation_and_relayout(doc_state, ldoc); + pos + }; + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + set_collapsed_cursor(doc_state, cursor_state, collapsed); + SelectionRemoval::Removed +} + +/// Handles a printable character: replaces the active selection (if any), +/// inserts the character, and places the caret after it. +/// +/// The selection delete and the insert share one relayout + commit, so +/// replace-typing is a single undo entry. +#[allow(clippy::too_many_arguments)] // mirrors the other keydown helpers' signals +pub(super) fn handle_character_key( + ch: String, + focus: DocumentPosition, + loro_doc: Signal>, + doc_state: &Arc>, + cursor_state: Signal, + undo_manager: Signal>, + can_undo: Signal, + can_redo: Signal, +) { + let insert_at = { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + let insert_at = if cursor_state.read().has_selection() { + // Replace-typing. A rejected range swallows the keystroke. + let Some(pos) = delete_selection_in_doc(ldoc, &cursor_state.read()) else { + return; + }; + pos + } else { + focus + }; + if insert_text_at(ldoc, &insert_at.block_path(), insert_at.byte_offset, &ch).is_err() { + return; + } + apply_mutation_and_relayout(doc_state, ldoc); + insert_at + }; + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + let new_pos = DocumentPosition { + byte_offset: insert_at.byte_offset + ch.len(), + ..insert_at + }; + set_collapsed_cursor(doc_state, cursor_state, new_pos); +} + +/// Handles Backspace: removes the active selection, or merges with the +/// previous block at offset 0, or deletes the previous grapheme. +#[allow(clippy::too_many_arguments)] // mirrors the other keydown helpers' signals +pub(super) fn handle_backspace_key( + focus: DocumentPosition, + loro_doc: Signal>, + doc_state: &Arc>, + cursor_state: Signal, + undo_manager: Signal>, + can_undo: Signal, + can_redo: Signal, +) { + match remove_selection( + loro_doc, + doc_state, + cursor_state, + undo_manager, + can_undo, + can_redo, + ) { + SelectionRemoval::Removed | SelectionRemoval::Rejected => return, + SelectionRemoval::NoSelection => {} + } + if focus.byte_offset == 0 { + // Backspace-at-start merges this block into its previous sibling + // within the same container. `merge_block_at` returns + // `NoPreviousBlock` at the first block of a container (a top-level + // paragraph 0 or the first block of a cell / note body), making this + // a no-op there. + let merged_offset = { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + let Ok(merged_offset) = merge_block_at(ldoc, &focus.block_path()) else { + return; + }; + apply_mutation_and_relayout(doc_state, ldoc); + merged_offset + }; + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + // Caret lands at the join point in the previous sibling block (its + // page_index is re-derived from the merged layout). + let new_pos = focus.sibling_block(-1, merged_offset); + set_collapsed_cursor(doc_state, cursor_state, new_pos); + return; + } + let prev = { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + let text = get_block_text_at(ldoc, &focus.block_path()); + let prev = prev_grapheme_boundary(&text, focus.byte_offset); + let len = focus.byte_offset - prev; + if delete_text_at(ldoc, &focus.block_path(), prev, len).is_err() { + return; + } + apply_mutation_and_relayout(doc_state, ldoc); + prev + }; + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + let new_pos = DocumentPosition { + byte_offset: prev, + ..focus + }; + set_collapsed_cursor(doc_state, cursor_state, new_pos); +} diff --git a/loki-text/src/routes/editor/editor_keydown_text_tests.rs b/loki-text/src/routes/editor/editor_keydown_text_tests.rs new file mode 100644 index 00000000..62956bce --- /dev/null +++ b/loki-text/src/routes/editor/editor_keydown_text_tests.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use loki_doc_model::PathStep; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document}; +use loki_doc_model::loro_mutation::get_block_text; + +use super::{delete_selection_in_doc, selection_start}; +use crate::editing::cursor::{CursorState, DocumentPosition}; + +fn para(s: &str) -> Block { + Block::Para(vec![Inline::Str(s.into())]) +} + +fn loro_with_paras(texts: &[&str]) -> loro::LoroDoc { + let mut doc = Document::new(); + doc.sections[0].blocks = texts.iter().map(|t| para(t)).collect(); + document_to_loro(&doc).unwrap() +} + +fn selection(anchor: DocumentPosition, focus: DocumentPosition) -> CursorState { + let mut cs = CursorState::new(); + cs.anchor = Some(anchor); + cs.focus = Some(focus); + cs +} + +#[test] +fn selection_start_orders_top_level_positions() { + let a = DocumentPosition::top_level(0, 2, 1); + let b = DocumentPosition::top_level(1, 0, 9); + // Block 0 precedes block 2 regardless of byte offsets or endpoint order. + assert_eq!(selection_start(&a, &b), b); + assert_eq!(selection_start(&b, &a), b); +} + +#[test] +fn selection_start_orders_by_byte_within_one_block() { + let a = DocumentPosition::top_level(0, 1, 7); + let b = DocumentPosition::top_level(0, 1, 3); + assert_eq!(selection_start(&a, &b), b); +} + +#[test] +fn selection_start_orders_nested_positions_by_leaf_block() { + let mk = |block: usize, byte: usize| DocumentPosition { + page_index: 0, + paragraph_index: 1, + byte_offset: byte, + path: vec![PathStep::Cell { cell: 0, block }], + }; + assert_eq!(selection_start(&mk(1, 0), &mk(0, 5)), mk(0, 5)); +} + +#[test] +fn no_selection_deletes_nothing() { + let loro = loro_with_paras(&["hello"]); + let mut cs = CursorState::new(); + cs.anchor = Some(DocumentPosition::top_level(0, 0, 2)); + cs.focus = cs.anchor.clone(); // point cursor, not a range + assert_eq!(delete_selection_in_doc(&loro, &cs), None); + assert_eq!(get_block_text(&loro, 0), "hello"); +} + +#[test] +fn same_block_selection_collapses_to_range_start() { + let loro = loro_with_paras(&["hello world"]); + // Focus before anchor (backwards drag): bytes 3..9 selected. + let cs = selection( + DocumentPosition::top_level(0, 0, 9), + DocumentPosition::top_level(0, 0, 3), + ); + let pos = delete_selection_in_doc(&loro, &cs).unwrap(); + assert_eq!(pos, DocumentPosition::top_level(0, 0, 3)); + assert_eq!(get_block_text(&loro, 0), "helld"); +} + +#[test] +fn cross_block_selection_keeps_the_start_pages_index() { + let loro = loro_with_paras(&["first", "second", "third"]); + // Anchor on (page 3, block 2), focus on (page 1, block 0): the collapsed + // cursor is the ordered start — focus's page. + let cs = selection( + DocumentPosition::top_level(3, 2, 3), + DocumentPosition::top_level(1, 0, 2), + ); + let pos = delete_selection_in_doc(&loro, &cs).unwrap(); + assert_eq!(pos, DocumentPosition::top_level(1, 0, 2)); + assert_eq!(get_block_text(&loro, 0), "fird"); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(rebuilt.sections[0].blocks.len(), 1); +} + +#[test] +fn rejected_cross_container_selection_mutates_nothing() { + let loro = loro_with_paras(&["top level"]); + // Anchor at top level, focus inside a (nonexistent, but structurally + // nested) cell path — different containers, rejected before resolution. + let cs = selection( + DocumentPosition::top_level(0, 0, 1), + DocumentPosition { + page_index: 0, + paragraph_index: 0, + byte_offset: 1, + path: vec![PathStep::Cell { cell: 0, block: 0 }], + }, + ); + assert_eq!(delete_selection_in_doc(&loro, &cs), None); + assert_eq!(get_block_text(&loro, 0), "top level"); +} diff --git a/loki-text/src/routes/editor/editor_path_sync.rs b/loki-text/src/routes/editor/editor_path_sync.rs index 2cf041e2..14585f4a 100644 --- a/loki-text/src/routes/editor/editor_path_sync.rs +++ b/loki-text/src/routes/editor/editor_path_sync.rs @@ -16,8 +16,10 @@ use dioxus::prelude::*; use super::editor_state::StyleDraft; use crate::editing::cursor::CursorState; +use crate::editing::saved_state::SavedStateHandle; use crate::editing::state::DocumentState; use crate::sessions::{DocSession, DocSessions}; +use crate::tabs::OpenTab; /// All per-document signals reset or restored on tab switch, grouped to keep /// the [`sync_path_and_reset`] signature manageable. @@ -34,6 +36,7 @@ pub(super) struct PathSyncSignals { pub editing_style_draft: Signal>, pub save_message: Signal>, pub baseline_gen: Signal, + pub saved_state: Signal, } /// Synchronises `path_signal` with the `path` prop. On change, stashes the @@ -43,6 +46,7 @@ pub(super) fn sync_path_and_reset( path: &str, path_signal: &mut Signal, doc_state: &Arc>, + tabs: Signal>, mut sessions: Signal, sig: &mut PathSyncSignals, ) { @@ -57,7 +61,7 @@ pub(super) fn sync_path_and_reset( ); path_signal.set(path.to_owned()); - stash_outgoing(¤t, doc_state, &mut sessions, sig); + stash_outgoing(¤t, doc_state, tabs, &mut sessions, sig); // Transient UI state never carries across documents. sig.dismiss_font_warning.set(false); @@ -72,14 +76,18 @@ pub(super) fn sync_path_and_reset( } } -/// Move the outgoing document's live state into the session map (no-op when -/// no document ever finished loading in this editor). +/// Move the outgoing document's live state into the session map. No-op when no +/// document ever finished loading, or when no tab points at `old_path` any more +/// — a closed (or Save-As-repointed) tab must not resurrect its old state on +/// reopen. The liveness guard lives here so both call sites (path change and +/// the unmount hook) are covered uniformly. /// /// Called on path change (doc → doc tab switch) and from the unmount hook in /// `EditorInner` (doc → Home navigation unmounts the editor route). pub(super) fn stash_outgoing( old_path: &str, doc_state: &Arc>, + tabs: Signal>, sessions: &mut Signal, sig: &mut PathSyncSignals, ) { @@ -87,6 +95,9 @@ pub(super) fn stash_outgoing( return; // nothing loaded — nothing to stash }; let undo_manager = sig.undo_manager.write().take(); + if !tabs.peek().iter().any(|t| t.path == old_path) { + return; // tab closed or Save-As-repointed — do not resurrect on reopen + } let (document, generation, page_count, paginated_layout, page_width_px, page_height_px) = match doc_state.lock() { Ok(s) => ( @@ -115,6 +126,7 @@ pub(super) fn stash_outgoing( page_height_px, cursor: sig.cursor_state.peek().clone(), baseline_gen: *sig.baseline_gen.peek(), + saved_state: sig.saved_state.peek().clone(), can_undo: *sig.can_undo.peek(), can_redo: *sig.can_redo.peek(), }, @@ -148,6 +160,7 @@ pub(super) fn restore_session( sig.can_undo.set(session.can_undo); sig.can_redo.set(session.can_redo); sig.baseline_gen.set(session.baseline_gen); + sig.saved_state.set(session.saved_state); } /// Reset all per-document state ahead of a fresh `load_document` pass. @@ -167,4 +180,5 @@ fn reset_for_fresh_load(doc_state: &Arc>, sig: &mut PathSyn sig.current_page.set(1); sig.can_undo.set(false); sig.can_redo.set(false); + sig.saved_state.set(SavedStateHandle::new()); } diff --git a/loki-text/src/routes/editor/editor_pointer.rs b/loki-text/src/routes/editor/editor_pointer.rs index 2f849cac..fe5ea511 100644 --- a/loki-text/src/routes/editor/editor_pointer.rs +++ b/loki-text/src/routes/editor/editor_pointer.rs @@ -61,6 +61,7 @@ pub(super) fn make_mousemove_handler( mut cursor_state: Signal, page_gap_px: f32, view_mode: Signal, + zoom_percent: Signal, ) -> impl FnMut(MouseEvent) { move |evt: MouseEvent| { // Reflow drag-select is handled per-tile (clean tile-local coordinates); @@ -94,8 +95,11 @@ pub(super) fn make_mousemove_handler( ) }; let Some(layout) = layout_opt else { return }; + // Tiles are painted at `zoom` scale and centred at that painted width, so + // the centring origin and the hit-test both use the zoom factor. + let zoom = zoom_percent() as f32 / 100.0; let viewport = Viewport::new(scroll_metrics.peek().client_width); - let x_off = viewport.centred_origin_x(page_width_px); + let x_off = viewport.centred_origin_x(page_width_px * zoom); let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); if let Some(p) = hit_test_document( cx, @@ -106,6 +110,7 @@ pub(super) fn make_mousemove_handler( page_width_px, page_height_px, page_gap_px, + zoom, ) { cursor_state.write().focus = Some(p); } @@ -123,6 +128,7 @@ pub(super) fn make_touchmove_handler( page_gap_px: f32, view_mode: Signal, scroll_metrics: Signal, + zoom_percent: Signal, ) -> impl FnMut(TouchEvent) { move |evt: TouchEvent| { let Some(mut ts) = touch_state() else { return }; @@ -162,8 +168,9 @@ pub(super) fn make_touchmove_handler( ) }; layout_opt.and_then(|layout| { + let zoom = zoom_percent() as f32 / 100.0; let viewport = Viewport::new(scroll_metrics.peek().client_width); - let x_off = viewport.centred_origin_x(page_width_px); + let x_off = viewport.centred_origin_x(page_width_px * zoom); let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); hit_test_document( start.0, @@ -174,6 +181,7 @@ pub(super) fn make_touchmove_handler( page_width_px, page_height_px, page_gap_px, + zoom, ) .map(|p| (p.page_index, p.paragraph_index, p.byte_offset)) }) @@ -205,6 +213,7 @@ pub(super) fn make_touchend_handler( page_gap_px: f32, view_mode: Signal, scroll_metrics: Signal, + zoom_percent: Signal, ) -> impl FnMut(TouchEvent) { move |_evt: TouchEvent| { let Some(ts) = touch_state() else { return }; @@ -242,8 +251,9 @@ pub(super) fn make_touchend_handler( ) }; if let Some(layout) = layout_opt { + let zoom = zoom_percent() as f32 / 100.0; let viewport = Viewport::new(scroll_metrics.peek().client_width); - let x_off = viewport.centred_origin_x(page_width_px); + let x_off = viewport.centred_origin_x(page_width_px * zoom); let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); if let Some(pos) = hit_test_document( ts.start_pos.0, @@ -254,6 +264,7 @@ pub(super) fn make_touchend_handler( page_width_px, page_height_px, page_gap_px, + zoom, ) { let loro_cursor = loro_doc.read().as_ref().and_then(|ldoc| { derive_loro_cursor(ldoc, pos.paragraph_index, pos.byte_offset) diff --git a/loki-text/src/routes/editor/editor_ribbon.rs b/loki-text/src/routes/editor/editor_ribbon.rs index 352b03ab..34c62c0e 100644 --- a/loki-text/src/routes/editor/editor_ribbon.rs +++ b/loki-text/src/routes/editor/editor_ribbon.rs @@ -3,8 +3,7 @@ //! Write tab ribbon content for the document editor (was "Home", Spec 04 D1). //! //! [`write_tab_content`] returns the `Element` passed to [`AtRibbon::tab_content`]. -//! Separating it here keeps `editor_inner.rs` under the 300-line ceiling and -//! makes ribbon content easy to extend independently. +//! Separating it here keeps `editor_inner.rs` under the 300-line ceiling. use std::sync::{Arc, Mutex}; @@ -19,11 +18,9 @@ use loro::LoroDoc; use crate::editing::cursor::CursorState; use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; -use crate::new_document::is_untitled; use super::editor_formatting; use super::editor_keydown_ctrl::post_mutation_sync; -use super::editor_save::save_document_to_path; use super::editor_state::StyleDraft; use super::editor_style_catalog::get_catalog_style; use super::editor_style_editor::style_to_draft; @@ -53,15 +50,13 @@ pub(super) fn write_tab_content( subscript_active: Signal, current_style_name: String, mut is_style_picker_open: Signal, - path_signal: Signal, - mut save_message: Signal>, + mut save_request: Signal, + is_dirty: Signal, mut editing_style_draft: Signal>, save_as: Callback<()>, save_as_template: Callback<()>, - mut baseline_gen: Signal, ) -> Element { // One Arc clone per button — cheap reference-count increment. - let ds_save = Arc::clone(doc_state); let ds_para = Arc::clone(doc_state); let current_style_name_para = current_style_name.clone(); let ds_undo = Arc::clone(doc_state); @@ -82,24 +77,15 @@ pub(super) fn write_tab_content( AtRibbonIconButton { aria_label: fl!("ribbon-save-aria"), is_active: false, - is_disabled: false, + // Disabled when clean (plan 4b.3); untitled reads as dirty. + is_disabled: !is_dirty(), on_click: move |_| { - let path = path_signal(); - // An untitled document has no file yet — route to Save As. - if is_untitled(&path) { - save_as.call(()); - return; - } - let msg = match save_document_to_path(&path, &ds_save) { - Ok(()) => { - // Mark the current generation as clean so the tab's - // unsaved-changes indicator clears. - baseline_gen.set(cursor_state.peek().document_generation); - fl!("editor-save-success") - } - Err(e) => fl!("editor-save-error", reason = e.to_string()), - }; - save_message.set(Some(msg)); + // Route through the shared save handler (the Ctrl+S effect + // in `EditorInner`), which owns the untitled→Save-As + // routing, the clean baseline, the status message, and + // post-save history compaction. + let next = save_request.peek().wrapping_add(1); + save_request.set(next); }, AtIcon { path_d: LUCIDE_SAVE.to_string() } } diff --git a/loki-text/src/routes/editor/editor_save_callbacks.rs b/loki-text/src/routes/editor/editor_save_callbacks.rs index 22ce1011..9b98f8a7 100644 --- a/loki-text/src/routes/editor/editor_save_callbacks.rs +++ b/loki-text/src/routes/editor/editor_save_callbacks.rs @@ -2,9 +2,8 @@ //! Save-dialog callbacks extracted from `editor_inner` (an oversized file). //! -//! Currently hosts the **Save as Template** flow, which is self-contained (it -//! does not repoint the tab or navigate). Save As stays in `editor_inner` -//! because it also touches tab/recents/navigation state. +//! Hosts the **Save As** flow (repoints the tab, records recents, navigates +//! to the new path) and the self-contained **Save as Template** flow. use std::sync::{Arc, Mutex}; @@ -12,13 +11,85 @@ use dioxus::prelude::*; use loki_file_access::{FilePicker, SaveOptions}; use loki_i18n::fl; -use super::editor_save::export_template_to_token; +use super::editor_save::{export_document_to_token, export_template_to_token}; use crate::editing::state::DocumentState; +use crate::recent_documents::RecentDocuments; +use crate::routes::Route; +use crate::tabs::OpenTab; use crate::utils::display_title_from_path; +/// MIME type used by the Save As flow (Word `.docx`). +const DOCX_MIME: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + /// MIME type used by the "Save as Template" flow (Word `.dotx`). const DOTX_MIME: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.template"; +/// Builds the Save As callback: picks a destination via the platform save +/// dialog, exports DOCX to it, then repoints the current tab at the new file, +/// records it in recents, and navigates to the new path (the editor reloads +/// it and re-establishes a clean baseline). This is the only way to persist +/// an untitled document. +pub(super) fn use_save_as_callback( + doc_state: Arc>, + save_message: Signal>, + baseline_gen: Signal, + path_signal: Signal, +) -> Callback<()> { + let tabs = use_context::>>(); + let recent_docs = use_context::>(); + let navigator = use_navigator(); + use_callback(move |_: ()| { + let doc_state = Arc::clone(&doc_state); + let mut tabs = tabs; + let mut recent = recent_docs; + let mut save_message = save_message; + let mut baseline_gen = baseline_gen; + let nav = navigator; + let cur_path = path_signal.peek().clone(); + let suggested = { + let stem = display_title_from_path(&cur_path); + format!("{stem}.docx") + }; + spawn(async move { + let picker = FilePicker::new(); + let opts = SaveOptions { + mime_type: Some(DOCX_MIME.to_string()), + suggested_name: Some(suggested), + }; + match picker.pick_file_to_save(opts).await { + Ok(Some(token)) => match export_document_to_token(&token, &doc_state) { + Ok(()) => { + let new_path = token.serialize(); + let new_title = display_title_from_path(&new_path); + { + let mut t = tabs.write(); + if let Some(tab) = t.iter_mut().find(|tab| tab.path == cur_path) { + tab.path = new_path.clone(); + tab.title = new_title.clone(); + tab.is_dirty = false; + } + } + recent.write().record(new_path.clone(), new_title); + recent.read().save(); + save_message.set(Some(fl!("editor-save-success"))); + // Navigate to the saved file; the editor reloads it and + // re-establishes a clean baseline. + baseline_gen.set(0); + nav.push(Route::Editor { path: new_path }); + } + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + } + }, + Ok(None) => { /* user cancelled — no-op */ } + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + } + } + }); + }) +} + /// Builds the "Save as Template" callback: exports the current document as a /// Word template (`.dotx`) to a picked destination. Unlike Save As it does not /// repoint the tab — the template is a separate artifact. @@ -53,3 +124,75 @@ pub(super) fn use_save_as_template_callback( }); }) } + +/// Signals the Ctrl+S flow reads and writes, grouped for the hook call. +pub(super) struct CtrlSCtx { + pub doc_state: Arc>, + pub path_signal: Signal, + pub save_request: Signal, + pub save_as: Callback<()>, + pub baseline_gen: Signal, + pub cursor_state: Signal, + pub loro_doc: Signal>, + pub undo_manager: Signal>, + pub saved_state: Signal, + pub can_undo: Signal, + pub can_redo: Signal, + pub save_message: Signal>, +} + +/// The Ctrl+S save effect: the keydown handler bumps `save_request`; the save +/// runs here, where the tab/recents context is reachable. Untitled documents +/// route to Save As. On success the clean baseline + undo checkpoint are +/// recorded and the CRDT history is compacted (see `editor_compact`). +pub(super) fn use_ctrl_s_save(ctx: CtrlSCtx) { + let CtrlSCtx { + doc_state, + path_signal, + save_request, + save_as, + mut baseline_gen, + cursor_state, + loro_doc, + mut undo_manager, + saved_state, + can_undo, + can_redo, + mut save_message, + } = ctx; + use_effect(move || { + let n = save_request(); // subscribe — fires on each Ctrl+S + if n == 0 { + return; // initial value — nothing requested yet + } + let path = path_signal.peek().clone(); + if crate::new_document::is_untitled(&path) { + save_as.call(()); + return; + } + let msg = match super::editor_save::save_document_to_path(&path, &doc_state) { + Ok(()) => { + baseline_gen.set(cursor_state.peek().document_generation); + // Record the undo-stack clean checkpoint: undoing back to + // this depth means the document equals the file again. + if let Some(um) = undo_manager.write().as_mut() { + let _ = um.record_new_checkpoint(); + } + saved_state.peek().mark_saved(); + // The file is now the durable state — bound the CRDT history + // memory (memory-audit Finding 6; see editor_compact.rs). + super::editor_compact::compact_after_save( + loro_doc, + undo_manager, + saved_state, + can_undo, + can_redo, + &doc_state, + ); + fl!("editor-save-success") + } + Err(e) => fl!("editor-save-error", reason = e.to_string()), + }; + save_message.set(Some(msg)); + }); +} diff --git a/loki-text/src/routes/editor/editor_state.rs b/loki-text/src/routes/editor/editor_state.rs index 0fb100d0..cafc30b2 100644 --- a/loki-text/src/routes/editor/editor_state.rs +++ b/loki-text/src/routes/editor/editor_state.rs @@ -15,6 +15,7 @@ use loki_renderer::ViewMode; use super::editor_scrollbar::{CanvasMounted, ScrollMetrics, ThumbDrag}; use crate::editing::cursor::CursorState; +use crate::editing::saved_state::SavedStateHandle; use crate::editing::state::DocumentState; use crate::editing::touch::TouchInteractionState; @@ -114,12 +115,13 @@ pub(super) struct EditorState { pub superscript_active: Signal, pub subscript_active: Signal, /// Loro undo manager — `None` until the document loads. - /// - /// // TODO(undo-dirty): `can_undo` does not track whether the document is - /// saved relative to the undo stack. When a Save action is implemented, - /// call `UndoManager::record_new_checkpoint()` to mark the clean state so - /// the ribbon Save button can be disabled when there is nothing to save. pub undo_manager: Signal>, + /// Clean-checkpoint tracker paired with `undo_manager` (plan 4b.3): save + /// records the undo-stack depth, so undoing back to the saved state + /// clears the tab's dirty indicator. Replaced together with the manager + /// (load, post-save compaction swap) and stashed with it in the tab's + /// `DocSession`. + pub saved_state: Signal, /// Whether Ctrl+Z is currently applicable (derived from `undo_manager`). pub can_undo: Signal, /// Whether Ctrl+Y / Ctrl+Shift+Z is currently applicable. @@ -128,6 +130,14 @@ pub(super) struct EditorState { pub is_style_picker_open: Signal, /// Style catalog editor draft — `Some` when the editor panel is open. pub editing_style_draft: Signal>, + /// Paginated render zoom, in percent (100 = 1:1). Cycled by the status + /// bar's zoom badge; scales page tiles + paint together (4c.5 / F6d). + pub zoom_percent: Signal, + /// Whether the document has unsaved changes (mirror of the tab's dirty + /// indicator; plan 4b.3). Drives the ribbon Save button's disabled state — + /// a clean *titled* document has nothing to save. Untitled documents stay + /// dirty (Save routes to Save As), so their Save button stays enabled. + pub is_dirty: Signal, /// Last save result message (`None` = nothing to show). pub save_message: Signal>, /// Monotonic counter bumped by the Ctrl+S handler. `EditorInner` watches it @@ -189,10 +199,13 @@ pub(super) fn use_editor_state() -> EditorState { superscript_active: use_signal(|| false), subscript_active: use_signal(|| false), undo_manager: use_signal(|| None), + saved_state: use_signal(SavedStateHandle::new), can_undo: use_signal(|| false), can_redo: use_signal(|| false), is_style_picker_open: use_signal(|| false), editing_style_draft: use_signal(|| None), + zoom_percent: use_signal(|| 100_u32), + is_dirty: use_signal(|| false), save_message: use_signal(|| None), save_request: use_signal(|| 0_u32), active_ribbon_tab: use_signal(|| 0_usize), diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index fd2593b3..b7082143 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -10,15 +10,21 @@ //! All editing logic lives in [`editor_inner::EditorInner`]. mod editor_canvas; +mod editor_canvas_loading; +mod editor_compact; +mod editor_dirty; mod editor_docked_panels; mod editor_error_view; mod editor_font_warning; +mod editor_format_range; mod editor_formatting; mod editor_inner; mod editor_insert; mod editor_insert_panel; mod editor_keydown; mod editor_keydown_ctrl; +mod editor_keydown_enter; +mod editor_keydown_text; mod editor_language_panel; mod editor_layout_task; mod editor_load; diff --git a/loki-text/src/routes/home.rs b/loki-text/src/routes/home.rs index 06408dc2..0447633d 100644 --- a/loki-text/src/routes/home.rs +++ b/loki-text/src/routes/home.rs @@ -9,14 +9,18 @@ //! All user-visible strings come from `loki_i18n::fl!()` — no hardcoded //! English literals. -use appthere_ui::{AtHomeTab, BuiltinTemplate, RecentDocument}; +use appthere_ui::{AtConfirmDialog, AtHomeTab, BuiltinTemplate, RecentDocument}; use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, PickOptions, PickerError, SaveOptions}; use loki_i18n::fl; +use super::home_util::{ + close_tab_for_path, is_template_name, push_new_tab, push_or_switch_tab, suggested_copy_name, +}; use crate::new_document::{new_blank_tab, new_import_tab, new_template_tab}; use crate::recent_documents::RecentDocuments; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -71,71 +75,6 @@ fn make_templates() -> Vec { ] } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Push `path` as a new open tab, or switch to its existing tab if already open. -fn push_or_switch_tab(mut tabs: Signal>, mut active_tab: Signal, path: String) { - let title = display_title_from_path(&path); - let existing = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = existing { - *active_tab.write() = idx + 1; - } else { - tabs.write().push(OpenTab { - title, - path, - is_dirty: false, - is_discarded: false, - }); - // TODO(tabs): Replace router-driven navigation with tab-driven navigation. - *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home - } -} - -/// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. -fn close_tab_for_path(mut tabs: Signal>, mut active_tab: Signal, path: &str) { - let removed = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = removed { - tabs.write().remove(idx); - // active_tab is 1-based (index 0 = Home). Reset to Home if the active - // selection pointed at or past the removed tab to avoid a stale index. - if *active_tab.read() > idx { - *active_tab.write() = 0; - } - } -} - -/// True if `name` has a template extension (Word `.dotx`/`.dotm` or -/// LibreOffice `.ott`/`.ots`). Templates open as fresh, detached documents. -fn is_template_name(name: &str) -> bool { - name.rsplit('.') - .next() - .map(|e| e.to_ascii_lowercase()) - .is_some_and(|e| matches!(e.as_str(), "dotx" | "dotm" | "ott" | "ots")) -} - -/// Push `tab` as a new open tab (last position) and return its path so the -/// caller can navigate to the editor. -fn push_new_tab( - mut tabs: Signal>, - mut active_tab: Signal, - tab: OpenTab, -) -> String { - let path = tab.path.clone(); - tabs.write().push(tab); - *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home - path -} - -/// Build a " Copy." filename from a token's display name. -fn suggested_copy_name(token: &FileAccessToken) -> String { - let name = token.display_name(); - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), - _ => format!("{name} Copy"), - } -} - // ── Home ────────────────────────────────────────────────────────────────────── /// Home screen component — wraps [`AtHomeTab`] with Loki Text data and routing. @@ -148,6 +87,7 @@ pub fn Home() -> Element { let tabs = use_context::>>(); let active_tab = use_context::>(); + let doc_sessions = use_context::>(); let mut recent_docs = use_context::>(); // Holds the last file-picker error message, if any. @@ -240,15 +180,23 @@ pub fn Home() -> Element { // ── on_recent_delete ────────────────────────────────────────────────────── // - // `path` is a serialised FileAccessToken, not a filesystem path. Decode it, - // delete the underlying file via the capability token, close any open tab - // for it, then drop the recents entry. + // Deleting a file is destructive, so the menu action only *requests* it: + // the confirmation dialog below performs the deletion on confirm (4c.1). + let mut pending_delete: Signal> = use_signal(|| None); let on_recent_delete = move |idx: usize| { - let mut err_sig = pick_error; - let Some(path) = recent_docs.read().entries.get(idx).map(|e| e.path.clone()) else { - return; - }; + let entry = recent_docs + .read() + .entries + .get(idx) + .map(|e| (e.path.clone(), e.title.clone())); + if let Some(path_and_title) = entry { + pending_delete.set(Some(path_and_title)); + } + }; + // Confirmed deletion: delete the file, close its tab + stashed session, drop recents. + let mut delete_recent = move |path: String| { + let mut err_sig = pick_error; match FileAccessToken::deserialize(&path) { Ok(token) => { if let Err(e) = token.delete() { @@ -262,8 +210,7 @@ pub fn Home() -> Element { } } - close_tab_for_path(tabs, active_tab, &path); - // TODO(ux): Add a confirmation dialog before destructive deletion. + close_tab_for_path(tabs, active_tab, doc_sessions, &path); recent_docs.write().remove(&path); recent_docs.read().save(); }; @@ -337,8 +284,12 @@ pub fn Home() -> Element { .collect(); rsx! { - AtHomeTab { - templates: make_templates(), + // position: relative anchors the AtConfirmDialog overlay over the + // home area (AtHomeTab sizes itself to the viewport minus tab bar). + div { + style: "position: relative;", + AtHomeTab { + templates: make_templates(), recent_documents: recent_list, templates_label: fl!("home-templates-heading"), recent_label: fl!("home-recent-heading"), @@ -350,15 +301,31 @@ pub fn Home() -> Element { recent_remove_label: fl!("home-recent-menu-remove"), recent_delete_label: fl!("home-recent-menu-delete"), recent_open_copy_label: fl!("home-recent-menu-open-copy"), - pick_error: pick_error, - on_template_select: on_template_select, - // TODO(browse-templates): open a template browser dialog. - on_browse_templates: |_| {}, - on_recent_open: on_recent_open, - on_open_file: on_open_file, - on_recent_remove: on_recent_remove, - on_recent_delete: on_recent_delete, - on_recent_open_copy: on_recent_open_copy, + pick_error: pick_error, + on_template_select: on_template_select, + // TODO(browse-templates): open a template browser dialog. + on_browse_templates: |_| {}, + on_recent_open: on_recent_open, + on_open_file: on_open_file, + on_recent_remove: on_recent_remove, + on_recent_delete: on_recent_delete, + on_recent_open_copy: on_recent_open_copy, + } + + // ── Delete confirmation (ADR-0013 boundary mount) ───────────────── + {pending_delete.read().clone().map(|(path, title)| rsx! { + AtConfirmDialog { + title: fl!("home-delete-confirm-title"), + message: fl!("home-delete-confirm-message", title = title), + confirm_label: fl!("home-delete-confirm-confirm"), + cancel_label: fl!("home-delete-confirm-cancel"), + on_confirm: move |_| { + pending_delete.set(None); + delete_recent(path.clone()); + }, + on_cancel: move |_| pending_delete.set(None), + } + })} } } } diff --git a/loki-text/src/routes/home_util.rs b/loki-text/src/routes/home_util.rs new file mode 100644 index 00000000..e301fb81 --- /dev/null +++ b/loki-text/src/routes/home_util.rs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tab and file-token helpers for the Home route. +//! +//! Extracted from `home.rs` to keep that file under the 300-line ceiling. + +use dioxus::prelude::*; +use loki_file_access::FileAccessToken; + +use crate::sessions::DocSessions; +use crate::tabs::OpenTab; +use crate::utils::display_title_from_path; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Push `path` as a new open tab, or switch to its existing tab if already open. +pub(super) fn push_or_switch_tab( + mut tabs: Signal>, + mut active_tab: Signal, + path: String, +) { + let title = display_title_from_path(&path); + let existing = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = existing { + *active_tab.write() = idx + 1; + } else { + tabs.write().push(OpenTab { + title, + path, + is_dirty: false, + is_discarded: false, + }); + // TODO(tabs): Replace router-driven navigation with tab-driven navigation. + *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home + } +} + +/// Close any open tab whose `path` matches `path`, resetting the active tab to +/// Home when the closed (or a now-shifted) tab was selected, and drop any +/// stashed editing session for that path. +/// +/// The session must be dropped here (not only in the shell's tab-close button): +/// deleting a file from the recents list while it is open, or with a session +/// stashed from an earlier tab switch, would otherwise leak the whole +/// `LoroDoc`/layout in the map — and a later file created at the same token +/// key would restore the deleted document's content instead of loading fresh. +pub(super) fn close_tab_for_path( + mut tabs: Signal>, + mut active_tab: Signal, + mut sessions: Signal, + path: &str, +) { + let removed = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = removed { + tabs.write().remove(idx); + // active_tab is 1-based (index 0 = Home). Reset to Home if the active + // selection pointed at or past the removed tab to avoid a stale index. + if *active_tab.read() > idx { + *active_tab.write() = 0; + } + } + // Drop the stashed session regardless of whether a tab was open — a session + // can outlive its tab (stashed on tab switch, then the tab closed). + sessions.write().remove(path); +} + +/// True if `name` has a template extension (Word `.dotx`/`.dotm` or +/// LibreOffice `.ott`/`.ots`). Templates open as fresh, detached documents. +pub(super) fn is_template_name(name: &str) -> bool { + name.rsplit('.') + .next() + .map(|e| e.to_ascii_lowercase()) + .is_some_and(|e| matches!(e.as_str(), "dotx" | "dotm" | "ott" | "ots")) +} + +/// Push `tab` as a new open tab (last position) and return its path so the +/// caller can navigate to the editor. +pub(super) fn push_new_tab( + mut tabs: Signal>, + mut active_tab: Signal, + tab: OpenTab, +) -> String { + let path = tab.path.clone(); + tabs.write().push(tab); + *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home + path +} + +/// Build a " Copy." filename from a token's display name. +pub(super) fn suggested_copy_name(token: &FileAccessToken) -> String { + let name = token.display_name(); + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), + _ => format!("{name} Copy"), + } +} diff --git a/loki-text/src/routes/mod.rs b/loki-text/src/routes/mod.rs index b4ed9cd1..08914bdc 100644 --- a/loki-text/src/routes/mod.rs +++ b/loki-text/src/routes/mod.rs @@ -13,6 +13,7 @@ pub mod editor; pub mod home; +mod home_util; pub mod shell; use dioxus::prelude::*; diff --git a/loki-text/src/routes/shell.rs b/loki-text/src/routes/shell.rs index f6455881..ea5e27ff 100644 --- a/loki-text/src/routes/shell.rs +++ b/loki-text/src/routes/shell.rs @@ -17,14 +17,65 @@ //! ``` use appthere_ui::tokens; -use appthere_ui::{AtDocumentTabData, AtTabBar, use_safe_area}; +use appthere_ui::{AtConfirmDialog, AtDocumentTabData, AtTabBar, use_safe_area}; use dioxus::prelude::*; +use dioxus_router::Navigator; use loki_i18n::fl; use crate::routes::Route; use crate::sessions::DocSessions; use crate::tabs::OpenTab; +/// Closes the tab at 1-based tab-bar index `idx`: drops its stashed editing +/// session (so a later reopen loads fresh from disk instead of resurrecting +/// discarded unsaved edits) and fixes up the active tab / route. +fn close_tab( + idx: usize, + mut tabs: Signal>, + mut active_tab: Signal, + mut doc_sessions: Signal, + navigator: Navigator, +) { + let vec_idx = idx - 1; + // Guard: idx is captured at event time; a rapid second close (or a close + // confirmed after the list changed) must not index out of bounds. + if vec_idx >= tabs.read().len() { + return; + } + let current_active = *active_tab.read(); + + let closed_path = tabs.read().get(vec_idx).map(|t| t.path.clone()); + if let Some(p) = closed_path { + doc_sessions.write().remove(&p); + } + + tabs.write().remove(vec_idx); + let new_len = tabs.read().len(); + + if new_len == 0 { + // No documents remain — go Home. + *active_tab.write() = 0; + navigator.push(Route::Home {}); + } else if idx == current_active { + // Closed the active tab — activate the nearest remaining tab. + // Prefer the tab to the left; fall back to the first tab. + let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; + *active_tab.write() = new_active; + if let Some(tab) = tabs.read().get(new_active - 1) { + navigator.push(Route::Editor { + path: tab.path.clone(), + }); + } + } else if idx < current_active { + // Closed a tab to the LEFT of the active tab — the Vec shifted so the + // active document's index decrements by 1. Do NOT navigate: the + // displayed document is unchanged. + *active_tab.write() = current_active - 1; + } + // Closing a tab to the RIGHT of the active tab: no index change and no + // navigation — displayed document is unchanged. +} + /// Persistent application shell. /// /// Reads `Signal>` and `Signal` from Dioxus context @@ -36,10 +87,13 @@ use crate::tabs::OpenTab; /// space below the tab bar. #[component] pub fn Shell() -> Element { - let mut tabs = use_context::>>(); + let tabs = use_context::>>(); let mut active_tab = use_context::>(); - let mut doc_sessions = use_context::>(); + let doc_sessions = use_context::>(); let navigator = use_navigator(); + // A dirty tab awaiting close confirmation: `(tab-bar index, title)`. + // While `Some`, the confirmation dialog overlays the shell (plan 4b.6). + let mut pending_close: Signal> = use_signal(|| None); // Safe-area insets are set by android_main from the OS-reported system-bar // heights before Dioxus launches. On desktop they are always (0, 0, 0, 0). @@ -72,8 +126,11 @@ pub fn Shell() -> Element { // definite length for child flex: 1 to resolve against. The safe-area // insets are pre-summed into a single value so the expression stays in // the `calc(100vh - Npx)` form that is confirmed working in Blitz. + // position: relative anchors the AtConfirmDialog overlay (its + // absolute backdrop resolves against this shell-sized box). style: format!( "height: {shell_height}; \ + position: relative; \ display: flex; flex-direction: column; \ overflow: hidden; background: {bg};", bg = tokens::COLOR_SURFACE_BASE, @@ -103,45 +160,18 @@ pub fn Shell() -> Element { return; // Home tab cannot be closed. } let vec_idx = idx - 1; - // Guard: idx is captured at render time; a rapid second close event - // before the deferred DOM re-render produces a stale vec_idx that is - // already out of bounds after the first removal. - if vec_idx >= tabs.read().len() { + // A dirty tab gets a confirmation dialog instead of an + // immediate close — closing discards its unsaved edits + // together with the stashed session (plan 4b.6 / F3c). + let dirty_title = tabs + .read() + .get(vec_idx) + .and_then(|t| t.is_dirty.then(|| t.title.clone())); + if let Some(title) = dirty_title { + pending_close.set(Some((idx, title))); return; } - let current_active = *active_tab.read(); - - // Drop the closed document's stashed editing session so a - // later reopen loads fresh from disk instead of resurrecting - // discarded unsaved edits. - let closed_path = tabs.read().get(vec_idx).map(|t| t.path.clone()); - if let Some(p) = closed_path { - doc_sessions.write().remove(&p); - } - - tabs.write().remove(vec_idx); - let new_len = tabs.read().len(); - - if new_len == 0 { - // No documents remain — go Home. - *active_tab.write() = 0; - navigator.push(Route::Home {}); - } else if idx == current_active { - // Closed the active tab — activate the nearest remaining tab. - // Prefer the tab to the left; fall back to the first tab. - let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; - *active_tab.write() = new_active; - if let Some(tab) = tabs.read().get(new_active - 1) { - navigator.push(Route::Editor { path: tab.path.clone() }); - } - } else if idx < current_active { - // Closed a tab to the LEFT of the active tab — the Vec - // shifted so the active document's index decrements by 1. - // Do NOT navigate: the displayed document is unchanged. - *active_tab.write() = current_active - 1; - } - // Closing a tab to the RIGHT of the active tab: no index - // change and no navigation — displayed document is unchanged. + close_tab(idx, tabs, active_tab, doc_sessions, navigator); }, on_new_tab: move |_| { // Navigate to Home so the user can pick a template or file. @@ -167,6 +197,21 @@ pub fn Shell() -> Element { ), Outlet:: {} } + + // ── Dirty-tab close confirmation (ADR-0013 boundary mount) ──────── + {pending_close.read().clone().map(|(idx, title)| rsx! { + AtConfirmDialog { + title: fl!("shell-close-dirty-title"), + message: fl!("shell-close-dirty-message", title = title), + confirm_label: fl!("shell-close-dirty-confirm"), + cancel_label: fl!("shell-close-dirty-cancel"), + on_confirm: move |_| { + pending_close.set(None); + close_tab(idx, tabs, active_tab, doc_sessions, navigator); + }, + on_cancel: move |_| pending_close.set(None), + } + })} } } } diff --git a/loki-text/src/sessions.rs b/loki-text/src/sessions.rs index 4c53612f..0cc841a6 100644 --- a/loki-text/src/sessions.rs +++ b/loki-text/src/sessions.rs @@ -45,6 +45,8 @@ pub struct DocSession { pub cursor: CursorState, /// Document generation considered clean (matches the on-disk file). pub baseline_gen: u64, + /// Undo-stack clean-checkpoint tracker paired with `undo_manager`. + pub saved_state: crate::editing::saved_state::SavedStateHandle, /// Ribbon undo/redo button state at stash time. pub can_undo: bool, /// Ribbon undo/redo button state at stash time. diff --git a/patches/loki-file-access/.cargo-ok b/patches/loki-file-access/.cargo-ok deleted file mode 100644 index 5f8b7958..00000000 --- a/patches/loki-file-access/.cargo-ok +++ /dev/null @@ -1 +0,0 @@ -{"v":1} \ No newline at end of file diff --git a/patches/loki-file-access/.cargo_vcs_info.json b/patches/loki-file-access/.cargo_vcs_info.json deleted file mode 100644 index 9c32ab5e..00000000 --- a/patches/loki-file-access/.cargo_vcs_info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "git": { - "sha1": "f291e6550b254e2dca68ef0aed622cbc92dc0cbb" - }, - "path_in_vcs": "" -} \ No newline at end of file diff --git a/patches/loki-file-access/.gitignore b/patches/loki-file-access/.gitignore deleted file mode 100644 index ad679558..00000000 --- a/patches/loki-file-access/.gitignore +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Cargo -# will have compiled files and executables -debug -target - -# These are backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - -# Generated by cargo mutants -# Contains mutation testing data -**/mutants.out*/ - -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ diff --git a/patches/loki-file-access/Cargo.lock b/patches/loki-file-access/Cargo.lock deleted file mode 100644 index b54b2b15..00000000 --- a/patches/loki-file-access/Cargo.lock +++ /dev/null @@ -1,2185 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "js-sys" -version = "0.3.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.184" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "loki-file-access" -version = "0.1.1" -dependencies = [ - "base64", - "jni", - "js-sys", - "ndk-context", - "objc2", - "objc2-foundation", - "objc2-ui-kit", - "pollster", - "rfd", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags", - "block2", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags", - "block2", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-core-text", - "objc2-foundation", - "objc2-quartz-core", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "rfd" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" -dependencies = [ - "ashpd", - "block2", - "dispatch2", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "pollster", - "raw-window-handle", - "urlencoding", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.10+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow 1.0.1", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.1", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.15", - "zvariant", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zvariant" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" -dependencies = [ - "endi", - "enumflags2", - "serde", - "url", - "winnow 0.7.15", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", - "winnow 0.7.15", -] diff --git a/patches/loki-file-access/Cargo.toml b/patches/loki-file-access/Cargo.toml deleted file mode 100644 index edc97071..00000000 --- a/patches/loki-file-access/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "loki-file-access" -version = "0.1.2" -edition = "2024" -license = "MIT" -description = "Cross-platform, frontend-agnostic file picker and capability-based file access for Rust" -repository = "https://github.com/appthere/loki-file-access" -keywords = ["file-picker", "android", "ios", "saf", "cross-platform"] -categories = ["filesystem", "os", "gui"] - -[features] -default = [] - -[dependencies] -thiserror = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -base64 = "0.22" -tracing = "0.1" - -[target.'cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))'.dependencies] -rfd = "0.15" - -[target.'cfg(target_os = "android")'.dependencies] -jni = "0.21" -ndk-context = "0.1" - -[target.'cfg(target_os = "ios")'.dependencies] -objc2 = "0.6" -objc2-ui-kit = { version = "0.3", features = ["UIDocumentPickerViewController", "UIViewController", "UIResponder"] } -objc2-foundation = { version = "0.3", features = ["NSURL", "NSData", "NSError"] } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = [ - "HtmlInputElement", "File", "FileList", "Event", - "EventTarget", "Document", "Window", "Blob", "Url" -] } -js-sys = "0.3" - -[dev-dependencies] -pollster = "0.4" -tempfile = "3" -serde_json = "1" -base64 = "0.22" - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] diff --git a/patches/loki-file-access/Cargo.toml.orig b/patches/loki-file-access/Cargo.toml.orig deleted file mode 100644 index 2408d9b2..00000000 --- a/patches/loki-file-access/Cargo.toml.orig +++ /dev/null @@ -1,49 +0,0 @@ -[package] -name = "loki-file-access" -version = "0.1.1" -edition = "2024" -license = "MIT" -description = "Cross-platform, frontend-agnostic file picker and capability-based file access for Rust" -repository = "https://github.com/appthere/loki-file-access" -keywords = ["file-picker", "android", "ios", "saf", "cross-platform"] -categories = ["filesystem", "os", "gui"] - -[features] -default = [] - -[dependencies] -thiserror = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -base64 = "0.22" - -[target.'cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))'.dependencies] -rfd = "0.15" - -[target.'cfg(target_os = "android")'.dependencies] -jni = "0.21" -ndk-context = "0.1" - -[target.'cfg(target_os = "ios")'.dependencies] -objc2 = "0.6" -objc2-ui-kit = { version = "0.3", features = ["UIDocumentPickerViewController", "UIViewController", "UIResponder"] } -objc2-foundation = { version = "0.3", features = ["NSURL", "NSData", "NSError"] } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = [ - "HtmlInputElement", "File", "FileList", "Event", - "EventTarget", "Document", "Window", "Blob", "Url" -] } -js-sys = "0.3" - -[dev-dependencies] -pollster = "0.4" -tempfile = "3" -serde_json = "1" -base64 = "0.22" - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] diff --git a/patches/loki-file-access/LICENSE b/patches/loki-file-access/LICENSE deleted file mode 100644 index 0ae7426c..00000000 --- a/patches/loki-file-access/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 AppThere - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/patches/loki-file-access/android/FilePickerActivity.java b/patches/loki-file-access/android/FilePickerActivity.java deleted file mode 100644 index 5b6d8dfe..00000000 --- a/patches/loki-file-access/android/FilePickerActivity.java +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -package io.github.appthere.lokifileaccess; - -import android.app.Activity; -import android.content.ClipData; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; - -/** - * Transparent trampoline Activity for Android file picking via the Storage Access Framework. - * - *

{@code ANativeActivityCallbacks} has no {@code onActivityResult} callback, so - * NativeActivity cannot receive file-picker results directly. This Activity: - *

    - *
  1. Is started by NativeActivity via {@code startActivity} (no result needed).
  2. - *
  3. Calls {@code startActivityForResult(ACTION_OPEN_DOCUMENT / ACTION_CREATE_DOCUMENT)}.
  4. - *
  5. Receives the result in its own {@code onActivityResult}.
  6. - *
  7. Delivers the selected URI to the Rust future via {@code nativeOnResult}.
  8. - *
  9. Calls {@code finish()} to remove itself from the back stack.
  10. - *
- * - *

The native library is already loaded by NativeActivity before this Activity is - * ever created, so {@code System.loadLibrary} is not required here. - * - *

Register in {@code AndroidManifest.xml}: - *

{@code
- * 
- * }
- */ -public class FilePickerActivity extends Activity { - - static { - // Android 7+ scopes native-library JNI lookups to the class loader that - // called System.loadLibrary. NativeActivity loads libloki_text.so from - // the framework bootstrap class loader; FilePickerActivity runs under the - // application PathClassLoader. Without this call, nativeOnResult cannot - // be resolved and throws UnsatisfiedLinkError at runtime. - // Safe to call if the library is already loaded — ART returns immediately. - System.loadLibrary("loki_text"); - } - - private static final int REQUEST_OPEN = 1001; - private static final int REQUEST_CREATE = 1002; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Intent src = getIntent(); - String mode = src.getStringExtra("mode"); - if ("CREATE".equals(mode)) { - launchCreate(src); - } else { - launchOpen(src); - } - } - - private void launchOpen(Intent src) { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - - // mime_types is passed as a comma-separated string to avoid JNI array complexity. - String mimeTypesRaw = src.getStringExtra("mime_types"); - if (mimeTypesRaw != null && !mimeTypesRaw.isEmpty()) { - String[] mimes = mimeTypesRaw.split(","); - if (mimes.length == 1) { - intent.setType(mimes[0]); - } else { - intent.setType("*/*"); - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimes); - } - } else { - intent.setType("*/*"); - } - - boolean allowMultiple = src.getBooleanExtra("allow_multiple", false); - if (allowMultiple) { - intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - } - - startActivityForResult(intent, REQUEST_OPEN); - } - - private void launchCreate(Intent src) { - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - - String mimeType = src.getStringExtra("mime_type"); - intent.setType(mimeType != null ? mimeType : "*/*"); - - String suggestedName = src.getStringExtra("suggested_name"); - if (suggestedName != null) { - intent.putExtra(Intent.EXTRA_TITLE, suggestedName); - } - - startActivityForResult(intent, REQUEST_CREATE); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - String result = null; - if (resultCode == RESULT_OK && data != null) { - // Multi-select delivers URIs through ClipData; single-select uses getData(). - // Join all URIs with '\n' so a single nativeOnResult call carries the full - // selection without requiring a JNI array parameter. - ClipData clip = data.getClipData(); - if (clip != null && clip.getItemCount() > 0) { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < clip.getItemCount(); i++) { - Uri u = clip.getItemAt(i).getUri(); - if (u != null) { - if (sb.length() > 0) sb.append('\n'); - sb.append(u.toString()); - } - } - if (sb.length() > 0) result = sb.toString(); - } else { - Uri u = data.getData(); - if (u != null) result = u.toString(); - } - } - nativeOnResult(result); - finish(); - } - - /** - * Delivers the selected URI (or {@code null} for cancellation) to the pending Rust future. - * - *

Resolved via JNI against - * {@code Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult} - * in the host application's native library, which is already loaded by the time - * this Activity is created. - */ - private native void nativeOnResult(String uri); -} diff --git a/patches/loki-file-access/android/ImeInsetsListener.java b/patches/loki-file-access/android/ImeInsetsListener.java deleted file mode 100644 index 547c03ea..00000000 --- a/patches/loki-file-access/android/ImeInsetsListener.java +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -package io.github.appthere.lokifileaccess; - -import android.app.Activity; -import android.os.Build; -import android.view.View; -import android.view.WindowInsets; - -/** - * Bridges Android soft-keyboard (IME) visibility changes to native code. - * - *

On a {@code NativeActivity} the OS never reports when the user dismisses or - * re-summons the soft keyboard (back button, swipe-down gesture, keyboard hide - * key), and the surface is not resized. This listener observes the decor view's - * window insets — which include the IME inset on API 30+ — and calls - * {@code nativeOnImeInsetsChanged} on every visibility transition so the Rust - * side can re-reserve (or release) the bottom safe area. - * - *

The native method is bound from Rust via {@code RegisterNatives}, so no - * {@code System.loadLibrary} is required here and the binding is independent of - * the host application's native-library name. - */ -public final class ImeInsetsListener implements View.OnApplyWindowInsetsListener { - - private boolean lastImeVisible; - - /** - * Install the listener on the activity's decor view. - * - *

Runs on the UI thread (View listeners must be set there) and is a no-op - * below API 30, where {@code WindowInsets.Type.ime()} / {@code isVisible} - * are unavailable — matching the query-side API-30 fallback in Rust. - */ - public static void install(final Activity activity) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - return; - } - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - View decor = activity.getWindow().getDecorView(); - decor.setOnApplyWindowInsetsListener(new ImeInsetsListener()); - // Kick an immediate inset dispatch so the initial state is known. - decor.requestApplyInsets(); - } - }); - } - - @Override - public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - boolean imeVisible = insets.isVisible(WindowInsets.Type.ime()); - if (imeVisible != lastImeVisible) { - lastImeVisible = imeVisible; - nativeOnImeInsetsChanged(imeVisible); - } - // Pass through so we neither consume nor alter the system's inset handling. - return v.onApplyWindowInsets(insets); - } - - private static native void nativeOnImeInsetsChanged(boolean imeVisible); -} diff --git a/patches/loki-file-access/build.rs b/patches/loki-file-access/build.rs deleted file mode 100644 index 21ce0ecb..00000000 --- a/patches/loki-file-access/build.rs +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Build script — compiles the Android Java shims into `classes.dex` for -//! Android targets. -//! -//! Shims (in `android/`): -//! - `FilePickerActivity.java` — Storage Access Framework trampoline. -//! - `ImeInsetsListener.java` — soft-keyboard (IME) visibility bridge. -//! -//! Requires: -//! - `ANDROID_HOME` or `ANDROID_SDK_ROOT` pointing to the Android SDK -//! - `javac` on PATH, in `JAVA_HOME/bin`, or in Android Studio's bundled JDK -//! -//! On non-Android targets this script does nothing. - -use std::path::PathBuf; - -/// Java shim source files (relative to `android/`) compiled into the DEX. -const JAVA_SHIMS: &[&str] = &["FilePickerActivity.java", "ImeInsetsListener.java"]; - -fn main() { - for shim in JAVA_SHIMS { - println!("cargo:rerun-if-changed=android/{shim}"); - } - - let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target_os != "android" { - return; - } - - let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); - let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let java_srcs: Vec = JAVA_SHIMS - .iter() - .map(|shim| manifest_dir.join("android").join(shim)) - .collect(); - let dex_out = out_dir.join("classes.dex"); - - match compile_to_dex(&java_srcs, &out_dir, &dex_out) { - Ok(()) => { - println!("cargo:warning=Java shim DEX: {}", dex_out.display()); - println!("cargo:warning=Inject into APK with: scripts/build-android.ps1 -Install"); - } - Err(e) => { - println!("cargo:warning=Java shim compile skipped: {e}"); - println!("cargo:warning=Run scripts/build-android.ps1 which compiles the DEX itself."); - } - } - - println!("cargo:rustc-env=LOKI_FILE_ACCESS_DEX={}", dex_out.display()); -} - -fn compile_to_dex( - java_srcs: &[PathBuf], - out_dir: &std::path::Path, - dex_out: &std::path::Path, -) -> Result<(), String> { - let android_home = std::env::var("ANDROID_HOME") - .or_else(|_| std::env::var("ANDROID_SDK_ROOT")) - .map_err(|_| "ANDROID_HOME not set".to_owned())?; - let android_home = PathBuf::from(android_home); - - let android_jar = find_android_jar(&android_home)?; - let d8 = find_d8(&android_home)?; - let javac = find_javac(); - - let classes_dir = out_dir.join("java_classes"); - std::fs::create_dir_all(&classes_dir).map_err(|e| format!("mkdir classes: {e}"))?; - - let mut javac_args: Vec = vec![ - "-source".into(), - "8".into(), - "-target".into(), - "8".into(), - "-classpath".into(), - android_jar.to_str().unwrap().to_owned(), - "-d".into(), - classes_dir.to_str().unwrap().to_owned(), - ]; - javac_args.extend(java_srcs.iter().map(|p| p.to_str().unwrap().to_owned())); - - let status = std::process::Command::new(&javac) - .args(&javac_args) - .status() - .map_err(|e| format!("javac exec failed: {e}"))?; - if !status.success() { - return Err(format!("javac exited {status}")); - } - - // Collect every produced `.class` file (including inner classes such as the - // anonymous `Runnable` in `ImeInsetsListener`) so d8 dexes all of them. - let class_files = collect_class_files(&classes_dir); - if class_files.is_empty() { - return Err("no .class files produced by javac".to_owned()); - } - - let dex_dir = out_dir.join("dex_out"); - std::fs::create_dir_all(&dex_dir).map_err(|e| format!("mkdir dex: {e}"))?; - - let mut d8_args: Vec = class_files - .iter() - .map(|p| p.to_str().unwrap().to_owned()) - .collect(); - d8_args.push("--output".into()); - d8_args.push(dex_dir.to_str().unwrap().to_owned()); - d8_args.push("--min-api".into()); - d8_args.push("26".into()); - - let status = std::process::Command::new(&d8) - .args(&d8_args) - .status() - .map_err(|e| format!("d8 exec failed: {e}"))?; - if !status.success() { - return Err(format!("d8 exited {status}")); - } - - std::fs::copy(dex_dir.join("classes.dex"), dex_out).map_err(|e| format!("copy dex: {e}"))?; - - Ok(()) -} - -/// Recursively collect all `.class` files under `dir`. -fn collect_class_files(dir: &std::path::Path) -> Vec { - let mut out = Vec::new(); - let Ok(entries) = std::fs::read_dir(dir) else { - return out; - }; - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.is_dir() { - out.extend(collect_class_files(&path)); - } else if path.extension().is_some_and(|ext| ext == "class") { - out.push(path); - } - } - out -} - -fn find_android_jar(android_home: &std::path::Path) -> Result { - let platforms = android_home.join("platforms"); - for api in (26..=36).rev() { - let jar = platforms.join(format!("android-{api}")).join("android.jar"); - if jar.exists() { - return Ok(jar); - } - } - Err("android.jar not found under $ANDROID_HOME/platforms/android-*/".to_owned()) -} - -fn find_d8(android_home: &std::path::Path) -> Result { - let build_tools = android_home.join("build-tools"); - let mut entries: Vec<_> = std::fs::read_dir(&build_tools) - .map_err(|e| format!("read build-tools: {e}"))? - .filter_map(|e| e.ok()) - .collect(); - entries.sort_by(|a, b| b.file_name().cmp(&a.file_name())); - for entry in entries { - for name in &["d8.bat", "d8"] { - let d8 = entry.path().join(name); - if d8.exists() { - return Ok(d8); - } - } - } - Err("d8 not found under $ANDROID_HOME/build-tools/".to_owned()) -} - -fn find_javac() -> PathBuf { - if let Ok(java_home) = std::env::var("JAVA_HOME") { - for name in &["bin/javac", "bin/javac.exe"] { - let p = PathBuf::from(&java_home).join(name); - if p.exists() { - return p; - } - } - } - // Android Studio bundled JDK (Windows — Program Files location) - #[cfg(target_os = "windows")] - { - let pf = std::env::var("PROGRAMFILES").unwrap_or_else(|_| "C:\\Program Files".into()); - let p = PathBuf::from(pf).join("Android\\Android Studio\\jbr\\bin\\javac.exe"); - if p.exists() { - return p; - } - } - PathBuf::from(if cfg!(target_os = "windows") { - "javac.exe" - } else { - "javac" - }) -} diff --git a/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md b/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md deleted file mode 100644 index 784b7324..00000000 --- a/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md +++ /dev/null @@ -1,58 +0,0 @@ -# ADR 0001: Runtime-Agnostic Future Implementation - -## Status - -Accepted - -## Context - -`loki-file-access` must present native file-picker dialogs and return the -result as a Rust `Future`. The crate targets a wide range of consumers: -Dioxus, egui, Iced, Xilem, and applications using `pollster::block_on` for -synchronous contexts. - -Each of these frameworks has its own executor (or uses none at all). Tying -the crate to a specific async runtime would force every consumer to depend on -that runtime, even if they never use it elsewhere. - -## Decision - -Implement a minimal one-shot future (`PickFuture`) using only `std` -primitives: - -- `Arc>>` holds the shared state. -- `PickState` contains an `Option` for the result and an - `Option` for the executor's waker. -- The `Future` implementation checks for a result on each `poll`, stores the - waker if pending, and returns `Poll::Ready` once a result is delivered. -- A `deliver()` function is called from the platform callback (JNI, Objective-C - delegate, JS event listener) to set the result and wake the executor. - -No Tokio, async-std, or other runtime crate is required. - -## Consequences - -### Positive - -- **Universal compatibility**: Works with any executor, including - `pollster::block_on`, Tokio, async-std, smol, and custom executors. -- **Zero runtime dependencies**: The crate's dependency footprint is minimal. -- **Simple mental model**: One future, one result, one waker — easy to audit. - -### Negative - -- **Slightly more code** than using `tokio::sync::oneshot` or a similar - runtime-provided channel. -- **No built-in timeout**: Consumers must implement their own timeout logic - if needed (e.g. `tokio::time::timeout` wrapping the future). -- **Mutex overhead**: Each `poll` and `deliver` call acquires a mutex lock. - This is negligible for file-picker operations (user-driven, infrequent). - -## Alternatives Rejected - -| Alternative | Reason for rejection | -|---|---| -| `tokio::sync::oneshot` | Adds a mandatory Tokio dependency. Consumers using other runtimes or `pollster` would be forced to pull in Tokio. | -| `async_channel` | Additional dependency for a single-use channel. The crate only needs one-shot delivery, not a full channel. | -| `flume` | Additional dependency. Same reasoning as `async_channel` — the problem is simpler than what `flume` solves. | -| `futures::channel::oneshot` | Adds the `futures` crate as a dependency. While lighter than Tokio, it is still an unnecessary dependency for this use case. | diff --git a/patches/loki-file-access/docs/adr/0002-mit-license.md b/patches/loki-file-access/docs/adr/0002-mit-license.md deleted file mode 100644 index 041aff34..00000000 --- a/patches/loki-file-access/docs/adr/0002-mit-license.md +++ /dev/null @@ -1,58 +0,0 @@ -# ADR 0002: MIT License - -## Status - -Accepted - -## Context - -`loki-file-access` is a general-purpose utility crate for cross-platform file -access. It is intended for broad adoption across the Rust ecosystem, including -use in projects under a variety of open-source and proprietary licenses. - -The two most common choices for Rust crates are MIT and Apache-2.0 (often -dual-licensed as `MIT OR Apache-2.0`). A license decision is needed. - -## Decision - -Use the **MIT license** exclusively (not dual-licensed with Apache-2.0). - -## Rationale - -- **Maximum compatibility**: MIT is compatible with a strictly wider range of - downstream projects than Apache-2.0. Some projects and organisations cannot - accept Apache-2.0 due to concerns about the patent clause (Section 3) — for - example, projects under GPLv2-only or certain corporate policies. MIT has - no patent clause, removing this friction. - -- **No strategic IP**: As a low-level utility crate with no novel algorithms or - patentable inventions, the patent protection offered by Apache-2.0 provides - negligible benefit to the project or its contributors. - -- **Simplicity**: A single license is easier to understand, audit, and comply - with than a dual-license arrangement. Downstream consumers do not need to - choose between two options or evaluate which one to apply. - -- **Ecosystem norms**: Many widely-used Rust crates (serde, rand, base64, etc.) - are MIT-licensed. Using the same license reduces cognitive overhead for - consumers evaluating dependency licenses. - -## Consequences - -### Positive - -- Downstream projects under **any** OSI-approved license can use this crate - without restriction. -- Contributors do not need to agree to a CLA or patent grant beyond what MIT - already provides. -- License compliance is straightforward: include the copyright notice and - permission notice. - -### Negative - -- No explicit patent grant from contributors. If a contributor holds a patent - that reads on the crate's functionality, MIT alone does not provide an - express license to those patent claims. This is an acceptable risk given the - crate's nature as a utility library. -- Cannot be relicensed to Apache-2.0 later without consent from all copyright - holders (though this is unlikely to be needed). diff --git a/patches/loki-file-access/src/api.rs b/patches/loki-file-access/src/api.rs deleted file mode 100644 index 54850aa1..00000000 --- a/patches/loki-file-access/src/api.rs +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Public API surface for presenting file-picker dialogs. -//! -//! This module defines [`FilePicker`], [`PickOptions`], and [`SaveOptions`] — -//! the primary entry points for all file-picker operations. Platform-specific -//! behaviour is fully abstracted behind these types. - -use crate::error::PickerError; -use crate::token::FileAccessToken; - -/// Options for opening an existing file via a platform file-picker dialog. -/// -/// # Examples -/// -/// ``` -/// use loki_file_access::PickOptions; -/// -/// let opts = PickOptions { -/// mime_types: vec!["image/png".into(), "image/jpeg".into()], -/// filter_label: Some("Images".into()), -/// multi: false, -/// }; -/// ``` -#[derive(Debug, Clone, Default)] -pub struct PickOptions { - /// MIME types to filter in the picker dialog. - /// - /// An empty vector means all file types are shown. - pub mime_types: Vec, - - /// Display label for the file-type filter in the picker UI. - /// - /// Not all platforms support this (e.g. Android ignores it). - pub filter_label: Option, - - /// Whether the user may select multiple files. - /// - /// When `false`, at most one file is returned. - pub multi: bool, -} - -/// Options for saving a new file or overwriting an existing one. -/// -/// # Examples -/// -/// ``` -/// use loki_file_access::SaveOptions; -/// -/// let opts = SaveOptions { -/// mime_type: Some("text/plain".into()), -/// suggested_name: Some("notes.txt".into()), -/// }; -/// ``` -#[derive(Debug, Clone, Default)] -pub struct SaveOptions { - /// The MIME type of the file being saved. - /// - /// Used by some platforms (Android SAF) to pre-filter the save location. - pub mime_type: Option, - - /// Suggested filename including extension. - /// - /// The user may change this in the save dialog. - pub suggested_name: Option, -} - -/// Frontend-agnostic file picker that delegates to the native platform dialog. -/// -/// `FilePicker` has no state and is cheap to construct. All methods return -/// standard [`Future`] values that can be awaited from any async runtime, -/// including `pollster::block_on` for synchronous contexts. -/// -/// # Examples -/// -/// ```no_run -/// use loki_file_access::{FilePicker, PickOptions}; -/// -/// # async fn example() -> Result<(), Box> { -/// let picker = FilePicker::new(); -/// let token = picker -/// .pick_file_to_open(PickOptions::default()) -/// .await?; -/// -/// if let Some(token) = token { -/// println!("Selected: {}", token.display_name()); -/// } -/// # Ok(()) -/// # } -/// ``` -#[derive(Debug, Clone, Default)] -pub struct FilePicker; - -impl FilePicker { - /// Create a new `FilePicker` instance. - #[must_use] - pub fn new() -> Self { - Self - } - - /// Present a platform dialog for the user to select a single file. - /// - /// Returns `Ok(Some(token))` if the user selected a file, or `Ok(None)` - /// if the user cancelled the dialog. The `multi` field of `options` is - /// ignored — use [`pick_files_to_open`](Self::pick_files_to_open) for - /// multi-selection. - /// - /// # Errors - /// - /// Returns [`PickerError`] if the platform dialog could not be presented. - #[must_use = "this returns a Result that may contain an error"] - pub async fn pick_file_to_open( - &self, - options: PickOptions, - ) -> Result, PickerError> { - let opts = PickOptions { - multi: false, - ..options - }; - crate::platform::pick_open_single(opts).await - } - - /// Present a platform dialog for the user to select multiple files. - /// - /// Returns a (possibly empty) vector of tokens. An empty vector means - /// the user cancelled the dialog. The `multi` field of `options` is - /// forced to `true`. - /// - /// # Errors - /// - /// Returns [`PickerError`] if the platform dialog could not be presented. - #[must_use = "this returns a Result that may contain an error"] - pub async fn pick_files_to_open( - &self, - options: PickOptions, - ) -> Result, PickerError> { - let opts = PickOptions { - multi: true, - ..options - }; - crate::platform::pick_open_multi(opts).await - } - - /// Present a platform dialog for the user to choose a save location. - /// - /// Returns `Ok(Some(token))` if the user confirmed a save location, or - /// `Ok(None)` if the user cancelled. - /// - /// # Platform notes - /// - /// On WASM, this triggers a browser download via a Blob URL rather than - /// presenting a traditional save dialog. The returned token wraps an - /// in-memory buffer. - /// - /// # Errors - /// - /// Returns [`PickerError`] if the platform dialog could not be presented. - #[must_use = "this returns a Result that may contain an error"] - pub async fn pick_file_to_save( - &self, - options: SaveOptions, - ) -> Result, PickerError> { - crate::platform::pick_save(options).await - } -} diff --git a/patches/loki-file-access/src/error.rs b/patches/loki-file-access/src/error.rs deleted file mode 100644 index 4977b04e..00000000 --- a/patches/loki-file-access/src/error.rs +++ /dev/null @@ -1,209 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Error types for the `loki-file-access` crate. -//! -//! This module defines all error enums used across the public API surface: -//! -//! - [`PickerError`] — errors originating from the platform file-picker dialog. -//! - [`AccessError`] — errors when reading from or writing to a previously granted file. -//! - [`TokenParseError`] — errors when deserializing a stored [`crate::FileAccessToken`]. -//! -//! All enums are `#[non_exhaustive]` so that new variants can be added in -//! future minor versions without breaking downstream matches. - -/// Errors that can occur when presenting a file-picker dialog. -/// -/// Note that the user cancelling the dialog is **not** an error — it is -/// represented as `Ok(None)` on the single-file methods and `Ok(vec![])` on -/// multi-file methods. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum PickerError { - /// The platform returned an error from the native file-picker API. - #[error("platform file-picker error: {message}")] - Platform { - /// Human-readable description of the platform error. - message: String, - }, - - /// The current platform does not support the requested operation. - #[error("operation not supported on this platform: {operation}")] - Unsupported { - /// Description of the unsupported operation. - operation: String, - }, - - /// An internal synchronisation error occurred (e.g. a poisoned mutex). - #[error("internal synchronisation error: {message}")] - Internal { - /// Human-readable description of the internal error. - message: String, - }, -} - -/// Errors that can occur when opening or accessing a previously granted file. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum AccessError { - /// The permission grant for this file has been revoked by the user or OS. - #[error("file access permission has been revoked")] - PermissionRevoked, - - /// An I/O error occurred while reading from or writing to the file. - #[error("I/O error: {source}")] - Io { - /// The underlying I/O error. - #[from] - source: std::io::Error, - }, - - /// The file descriptor or handle returned by the platform was invalid. - #[error("invalid file descriptor returned by platform")] - InvalidDescriptor, - - /// The platform returned an error when attempting to open the file. - #[error("platform access error: {message}")] - Platform { - /// Human-readable description of the platform error. - message: String, - }, - - /// The requested operation is not supported on the current platform. - #[error("operation not supported on this platform: {operation}")] - Unsupported { - /// Description of the unsupported operation. - operation: String, - }, -} - -/// Errors that can occur when deserializing a stored [`crate::FileAccessToken`]. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum TokenParseError { - /// The base64 encoding of the token is invalid. - #[error("invalid base64 encoding: {message}")] - InvalidBase64 { - /// Description of the base64 decode error. - message: String, - }, - - /// The JSON payload inside the token is malformed. - #[error("invalid JSON in token: {message}")] - InvalidJson { - /// Description of the JSON parse error. - message: String, - }, - - /// The token contains an unrecognised platform variant. - #[error("unknown token variant: {variant}")] - UnknownVariant { - /// The variant tag that was not recognised. - variant: String, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn picker_error_platform_displays_message() { - let err = PickerError::Platform { - message: "dialog failed".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty(), "display string must not be empty"); - assert!(msg.contains("dialog failed")); - } - - #[test] - fn picker_error_unsupported_displays_message() { - let err = PickerError::Unsupported { - operation: "save".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("save")); - } - - #[test] - fn picker_error_internal_displays_message() { - let err = PickerError::Internal { - message: "mutex poisoned".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("mutex poisoned")); - } - - #[test] - fn access_error_permission_revoked_displays_message() { - let err = AccessError::PermissionRevoked; - assert!(!err.to_string().is_empty()); - } - - #[test] - fn access_error_io_displays_message() { - let err = AccessError::Io { - source: std::io::Error::new(std::io::ErrorKind::NotFound, "gone"), - }; - assert!(!err.to_string().is_empty()); - } - - #[test] - fn access_error_invalid_descriptor_displays_message() { - let err = AccessError::InvalidDescriptor; - assert!(!err.to_string().is_empty()); - } - - #[test] - fn access_error_platform_displays_message() { - let err = AccessError::Platform { - message: "fd error".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("fd error")); - } - - #[test] - fn access_error_unsupported_displays_message() { - let err = AccessError::Unsupported { - operation: "delete".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("delete")); - } - - #[test] - fn token_parse_error_base64_displays_message() { - let err = TokenParseError::InvalidBase64 { - message: "bad padding".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("bad padding")); - } - - #[test] - fn token_parse_error_json_displays_message() { - let err = TokenParseError::InvalidJson { - message: "unexpected EOF".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("unexpected EOF")); - } - - #[test] - fn token_parse_error_unknown_variant_displays_message() { - let err = TokenParseError::UnknownVariant { - variant: "FuturePlatform".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("FuturePlatform")); - } -} diff --git a/patches/loki-file-access/src/future.rs b/patches/loki-file-access/src/future.rs deleted file mode 100644 index cfe6b500..00000000 --- a/patches/loki-file-access/src/future.rs +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Runtime-agnostic one-shot future for delivering file-picker results. -//! -//! [`PickFuture`] and [`PickState`] implement a simple single-value future -//! using only `std` primitives (`Arc`, `Mutex`, `Waker`). This avoids any -//! dependency on Tokio, async-std, or other async runtimes, making the crate -//! usable from **any** executor — including `pollster::block_on`, Dioxus, -//! egui, Iced, and Xilem. -//! -//! # Usage (crate-internal) -//! -//! 1. Create a shared `Arc>>`. -//! 2. Return a `PickFuture` wrapping that state to the caller. -//! 3. From the platform callback, call [`deliver`] with the result value. - -use std::pin::Pin; -use std::sync::{Arc, Mutex}; -use std::task::{Context, Poll, Waker}; - -/// Shared state between the [`PickFuture`] and the platform callback that -/// delivers the result. -// Used by non-desktop platforms (Android, iOS) that drive callbacks through -// `deliver()`. On desktop the `rfd` crate's own async API is used instead. -#[allow(dead_code)] -pub(crate) struct PickState { - /// The result value, set exactly once by [`deliver`]. - pub result: Option, - /// The most recent [`Waker`] registered by the executor. - pub waker: Option, -} - -/// A future that resolves to a single value of type `T`. -/// -/// This is the core async primitive used by all platform picker -/// implementations to bridge callback-based native APIs into Rust futures. -// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. -#[allow(dead_code)] -#[must_use = "futures do nothing unless polled"] -pub(crate) struct PickFuture { - /// Shared state with the callback side. - pub state: Arc>>, -} - -impl Future for PickFuture { - type Output = T; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let mut guard = match self.state.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - - if let Some(value) = guard.result.take() { - return Poll::Ready(value); - } - - guard.waker = Some(cx.waker().clone()); - Poll::Pending - } -} - -/// Deliver a result value to a [`PickFuture`] and wake the executor. -/// -/// This function is called from platform callbacks (JNI, Objective-C delegates, -/// JS event listeners, etc.) to complete the associated future. -/// -/// # Panics -/// -/// This function does not panic. If the mutex is poisoned, it recovers the -/// inner state and proceeds normally. -// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. -#[allow(dead_code)] -pub(crate) fn deliver(state: &Arc>>, value: T) { - let waker = { - let mut guard = match state.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - guard.result = Some(value); - guard.waker.take() - }; - - if let Some(w) = waker { - w.wake(); - } -} - -/// Create a new `(PickFuture, Arc>>)` pair. -/// -/// The returned `Arc` should be passed to the platform callback side, while -/// the `PickFuture` is returned to the caller to be awaited. -// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. -#[allow(dead_code)] -pub(crate) fn new_pick_future() -> (PickFuture, Arc>>) { - let state = Arc::new(Mutex::new(PickState { - result: None, - waker: None, - })); - let future = PickFuture { - state: Arc::clone(&state), - }; - (future, state) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::task::{RawWaker, RawWakerVTable}; - - /// Create a no-op waker for testing poll behaviour without an executor. - fn noop_waker() -> Waker { - fn noop(_: *const ()) {} - fn clone(p: *const ()) -> RawWaker { - RawWaker::new(p, &VTABLE) - } - const VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop); - // SAFETY: The noop waker functions are trivially safe — they perform - // no operations on the data pointer. - unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } - } - - #[test] - fn future_returns_pending_before_delivery() { - let (mut future, _state) = new_pick_future::(); - let waker = noop_waker(); - let mut cx = Context::from_waker(&waker); - let pinned = Pin::new(&mut future); - assert!( - pinned.poll(&mut cx).is_pending(), - "future should be Pending before deliver()" - ); - } - - #[test] - fn future_returns_ready_after_delivery() { - let (future, state) = new_pick_future::(); - deliver(&state, 42); - let result = pollster::block_on(future); - assert_eq!(result, 42); - } - - #[test] - fn future_returns_ready_after_delivery_with_pollster() { - let (future, state) = new_pick_future::(); - deliver(&state, "hello".to_owned()); - let result = pollster::block_on(future); - assert_eq!(result, "hello"); - } - - #[test] - fn deliver_wakes_the_waker() { - use std::sync::atomic::{AtomicBool, Ordering}; - - static WOKEN: AtomicBool = AtomicBool::new(false); - - fn noop(_: *const ()) {} - fn wake(_: *const ()) { - WOKEN.store(true, Ordering::SeqCst); - } - fn clone_fn(p: *const ()) -> RawWaker { - RawWaker::new(p, &WAKE_VTABLE) - } - const WAKE_VTABLE: RawWakerVTable = RawWakerVTable::new(clone_fn, wake, wake, noop); - - WOKEN.store(false, Ordering::SeqCst); - - let (mut future, state) = new_pick_future::(); - - // SAFETY: The custom waker functions are trivially safe. - let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &WAKE_VTABLE)) }; - let mut cx = Context::from_waker(&waker); - let _ = Pin::new(&mut future).poll(&mut cx); - - deliver(&state, 99); - assert!( - WOKEN.load(Ordering::SeqCst), - "waker should have been called" - ); - } -} diff --git a/patches/loki-file-access/src/lib.rs b/patches/loki-file-access/src/lib.rs deleted file mode 100644 index aaf6345c..00000000 --- a/patches/loki-file-access/src/lib.rs +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! # loki-file-access -//! -//! Cross-platform, frontend-agnostic file picker and capability-based file -//! access for Rust applications. -//! -//! This crate provides a unified API for presenting native file-picker dialogs -//! and accessing user-selected files across all major platforms: -//! -//! - **Desktop** (Windows, macOS, Linux, BSD) — via the [`rfd`](https://crates.io/crates/rfd) crate -//! - **Android** — via the Storage Access Framework with persistable URI permissions -//! - **iOS** — via `UIDocumentPickerViewController` with security-scoped bookmarks -//! - **WASM** — via `` with in-memory file buffers -//! -//! # Zero UI Framework Dependencies -//! -//! `loki-file-access` has **no UI framework dependencies**. It returns -//! standard [`Future`] values implemented with only `std` primitives (no Tokio, -//! no async-std required). It is usable from Dioxus, egui, Iced, Xilem, -//! `pollster::block_on`, or any other async or sync Rust context. -//! -//! # Quick Start -//! -//! ```no_run -//! use loki_file_access::{FilePicker, PickOptions}; -//! use std::io::Read; -//! -//! # async fn example() -> Result<(), Box> { -//! let picker = FilePicker::new(); -//! -//! // Pick a file to open -//! let token = picker -//! .pick_file_to_open(PickOptions { -//! mime_types: vec!["text/plain".into()], -//! ..Default::default() -//! }) -//! .await?; -//! -//! if let Some(token) = token { -//! let mut reader = token.open_read()?; -//! let mut contents = String::new(); -//! reader.read_to_string(&mut contents)?; -//! println!("File contents: {contents}"); -//! -//! // Serialize the token for later use -//! let stored = token.serialize(); -//! println!("Token: {stored}"); -//! } -//! # Ok(()) -//! # } -//! ``` -//! -//! # Capability Tokens -//! -//! Every picker operation returns a [`FileAccessToken`] — a serializable -//! capability that encapsulates all platform-specific state needed to re-open -//! the file. Tokens can be serialized to a URL-safe string for storage in a -//! recent-files list and deserialized to re-open files across app restarts. -//! -//! On Android, the token holds a content URI with a persistable permission -//! grant. On iOS, it holds a security-scoped bookmark. On desktop, it holds -//! a filesystem path. On WASM, it holds the file data in memory. - -pub mod api; -pub mod error; -pub(crate) mod future; -mod platform; -pub mod token; - -// Re-export public types at crate root for convenience. -pub use api::{FilePicker, PickOptions, SaveOptions}; -pub use error::{AccessError, PickerError, TokenParseError}; -pub use token::{FileAccessToken, PermissionStatus, ReadSeek, WriteSeek}; - -#[cfg(target_os = "android")] -pub use platform::{ - init_android, install_ime_listener, on_activity_result, query_insets_dp, query_window_insets_dp, - set_ime_visibility_listener, -}; diff --git a/patches/loki-file-access/src/platform/android/jni_activity.rs b/patches/loki-file-access/src/platform/android/jni_activity.rs deleted file mode 100644 index e64ceb6d..00000000 --- a/patches/loki-file-access/src/platform/android/jni_activity.rs +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! JNI helpers for launching `FilePickerActivity`. -//! -//! `FilePickerActivity` is a thin Java trampoline that works around the -//! `ANativeActivityCallbacks` limitation: NativeActivity has no -//! `onActivityResult` slot, but a plain `Activity` subclass does. -//! -//! Flow: -//! 1. NativeActivity calls `startActivity(Intent → FilePickerActivity)`. -//! 2. `FilePickerActivity.onCreate` calls `startActivityForResult(ACTION_OPEN_*)`. -//! 3. `FilePickerActivity.onActivityResult` receives the URI and calls -//! `nativeOnResult(uri)` — the pre-compiled JNI hook in the Rust binary. -//! 4. The Rust future resolves. - -use super::jni_common::{attach_err, jvm_err, platform_err}; -use crate::api::{PickOptions, SaveOptions}; -use crate::error::PickerError; - -// Fully-qualified Java class name for FilePickerActivity. -// NOTE: do NOT use this as a ComponentName package — that field identifies the -// APK, not the Java package. Use Intent.setClassName(Context, String) instead -// so the runtime APK package name is derived from the Application context. -const FPA_CLASS: &str = "io.github.appthere.lokifileaccess.FilePickerActivity"; - -// ── Public entry points ─────────────────────────────────────────────────────── - -/// Start `FilePickerActivity` to open one or more files. -pub(super) fn fire_open_file_picker( - options: &PickOptions, - allow_multiple: bool, -) -> Result<(), PickerError> { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; - let mut env = vm.attach_current_thread().map_err(attach_err)?; - - let intent = build_fpa_intent(&mut env, "OPEN")?; - - let mimes = options.mime_types.join(","); - put_string_extra(&mut env, &intent, "mime_types", &mimes)?; - - // putExtra(String, boolean) — JNI signature: (Ljava/lang/String;Z) - let key = env - .new_string("allow_multiple") - .map_err(|e| platform_err("allow_multiple key", e))?; - env.call_method( - &intent, - "putExtra", - "(Ljava/lang/String;Z)Landroid/content/Intent;", - &[ - jni::objects::JValueGen::Object(&key), - jni::objects::JValueGen::Bool(u8::from(allow_multiple)), - ], - ) - .map_err(|e| platform_err("putExtra allow_multiple", e))?; - - start_activity(&mut env, &intent) -} - -/// Start `FilePickerActivity` to create/save a file. -pub(super) fn fire_create_file_picker( - options: &SaveOptions, -) -> Result<(), PickerError> { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; - let mut env = vm.attach_current_thread().map_err(attach_err)?; - - let intent = build_fpa_intent(&mut env, "CREATE")?; - - if let Some(ref mime) = options.mime_type { - put_string_extra(&mut env, &intent, "mime_type", mime)?; - } - if let Some(ref name) = options.suggested_name { - put_string_extra(&mut env, &intent, "suggested_name", name)?; - } - - start_activity(&mut env, &intent) -} - -// ── Private helpers ─────────────────────────────────────────────────────────── - -/// Build an explicit `Intent` targeting `FilePickerActivity` with a `mode` extra. -/// -/// Uses `Intent.setClassName(Context, String)` rather than constructing a -/// `ComponentName` directly. `ComponentName(pkg, cls)` uses `pkg` as the -/// *application* identifier (the APK package name), not the Java package. -/// Hardcoding the Java package `io.github.appthere.lokifileaccess` would cause -/// Android to return `START_CLASS_NOT_FOUND` (-92) because no installed APK has -/// that package name. `setClassName(Context, cls)` derives the package from the -/// Application context at runtime, correctly targeting this APK regardless of -/// what package name the host app uses. -fn build_fpa_intent<'a>( - env: &mut jni::JNIEnv<'a>, - mode: &str, -) -> Result, PickerError> { - // new Intent() - let intent_cls = env - .find_class("android/content/Intent") - .map_err(|e| platform_err("Intent class", e))?; - let intent = env - .new_object(&intent_cls, "()V", &[]) - .map_err(|e| platform_err("Intent()", e))?; - - // intent.setClassName(context, FPA_CLASS) — resolves the APK package from - // the Application context so the component is found in this app's process. - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the Application jobject initialised before android_main. - let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - let cls_str = env - .new_string(FPA_CLASS) - .map_err(|e| platform_err("fpa class string", e))?; - env.call_method( - &intent, - "setClassName", - "(Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent;", - &[ - jni::objects::JValueGen::Object(&context), - jni::objects::JValueGen::Object(&cls_str), - ], - ) - .map_err(|e| platform_err("setClassName", e))?; - - // intent.putExtra("mode", mode) - put_string_extra(env, &intent, "mode", mode)?; - - Ok(intent) -} - -/// Add a `String` extra to an `Intent`. -fn put_string_extra( - env: &mut jni::JNIEnv<'_>, - intent: &jni::objects::JObject<'_>, - key: &str, - value: &str, -) -> Result<(), PickerError> { - let k = env.new_string(key).map_err(|e| platform_err("extra key", e))?; - let v = env.new_string(value).map_err(|e| platform_err("extra value", e))?; - env.call_method( - intent, - "putExtra", - "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", - &[ - jni::objects::JValueGen::Object(&k), - jni::objects::JValueGen::Object(&v), - ], - ) - .map_err(|e| platform_err("putExtra string", e))?; - Ok(()) -} - -/// Call `Context.startActivity(intent)` using the Application context. -/// -/// `ndk_context` provides the Application object set by android-activity before -/// `android_main` is called. Starting an Activity from a non-Activity context -/// requires `FLAG_ACTIVITY_NEW_TASK`, which is added to the intent here. -/// -/// `FilePickerActivity` is a transparent trampoline: it receives its own -/// `onActivityResult` from the SAF picker (within its own task) and delivers -/// the URI to Rust via `nativeOnResult`. No result needs to flow back to -/// NativeActivity, so the new-task restriction is not a problem. -fn start_activity( - env: &mut jni::JNIEnv<'_>, - intent: &jni::objects::JObject<'_>, -) -> Result<(), PickerError> { - // FLAG_ACTIVITY_NEW_TASK — required when calling startActivity from a - // non-Activity Context such as Application. - const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000; - let flags_result = env.call_method( - intent, - "addFlags", - "(I)Landroid/content/Intent;", - &[jni::objects::JValueGen::Int(FLAG_ACTIVITY_NEW_TASK)], - ); - if let Err(e) = flags_result { - // Clear any pending JNI exception before returning so subsequent - // JNI calls on this env do not trigger an ART abort. - let _ = env.exception_clear(); - return Err(platform_err("addFlags", e)); - } - - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the Application jobject initialised by - // android-activity before android_main runs. Valid for the process lifetime. - let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - - let result = env.call_method( - &context, - "startActivity", - "(Landroid/content/Intent;)V", - &[jni::objects::JValueGen::Object(intent)], - ); - - if result.is_err() { - // Clear any pending JNI exception (e.g. ActivityNotFoundException) so - // subsequent JNI calls in this env do not trigger an ART abort. - let _ = env.exception_clear(); - } - - result.map(|_| ()).map_err(|e| platform_err("startActivity", e)) -} diff --git a/patches/loki-file-access/src/platform/android/jni_common.rs b/patches/loki-file-access/src/platform/android/jni_common.rs deleted file mode 100644 index ebe01da7..00000000 --- a/patches/loki-file-access/src/platform/android/jni_common.rs +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Shared JNI error helpers used by the Android platform modules. -//! -//! Extracted here so both [`super::jni_intents`] and [`super::jni_activity`] -//! can import without circular dependencies. - -use crate::error::PickerError; - -// ── Error conversions ───────────────────────────────────────────────────────── - -pub(super) fn jvm_err(e: jni::errors::Error) -> PickerError { - PickerError::Platform { - message: format!("failed to get JavaVM: {e}"), - } -} - -pub(super) fn attach_err(e: jni::errors::Error) -> PickerError { - PickerError::Platform { - message: format!("failed to attach JNI thread: {e}"), - } -} - -pub(super) fn platform_err(ctx: &str, e: impl std::fmt::Display) -> PickerError { - PickerError::Platform { - message: format!("{ctx}: {e}"), - } -} diff --git a/patches/loki-file-access/src/platform/android/jni_fd.rs b/patches/loki-file-access/src/platform/android/jni_fd.rs deleted file mode 100644 index 1d94214c..00000000 --- a/patches/loki-file-access/src/platform/android/jni_fd.rs +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Android file-descriptor and permission-checking JNI helpers. -//! -//! Split from [`super::jni_intents`] to keep each file under 300 lines. - -use super::jni_common::{attach_err, jvm_err, platform_err}; -use crate::error::{AccessError, PickerError}; -use crate::token::PermissionStatus; - -/// Query `ContentResolver.getPersistedUriPermissions()` for a URI. -pub(in crate::platform) fn check_persisted_permission( - uri: &str, -) -> Result { - let ctx = ndk_context::android_context(); - let vm = - unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; - let mut env = vm - .attach_current_thread() - .map_err(attach_err)?; - - let resolver = super::jni_intents::get_content_resolver(&mut env, &ctx)?; - - let list = env - .call_method( - &resolver, - "getPersistedUriPermissions", - "()Ljava/util/List;", - &[], - ) - .map_err(|e| platform_err("getPersistedUriPermissions", e))? - .l() - .map_err(|e| platform_err("result", e))?; - - let size = env - .call_method(&list, "size", "()I", &[]) - .map_err(|e| platform_err("size", e))? - .i() - .map_err(|e| platform_err("size int", e))?; - - for i in 0..size { - let perm = env - .call_method( - &list, - "get", - "(I)Ljava/lang/Object;", - &[jni::objects::JValueGen::Int(i)], - ) - .map_err(|e| platform_err("get", e))? - .l() - .map_err(|e| platform_err("get object", e))?; - - let perm_uri = env - .call_method(&perm, "getUri", "()Landroid/net/Uri;", &[]) - .map_err(|e| platform_err("getUri", e))? - .l() - .map_err(|e| platform_err("getUri object", e))?; - - let s: String = env - .call_method(&perm_uri, "toString", "()Ljava/lang/String;", &[]) - .map_err(|e| platform_err("toString", e))? - .l() - .map_err(|e| platform_err("toString object", e)) - .and_then(|obj| { - let jstr: jni::objects::JString = obj.into(); - env.get_string(&jstr) - .map_err(|e| platform_err("read string", e)) - .map(|js| js.into()) - })?; - - if s == uri { - return Ok(PermissionStatus::Valid); - } - } - - Ok(PermissionStatus::Revoked) -} - -/// Open a file descriptor for a content URI via `ContentResolver`. -pub(in crate::platform) fn open_fd(uri: &str, mode: &str) -> Result { - let ctx = ndk_context::android_context(); - let vm = - unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(|_| access_err("get JavaVM"))?; - let mut env = vm - .attach_current_thread() - .map_err(|_| access_err("attach thread"))?; - - let uri_obj = parse_uri_for_access(&mut env, uri)?; - let mode_str = env - .new_string(mode) - .map_err(|_| access_err("mode string"))?; - - let activity = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - let resolver = env - .call_method( - &activity, - "getContentResolver", - "()Landroid/content/ContentResolver;", - &[], - ) - .map_err(|_| access_err("getContentResolver"))? - .l() - .map_err(|_| AccessError::InvalidDescriptor)?; - - let pfd = env - .call_method( - &resolver, - "openFileDescriptor", - "(Landroid/net/Uri;Ljava/lang/String;)Landroid/os/ParcelFileDescriptor;", - &[ - jni::objects::JValueGen::Object(&uri_obj), - jni::objects::JValueGen::Object(&mode_str), - ], - ) - .map_err(|_| access_err("openFileDescriptor"))? - .l() - .map_err(|_| AccessError::InvalidDescriptor)?; - - env.call_method(&pfd, "detachFd", "()I", &[]) - .map_err(|_| access_err("detachFd"))? - .i() - .map_err(|_| AccessError::InvalidDescriptor) -} - -/// Parse a URI string for access-error contexts. -fn parse_uri_for_access<'a>( - env: &mut jni::JNIEnv<'a>, - uri: &str, -) -> Result, AccessError> { - let cls = env - .find_class("android/net/Uri") - .map_err(|_| access_err("Uri class"))?; - let s = env.new_string(uri).map_err(|_| access_err("URI string"))?; - env.call_static_method( - &cls, - "parse", - "(Ljava/lang/String;)Landroid/net/Uri;", - &[jni::objects::JValueGen::Object(&s)], - ) - .map_err(|_| access_err("Uri.parse"))? - .l() - .map_err(|_| AccessError::InvalidDescriptor) -} - -fn access_err(ctx: &str) -> AccessError { - AccessError::Platform { - message: format!("{ctx} failed"), - } -} diff --git a/patches/loki-file-access/src/platform/android/jni_ime.rs b/patches/loki-file-access/src/platform/android/jni_ime.rs deleted file mode 100644 index f38a622f..00000000 --- a/patches/loki-file-access/src/platform/android/jni_ime.rs +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! JNI install of the Android soft-keyboard (IME) visibility listener. -//! -//! `ImeInsetsListener` (a Java shim) observes the decor view's window insets and -//! calls back into `nativeOnImeInsetsChanged` on every IME visibility change — -//! including the user-initiated dismiss / re-summon that the OS otherwise never -//! reports to a `NativeActivity`. -//! -//! Install flow: -//! 1. Load the shim through the *application* class loader — a JNI-attached -//! native thread's default `FindClass` loader resolves only framework -//! classes, so an app class must be reached via the activity's own loader. -//! 2. Bind the native callback with `RegisterNatives`, so the binding does not -//! depend on the host application's `.so` name (unlike symbol-name -//! resolution, which is class-loader-scoped on Android 7+). -//! 3. Call the shim's static `install`, which registers the decor-view listener -//! on the UI thread. - -use std::ffi::c_void; -use std::sync::{Mutex, OnceLock}; - -use jni::objects::{JClass, JObject, JValueGen}; -use jni::sys::jboolean; -use jni::{JNIEnv, NativeMethod}; - -/// Fully-qualified name of the Java shim (for `ClassLoader.loadClass`). -const IME_CLASS_DOT: &str = "io.github.appthere.lokifileaccess.ImeInsetsListener"; - -/// Closure invoked (on the Android UI thread) whenever the soft keyboard's -/// visibility changes. `true` = keyboard now visible, `false` = collapsed. -type ImeCallback = Box; -static IME_CALLBACK: OnceLock>> = OnceLock::new(); - -fn ime_callback() -> &'static Mutex> { - IME_CALLBACK.get_or_init(|| Mutex::new(None)) -} - -/// Register the closure invoked on every soft-keyboard visibility change. -/// -/// Call once before [`install_ime_listener`]. A later call replaces the -/// previous closure. -pub fn set_ime_visibility_listener(callback: ImeCallback) { - if let Ok(mut guard) = ime_callback().lock() { - *guard = Some(callback); - } -} - -/// Install the decor-view IME inset listener for `activity_ptr` -/// (`AndroidApp::activity_as_ptr()`). -/// -/// Returns `true` when the Java `install` was invoked. Returns `false` on a null -/// pointer or any JNI failure; the Java side additionally no-ops below API 30, -/// matching the query-side fallback. Installing twice simply replaces the decor -/// view's listener. -pub fn install_ime_listener(activity_ptr: *mut c_void) -> bool { - if activity_ptr.is_null() { - return false; - } - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the JVM pointer initialised by android-activity - // before android_main; valid for the process lifetime. - let vm = match unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) } { - Ok(vm) => vm, - Err(_) => return false, - }; - let mut env = match vm.attach_current_thread() { - Ok(env) => env, - Err(_) => return false, - }; - let ok = install_with_env(&mut env, activity_ptr).is_some(); - // Clear any pending exception (e.g. from a failed class load) so the calling - // thread stays usable. - let _ = env.exception_clear(); - ok -} - -fn install_with_env(env: &mut JNIEnv<'_>, activity_ptr: *mut c_void) -> Option<()> { - // SAFETY: `activity_ptr` is the global-ref activity jobject owned by - // AndroidApp, valid for the activity's lifetime. - let activity = unsafe { JObject::from_raw(activity_ptr.cast()) }; - - let class = load_app_class(env, &activity)?; - register_native(env, &class)?; - - // ImeInsetsListener.install(activity) - env.call_static_method( - &class, - "install", - "(Landroid/app/Activity;)V", - &[JValueGen::Object(&activity)], - ) - .ok()?; - Some(()) -} - -/// Load `ImeInsetsListener` through the application class loader. -/// -/// A native thread's default class loader resolves only framework classes, so -/// we reach the app class via the activity's own `getClassLoader().loadClass`. -fn load_app_class<'a>(env: &mut JNIEnv<'a>, activity: &JObject<'_>) -> Option> { - let activity_class = env.get_object_class(activity).ok()?; - let loader = env - .call_method( - &activity_class, - "getClassLoader", - "()Ljava/lang/ClassLoader;", - &[], - ) - .ok()? - .l() - .ok()?; - let name = env.new_string(IME_CLASS_DOT).ok()?; - let class_obj = env - .call_method( - &loader, - "loadClass", - "(Ljava/lang/String;)Ljava/lang/Class;", - &[JValueGen::Object(&name)], - ) - .ok()? - .l() - .ok()?; - Some(JClass::from(class_obj)) -} - -/// Bind `nativeOnImeInsetsChanged` to [`ime_insets_changed`] via -/// `RegisterNatives`, so the callback resolves regardless of the host -/// application's native-library name. -fn register_native(env: &mut JNIEnv<'_>, class: &JClass<'_>) -> Option<()> { - let methods = [NativeMethod { - name: "nativeOnImeInsetsChanged".into(), - sig: "(Z)V".into(), - fn_ptr: ime_insets_changed as *mut c_void, - }]; - env.register_native_methods(class, &methods).ok() -} - -/// JNI callback invoked by `ImeInsetsListener.onApplyWindowInsets` on the -/// Android UI thread whenever the soft keyboard's visibility changes. -extern "system" fn ime_insets_changed(_env: JNIEnv<'_>, _class: JClass<'_>, ime_visible: jboolean) { - let visible = ime_visible != 0; - if let Ok(guard) = ime_callback().lock() - && let Some(callback) = guard.as_ref() - { - callback(visible); - } -} diff --git a/patches/loki-file-access/src/platform/android/jni_insets.rs b/patches/loki-file-access/src/platform/android/jni_insets.rs deleted file mode 100644 index 84088aed..00000000 --- a/patches/loki-file-access/src/platform/android/jni_insets.rs +++ /dev/null @@ -1,294 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! JNI query for Android system-bar heights. -//! -//! Uses `Resources.getDimensionPixelSize` with the well-known system resource -//! identifiers `status_bar_height` and `navigation_bar_height`. These resource -//! identifiers are available immediately at process start — before the window -//! is laid out — making them safe to query from `android_main`. -//! -//! Returns heights in density-independent pixels (dp / CSS px) so callers can -//! apply them directly as CSS `padding` values. - -use jni::objects::{JObject, JValueGen}; -use jni::JNIEnv; - -/// Query Android system-bar heights from OS resources. -/// -/// Returns `(top_dp, bottom_dp)` where: -/// - `top_dp` is the status-bar height (top of screen) -/// - `bottom_dp` is the navigation-bar height (bottom of screen; 0 when using -/// full gesture navigation with no visible bar) -/// -/// Falls back to `(24.0, 0.0)` on any JNI failure so the status bar area is -/// always reserved even if the exact height cannot be determined. -pub(super) fn query_insets_dp() -> (f32, f32) { - // Clear any stale JNI exception from the fallback path so the caller's - // thread remains usable. Any pending exception is cleared at the end of - // do_query regardless of the outcome. - do_query().unwrap_or((24.0, 0.0)) -} - -// ── Implementation ──────────────────────────────────────────────────────────── - -fn do_query() -> Option<(f32, f32)> { - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the JVM pointer initialised by android-activity - // before android_main is called. It is valid for the process lifetime. - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; - let mut env = vm.attach_current_thread().ok()?; - - let result = query_with_env(&mut env); - - // Clear any pending JNI exception so the calling thread stays usable. - let _ = env.exception_clear(); - - result -} - -fn query_with_env(env: &mut JNIEnv<'_>) -> Option<(f32, f32)> { - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the Application jobject; Application IS a - // Context and provides Resources, so this is safe for resource queries. - let context = unsafe { JObject::from_raw(ctx.context().cast()) }; - - // context.getResources() → android.content.res.Resources - let resources = env - .call_method( - &context, - "getResources", - "()Landroid/content/res/Resources;", - &[], - ) - .ok()? - .l() - .ok()?; - - // resources.getDisplayMetrics() → android.util.DisplayMetrics - let metrics = env - .call_method( - &resources, - "getDisplayMetrics", - "()Landroid/util/DisplayMetrics;", - &[], - ) - .ok()? - .l() - .ok()?; - - // DisplayMetrics.density: float — e.g. 1.0 (mdpi), 2.0 (xhdpi), 3.0 (xxhdpi) - let density = env.get_field(&metrics, "density", "F").ok()?.f().ok()?; - if density <= 0.0 { - return None; - } - - let top_dp = dimen_dp(env, &resources, "status_bar_height", density).unwrap_or(24.0); - let bottom_dp = dimen_dp(env, &resources, "navigation_bar_height", density).unwrap_or(0.0); - - Some((top_dp, bottom_dp)) -} - -// ── Orientation-aware window insets (edge-to-edge) ────────────────────────────── - -/// Query the actual per-side safe-area insets from the activity's window, in dp. -/// -/// Returns `(top, bottom, left, right)` from -/// `decorView.getRootWindowInsets().getInsets(systemBars | displayCutout | ime)` -/// — the real, orientation-aware insets for an edge-to-edge window (e.g. in -/// landscape the navigation bar / cutout move to a side, so `left`/`right` -/// become non-zero and `top` shrinks). Unlike [`query_insets_dp`], this is not -/// orientation-independent. -/// -/// The mask also includes the soft-keyboard (IME) inset, so when the keyboard -/// is visible the returned `bottom` grows to the keyboard height (the -/// `getInsets` union takes the per-side max, and `ime()` only contributes a -/// bottom inset). Re-querying this after the keyboard is shown/hidden — see the -/// IME-settle re-sync in `blitz-shell` — lets the app reserve a bottom safe -/// area for the keyboard on a `NativeActivity` whose surface does not resize. -/// -/// `activity_ptr` is the activity `jobject` from -/// `android_activity::AndroidApp::activity_as_ptr()` — the `ndk_context` context -/// is the *Application*, which has no window, so the activity must be passed in. -/// -/// Returns `None` (caller should fall back to [`query_insets_dp`]) when the view -/// is not yet attached (`getRootWindowInsets` is null), on API < 30 -/// (`getInsets(int)` unavailable), or on any JNI failure. -pub(super) fn query_window_insets_dp( - activity_ptr: *mut std::ffi::c_void, -) -> Option<(f32, f32, f32, f32)> { - if activity_ptr.is_null() { - return None; - } - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the JVM pointer initialised by android-activity - // before android_main; valid for the process lifetime. - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; - let mut env = vm.attach_current_thread().ok()?; - let result = window_insets_with_env(&mut env, activity_ptr); - // Clear any pending exception (e.g. NoSuchMethodError on API < 30) so the - // calling thread stays usable. - let _ = env.exception_clear(); - result -} - -fn window_insets_with_env( - env: &mut JNIEnv<'_>, - activity_ptr: *mut std::ffi::c_void, -) -> Option<(f32, f32, f32, f32)> { - // SAFETY: `activity_ptr` is a global reference jobject owned by AndroidApp, - // valid for the activity's lifetime. - let activity = unsafe { JObject::from_raw(activity_ptr.cast()) }; - - // activity.getWindow().getDecorView().getRootWindowInsets() - let window = env - .call_method(&activity, "getWindow", "()Landroid/view/Window;", &[]) - .ok()? - .l() - .ok()?; - let decor = env - .call_method(&window, "getDecorView", "()Landroid/view/View;", &[]) - .ok()? - .l() - .ok()?; - let insets_obj = env - .call_method( - &decor, - "getRootWindowInsets", - "()Landroid/view/WindowInsets;", - &[], - ) - .ok()? - .l() - .ok()?; - if insets_obj.as_raw().is_null() { - return None; // view not attached yet - } - - // mask = WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout() - // | WindowInsets.Type.ime() (all static int methods, API 30+). - let type_cls = env.find_class("android/view/WindowInsets$Type").ok()?; - let system_bars = env - .call_static_method(&type_cls, "systemBars", "()I", &[]) - .ok()? - .i() - .ok()?; - let cutout = env - .call_static_method(&type_cls, "displayCutout", "()I", &[]) - .ok()? - .i() - .ok()?; - // Fold in the soft-keyboard (IME) inset so `bottom` reserves space for the - // keyboard when it is visible. `getInsets` returns the per-side union and - // `ime()` contributes only a bottom inset, so top/left/right are unaffected - // while the keyboard is hidden (its bottom inset is then 0). `ime()` is API - // 30+, the same level as `getInsets(int)`, so the existing `.ok()?` - // fallback to `query_insets_dp` already covers API < 30. - let ime = env - .call_static_method(&type_cls, "ime", "()I", &[]) - .ok()? - .i() - .ok()?; - let mask = system_bars | cutout | ime; - - // insets = windowInsets.getInsets(mask) → android.graphics.Insets (API 30+) - let insets = env - .call_method( - &insets_obj, - "getInsets", - "(I)Landroid/graphics/Insets;", - &[JValueGen::Int(mask)], - ) - .ok()? - .l() - .ok()?; - let left = env.get_field(&insets, "left", "I").ok()?.i().ok()?; - let top = env.get_field(&insets, "top", "I").ok()?.i().ok()?; - let right = env.get_field(&insets, "right", "I").ok()?.i().ok()?; - let bottom = env.get_field(&insets, "bottom", "I").ok()?.i().ok()?; - - let density = display_density(env)?; - if density <= 0.0 { - return None; - } - Some(( - top as f32 / density, - bottom as f32 / density, - left as f32 / density, - right as f32 / density, - )) -} - -/// Display density (e.g. 2.625) from the Application's `DisplayMetrics`. -fn display_density(env: &mut JNIEnv<'_>) -> Option { - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the Application jobject; it provides Resources. - let context = unsafe { JObject::from_raw(ctx.context().cast()) }; - let resources = env - .call_method( - &context, - "getResources", - "()Landroid/content/res/Resources;", - &[], - ) - .ok()? - .l() - .ok()?; - let metrics = env - .call_method( - &resources, - "getDisplayMetrics", - "()Landroid/util/DisplayMetrics;", - &[], - ) - .ok()? - .l() - .ok()?; - env.get_field(&metrics, "density", "F").ok()?.f().ok() -} - -/// Look up one Android system dimension resource and convert physical pixels → dp. -fn dimen_dp( - env: &mut JNIEnv<'_>, - resources: &JObject<'_>, - name: &str, - density: f32, -) -> Option { - let name_jstr = env.new_string(name).ok()?; - let type_jstr = env.new_string("dimen").ok()?; - let pkg_jstr = env.new_string("android").ok()?; - - // resources.getIdentifier(name, "dimen", "android") → int resource ID - let res_id: i32 = env - .call_method( - resources, - "getIdentifier", - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I", - &[ - JValueGen::Object(&name_jstr), - JValueGen::Object(&type_jstr), - JValueGen::Object(&pkg_jstr), - ], - ) - .ok()? - .i() - .ok()?; - - if res_id == 0 { - return Some(0.0); - } - - // resources.getDimensionPixelSize(resId) → int physical pixels - let px: i32 = env - .call_method( - resources, - "getDimensionPixelSize", - "(I)I", - &[JValueGen::Int(res_id)], - ) - .ok()? - .i() - .ok()?; - - Some(px as f32 / density) -} diff --git a/patches/loki-file-access/src/platform/android/jni_intents.rs b/patches/loki-file-access/src/platform/android/jni_intents.rs deleted file mode 100644 index 48af0478..00000000 --- a/patches/loki-file-access/src/platform/android/jni_intents.rs +++ /dev/null @@ -1,238 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! JNI helpers for post-pick SAF operations. -//! -//! Covers `ContentResolver.takePersistableUriPermission`, URI parsing, and the -//! content resolver accessor used by [`super::jni_fd`]. Intent launching is -//! handled by [`super::jni_activity`] (via `FilePickerActivity` trampoline). - -use super::jni_common::{attach_err, jvm_err, platform_err}; -use crate::error::PickerError; - -// ── SAF post-pick operations ────────────────────────────────────────────────── - -/// Call `ContentResolver.takePersistableUriPermission` for a content URI. -/// -/// Grants READ | WRITE persistable permission so the URI survives app restarts. -pub(super) fn take_persistable_uri_permission(uri: &str) -> Result<(), PickerError> { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; - let mut env = vm.attach_current_thread().map_err(attach_err)?; - - let uri_obj = parse_uri(&mut env, uri)?; - let resolver = get_content_resolver(&mut env, &ctx)?; - - // FLAG_GRANT_READ_URI_PERMISSION (1) | FLAG_GRANT_WRITE_URI_PERMISSION (2) - env.call_method( - &resolver, - "takePersistableUriPermission", - "(Landroid/net/Uri;I)V", - &[ - jni::objects::JValueGen::Object(&uri_obj), - jni::objects::JValueGen::Int(3), - ], - ) - .map_err(|e| platform_err("takePersistableUriPermission", e))?; - - Ok(()) -} - -// ── Display name query ──────────────────────────────────────────────────────── - -/// Query the human-readable display name for a content URI from ContentResolver. -/// -/// Uses `OpenableColumns.DISPLAY_NAME` (`"_display_name"`) via a cursor query. -/// Falls back to the last URI path segment (percent-decoded) on any JNI failure. -pub(super) fn query_display_name(uri_str: &str) -> String { - query_display_name_inner(uri_str).unwrap_or_else(|| { - let raw = uri_str.rsplit('/').next().unwrap_or("unnamed"); - percent_decode_last_segment(raw) - }) -} - -fn query_display_name_inner(uri_str: &str) -> Option { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; - let mut env = vm.attach_current_thread().ok()?; - - let uri_obj = parse_uri(&mut env, uri_str).ok()?; - let resolver = get_content_resolver(&mut env, &ctx).ok()?; - - // projection = new String[]{"_display_name"} - let str_cls = env.find_class("java/lang/String").ok()?; - let col_str = env.new_string("_display_name").ok()?; - let projection = env.new_object_array(1, &str_cls, &col_str).ok()?; - - // ContentResolver.query(uri, projection, null, null, null) → Cursor - // A misbehaving SAF provider may throw instead of returning null. Clear any - // pending JNI exception before returning so subsequent calls are not poisoned. - let null_obj = jni::objects::JObject::null(); - let query_result = env.call_method( - &resolver, - "query", - "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;\ - [Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;", - &[ - jni::objects::JValueGen::Object(&uri_obj), - jni::objects::JValueGen::Object(&projection), - jni::objects::JValueGen::Object(&null_obj), - jni::objects::JValueGen::Object(&null_obj), - jni::objects::JValueGen::Object(&null_obj), - ], - ); - let cursor = match query_result { - Ok(v) => v.l().ok()?, - Err(_) => { - let _ = env.exception_clear(); - return None; - } - }; - - if cursor.is_null() { - return None; - } - - // Extract the display name, then close the cursor unconditionally. - // read_display_name uses ok()? internally, which can exit without clearing - // a pending JNI exception from moveToFirst/getColumnIndex/getString. - // Calling close() while an exception is pending violates the JNI spec and - // risks an ART abort, so we clear any pending exception first. - let result = read_display_name(&mut env, &cursor); - let _ = env.exception_clear(); - let _ = env.call_method(&cursor, "close", "()V", &[]); - result -} - -/// Read `_display_name` from an already-moved-to-first cursor. -/// Separated from the main function so that every return path is guaranteed -/// to reach `cursor.close()` in the caller — no `?` can skip it. -fn read_display_name( - env: &mut jni::JNIEnv<'_>, - cursor: &jni::objects::JObject<'_>, -) -> Option { - let moved = env - .call_method(cursor, "moveToFirst", "()Z", &[]) - .ok()? - .z() - .ok()?; - - if !moved { - return None; - } - - // Column index is always 0 when the single-column projection {"_display_name"} - // is honoured by the provider. Use getColumnIndex as a defensive check. - let col_name = env.new_string("_display_name").ok()?; - let col_idx = env - .call_method( - cursor, - "getColumnIndex", - "(Ljava/lang/String;)I", - &[jni::objects::JValueGen::Object(&col_name)], - ) - .ok()? - .i() - .ok()?; - - if col_idx < 0 { - return None; - } - - let s_obj = env - .call_method( - cursor, - "getString", - "(I)Ljava/lang/String;", - &[jni::objects::JValueGen::Int(col_idx)], - ) - .ok()? - .l() - .ok()?; - - if s_obj.is_null() { - return None; - } - - let jstr: jni::objects::JString = s_obj.into(); - env.get_string(&jstr).ok().map(|js| String::from(js)) -} - -/// Minimal percent-decoder for the last URI path segment used as fallback. -/// -/// Decodes `%XX` sequences by accumulating raw bytes and then interpreting the -/// entire buffer as UTF-8. This correctly handles multi-byte UTF-8 sequences -/// such as `%C3%A9` (é) — decoding each byte individually via `char::from(u8)` -/// would produce mojibake for any non-ASCII character. -/// -/// Invalid or incomplete `%XX` sequences (e.g. `%GG`, `%2`, `%`) are emitted -/// literally rather than dropped. In URI path segments `+` is a literal plus -/// sign, not a space — only `%20` encodes a space. -fn percent_decode_last_segment(s: &str) -> String { - let mut buf: Vec = Vec::with_capacity(s.len()); - let bytes = s.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - let hi = (bytes[i + 1] as char).to_digit(16); - let lo = (bytes[i + 2] as char).to_digit(16); - if let (Some(h), Some(l)) = (hi, lo) { - buf.push((h * 16 + l) as u8); - i += 3; - continue; - } - } - // Not a valid %XX sequence — emit the byte as-is. - buf.push(bytes[i]); - i += 1; - } - // from_utf8 avoids a Cow allocation on valid UTF-8 (the common case for - // URI path segments). The lossy fallback is only reached for malformed - // byte sequences, which should not occur in well-formed content URIs. - let decoded = String::from_utf8(buf) - .unwrap_or_else(|e| String::from_utf8_lossy(&e.into_bytes()).into_owned()); - if decoded.is_empty() { "unnamed".to_string() } else { decoded } -} - -// ── Shared helpers ──────────────────────────────────────────────────────────── - -/// Parse a URI string into a `Uri` JNI object via `Uri.parse(String)`. -pub(super) fn parse_uri<'a>( - env: &mut jni::JNIEnv<'a>, - uri: &str, -) -> Result, PickerError> { - let cls = env - .find_class("android/net/Uri") - .map_err(|e| platform_err("Uri class", e))?; - let s = env - .new_string(uri) - .map_err(|e| platform_err("URI string", e))?; - env.call_static_method( - &cls, - "parse", - "(Ljava/lang/String;)Landroid/net/Uri;", - &[jni::objects::JValueGen::Object(&s)], - ) - .map_err(|e| platform_err("Uri.parse", e))? - .l() - .map_err(|e| platform_err("Uri.parse object", e)) -} - -/// Get the `ContentResolver` from the ndk_context Application/Activity. -pub(super) fn get_content_resolver<'a>( - env: &mut jni::JNIEnv<'a>, - ctx: &ndk_context::AndroidContext, -) -> Result, PickerError> { - // SAFETY: ndk_context stores a valid Application jobject. - let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - env.call_method( - &context, - "getContentResolver", - "()Landroid/content/ContentResolver;", - &[], - ) - .map_err(|e| platform_err("getContentResolver", e))? - .l() - .map_err(|e| platform_err("getContentResolver object", e)) -} - diff --git a/patches/loki-file-access/src/platform/android/mod.rs b/patches/loki-file-access/src/platform/android/mod.rs deleted file mode 100644 index 83d129c2..00000000 --- a/patches/loki-file-access/src/platform/android/mod.rs +++ /dev/null @@ -1,368 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Android file-picker implementation using the Storage Access Framework (SAF). -//! -//! File access is mediated through content URIs and `ContentResolver`, -//! ensuring that the app never accesses files via filesystem paths (unreliable -//! on modern Android). -//! -//! # Persistence -//! -//! After the user selects a file, `ContentResolver.takePersistableUriPermission()` -//! is called with READ | WRITE flags. This ensures the URI grant survives app -//! restarts and device reboots. -//! -//! # NativeActivity integration -//! -//! Call [`init_android`] from `android_main` before launching Dioxus: -//! -//! ```rust,no_run -//! fn android_main(android_app: android_activity::AndroidApp) { -//! // init_android is a no-op kept for API compatibility; ndk_context -//! // (initialised by android-activity before android_main) provides the -//! // Application context used by all JNI calls. -//! unsafe { loki_file_access::init_android(std::ptr::null_mut()); } -//! blitz_shell::set_android_app(android_app); -//! dioxus::launch(App); -//! } -//! ``` -//! -//! # Result delivery -//! -//! `ANativeActivityCallbacks` has no `onActivityResult` slot, so results cannot -//! be delivered directly to NativeActivity. Instead, NativeActivity calls -//! `startActivity(Intent → FilePickerActivity)`, which is a transparent Java -//! trampoline that runs its own `startActivityForResult(ACTION_OPEN_DOCUMENT)`. -//! `FilePickerActivity.onActivityResult` delivers the URI via the pre-compiled -//! JNI hook `Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult`. -//! -//! **Prerequisite**: `FilePickerActivity` must be declared in `AndroidManifest.xml` -//! and its compiled `classes.dex` injected into the APK (see `scripts/build-android.ps1`). - -mod jni_activity; -mod jni_common; -mod jni_fd; -mod jni_ime; -mod jni_insets; -mod jni_intents; - -use std::sync::{Arc, Mutex, OnceLock}; - -use crate::api::{PickOptions, SaveOptions}; -use crate::error::{AccessError, PickerError}; -use crate::future::{deliver, new_pick_future}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// Pending pick state shared between the intent launcher and the JNI callback. -/// -/// The payload is `Vec`: empty means cancelled, non-empty contains the -/// selected content URIs (one for single-pick, one or more for multi-pick). -static PENDING_PICK: OnceLock< - Mutex>>>>>, -> = OnceLock::new(); - -fn pending_pick( -) -> &'static Mutex>>>>> { - PENDING_PICK.get_or_init(|| Mutex::new(None)) -} - -// ── Public Android initialisation ───────────────────────────────────────────── - -/// Initialise the file-access layer. -/// -/// Must be called from `android_main` before launching Dioxus. The parameter -/// is accepted for API compatibility but is no longer stored — `startActivity` -/// now uses the Application context from `ndk_context` directly, which is set -/// up by `android-activity` before `android_main` is called. -/// -/// # Safety -/// -/// The caller is responsible for ensuring `android_main` setup (including -/// `ndk_context` initialisation by `android-activity`) is complete before -/// any file-picker calls are made. -pub unsafe fn init_android(_activity_as_ptr: *mut std::ffi::c_void) { - // No-op: ndk_context provides the Application object used by all JNI calls. -} - -/// Query Android system-bar heights from OS resources. -/// -/// Returns `(top_dp, bottom_dp)` — heights in density-independent pixels for -/// the status bar (top) and navigation bar (bottom). Safe to call immediately -/// after [`init_android`] before the window is laid out. -pub fn query_insets_dp() -> (f32, f32) { - jni_insets::query_insets_dp() -} - -/// Query orientation-aware safe-area insets from the activity window, in dp. -/// -/// Returns `(top, bottom, left, right)` from the real window insets (system bars -/// + display cutout), which — unlike [`query_insets_dp`] — change with -/// orientation. `activity_ptr` is `AndroidApp::activity_as_ptr()`. Returns -/// `None` before the window is laid out / on API < 30; callers fall back to -/// [`query_insets_dp`]. -pub fn query_window_insets_dp(activity_ptr: *mut std::ffi::c_void) -> Option<(f32, f32, f32, f32)> { - jni_insets::query_window_insets_dp(activity_ptr) -} - -// ── Soft-keyboard (IME) visibility signal ───────────────────────────────────── - -pub use jni_ime::{install_ime_listener, set_ime_visibility_listener}; - -// ── JNI result callback (called from Java FilePickerActivity) ───────────────── - -/// Delivers the selected URI (or `null` for cancellation) to the pending Rust future. -/// -/// Called by `FilePickerActivity.onActivityResult` via JNI. The method is -/// declared `private native void nativeOnResult(String)` in the Java class -/// `io.github.appthere.lokifileaccess.FilePickerActivity`, so the JVM resolves -/// it to this exported symbol automatically once the native library is loaded. -/// -/// **The native library is loaded by NativeActivity before `FilePickerActivity` -/// is ever created**, so `System.loadLibrary` is not needed in the Java class. -/// -/// # Safety -/// -/// Must be called from a JNI-attached Java thread. -#[allow(non_snake_case)] -#[unsafe(no_mangle)] -pub unsafe extern "system" fn Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult( - mut env: jni::JNIEnv<'_>, - _this: jni::objects::JObject<'_>, - uri: jni::objects::JString<'_>, -) { - // The Java side sends a '\n'-delimited string of content URIs. - // Null or an empty string means the user cancelled. - let uris: Vec = if uri.is_null() { - Vec::new() - } else { - env.get_string(&uri) - .ok() - .map(|s| { - let joined: String = s.into(); - joined - .split('\n') - .filter(|s| !s.is_empty()) - .map(str::to_owned) - .collect() - }) - .unwrap_or_default() - }; - on_activity_result(uris); -} - -// ── Pick / save entry points ────────────────────────────────────────────────── - -/// Pick a single file for reading via `ACTION_OPEN_DOCUMENT`. -pub(crate) async fn pick_open_single( - options: PickOptions, -) -> Result, PickerError> { - let uris = launch_open_intent(&options, false).await?; - match uris.into_iter().next() { - None => Ok(None), - Some(uri_str) => { - jni_intents::take_persistable_uri_permission(&uri_str)?; - let display_name = jni_intents::query_display_name(&uri_str); - Ok(Some(FileAccessToken { - inner: TokenInner::Android { - uri: uri_str, - display_name, - mime_type: None, - }, - })) - } - } -} - -/// Pick multiple files for reading via `ACTION_OPEN_DOCUMENT`. -pub(crate) async fn pick_open_multi( - options: PickOptions, -) -> Result, PickerError> { - let uris = launch_open_intent(&options, true).await?; - let mut tokens = Vec::with_capacity(uris.len()); - for uri_str in uris { - // Skip URIs whose persistable grant fails (e.g. a cloud provider that - // revoked the grant between SAF delivery and this call, or a URI that - // exceeds Android's per-app persisted-permission quota). Aborting the - // entire batch with `?` would orphan grants already taken for earlier - // URIs — those cannot be un-granted, silently consuming quota. - if jni_intents::take_persistable_uri_permission(&uri_str).is_err() { - tracing::warn!("loki-file-access: skipping URI with failed permission grant"); - continue; - } - let display_name = jni_intents::query_display_name(&uri_str); - tokens.push(FileAccessToken { - inner: TokenInner::Android { - uri: uri_str, - display_name, - mime_type: None, - }, - }); - } - Ok(tokens) -} - -/// Pick a save location via `ACTION_CREATE_DOCUMENT`. -pub(crate) async fn pick_save( - options: SaveOptions, -) -> Result, PickerError> { - let uris = launch_create_intent(&options).await?; - match uris.into_iter().next() { - None => Ok(None), - Some(uri_str) => { - jni_intents::take_persistable_uri_permission(&uri_str)?; - let display_name = options - .suggested_name - .clone() - .unwrap_or_else(|| "untitled".into()); - Ok(Some(FileAccessToken { - inner: TokenInner::Android { - uri: uri_str, - display_name, - mime_type: options.mime_type.clone(), - }, - })) - } - } -} - -/// Open a content URI for reading. -pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Android { uri, .. } => { - let fd = jni_fd::open_fd(uri, "r")?; - // SAFETY: `open_fd` returns a valid file descriptor from - // Android's `ContentResolver.openFileDescriptor` after detaching it. - // The caller takes ownership; it must not be double-closed. - let file: std::fs::File = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) }; - Ok(Box::new(file)) - } - _ => Err(AccessError::Platform { - message: "non-Android token on Android platform".into(), - }), - } -} - -/// Open a content URI for writing. -pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Android { uri, .. } => { - let fd = jni_fd::open_fd(uri, "w")?; - // SAFETY: Same invariant as `open_read` — see above. - let file: std::fs::File = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) }; - Ok(Box::new(file)) - } - _ => Err(AccessError::Platform { - message: "non-Android token on Android platform".into(), - }), - } -} - -/// Delete the file referenced by a token. -/// -/// Deleting a SAF document URI requires `DocumentsContract.deleteDocument`, -/// which is not yet wired through JNI. Return an explicit unsupported error -/// rather than silently succeeding. -pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { - Err(AccessError::Unsupported { - operation: "delete (Android SAF content-URI deletion not implemented)".into(), - }) -} - -/// Check whether a persistable URI permission is still held. -pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { - match inner { - TokenInner::Android { uri, .. } => jni_fd::check_persisted_permission(uri) - .unwrap_or(PermissionStatus::Unknown), - _ => PermissionStatus::Unknown, - } -} - -/// Deliver the selected URIs (or an empty Vec for cancellation) to the pending future. -pub fn on_activity_result(uris: Vec) { - let guard = match pending_pick().lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - if let Some(ref state) = *guard { - deliver(state, uris); - } -} - -// ── Internal helpers ────────────────────────────────────────────────────────── - -/// Seconds to wait before treating a non-returning file picker as abandoned. -const PICKER_TIMEOUT_SECS: u64 = 600; - -/// Spawn a background thread that cancels the pick after [`PICKER_TIMEOUT_SECS`]. -/// -/// If `nativeOnResult` fires before the deadline, the PickState already has a -/// result (`result.is_some()`). The thread then exits without overwriting it, -/// so a real URI is never silently replaced by a spurious cancellation. -/// -/// This guards against Android 12+ silently blocking `startActivity` when the -/// app has no foreground window: without this thread, `future.await` hangs -/// forever. The spawned thread exits immediately after the normal pick -/// completes, so the OS thread count stays bounded in typical usage. -fn spawn_timeout_guard(state: Arc>>>) { - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(PICKER_TIMEOUT_SECS)); - // Only deliver if the pick has not already resolved. Overwriting a - // real result here would silently cancel a successful file open. - let waker = { - let mut guard = match state.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - if guard.result.is_some() { - return; // Real result already delivered; nothing to do. - } - guard.result = Some(Vec::new()); - guard.waker.take() - }; - if let Some(w) = waker { - w.wake(); - } - }); -} - -fn store_pending( - state: Arc>>>, -) -> Result<(), PickerError> { - let mut guard = pending_pick().lock().map_err(|e| PickerError::Internal { - message: e.to_string(), - })?; - *guard = Some(state); - Ok(()) -} - -/// Launch `FilePickerActivity` to open a file and await the result. -/// -/// Returns an empty `Vec` on cancellation, or one URI per selected file. -async fn launch_open_intent( - options: &PickOptions, - allow_multiple: bool, -) -> Result, PickerError> { - let (future, state) = new_pick_future::>(); - // Clone the Arc before moving `state` into store_pending so the timeout - // thread can deliver an empty result if nativeOnResult never fires. - let timeout_state = Arc::clone(&state); - store_pending(state)?; - jni_activity::fire_open_file_picker(options, allow_multiple)?; - spawn_timeout_guard(timeout_state); - Ok(future.await) -} - -/// Launch `FilePickerActivity` to save a file and await the result. -/// -/// Returns an empty `Vec` on cancellation, or a single-element `Vec` on success. -async fn launch_create_intent( - options: &SaveOptions, -) -> Result, PickerError> { - let (future, state) = new_pick_future::>(); - let timeout_state = Arc::clone(&state); - store_pending(state)?; - jni_activity::fire_create_file_picker(options)?; - spawn_timeout_guard(timeout_state); - Ok(future.await) -} diff --git a/patches/loki-file-access/src/platform/desktop/filters.rs b/patches/loki-file-access/src/platform/desktop/filters.rs deleted file mode 100644 index 14131183..00000000 --- a/patches/loki-file-access/src/platform/desktop/filters.rs +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! MIME-type-to-extension mapping and extension validation for `rfd` filters. -//! -//! Kept in its own file to stay within the 300-line ceiling for each source -//! file while allowing `super` to use both helpers without qualification. - -/// Returns `true` if `ext` is safe to pass as an `rfd` filter extension. -/// -/// Windows `IFileOpenDialog::SetFileTypes` requires extension strings that -/// contain no dots, slashes, or whitespace. MIME subtype fallbacks for -/// `vnd.*` types (e.g. `vnd.openxmlformats-officedocument.wordprocessingml.document`) -/// contain dots and would produce a filter pattern that matches no real files, -/// causing the dialog to display only folder entries. -pub(super) fn is_valid_extension(ext: &str) -> bool { - !ext.is_empty() - && ext - .bytes() - .all(|b| b != b'.' && b != b'/' && b != b'\\' && !b.is_ascii_whitespace()) -} - -/// Convert MIME types to file extensions for the `rfd` filter. -/// -/// Returns one extension string per input MIME type. Results that are not -/// valid Windows `SetFileTypes` extension strings (containing dots, slashes, -/// or whitespace) should be discarded by the caller via [`is_valid_extension`] -/// before passing to `rfd::AsyncFileDialog::add_filter`. -/// -/// For MIME types not in the static table the subtype component is returned -/// as-is; [`is_valid_extension`] will reject any subtype that contains dots -/// (e.g. all `vnd.*` types not listed here). -pub(super) fn mime_types_to_extensions(mime_types: &[String]) -> Vec { - mime_types - .iter() - .map(|mime| match mime.as_str() { - // Plain text / markup - "text/plain" => "txt".into(), - "text/html" => "html".into(), - "text/css" => "css".into(), - "text/csv" => "csv".into(), - "text/markdown" | "text/x-markdown" => "md".into(), - "text/rtf" | "application/rtf" => "rtf".into(), - // Data formats - "application/json" => "json".into(), - "application/xml" | "text/xml" => "xml".into(), - "application/pdf" => "pdf".into(), - // Archives - "application/zip" => "zip".into(), - "application/x-tar" => "tar".into(), - "application/gzip" | "application/x-gzip" => "gz".into(), - "application/x-bzip2" => "bz2".into(), - "application/x-7z-compressed" => "7z".into(), - // Images - "image/png" => "png".into(), - "image/jpeg" => "jpg".into(), - "image/gif" => "gif".into(), - "image/svg+xml" => "svg".into(), - "image/webp" => "webp".into(), - "image/tiff" => "tiff".into(), - "image/bmp" => "bmp".into(), - // Audio / video - "audio/mpeg" => "mp3".into(), - "audio/wav" | "audio/x-wav" => "wav".into(), - "audio/ogg" => "ogg".into(), - "audio/flac" => "flac".into(), - "video/mp4" => "mp4".into(), - "video/webm" => "webm".into(), - "video/x-matroska" => "mkv".into(), - // Microsoft Office (legacy binary formats) - "application/msword" => "doc".into(), - "application/vnd.ms-excel" => "xls".into(), - "application/vnd.ms-powerpoint" => "ppt".into(), - // Microsoft Office Open XML - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { - "docx".into() - } - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx".into(), - "application/vnd.openxmlformats-officedocument.presentationml.presentation" => { - "pptx".into() - } - // OpenDocument formats - "application/vnd.oasis.opendocument.text" => "odt".into(), - "application/vnd.oasis.opendocument.spreadsheet" => "ods".into(), - "application/vnd.oasis.opendocument.presentation" => "odp".into(), - // E-book / other documents - "application/epub+zip" => "epub".into(), - other => { - // Fall back to the subtype component; callers must discard - // results that fail `is_valid_extension` (e.g. vnd.* subtypes - // not listed above that contain dots). - other.split('/').nth(1).unwrap_or(other).to_owned() - } - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn known_mime_types_produce_valid_extensions() { - let cases = [ - ("text/plain", "txt"), - ("application/pdf", "pdf"), - ("application/msword", "doc"), - ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "docx", - ), - ("application/vnd.ms-excel", "xls"), - ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "xlsx", - ), - ("application/vnd.oasis.opendocument.text", "odt"), - ("image/svg+xml", "svg"), - ("application/epub+zip", "epub"), - ]; - for (mime, expected_ext) in cases { - let result = mime_types_to_extensions(&[mime.to_owned()]); - assert_eq!(result[0], expected_ext, "wrong ext for {mime}"); - assert!( - is_valid_extension(&result[0]), - "ext '{}' for '{mime}' failed is_valid_extension", - result[0] - ); - } - } - - #[test] - fn unlisted_vnd_mime_type_produces_invalid_extension() { - // An unlisted vnd.* type hits the fallback and produces a dotted - // subtype that must be rejected by is_valid_extension. - let result = mime_types_to_extensions(&["application/vnd.example.format".to_owned()]); - assert!( - !is_valid_extension(&result[0]), - "dotted fallback must be invalid" - ); - } - - #[test] - fn is_valid_extension_rejects_dots_and_slashes() { - assert!(!is_valid_extension("")); - assert!(!is_valid_extension("vnd.foo.bar")); - assert!(!is_valid_extension("foo/bar")); - assert!(!is_valid_extension("foo bar")); - assert!(is_valid_extension("docx")); - assert!(is_valid_extension("txt")); - assert!(is_valid_extension("7z")); - } -} diff --git a/patches/loki-file-access/src/platform/desktop/mod.rs b/patches/loki-file-access/src/platform/desktop/mod.rs deleted file mode 100644 index cba1dda8..00000000 --- a/patches/loki-file-access/src/platform/desktop/mod.rs +++ /dev/null @@ -1,420 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Desktop file-picker implementation (Windows, macOS, Linux, BSD). -//! -//! This module wraps [`rfd::AsyncFileDialog`] and simply `.await`s its -//! futures. `rfd` carries its own internal dispatch mechanism and does **not** -//! require Tokio or async-std; any executor that correctly yields between polls -//! works (Dioxus, egui, Iced, `pollster::block_on`, etc.). -//! -//! ## Why `.await` instead of `pollster::block_on` -//! -//! On **macOS**, `rfd::AsyncFileDialog` presents `NSOpenPanel` by dispatching -//! to the main thread via Grand Central Dispatch (`dispatch_async` on the main -//! queue). If the caller blocks its own thread with `pollster::block_on` -//! *and that thread is the main thread* (the typical case in Dioxus Native), -//! GCD can never execute the dispatch — the dialog never appears and the app -//! hangs with a spinning beach ball. -//! -//! Co-operatively awaiting the future with `.await` yields to the executor, -//! keeping the main-thread run loop free to process GCD events. On -//! **Windows** and **Linux** there is no main-thread constraint, so `.await` -//! is equally correct there. -//! -//! ## Linux fallback -//! -//! On Linux, rfd uses the XDG Desktop Portal via D-Bus. When the portal is -//! unavailable (e.g. ChromeOS Crostini), rfd returns `None`. In that case -//! the picker falls back to `zenity` (a GNOME subprocess dialog) if it is -//! installed. The zenity call runs on a dedicated thread so it does not block -//! the async executor. - -mod filters; -#[cfg(target_os = "linux")] -mod zenity; - -use filters::{is_valid_extension, mime_types_to_extensions}; - -use crate::api::{PickOptions, SaveOptions}; -use crate::error::{AccessError, PickerError}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// On Linux, check the `LOKI_FILE_ACCESS_BACKEND` environment variable. -/// -/// Setting it to `"none"` disables the picker and surfaces a clear error -/// instead of a silent no-op. This is a developer escape hatch for -/// environments where neither the XDG Desktop Portal nor zenity is available. -/// Valid values: `"auto"` (default), `"none"`. -#[cfg(target_os = "linux")] -fn check_backend_env() -> Result<(), PickerError> { - if std::env::var("LOKI_FILE_ACCESS_BACKEND").as_deref() == Ok("none") { - return Err(PickerError::Platform { - message: "file picker disabled via LOKI_FILE_ACCESS_BACKEND=none. \ - On Linux, the XDG Desktop Portal or zenity must be available. \ - Unset LOKI_FILE_ACCESS_BACKEND or set it to \"auto\" to re-enable." - .into(), - }); - } - Ok(()) -} - -#[cfg(not(target_os = "linux"))] -#[inline(always)] -fn check_backend_env() -> Result<(), PickerError> { - Ok(()) -} - -/// Pick a single file for reading. -/// -/// Converts `options.mime_types` to file-extension filters understood by the -/// native dialog. On Windows, extensions that contain dots or other -/// characters invalid for `IFileOpenDialog::SetFileTypes` are silently -/// dropped; if all extensions are invalid the filter is omitted entirely so -/// that all files remain visible. -/// -/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. -/// -/// # Errors -/// -/// Returns [`PickerError`] if the platform dialog could not be presented. -/// -/// # Examples -/// -/// ```no_run -/// # async fn example() -> Result<(), loki_file_access::PickerError> { -/// use loki_file_access::{FilePicker, PickOptions}; -/// let token = FilePicker::new() -/// .pick_file_to_open(PickOptions { -/// mime_types: vec!["application/pdf".into()], -/// ..Default::default() -/// }) -/// .await?; -/// # Ok(()) } -/// ``` -pub(crate) async fn pick_open_single( - options: PickOptions, -) -> Result, PickerError> { - check_backend_env()?; - - // Capture filter data before building the rfd dialog so the zenity fallback - // thread closure can take ownership of the same values. - let filter_label = options - .filter_label - .as_deref() - .unwrap_or("Files") - .to_owned(); - let filter_exts: Vec = if !options.mime_types.is_empty() { - mime_types_to_extensions(&options.mime_types) - .into_iter() - .filter(|e| is_valid_extension(e)) - .collect() - } else { - vec![] - }; - - let mut dialog = rfd::AsyncFileDialog::new(); - if !filter_exts.is_empty() { - let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); - dialog = dialog.add_filter(&filter_label, &ext_refs); - } - - match dialog.pick_file().await { - Some(h) => { - let path = h.path().to_path_buf(); - let display_name = file_name_from_path(&path); - Ok(Some(FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - })) - } - None => { - #[cfg(target_os = "linux")] - { - if zenity::is_zenity_available() { - tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); - let (fut, state) = crate::future::new_pick_future(); - std::thread::spawn(move || { - let result = - zenity::pick_file("Open File".into(), filter_label, filter_exts).map( - |opt| { - opt.map(|path| { - let display_name = file_name_from_path(&path); - FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - } - }) - }, - ); - crate::future::deliver(&state, result); - }); - return fut.await; - } - tracing::warn!( - "File picker returned no result. \ - On ChromeOS Crostini or minimal Linux environments, \ - install zenity for file dialog support: sudo apt install zenity" - ); - } - Ok(None) - } - } -} - -/// Pick multiple files for reading. -/// -/// Applies the same extension-validation logic as [`pick_open_single`]: -/// invalid extensions (containing dots etc.) are dropped, and if none remain -/// the filter is omitted so all files stay visible. -/// -/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. -/// -/// # Errors -/// -/// Returns [`PickerError`] if the platform dialog could not be presented. -/// -/// # Examples -/// -/// ```no_run -/// # async fn example() -> Result<(), loki_file_access::PickerError> { -/// use loki_file_access::{FilePicker, PickOptions}; -/// let tokens = FilePicker::new() -/// .pick_files_to_open(PickOptions { -/// mime_types: vec!["image/png".into(), "image/jpeg".into()], -/// ..Default::default() -/// }) -/// .await?; -/// # Ok(()) } -/// ``` -pub(crate) async fn pick_open_multi( - options: PickOptions, -) -> Result, PickerError> { - check_backend_env()?; - - let filter_label = options - .filter_label - .as_deref() - .unwrap_or("Files") - .to_owned(); - let filter_exts: Vec = if !options.mime_types.is_empty() { - mime_types_to_extensions(&options.mime_types) - .into_iter() - .filter(|e| is_valid_extension(e)) - .collect() - } else { - vec![] - }; - - let mut dialog = rfd::AsyncFileDialog::new(); - if !filter_exts.is_empty() { - let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); - dialog = dialog.add_filter(&filter_label, &ext_refs); - } - - match dialog.pick_files().await { - Some(list) => { - let tokens = list - .into_iter() - .map(|h| { - let path = h.path().to_path_buf(); - let display_name = file_name_from_path(&path); - FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - } - }) - .collect(); - Ok(tokens) - } - None => { - #[cfg(target_os = "linux")] - { - if zenity::is_zenity_available() { - tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); - let (fut, state) = crate::future::new_pick_future(); - std::thread::spawn(move || { - let result = - zenity::pick_files("Open Files".into(), filter_label, filter_exts).map( - |paths| { - paths - .into_iter() - .map(|path| { - let display_name = file_name_from_path(&path); - FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - } - }) - .collect() - }, - ); - crate::future::deliver(&state, result); - }); - return fut.await; - } - tracing::warn!( - "File picker returned no result. \ - On ChromeOS Crostini or minimal Linux environments, \ - install zenity for file dialog support: sudo apt install zenity" - ); - } - Ok(vec![]) - } - } -} - -/// Pick a save location. -/// -/// Applies the same extension-validation logic as the open-picker functions: -/// the MIME type is converted to an extension, and `add_filter` is only called -/// if the resulting extension is valid for the native dialog. -/// -/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. -/// -/// # Errors -/// -/// Returns [`PickerError`] if the platform dialog could not be presented. -/// -/// # Examples -/// -/// ```no_run -/// # async fn example() -> Result<(), loki_file_access::PickerError> { -/// use loki_file_access::{FilePicker, SaveOptions}; -/// let token = FilePicker::new() -/// .pick_file_to_save(SaveOptions { -/// mime_type: Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document".into()), -/// suggested_name: Some("report.docx".into()), -/// }) -/// .await?; -/// # Ok(()) } -/// ``` -pub(crate) async fn pick_save( - options: SaveOptions, -) -> Result, PickerError> { - check_backend_env()?; - - let suggested_name = options.suggested_name; - let filter_label = "File".to_owned(); - let filter_exts: Vec = if let Some(ref mime) = options.mime_type { - mime_types_to_extensions(std::slice::from_ref(mime)) - .into_iter() - .filter(|e| is_valid_extension(e)) - .collect() - } else { - vec![] - }; - - let mut dialog = rfd::AsyncFileDialog::new(); - if let Some(ref name) = suggested_name { - dialog = dialog.set_file_name(name); - } - if !filter_exts.is_empty() { - let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); - dialog = dialog.add_filter(&filter_label, &ext_refs); - } - - match dialog.save_file().await { - Some(h) => { - let path = h.path().to_path_buf(); - let display_name = file_name_from_path(&path); - Ok(Some(FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - })) - } - None => { - #[cfg(target_os = "linux")] - { - if zenity::is_zenity_available() { - tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); - let (fut, state) = crate::future::new_pick_future(); - std::thread::spawn(move || { - let result = zenity::pick_save( - "Save File".into(), - suggested_name, - filter_label, - filter_exts, - ) - .map(|opt| { - opt.map(|path| { - let display_name = file_name_from_path(&path); - FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - } - }) - }); - crate::future::deliver(&state, result); - }); - return fut.await; - } - tracing::warn!( - "Save-file picker returned no result. \ - On ChromeOS Crostini or minimal Linux environments, \ - install zenity for file dialog support: sudo apt install zenity" - ); - } - Ok(None) - } - } -} - -/// Open a token for reading. -pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Desktop { path, .. } => { - let file = std::fs::File::open(path)?; - Ok(Box::new(file)) - } - _ => Err(AccessError::Platform { - message: "non-desktop token on desktop platform".into(), - }), - } -} - -/// Open a token for writing. -pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Desktop { path, .. } => { - let file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .open(path)?; - Ok(Box::new(file)) - } - _ => Err(AccessError::Platform { - message: "non-desktop token on desktop platform".into(), - }), - } -} - -/// Delete the file referenced by a token. -pub(crate) fn delete(inner: &TokenInner) -> Result<(), AccessError> { - match inner { - TokenInner::Desktop { path, .. } => { - std::fs::remove_file(path)?; - Ok(()) - } - _ => Err(AccessError::Platform { - message: "non-desktop token on desktop platform".into(), - }), - } -} - -/// Check permission status for a token. -pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { - match inner { - TokenInner::Desktop { path, .. } => { - if path.exists() { - PermissionStatus::Valid - } else { - PermissionStatus::Revoked - } - } - _ => PermissionStatus::Unknown, - } -} - -/// Extract a display name from a filesystem path. -fn file_name_from_path(path: &std::path::Path) -> String { - path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed") - .to_owned() -} diff --git a/patches/loki-file-access/src/platform/desktop/zenity.rs b/patches/loki-file-access/src/platform/desktop/zenity.rs deleted file mode 100644 index 1e037b0a..00000000 --- a/patches/loki-file-access/src/platform/desktop/zenity.rs +++ /dev/null @@ -1,121 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Zenity subprocess fallback for Linux environments where the XDG Desktop -//! Portal is unavailable (e.g. ChromeOS Crostini). -//! -//! Requires `zenity` to be installed: `sudo apt install zenity` - -use std::path::PathBuf; -use std::process::Command; - -use crate::error::PickerError; - -/// Returns `true` if zenity is installed and executable. -pub(super) fn is_zenity_available() -> bool { - Command::new("zenity") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -/// Open a single-file picker via zenity. -/// -/// Returns `Ok(Some(path))` on selection, `Ok(None)` on cancel. -pub(super) fn pick_file( - title: String, - filter_label: String, - filter_exts: Vec, -) -> Result, PickerError> { - let mut cmd = build_open_cmd(&filter_label, &filter_exts); - cmd.arg("--title").arg(title); - run_single(cmd) -} - -/// Open a multi-file picker via zenity. -/// -/// Returns `Ok(vec)` of selected paths, empty on cancel. -pub(super) fn pick_files( - title: String, - filter_label: String, - filter_exts: Vec, -) -> Result, PickerError> { - let mut cmd = build_open_cmd(&filter_label, &filter_exts); - cmd.arg("--multiple"); - cmd.arg("--separator").arg("\n"); - cmd.arg("--title").arg(title); - run_multi(cmd) -} - -/// Open a save-file dialog via zenity. -/// -/// Returns `Ok(Some(path))` on selection, `Ok(None)` on cancel. -pub(super) fn pick_save( - title: String, - default_name: Option, - filter_label: String, - filter_exts: Vec, -) -> Result, PickerError> { - let mut cmd = build_open_cmd(&filter_label, &filter_exts); - cmd.arg("--save"); - cmd.arg("--confirm-overwrite"); - cmd.arg("--title").arg(title); - if let Some(name) = default_name { - cmd.arg("--filename").arg(name); - } - run_single(cmd) -} - -/// Build a base `zenity --file-selection` command with an optional file filter. -fn build_open_cmd(filter_label: &str, filter_exts: &[String]) -> Command { - let mut cmd = Command::new("zenity"); - cmd.arg("--file-selection"); - if !filter_exts.is_empty() { - // zenity --file-filter format: "Label | *.ext1 *.ext2" - let pattern = filter_exts - .iter() - .map(|e| format!("*.{}", e.trim_start_matches('.'))) - .collect::>() - .join(" "); - cmd.arg("--file-filter") - .arg(format!("{filter_label} | {pattern}")); - } - cmd -} - -/// Run zenity and parse a single path from stdout. -/// -/// Exit code 1 means the user cancelled — returned as `Ok(None)`, not an error. -fn run_single(mut cmd: Command) -> Result, PickerError> { - let output = cmd.output().map_err(|e| PickerError::Platform { - message: format!("failed to launch zenity: {e}"), - })?; - if !output.status.success() { - return Ok(None); - } - let path = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - if path.is_empty() { - Ok(None) - } else { - Ok(Some(PathBuf::from(path))) - } -} - -/// Run zenity and parse newline-separated paths from stdout. -/// -/// Exit code 1 means the user cancelled — returned as `Ok(vec![])`, not an error. -fn run_multi(mut cmd: Command) -> Result, PickerError> { - let output = cmd.output().map_err(|e| PickerError::Platform { - message: format!("failed to launch zenity: {e}"), - })?; - if !output.status.success() { - return Ok(vec![]); - } - let paths = String::from_utf8_lossy(&output.stdout) - .lines() - .filter(|l| !l.is_empty()) - .map(PathBuf::from) - .collect(); - Ok(paths) -} diff --git a/patches/loki-file-access/src/platform/ios/bookmark.rs b/patches/loki-file-access/src/platform/ios/bookmark.rs deleted file mode 100644 index 02bb6fc7..00000000 --- a/patches/loki-file-access/src/platform/ios/bookmark.rs +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! iOS security-scoped bookmark helpers. -//! -//! This submodule handles creating, resolving, and managing security-scoped -//! bookmarks for iOS file access. It also provides the [`ScopedBookmarkFile`] -//! RAII guard that calls `stopAccessingSecurityScopedResource()` on drop. - -use crate::error::{AccessError, PickerError}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// Create a [`FileAccessToken`] from an iOS file URL string. -/// -/// This function calls `startAccessingSecurityScopedResource()`, creates a -/// bookmark via `NSURL.bookmarkData(...)`, then calls -/// `stopAccessingSecurityScopedResource()`. -pub(super) fn token_from_url(url: &str) -> Result { - // In a full implementation: - // 1. Create NSURL from the string - // 2. Call startAccessingSecurityScopedResource() - // 3. Create bookmark data via bookmarkData(options:...) - // 4. Call stopAccessingSecurityScopedResource() - // 5. Extract the display name from the URL's lastPathComponent - let display_name = url.rsplit('/').next().unwrap_or("unnamed").to_owned(); - - Ok(FileAccessToken { - inner: TokenInner::Ios { - bookmark: url.as_bytes().to_vec(), - display_name, - mime_type: None, - }, - }) -} - -/// Open a security-scoped bookmark for reading. -/// -/// Resolves the bookmark to a URL, starts accessing the security-scoped -/// resource, and opens the file. Returns a [`ScopedBookmarkFile`] that -/// stops access on drop. -pub(super) fn open_read_bookmark(bookmark: &[u8]) -> Result, AccessError> { - // In a full implementation: - // 1. Resolve bookmark via NSURL.init(resolvingBookmarkData:...) - // 2. Call startAccessingSecurityScopedResource() - // 3. Open the file path for reading - // 4. Wrap in ScopedBookmarkFile - let _url = String::from_utf8(bookmark.to_vec()).map_err(|_| AccessError::Platform { - message: "invalid bookmark data".into(), - })?; - - Err(AccessError::Platform { - message: "iOS bookmark resolution requires Objective-C runtime".into(), - }) -} - -/// Open a security-scoped bookmark for writing. -pub(super) fn open_write_bookmark(bookmark: &[u8]) -> Result, AccessError> { - let _url = String::from_utf8(bookmark.to_vec()).map_err(|_| AccessError::Platform { - message: "invalid bookmark data".into(), - })?; - - Err(AccessError::Platform { - message: "iOS bookmark resolution requires Objective-C runtime".into(), - }) -} - -/// Check whether a bookmark can still be resolved. -pub(super) fn check_bookmark(bookmark: &[u8]) -> PermissionStatus { - // In a full implementation this would attempt to resolve the bookmark - // and check whether it is stale. - if bookmark.is_empty() { - PermissionStatus::Revoked - } else { - PermissionStatus::Unknown - } -} - -/// RAII guard that calls `stopAccessingSecurityScopedResource()` on drop. -/// -/// Wraps a `std::fs::File` and the URL handle so that the security scope -/// is released when the file is closed. -#[allow(dead_code)] // Used in full iOS implementation, placeholder here -pub(super) struct ScopedBookmarkFile { - /// The underlying file handle. - file: std::fs::File, - /// The URL string, retained for `stopAccessingSecurityScopedResource`. - url: String, -} - -impl std::io::Read for ScopedBookmarkFile { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - self.file.read(buf) - } -} - -impl std::io::Write for ScopedBookmarkFile { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.file.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.file.flush() - } -} - -impl std::io::Seek for ScopedBookmarkFile { - fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { - self.file.seek(pos) - } -} - -impl Drop for ScopedBookmarkFile { - fn drop(&mut self) { - // In a full implementation: - // Resolve self.url back to NSURL and call - // stopAccessingSecurityScopedResource(). - } -} diff --git a/patches/loki-file-access/src/platform/ios/mod.rs b/patches/loki-file-access/src/platform/ios/mod.rs deleted file mode 100644 index 825cdba7..00000000 --- a/patches/loki-file-access/src/platform/ios/mod.rs +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! iOS file-picker implementation using `UIDocumentPickerViewController`. -//! -//! This module presents the system document picker via UIKit and manages -//! security-scoped bookmarks for persistent file access. Each selected URL -//! is converted to a bookmark that can be stored and resolved across app -//! restarts, as required by Apple's security-scoped resource model. -//! -//! # Security-Scoped Resources -//! -//! iOS requires calling `startAccessingSecurityScopedResource()` before -//! opening a bookmarked URL and `stopAccessingSecurityScopedResource()` when -//! done. The [`ScopedBookmarkFile`] RAII guard handles this automatically. - -mod bookmark; - -use std::sync::Arc; - -use crate::api::{PickOptions, SaveOptions}; -use crate::error::{AccessError, PickerError}; -use crate::future::{deliver, new_pick_future}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// Pick a single file for reading. -pub(crate) async fn pick_open_single( - options: PickOptions, -) -> Result, PickerError> { - let urls = present_picker(&options, false).await?; - match urls.into_iter().next() { - None => Ok(None), - Some(url) => { - let token = bookmark::token_from_url(&url)?; - Ok(Some(token)) - } - } -} - -/// Pick multiple files for reading. -pub(crate) async fn pick_open_multi( - options: PickOptions, -) -> Result, PickerError> { - let urls = present_picker(&options, true).await?; - urls.iter() - .map(|url| bookmark::token_from_url(url)) - .collect() -} - -/// Pick a save location. -pub(crate) async fn pick_save( - options: SaveOptions, -) -> Result, PickerError> { - let url = present_save_picker(&options).await?; - match url { - None => Ok(None), - Some(u) => { - let token = bookmark::token_from_url(&u)?; - Ok(Some(token)) - } - } -} - -/// Open a bookmarked file for reading. -pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Ios { bookmark, .. } => bookmark::open_read_bookmark(bookmark), - _ => Err(AccessError::Platform { - message: "non-iOS token on iOS platform".into(), - }), - } -} - -/// Open a bookmarked file for writing. -pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Ios { bookmark, .. } => bookmark::open_write_bookmark(bookmark), - _ => Err(AccessError::Platform { - message: "non-iOS token on iOS platform".into(), - }), - } -} - -/// Delete the file referenced by a token. -/// -/// Deleting a security-scoped bookmarked file is not currently implemented. -/// Return an explicit unsupported error rather than silently succeeding. -pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { - Err(AccessError::Unsupported { - operation: "delete (iOS bookmarked-file deletion not implemented)".into(), - }) -} - -/// Check whether a bookmark is still resolvable. -pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { - match inner { - TokenInner::Ios { bookmark, .. } => bookmark::check_bookmark(bookmark), - _ => PermissionStatus::Unknown, - } -} - -/// Present the document picker for opening files. -async fn present_picker( - _options: &PickOptions, - _allow_multiple: bool, -) -> Result, PickerError> { - let (future, state) = new_pick_future::>(); - let state_clone = Arc::clone(&state); - - // The delegate callback will call `deliver` with the selected URLs. - // In a full implementation this would use `objc2::declare_class!` to - // create a `UIDocumentPickerDelegate` and present the picker on the - // root view controller. The delegate's - // `documentPicker:didPickDocumentsAtURLs:` method would extract the - // URL strings and call `deliver(&state_clone, urls)`. - // - // Placeholder: deliver an empty result to avoid hanging. - deliver(&state_clone, vec![]); - - Ok(future.await) -} - -/// Present the document picker for saving a file. -async fn present_save_picker(_options: &SaveOptions) -> Result, PickerError> { - let (future, state) = new_pick_future::>(); - let state_clone = Arc::clone(&state); - - // Same placeholder approach as `present_picker`. - deliver(&state_clone, None); - - Ok(future.await) -} diff --git a/patches/loki-file-access/src/platform/mod.rs b/patches/loki-file-access/src/platform/mod.rs deleted file mode 100644 index 75e84e91..00000000 --- a/patches/loki-file-access/src/platform/mod.rs +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Platform dispatch layer. -//! -//! This module selects the appropriate platform implementation at compile time -//! using `cfg` attributes and re-exports the four operations that the public -//! API delegates to: -//! -//! - [`pick_open_single`] — pick one file for reading -//! - [`pick_open_multi`] — pick multiple files for reading -//! - [`pick_save`] — pick a save location -//! - [`open_read`] / [`open_write`] — open a token for I/O -//! - [`check_permission`] — query whether a token is still valid - -#[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] -mod desktop; -#[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] -pub(crate) use desktop::{ - check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, -}; - -#[cfg(target_os = "android")] -mod android; -#[cfg(target_os = "android")] -pub(crate) use android::{ - check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, -}; -#[cfg(target_os = "android")] -pub use android::{ - init_android, install_ime_listener, on_activity_result, query_insets_dp, query_window_insets_dp, - set_ime_visibility_listener, -}; - -#[cfg(target_os = "ios")] -mod ios; -#[cfg(target_os = "ios")] -pub(crate) use ios::{ - check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, -}; - -#[cfg(target_arch = "wasm32")] -mod wasm; -#[cfg(target_arch = "wasm32")] -pub(crate) use wasm::{ - check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, -}; - -// Ensure a compile error on unsupported platforms rather than silent failure. -#[cfg(not(any( - target_os = "android", - target_os = "ios", - target_arch = "wasm32", - target_os = "windows", - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "dragonfly", -)))] -compile_error!( - "loki-file-access: unsupported target platform. \ - Supported: Windows, macOS, Linux, BSD, Android, iOS, WASM." -); diff --git a/patches/loki-file-access/src/platform/wasm.rs b/patches/loki-file-access/src/platform/wasm.rs deleted file mode 100644 index 38f00386..00000000 --- a/patches/loki-file-access/src/platform/wasm.rs +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! WASM file-picker implementation using ``. -//! -//! This module creates a hidden `` element in the DOM, -//! configures it with MIME-type filters and multi-select options, and listens -//! for the `change` event to capture the user's selection. Selected files are -//! read entirely into memory as `Vec`. -//! -//! # Save behaviour -//! -//! WASM has no traditional save dialog. [`pick_save`] triggers a browser -//! download via a Blob URL. The returned [`crate::FileAccessToken`] wraps an -//! in-memory buffer that the caller can write to before the download is -//! initiated. This is a fundamental platform limitation documented in the -//! public [`crate::FilePicker::pick_file_to_save`] doc comment. -//! -//! # Persistence -//! -//! WASM tokens hold file data in memory and do not survive page reloads. -//! Serialising a WASM token preserves the data, but restoring it on a new -//! page load gives back the original bytes — there is no way to re-acquire -//! filesystem access. - -use crate::api::{PickOptions, SaveOptions}; -use crate::error::{AccessError, PickerError}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// Pick a single file for reading. -pub(crate) async fn pick_open_single( - options: PickOptions, -) -> Result, PickerError> { - let tokens = pick_files(options, false).await?; - Ok(tokens.into_iter().next()) -} - -/// Pick multiple files for reading. -pub(crate) async fn pick_open_multi( - options: PickOptions, -) -> Result, PickerError> { - pick_files(options, true).await -} - -/// Trigger a browser download. -/// -/// On WASM there is no save dialog — this creates an empty in-memory buffer -/// token. The caller should write data into it, then call a platform-specific -/// download trigger (e.g. creating a Blob URL and clicking an anchor element). -pub(crate) async fn pick_save( - options: SaveOptions, -) -> Result, PickerError> { - let name = options.suggested_name.unwrap_or_else(|| "download".into()); - Ok(Some(FileAccessToken { - inner: TokenInner::Wasm { - data: Vec::new(), - name, - mime_type: options.mime_type, - }, - })) -} - -/// Open a WASM token for reading (reads from the in-memory buffer). -pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Wasm { data, .. } => Ok(Box::new(std::io::Cursor::new(data.clone()))), - _ => Err(AccessError::Platform { - message: "non-WASM token on WASM platform".into(), - }), - } -} - -/// Open a WASM token for writing (writes to an in-memory buffer). -pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Wasm { data, .. } => Ok(Box::new(std::io::Cursor::new(data.clone()))), - _ => Err(AccessError::Platform { - message: "non-WASM token on WASM platform".into(), - }), - } -} - -/// Delete the file referenced by a token. -/// -/// WASM tokens wrap an in-memory buffer with no backing filesystem, so there -/// is nothing to delete. Return an explicit unsupported error. -pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { - Err(AccessError::Unsupported { - operation: "delete (WASM in-memory tokens have no backing file)".into(), - }) -} - -/// WASM tokens are always valid while the page is loaded. -pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { - match inner { - TokenInner::Wasm { .. } => PermissionStatus::Valid, - _ => PermissionStatus::Unknown, - } -} - -/// Create and trigger a hidden `` element. -async fn pick_files( - options: PickOptions, - multiple: bool, -) -> Result, PickerError> { - use wasm_bindgen::JsCast as _; - use wasm_bindgen_futures::JsFuture; - - let window = web_sys::window().ok_or_else(|| PickerError::Platform { - message: "no global window object".into(), - })?; - let document = window.document().ok_or_else(|| PickerError::Platform { - message: "no document object".into(), - })?; - - let input: web_sys::HtmlInputElement = document - .create_element("input") - .map_err(|e| PickerError::Platform { - message: format!("create_element failed: {e:?}"), - })? - .dyn_into() - .map_err(|_| PickerError::Platform { - message: "element is not HtmlInputElement".into(), - })?; - - input.set_type("file"); - - if !options.mime_types.is_empty() { - input.set_accept(&options.mime_types.join(",")); - } - - if multiple { - input.set_multiple(true); - } - - // Create a promise that resolves when the change event fires. - let promise = js_sys::Promise::new(&mut |resolve, _reject| { - let resolve_clone = resolve.clone(); - let cb = wasm_bindgen::closure::Closure::once_into_js(move || { - let _ = resolve_clone.call0(&wasm_bindgen::JsValue::NULL); - }); - let _ = input.add_event_listener_with_callback("change", cb.as_ref().unchecked_ref()); - }); - - // Trigger the file dialog. - input.click(); - - // Wait for the user to select files. - JsFuture::from(promise) - .await - .map_err(|e| PickerError::Platform { - message: format!("file input promise rejected: {e:?}"), - })?; - - // Read selected files. - let file_list = match input.files() { - Some(fl) => fl, - None => return Ok(vec![]), - }; - - let mut tokens = Vec::new(); - for i in 0..file_list.length() { - let file = match file_list.get(i) { - Some(f) => f, - None => continue, - }; - let name = file.name(); - let mime = file.type_(); - let mime_type = if mime.is_empty() { None } else { Some(mime) }; - - let array_buf_promise = file.array_buffer(); - let array_buf = - JsFuture::from(array_buf_promise) - .await - .map_err(|e| PickerError::Platform { - message: format!("arrayBuffer() failed: {e:?}"), - })?; - let uint8 = js_sys::Uint8Array::new(&array_buf); - let data = uint8.to_vec(); - - tokens.push(FileAccessToken { - inner: TokenInner::Wasm { - data, - name, - mime_type, - }, - }); - } - - Ok(tokens) -} diff --git a/patches/loki-file-access/src/token.rs b/patches/loki-file-access/src/token.rs deleted file mode 100644 index b048a909..00000000 --- a/patches/loki-file-access/src/token.rs +++ /dev/null @@ -1,412 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Capability token for accessing user-selected files. -//! -//! [`FileAccessToken`] is the central type returned by every picker operation. -//! It encapsulates all platform-specific state needed to re-open a file, -//! including Android URIs, iOS security-scoped bookmarks, desktop paths, and -//! in-memory WASM data. -//! -//! Tokens are serializable to a URL-safe base64-encoded JSON string via -//! [`FileAccessToken::serialize`] and [`FileAccessToken::deserialize`], making -//! them suitable for persisting in a recent-files list or application database. - -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use std::path::PathBuf; - -use crate::error::{AccessError, TokenParseError}; - -/// Status of the permission grant associated with a [`FileAccessToken`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum PermissionStatus { - /// The token's permission is still valid and the file can be opened. - Valid, - /// The permission has been revoked by the user or the operating system. - Revoked, - /// The permission status cannot be determined on this platform. - Unknown, -} - -/// Internal representation of platform-specific token data. -/// -/// This enum is serialized to JSON and then base64-encoded for storage. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) enum TokenInner { - /// Desktop file identified by filesystem path. - Desktop { - /// Absolute path to the file. - path: PathBuf, - /// User-visible file name. - display_name: String, - }, - /// Android file identified by a content URI. - Android { - /// Content URI string (e.g. `content://...`). - uri: String, - /// User-visible file name from the document provider. - display_name: String, - /// MIME type reported by the document provider. - mime_type: Option, - }, - /// iOS file identified by a security-scoped bookmark. - Ios { - /// Opaque bookmark data created by `NSURL.bookmarkData(...)`. - bookmark: Vec, - /// User-visible file name. - display_name: String, - /// MIME type (often inferred from the file extension). - mime_type: Option, - }, - /// WASM file held entirely in memory. - Wasm { - /// Complete file contents. - data: Vec, - /// Original file name from the `` element. - name: String, - /// MIME type reported by the browser. - mime_type: Option, - }, -} - -/// A serializable capability token representing access to a user-selected file. -/// -/// Obtain instances from [`crate::FilePicker`] methods. Serialize via -/// [`serialize`](Self::serialize) for storage; deserialize to reopen files -/// across app restarts. -#[derive(Debug, Clone)] -pub struct FileAccessToken { - pub(crate) inner: TokenInner, -} - -impl FileAccessToken { - /// Open the file for reading. Returns `Read + Seek`. - /// - /// # Errors - /// - /// Returns [`AccessError`] if permission is revoked or the file cannot be opened. - #[must_use = "this returns a Result that may contain an error"] - pub fn open_read(&self) -> Result, AccessError> { - crate::platform::open_read(&self.inner) - } - - /// Open the file for writing. Returns `Write + Seek`. - /// - /// # Errors - /// - /// Returns [`AccessError`] if permission is revoked or the file cannot be opened. - #[must_use = "this returns a Result that may contain an error"] - pub fn open_write(&self) -> Result, AccessError> { - crate::platform::open_write(&self.inner) - } - - /// Delete the underlying file. - /// - /// On desktop this removes the file from the filesystem. On platforms - /// where deletion is not yet implemented (Android, iOS, WASM) this returns - /// [`AccessError::Unsupported`] rather than silently succeeding. - /// - /// # Errors - /// - /// Returns [`AccessError`] if the file cannot be deleted or deletion is - /// not supported on the current platform. - #[must_use = "this returns a Result that may contain an error"] - pub fn delete(&self) -> Result<(), AccessError> { - crate::platform::delete(&self.inner) - } - - /// Copy the full contents of this file into `dest`. - /// - /// Reads all bytes from `self` via [`open_read`](Self::open_read) and writes - /// them to `dest` via [`open_write`](Self::open_write). This works across - /// every platform because it relies only on the token I/O primitives, not - /// on filesystem paths. - /// - /// # Errors - /// - /// Returns [`AccessError`] if either token cannot be opened or an I/O error - /// occurs while transferring bytes. - #[must_use = "this returns a Result that may contain an error"] - pub fn copy_bytes_to(&self, dest: &FileAccessToken) -> Result<(), AccessError> { - use std::io::{Read as _, Write as _}; - - let mut reader = self.open_read()?; - let mut bytes = Vec::new(); - reader.read_to_end(&mut bytes)?; - - let mut writer = dest.open_write()?; - writer.write_all(&bytes)?; - writer.flush()?; - Ok(()) - } - - /// Returns the user-visible display name of the file (typically the filename). - #[must_use] - pub fn display_name(&self) -> &str { - match &self.inner { - TokenInner::Desktop { display_name, .. } - | TokenInner::Android { display_name, .. } - | TokenInner::Ios { display_name, .. } => display_name, - TokenInner::Wasm { name, .. } => name, - } - } - - /// Returns the MIME type of the file, if known. Desktop returns `None`. - #[must_use] - pub fn mime_type(&self) -> Option<&str> { - match &self.inner { - TokenInner::Desktop { .. } => None, - TokenInner::Android { mime_type, .. } - | TokenInner::Ios { mime_type, .. } - | TokenInner::Wasm { mime_type, .. } => mime_type.as_deref(), - } - } - - /// Check whether the permission grant for this file is still valid. - #[must_use] - pub fn check_permission(&self) -> PermissionStatus { - crate::platform::check_permission(&self.inner) - } - - /// Serialize the token to a URL-safe base64-encoded string for storage. - #[must_use] - pub fn serialize(&self) -> String { - // Serialization of the inner enum to JSON should not fail for our - // data types (no maps with non-string keys, no infinite floats). - // However, we handle the error path gracefully by returning an - // empty-object JSON fallback, which will fail on deserialization - // with a clear error rather than panicking here. - let json = match serde_json::to_string(&self.inner) { - Ok(j) => j, - Err(_) => return URL_SAFE_NO_PAD.encode(b"{}"), - }; - URL_SAFE_NO_PAD.encode(json.as_bytes()) - } - - /// Deserialize a token from a string previously returned by [`serialize`](Self::serialize). - /// - /// # Errors - /// - /// Returns [`TokenParseError`] if the string is malformed. - pub fn deserialize(s: &str) -> Result { - let bytes = URL_SAFE_NO_PAD - .decode(s) - .map_err(|e| TokenParseError::InvalidBase64 { - message: e.to_string(), - })?; - - let json = String::from_utf8(bytes).map_err(|e| TokenParseError::InvalidBase64 { - message: e.to_string(), - })?; - - let inner: TokenInner = - serde_json::from_str(&json).map_err(|e| TokenParseError::InvalidJson { - message: e.to_string(), - })?; - - Ok(Self { inner }) - } -} - -/// Trait object combining [`std::io::Read`] and [`std::io::Seek`]. -pub trait ReadSeek: std::io::Read + std::io::Seek + Send {} -impl ReadSeek for T {} - -/// Trait object combining [`std::io::Write`] and [`std::io::Seek`]. -pub trait WriteSeek: std::io::Write + std::io::Seek + Send {} -impl WriteSeek for T {} - -impl std::fmt::Display for FileAccessToken { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.serialize()) - } -} - -impl std::str::FromStr for FileAccessToken { - type Err = TokenParseError; - - fn from_str(s: &str) -> Result { - Self::deserialize(s) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trip_desktop_token() { - let token = FileAccessToken { - inner: TokenInner::Desktop { - path: PathBuf::from("/tmp/test.txt"), - display_name: "test.txt".into(), - }, - }; - let serialized = token.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - assert_eq!(restored.display_name(), "test.txt"); - assert!(restored.mime_type().is_none()); - } - - #[test] - fn round_trip_android_token() { - let token = FileAccessToken { - inner: TokenInner::Android { - uri: "content://com.example/doc/1".into(), - display_name: "photo.jpg".into(), - mime_type: Some("image/jpeg".into()), - }, - }; - let serialized = token.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - assert_eq!(restored.display_name(), "photo.jpg"); - assert_eq!(restored.mime_type(), Some("image/jpeg")); - } - - #[test] - fn round_trip_ios_token() { - let token = FileAccessToken { - inner: TokenInner::Ios { - bookmark: vec![0xDE, 0xAD, 0xBE, 0xEF], - display_name: "notes.pdf".into(), - mime_type: Some("application/pdf".into()), - }, - }; - let serialized = token.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - assert_eq!(restored.display_name(), "notes.pdf"); - assert_eq!(restored.mime_type(), Some("application/pdf")); - } - - #[test] - fn round_trip_wasm_token() { - let token = FileAccessToken { - inner: TokenInner::Wasm { - data: vec![1, 2, 3, 4, 5], - name: "data.bin".into(), - mime_type: Some("application/octet-stream".into()), - }, - }; - let serialized = token.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - assert_eq!(restored.display_name(), "data.bin"); - assert_eq!(restored.mime_type(), Some("application/octet-stream")); - } - - #[test] - fn deserialize_invalid_base64_returns_error() { - let result = FileAccessToken::deserialize("not!valid!base64!!!"); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - TokenParseError::InvalidBase64 { .. } - )); - } - - #[test] - fn deserialize_invalid_json_returns_error() { - let bad = URL_SAFE_NO_PAD.encode(b"not json"); - let result = FileAccessToken::deserialize(&bad); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - TokenParseError::InvalidJson { .. } - )); - } - - // The following tests exercise the platform-routed operations. They only - // run on desktop targets, where the desktop backend is compiled in. - #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - fn desktop_token_for(path: &std::path::Path) -> FileAccessToken { - FileAccessToken { - inner: TokenInner::Desktop { - path: path.to_path_buf(), - display_name: path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed") - .to_owned(), - }, - } - } - - #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - #[test] - fn desktop_delete_removes_file() { - let dir = std::env::temp_dir(); - let path = dir.join(format!("loki_delete_test_{}.txt", std::process::id())); - std::fs::write(&path, b"hello").expect("write temp file"); - assert!(path.exists()); - - let token = desktop_token_for(&path); - token.delete().expect("delete should succeed"); - assert!(!path.exists(), "file must be gone after delete"); - } - - #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - #[test] - fn desktop_delete_missing_file_is_io_error() { - let dir = std::env::temp_dir(); - let path = dir.join(format!("loki_delete_missing_{}.txt", std::process::id())); - let _ = std::fs::remove_file(&path); - - let token = desktop_token_for(&path); - let err = token - .delete() - .expect_err("deleting a missing file must error"); - assert!(matches!(err, AccessError::Io { .. })); - } - - #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - #[test] - fn desktop_copy_bytes_to_duplicates_contents() { - let dir = std::env::temp_dir(); - let pid = std::process::id(); - let src_path = dir.join(format!("loki_copy_src_{pid}.txt")); - let dest_path = dir.join(format!("loki_copy_dest_{pid}.txt")); - std::fs::write(&src_path, b"copy me").expect("write source"); - let _ = std::fs::remove_file(&dest_path); - - let src = desktop_token_for(&src_path); - let dest = desktop_token_for(&dest_path); - src.copy_bytes_to(&dest).expect("copy should succeed"); - - let copied = std::fs::read(&dest_path).expect("read dest"); - assert_eq!(copied, b"copy me"); - - let _ = std::fs::remove_file(&src_path); - let _ = std::fs::remove_file(&dest_path); - } - - // On non-desktop targets the unsupported-platform path is taken: deleting - // any token must surface AccessError::Unsupported rather than succeed. - #[cfg(any(target_os = "android", target_os = "ios", target_arch = "wasm32"))] - #[test] - fn non_desktop_delete_is_unsupported() { - let token = FileAccessToken { - inner: TokenInner::Desktop { - path: PathBuf::from("/tmp/whatever.txt"), - display_name: "whatever.txt".into(), - }, - }; - let err = token - .delete() - .expect_err("delete must be unsupported off-desktop"); - assert!(matches!(err, AccessError::Unsupported { .. })); - } - - #[test] - fn display_and_from_str_round_trip() { - let token = FileAccessToken { - inner: TokenInner::Desktop { - path: PathBuf::from("/tmp/x.txt"), - display_name: "x.txt".into(), - }, - }; - let s = token.to_string(); - let restored: FileAccessToken = s.parse().unwrap(); - assert_eq!(restored.display_name(), "x.txt"); - } -} diff --git a/patches/loki-file-access/tests/desktop.rs b/patches/loki-file-access/tests/desktop.rs deleted file mode 100644 index e10c8e87..00000000 --- a/patches/loki-file-access/tests/desktop.rs +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Integration tests for desktop file access. -//! -//! These tests are cfg-gated to run only on desktop platforms (not Android, -//! iOS, or WASM) where direct filesystem path access is available. - -#![cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - -use loki_file_access::{FileAccessToken, PermissionStatus}; -use std::io::{Read, Seek, Write}; - -#[test] -fn open_read_from_temp_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.txt"); - std::fs::write(&path, "hello world").unwrap(); - - let token = FileAccessToken::deserialize( - &create_desktop_token(&path, "test.txt").serialize(), - ) - .unwrap(); - - let mut reader = token.open_read().unwrap(); - let mut contents = String::new(); - reader.read_to_string(&mut contents).unwrap(); - assert_eq!(contents, "hello world"); -} - -#[test] -fn open_write_to_temp_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("output.txt"); - std::fs::write(&path, "").unwrap(); - - let token = create_desktop_token(&path, "output.txt"); - - { - let mut writer = token.open_write().unwrap(); - writer.write_all(b"written data").unwrap(); - } - - let contents = std::fs::read_to_string(&path).unwrap(); - assert_eq!(contents, "written data"); -} - -#[test] -fn check_permission_returns_valid_for_existing_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("exists.txt"); - std::fs::write(&path, "data").unwrap(); - - let token = create_desktop_token(&path, "exists.txt"); - assert_eq!(token.check_permission(), PermissionStatus::Valid); -} - -#[test] -fn check_permission_returns_revoked_for_missing_file() { - let token = create_desktop_token( - std::path::Path::new("/tmp/nonexistent_loki_test_file_12345.txt"), - "nonexistent.txt", - ); - assert_eq!(token.check_permission(), PermissionStatus::Revoked); -} - -#[test] -fn token_round_trip_serialization() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("roundtrip.txt"); - std::fs::write(&path, "round-trip content").unwrap(); - - let original = create_desktop_token(&path, "roundtrip.txt"); - let serialized = original.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - - assert_eq!(restored.display_name(), "roundtrip.txt"); - - let mut reader = restored.open_read().unwrap(); - let mut contents = String::new(); - reader.read_to_string(&mut contents).unwrap(); - assert_eq!(contents, "round-trip content"); -} - -#[test] -fn display_name_returns_filename() { - let token = create_desktop_token( - std::path::Path::new("/tmp/example.csv"), - "example.csv", - ); - assert_eq!(token.display_name(), "example.csv"); -} - -#[test] -fn mime_type_is_none_for_desktop() { - let token = create_desktop_token( - std::path::Path::new("/tmp/file.txt"), - "file.txt", - ); - assert!(token.mime_type().is_none()); -} - -#[test] -fn open_read_seek_rewind() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("seek.txt"); - std::fs::write(&path, "abcdef").unwrap(); - - let token = create_desktop_token(&path, "seek.txt"); - let mut reader = token.open_read().unwrap(); - - let mut buf = [0u8; 3]; - reader.read_exact(&mut buf).unwrap(); - assert_eq!(&buf, b"abc"); - - reader.seek(std::io::SeekFrom::Start(0)).unwrap(); - reader.read_exact(&mut buf).unwrap(); - assert_eq!(&buf, b"abc"); -} - -/// Helper to construct a desktop `FileAccessToken` directly. -/// -/// In production code, tokens are obtained from `FilePicker` methods. -/// For testing we construct them via the token's internal serialization format. -fn create_desktop_token(path: &std::path::Path, name: &str) -> FileAccessToken { - use base64::Engine as _; - - // Build the JSON payload that TokenInner::Desktop serializes to, - // then encode it to match FileAccessToken::serialize(). - let json = serde_json::json!({ - "Desktop": { - "path": path.to_str().unwrap(), - "display_name": name - } - }); - let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(json.to_string().as_bytes()); - FileAccessToken::deserialize(&encoded).unwrap() -} diff --git a/scripts/build-aab.sh b/scripts/build-aab.sh index 37ed69c7..7ca853a1 100755 --- a/scripts/build-aab.sh +++ b/scripts/build-aab.sh @@ -147,15 +147,28 @@ for i in "${!TRIPLES[@]}"; do echo " -> jniLibs/$abidir/libloki_text.so ($(du -h "$so" | cut -f1))" done -# ── Stage Java shims (single source of truth in patches/) ───────────────────── +# ── Stage Java shims (single source of truth: the loki-file-access crate) ───── + +# The shims live in the loki-file-access crate (a git dependency since the +# local patch was upstreamed, 2026-07-05); resolve its checkout directory +# from cargo metadata rather than hardcoding a path. +LFA_DIR="$(cd "$ROOT" && cargo metadata --format-version 1 | python3 -c ' +import json, os, sys +meta = json.load(sys.stdin) +for p in meta["packages"]: + if p["name"] == "loki-file-access": + print(os.path.dirname(p["manifest_path"])) + break +')" +[[ -n "$LFA_DIR" ]] || { echo "ERROR: loki-file-access not found in cargo metadata" >&2; exit 1; } JAVA_PKG_DIR="$ROOT/android/app/src/main/java/io/github/appthere/lokifileaccess" echo "" echo "==> Staging Java shims (FilePickerActivity, ImeInsetsListener)..." rm -rf "$ROOT/android/app/src/main/java" mkdir -p "$JAVA_PKG_DIR" -cp "$ROOT/patches/loki-file-access/android/FilePickerActivity.java" "$JAVA_PKG_DIR/" -cp "$ROOT/patches/loki-file-access/android/ImeInsetsListener.java" "$JAVA_PKG_DIR/" +cp "$LFA_DIR/android/FilePickerActivity.java" "$JAVA_PKG_DIR/" +cp "$LFA_DIR/android/ImeInsetsListener.java" "$JAVA_PKG_DIR/" # ── Gradle bundleRelease ────────────────────────────────────────────────────── diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1 index 666a92a0..4ef7e5ec 100644 --- a/scripts/build-android.ps1 +++ b/scripts/build-android.ps1 @@ -128,9 +128,16 @@ Write-Host "==> App: $App ($cargoPackage)" # ── Paths ───────────────────────────────────────────────────────────────────── $profile = if ($Release) { "release" } else { "debug" } +# The Java shims live in the loki-file-access crate (a git dependency since +# the local patch was upstreamed, 2026-07-05); resolve its checkout directory +# from cargo metadata rather than hardcoding a path. +$lfaPackage = (cargo metadata --format-version 1 | ConvertFrom-Json).packages | + Where-Object { $_.name -eq "loki-file-access" } | Select-Object -First 1 +if (-not $lfaPackage) { throw "loki-file-access not found in cargo metadata" } +$lfaDir = Split-Path $lfaPackage.manifest_path $javaSrcs = @( - "patches\loki-file-access\android\FilePickerActivity.java", - "patches\loki-file-access\android\ImeInsetsListener.java" + "$lfaDir\android\FilePickerActivity.java", + "$lfaDir\android\ImeInsetsListener.java" ) $outDir = "target\android-pkg" $apkSrc = "$PWD\target\$profile\apk\$apkBaseName.apk" diff --git a/scripts/build-android.sh b/scripts/build-android.sh index 61a939ff..b4c406fd 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -204,7 +204,19 @@ echo "==> javac: $JAVAC" # ── Paths ───────────────────────────────────────────────────────────────────── PROFILE="$([ "$RELEASE" -eq 1 ] && echo "release" || echo "debug")" -JAVA_SRC_DIR="patches/loki-file-access/android" +# The Java shims live in the loki-file-access crate (a git dependency since +# the local patch was upstreamed, 2026-07-05); resolve its checkout directory +# from cargo metadata rather than hardcoding a path. +LFA_DIR="$(cargo metadata --format-version 1 | python3 -c ' +import json, os, sys +meta = json.load(sys.stdin) +for p in meta["packages"]: + if p["name"] == "loki-file-access": + print(os.path.dirname(p["manifest_path"])) + break +')" +[[ -n "$LFA_DIR" ]] || { echo "ERROR: loki-file-access not found in cargo metadata" >&2; exit 1; } +JAVA_SRC_DIR="$LFA_DIR/android" JAVA_SRCS=("$JAVA_SRC_DIR/FilePickerActivity.java" "$JAVA_SRC_DIR/ImeInsetsListener.java") MANIFEST_XML="loki-text/AndroidManifest.xml" OUT_DIR="target/android-pkg" diff --git a/scripts/check-dependency-direction.py b/scripts/check-dependency-direction.py index 256383ae..a5395004 100755 --- a/scripts/check-dependency-direction.py +++ b/scripts/check-dependency-direction.py @@ -42,7 +42,7 @@ "loki-pdf": 3.5, # L4 render "loki-render-cache": 4, "appthere-canvas": 4, "loki-vello": 4, - "loki-renderer": 4, + "loki-renderer": 4, "loki-render-cpu": 4, # L5 ui / app-shell "appthere-ui": 5, "loki-app-shell": 5, # L6 app binaries diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index 2a7cc648..a809759c 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -3,38 +3,35 @@ # these may not GROW, and must be removed once split to <= 300 lines. # Regenerate with: scripts/check-file-ceiling.py --update -1979 loki-layout/src/para.rs 1953 loki-layout/src/flow.rs +1856 loki-layout/src/para.rs 1554 loki-odf/src/odt/reader/styles.rs 1494 loki-odf/src/odt/reader/document.rs -1244 loki-spreadsheet/src/routes/editor/editor_inner.rs 1209 loki-ooxml/src/docx/reader/document.rs 1073 loki-ooxml/src/docx/write/document.rs -984 loki-layout/src/resolve.rs +1047 loki-spreadsheet/src/routes/editor/editor_inner.rs +978 loki-layout/src/resolve.rs 948 loki-vello/src/scene.rs -878 loki-text/src/routes/editor/editor_inner.rs -761 loki-layout/src/flow_para.rs +803 loki-text/src/routes/editor/editor_inner.rs +741 loki-layout/src/flow_para.rs 730 loki-ooxml/src/docx/mapper/inline.rs 651 loki-odf/src/package.rs 611 loki-ooxml/src/docx/mapper/document.rs -594 loki-text/src/editing/navigation.rs 582 loki-ooxml/src/docx/mapper/props.rs 575 loki-ooxml/src/xlsx/import.rs -501 loki-text/src/routes/editor/editor_canvas.rs 466 loki-ooxml/src/xlsx/export.rs 465 loki-odf/src/ods/import.rs +460 loki-text/src/routes/editor/editor_canvas.rs 440 loki-doc-model/src/document.rs -392 loki-renderer/src/document_view.rs 388 loki-ooxml/src/docx/import.rs 373 loki-layout/src/incremental.rs 365 loki-odf/src/ods/export.rs -364 loki-text/src/routes/home.rs -333 loki-layout/src/lib.rs +334 loki-layout/src/lib.rs +331 loki-text/src/routes/home.rs 331 loki-text/src/editing/state.rs 328 loki-ooxml/src/docx/model/paragraph.rs 316 loki-text/src/routes/editor/editor_style.rs 315 loki-text/src/routes/editor/editor_publish.rs 315 loki-odf/src/odt/model/styles.rs -314 loki-text/src/routes/editor/editor_ribbon.rs 314 loki-renderer/src/page_paint_source.rs 307 loki-ooxml/src/docx/mapper/table.rs diff --git a/scripts/generate-odf-goldens.sh b/scripts/generate-odf-goldens.sh new file mode 100755 index 00000000..60ca4247 --- /dev/null +++ b/scripts/generate-odf-goldens.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 AppThere Loki contributors +# +# Generates the ODF golden set (Spec 02 §7.2 / M4, scripted LibreOffice path): +# +# fixture .odt --soffice --headless--> .pdf --pinned rasterizer--> page PNGs +# +# The rasterization stage is appthere-conformance's PdfRasterizer (pdftoppm at +# the fixed CONFORMANCE_DPI with pinned AA) — identical to every other path, +# so golden and candidate differ only in the layout/render engine (D3). +# +# Requirements: soffice (libreoffice), poppler-utils, and the bundled +# metric-compatible fonts installed for fontconfig (e.g. copy +# loki-fonts/fonts/*.ttf to ~/.fonts && fc-cache) so LibreOffice shapes with +# the same faces Loki bundles (D4). +# +# Output: appthere-conformance/goldens/odt//page-N.png plus a +# GENERATION.txt metadata record (operator, LO + rasterizer versions, date). + +set -euo pipefail +cd "$(dirname "$0")/.." + +FIXTURES=appthere-conformance/fixtures/odt +GOLDENS=appthere-conformance/goldens/odt +command -v soffice >/dev/null || { echo "ERROR: soffice not found" >&2; exit 1; } + +# Regenerate fixtures from source so they always match the checked-in producer. +cargo run -q -p loki-odf --example gen_conformance_fixtures + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$GOLDENS" +LO_VERSION=$(soffice --version 2>/dev/null | head -1) + +for odt in "$FIXTURES"/*.odt; do + stem=$(basename "$odt" .odt) + echo "==> $stem" + soffice --headless --convert-to pdf --outdir "$WORK" "$odt" >/dev/null + out="$GOLDENS/$stem" + rm -rf "$out" + mkdir -p "$out" + RASTER_VERSION=$(cargo run -q -p appthere-conformance --example rasterize_pdf -- \ + "$WORK/$stem.pdf" "$out" page | head -1) + { + echo "fixture: $stem.odt" + echo "reference: LibreOffice headless ($LO_VERSION)" + echo "rasterizer: $RASTER_VERSION @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes" + echo "generated: $(date -u +%Y-%m-%d)" + echo "operator: scripts/generate-odf-goldens.sh (scripted; Spec 02 §7.2)" + } > "$out/GENERATION.txt" +done + +echo "Goldens written to $GOLDENS"