diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9fd45e09..ce7030e8 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -10,13 +10,33 @@ env: CARGO_TERM_COLOR: always jobs: - build: - + # Fast-failing quality gates mandated by CLAUDE.md: formatting and clippy. + # These run first so style/lint regressions surface before the slower build. + lint: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install toolchain (rustfmt + clippy) + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Format check + run: cargo fmt --all --check + - name: Clippy (workspace, all features, warnings denied) + run: cargo clippy --workspace --all-features -- -D warnings + build-and-test: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install toolchain + uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # `--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. - name: Build - run: cargo build --verbose + run: cargo build --workspace --all-features --verbose - name: Run tests - run: cargo test --verbose + run: cargo test --workspace --all-features --verbose diff --git a/CLAUDE.md b/CLAUDE.md index 30e454b7..fed5f1a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,13 +126,43 @@ When adding `#[allow]` to any file: These files existed before the ceiling convention was established and have not yet been split. Do not add new code to them without splitting first. +A 2026-06-21 audit found **43 production files** over the ceiling (16 over 600 +lines). The full list and a proposed split strategy live in +[docs/audit-2026-06.md](docs/audit-2026-06.md) (finding Q-1); the worst +offenders are below. This is a dedicated split-pass backlog, not a per-change +blocker — but do not *grow* these files or add new ones over the ceiling. + +The split pass is **in progress** — **10 of 43 files done** (≈33 remain). Two +techniques: +1. *Inline-test extraction* (safest, no production-code change): move a file's + `#[cfg(test)] mod tests { … }` into a sibling `_tests.rs` referenced via + `#[cfg(test)] #[path = "_tests.rs"] mod tests;`. Done 2026-06-21 for + `block.rs`, `docx/mapper/{paragraph,numbering,mod,table}.rs`, `odt/import.rs`, + `odt/mapper/lists.rs`, `layout/result.rs`, `renderer/render_layout.rs` — each + was over the ceiling only because of a large inline test module. +2. *Directory split*: convert `foo.rs` → a `foo/` directory with section-cohesive + submodules (each starting `use super::*;`), re-export the public entry points + from `foo/mod.rs`, and move the tests via the same `#[path]` idiom. Done for + `odt/mapper/props.rs` → `odt/mapper/props/` (worked example). + +(Test files are exempt from the production-line count.) + | File | Current lines | Priority | |---|---|---| -| `loki-text/src/components/document_source.rs` | 1117 | High | -| `loki-text/src/routes/editor/editor_inner.rs` | ~945 | High | -| `loki-doc-model/src/loro_bridge/inlines.rs` | ~280 | Low | - -(`read.rs` was split into `read.rs` + `props_read.rs`; both are now under 300 lines.) +| `loki-layout/src/flow.rs` | 1612 | High | +| `loki-odf/src/odt/reader/styles.rs` | 1441 | High | +| `loki-odf/src/odt/reader/document.rs` | 1428 | High | +| `loki-layout/src/para.rs` | 1278 | High | +| `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1241 | High | +| `loki-ooxml/src/docx/write/document.rs` | 1169 | High | +| `loki-ooxml/src/docx/reader/document.rs` | 1126 | High | +| `loki-odf/src/odt/mapper/document.rs` | 1094 | High | +| `loki-text/src/routes/editor/editor_inner.rs` | 968 | High | +| … 24 more (300–600 lines) — see the audit (10 files split 2026-06-21) | | | + +(`read.rs` was split into `read.rs` + `props_read.rs`; both are now under 300 +lines. `loro_bridge/inlines.rs` is now 219 lines, under the ceiling. +`loki-text/src/components/document_source.rs` no longer exists.) ## Known tech debt — Loro bridge round-trip gaps @@ -157,12 +187,16 @@ The workspace is a set of focused crates (one responsibility each). Key groups: - **Formats (one crate per family):** - `loki-opc` — OPC/ZIP container shared by OOXML/ODF. - `loki-ooxml` — DOCX/XLSX import + DOCX export. - - `loki-odf` — ODT/ODS import + ODT/ODS export. + - `loki-odf` — ODT/ODS import + ODT/ODS export. ODT export (`odt/write/`) + writes `content.xml` / `styles.xml` / `meta.xml`: paragraphs, headings, + styled paragraphs, lists, tables, inline formatting, the named style + catalog, page geometry, and metadata. - `loki-pdf` — **PDF/X** export (X-1a/X-3/X-4) via `pdf-writer`; reuses `loki-layout` for positioning, embeds fonts + images (CMYK). - `loki-epub` — **EPUB 3.3** export (XHTML + OCF ZIP). - **Layout & rendering:** `loki-layout` (renderer-agnostic, Parley-based), - `loki-vello` / `loki-renderer` / `loki-render-cache` (GPU paint + tiering). + `loki-vello` / `loki-renderer` / `loki-render-cache` (GPU paint; per-page + tiles bounded by viewport virtualization). - **UI & apps:** `appthere-ui` (shared design system), `appthere-canvas`, `loki-i18n`, `loki-fonts`, and the binaries `loki-text` (word processor — the mature app), `loki-spreadsheet`, `loki-presentation`. diff --git a/Cargo.lock b/Cargo.lock index 6871046c..47062b50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,6 +147,15 @@ dependencies = [ "equator", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -305,11 +314,9 @@ dependencies = [ name = "appthere-canvas" version = "0.1.0" dependencies = [ - "dioxus", "loki-render-cache", "peniko", - "read-fonts 0.37.0", - "tokio", + "read-fonts 0.40.2", ] [[package]] @@ -366,7 +373,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -377,9 +384,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] name = "arref" @@ -539,7 +546,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -574,7 +581,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -931,7 +938,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -948,9 +955,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "calloop" @@ -986,9 +993,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.64" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -1190,7 +1197,7 @@ checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1201,7 +1208,7 @@ checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1324,25 +1331,24 @@ dependencies = [ [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", - "itertools 0.10.5", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", + "page_size", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -1350,12 +1356,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -1426,7 +1432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1475,7 +1481,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1488,7 +1494,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1499,7 +1505,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1510,7 +1516,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1539,7 +1545,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1560,7 +1566,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1682,7 +1688,7 @@ dependencies = [ "dioxus-rsx", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1798,7 +1804,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1905,7 +1911,7 @@ dependencies = [ "quote", "sha2", "slab", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1918,7 +1924,7 @@ dependencies = [ "proc-macro2-diagnostics", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1958,7 +1964,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1996,23 +2002,23 @@ dependencies = [ [[package]] name = "dirs" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2041,7 +2047,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2201,7 +2207,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2213,7 +2219,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2234,7 +2240,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2255,7 +2261,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2285,7 +2291,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2483,6 +2489,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bf886368962a7d8456f136073ed33ed1b0770c690d2063731bb6a776e99f33" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -2531,9 +2546,11 @@ dependencies = [ [[package]] name = "fontique" -version = "0.8.0" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "274fa4f0f0a926ae182c7c076c078cce8a38471d15e61a102a02cac984be9813" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", "objc2 0.6.4", @@ -2541,7 +2558,7 @@ dependencies = [ "objc2-core-text", "objc2-foundation 0.3.2", "parlance", - "read-fonts 0.37.0", + "read-fonts 0.39.2", "roxmltree 0.21.1", "smallvec", "windows 0.62.2", @@ -2567,7 +2584,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2648,7 +2665,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2767,15 +2784,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -2926,14 +2941,13 @@ dependencies = [ [[package]] name = "harfrust" -version = "0.5.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +checksum = "d12c7c642d4ce8c2e784b4751a6634bd89583912265add4a679a8882d123fbcd" dependencies = [ "bitflags 2.13.0", "bytemuck", - "core_maths", - "read-fonts 0.37.0", + "read-fonts 0.39.2", "smallvec", ] @@ -2971,15 +2985,15 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", -] [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "heapless" @@ -3315,12 +3329,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3446,7 +3454,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3474,40 +3482,29 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "itertools" -version = "0.10.5" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -3570,7 +3567,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3598,7 +3595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3711,12 +3708,6 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lebe" version = "0.5.3" @@ -3814,9 +3805,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loki-acid" @@ -3824,15 +3815,28 @@ version = "0.1.0" dependencies = [ "image", "loki-doc-model", + "loki-graphics", "loki-layout", "loki-odf", "loki-ooxml", + "loki-presentation-model", + "loki-primitives", "loki-sheet-model", "serde", "serde_json", "thiserror 2.0.18", ] +[[package]] +name = "loki-app-shell" +version = "0.1.0" +dependencies = [ + "dirs", + "loki-i18n", + "serde", + "serde_json", +] + [[package]] name = "loki-doc-model" version = "0.1.1" @@ -3858,6 +3862,7 @@ dependencies = [ "chrono", "loki-doc-model", "loki-primitives", + "quick-xml 0.36.2", "thiserror 2.0.18", "zip", ] @@ -3912,11 +3917,12 @@ version = "0.1.0" dependencies = [ "appthere-color", "criterion", - "fontique 0.8.0", + "fontique 0.10.0", "loki-doc-model", + "loki-fonts", "loki-primitives", "loro", - "parley 0.8.0", + "parley 0.10.0", "thiserror 2.0.18", "tracing", ] @@ -3953,9 +3959,9 @@ dependencies = [ "loki-presentation-model", "loki-primitives", "loki-sheet-model", - "parley 0.8.0", + "parley 0.10.0", "quick-xml 0.36.2", - "read-fonts 0.37.0", + "read-fonts 0.40.2", "thiserror 2.0.18", "zip", ] @@ -4000,11 +4006,13 @@ dependencies = [ "blitz-shell", "dioxus", "dirs", - "fontique 0.8.0", + "fontique 0.10.0", "kurbo 0.12.0", "log", + "loki-app-shell", "loki-doc-model", "loki-file-access", + "loki-fonts", "loki-graphics", "loki-i18n", "loki-layout", @@ -4051,9 +4059,7 @@ dependencies = [ name = "loki-render-cache" version = "0.1.0" dependencies = [ - "pollster", "thiserror 2.0.18", - "tracing", "wgpu", ] @@ -4097,11 +4103,13 @@ dependencies = [ "blitz-shell", "dioxus", "dirs", - "fontique 0.8.0", + "fontique 0.10.0", "kurbo 0.12.0", "log", + "loki-app-shell", "loki-doc-model", "loki-file-access", + "loki-fonts", "loki-i18n", "loki-layout", "loki-odf", @@ -4120,6 +4128,15 @@ dependencies = [ "wgpu_context", ] +[[package]] +name = "loki-templates" +version = "0.1.0" +dependencies = [ + "loki-doc-model", + "loki-ooxml", + "loki-primitives", +] + [[package]] name = "loki-text" version = "0.1.0" @@ -4131,10 +4148,11 @@ dependencies = [ "blitz-shell", "dioxus", "dirs", - "fontique 0.8.0", + "fontique 0.10.0", "futures-channel", "kurbo 0.12.0", "log", + "loki-app-shell", "loki-doc-model", "loki-epub", "loki-file-access", @@ -4145,6 +4163,7 @@ dependencies = [ "loki-ooxml", "loki-pdf", "loki-renderer", + "loki-templates", "loki-vello", "loro", "peniko", @@ -4171,8 +4190,6 @@ dependencies = [ "loki-ooxml", "peniko", "pollster", - "read-fonts 0.37.0", - "skrifa 0.40.0", "thiserror 2.0.18", "vello", "wgpu", @@ -4375,7 +4392,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4394,7 +4411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44db74bde26fdf427af23f1d146c211aed857c59e3be750cf2617f6b0b05c94" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -4439,7 +4456,7 @@ dependencies = [ "manganis-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4461,7 +4478,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4726,7 +4743,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4799,7 +4816,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5236,6 +5253,16 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -5287,27 +5314,27 @@ dependencies = [ [[package]] name = "parley" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c00ec192e1a402861526eff91a4b4690a2e5a17a4ae2deb772d72b49daf868" +checksum = "6b1cfcf399c774719fb1fa51bc6b91e86bdf003b03202a0902f1827ba6750746" dependencies = [ - "fontique 0.8.0", - "harfrust 0.5.2", - "hashbrown 0.16.1", + "fontique 0.10.0", + "harfrust 0.8.4", + "hashbrown 0.17.1", "icu_normalizer", "icu_properties", "icu_segmenter", "linebender_resource_handle", "parlance", "parley_data", - "skrifa 0.40.0", + "skrifa 0.42.1", ] [[package]] name = "parley_data" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "232313eddc02ac27f015e8f8eeb7facce8a896116df7e3eda1bfd9f600a9a39d" +checksum = "d1d3755e2cc12c0625b0cd2f2773c6a37dd11d1531940c945ade4aeb78f2b145" dependencies = [ "icu_properties", ] @@ -5379,7 +5406,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5432,7 +5459,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5467,7 +5494,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5630,16 +5657,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5666,7 +5683,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "version_check", ] @@ -5686,7 +5703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5990,15 +6007,25 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.37.0" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", - "core_maths", "font-types 0.11.3", ] +[[package]] +name = "read-fonts" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487889119a5f19ff7c0a20637196bdc76b9f54ebec17e3588b5d75e4999f8773" +dependencies = [ + "bytemuck", + "font-types 0.12.0", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -6028,13 +6055,13 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -6213,7 +6240,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.117", + "syn 2.0.118", "walkdir", ] @@ -6458,7 +6485,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6478,7 +6505,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6502,7 +6529,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6651,12 +6678,12 @@ dependencies = [ [[package]] name = "skrifa" -version = "0.40.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" dependencies = [ "bytemuck", - "read-fonts 0.37.0", + "read-fonts 0.39.2", ] [[package]] @@ -6682,7 +6709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6959,7 +6986,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -7086,7 +7113,7 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" dependencies = [ - "skrifa 0.40.0", + "skrifa 0.42.1", "yazi", "zeno", ] @@ -7104,9 +7131,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -7130,7 +7157,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7180,7 +7207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7238,7 +7265,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7249,7 +7276,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7369,7 +7396,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -7525,7 +7552,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7959,7 +7986,7 @@ checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7974,16 +8001,7 @@ version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[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 0.51.0", + "wit-bindgen", ] [[package]] @@ -8028,7 +8046,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -8041,28 +8059,6 @@ 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 = "wasm-streams" version = "0.4.2" @@ -8076,18 +8072,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "wayland-backend" version = "0.3.15" @@ -8138,9 +8122,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ "bitflags 2.13.0", "wayland-backend", @@ -8247,9 +8231,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -8419,6 +8403,22 @@ dependencies = [ "wgpu", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -8428,6 +8428,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.58.0" @@ -8504,7 +8510,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8515,7 +8521,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8526,7 +8532,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8537,7 +8543,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8602,15 +8608,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -8662,21 +8659,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -8725,12 +8707,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8749,12 +8725,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8773,12 +8743,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8809,12 +8773,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8833,12 +8791,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8857,12 +8809,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8881,12 +8827,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8969,100 +8909,12 @@ 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" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "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 0.5.0", - "indexmap", - "prettyplease", - "syn 2.0.117", - "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 2.0.117", - "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 2.13.0", - "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" @@ -9206,7 +9058,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -9301,7 +9153,7 @@ checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zbus-lockstep", "zbus_xml", "zvariant 4.2.0", @@ -9316,7 +9168,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zvariant_utils 2.1.0", ] @@ -9329,7 +9181,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zbus_names 4.3.2", "zvariant 5.12.0", "zvariant_utils 3.4.0", @@ -9393,7 +9245,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9413,7 +9265,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -9455,7 +9307,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9554,7 +9406,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zvariant_utils 2.1.0", ] @@ -9567,7 +9419,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zvariant_utils 3.4.0", ] @@ -9579,7 +9431,7 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -9591,6 +9443,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.118", "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 355c3024..2e5cc3b6 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-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"] +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"] [workspace.dependencies] # Shared across appthere-ui and any other workspace member that needs Dioxus. @@ -15,17 +15,12 @@ members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","lok # so re-vendoring was only a manifest version bump — re-check that on the next bump. dioxus = { version = "=0.7.9", features = ["native"] } thiserror = "2" -appthere-canvas = { path = "appthere-canvas", features = ["gpu", "dioxus", "font-cache"] } +appthere-canvas = { path = "appthere-canvas", features = ["gpu", "font-cache"] } loki-file-access = { git = "https://github.com/appthere/loki-file-access", branch = "main" } loki-fonts = { path = "loki-fonts" } android-activity = "0.6" [patch.crates-io] -# PATCH: fixes missing fontconfig_sys alias and dlopen/static feature-unification -# conflict with blitz-dom's fontique 0.6 — remove when fontique >0.8.0 on -# crates.io restores the alias and resolves the linkage conflict. See docs/patches.md. -fontique = { path = "patches/fontique" } - # PATCH: vendors dioxus-native-dom to avoid runtime unimplemented!() panics in # HtmlEventConverter (composition, touch, scroll, etc.) — remove when upstream # implements the converters Loki requires. See docs/patches.md. diff --git a/README.md b/README.md index 4df6d46b..e260feda 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,6 @@ and the **Upgrading Dioxus** procedure (the dioxus patches are version-pinned). | `patches/dioxus-native` | Calls `request_redraw()` after CSS head-element insertion (Android blank screen fix) | | `patches/blitz-net` | Switches reqwest from native-tls to rustls (Android has no `libssl.so`) | | `patches/blitz-dom` | Fixes tabindex focus-on-click for non-input elements | -| `patches/fontique` | Fixes missing `fontconfig_sys` alias in the crates.io 0.8.0 publish | ## AI Coding Assistants diff --git a/appthere-canvas/Cargo.toml b/appthere-canvas/Cargo.toml index 921802a8..c87ae792 100644 --- a/appthere-canvas/Cargo.toml +++ b/appthere-canvas/Cargo.toml @@ -7,16 +7,12 @@ description = "Generic GPU canvas infrastructure for AppThere applications" [features] default = [] -# The page cache, tier policy, scroll state, and GPU texture utilities are -# provided by loki-render-cache (the canonical implementation); `gpu` forwards -# to its wgpu-backed utilities. +# The PageSource trait and GPU texture handle are provided by loki-render-cache +# (the canonical implementation); `gpu` forwards to its wgpu-backed utilities. gpu = ["loki-render-cache/gpu"] -dioxus = ["dep:dioxus", "dep:tokio"] font-cache = ["dep:peniko"] [dependencies] loki-render-cache = { path = "../loki-render-cache", default-features = false } -dioxus = { version = "=0.7.9", features = ["native"], optional = true } -tokio = { version = "1", features = ["time", "sync"], optional = true } peniko = { version = "0.5", optional = true } -read-fonts = "0.37" +read-fonts = "0.40" diff --git a/appthere-canvas/src/dioxus/mod.rs b/appthere-canvas/src/dioxus/mod.rs deleted file mode 100644 index 64b0f187..00000000 --- a/appthere-canvas/src/dioxus/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Dioxus integration helpers for appthere-canvas. - -pub mod scroll_driver; - -pub use scroll_driver::{on_scroll_event, use_settle_detector}; diff --git a/appthere-canvas/src/dioxus/scroll_driver.rs b/appthere-canvas/src/dioxus/scroll_driver.rs deleted file mode 100644 index d3435e1b..00000000 --- a/appthere-canvas/src/dioxus/scroll_driver.rs +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Event-driven scroll-settle detector for Dioxus. -//! -//! [`on_scroll_event`] updates the scroll signal and sends the new phase via a -//! watch channel. [`use_settle_detector`] spawns an async task that debounces -//! scroll activity using [`tokio::time`] and fires `on_settle` exactly once per -//! settle edge. -//! -//! # EVENT-DRIVEN design -//! -// EVENT-DRIVEN: watch channel parks this task until phase changes. -// No 16ms polling loop. The Sender lives in the caller (e.g. RendererState). - -use dioxus::prelude::{spawn, use_drop, use_hook, ReadableExt, Signal, WritableExt}; -use tokio::sync::watch; - -use crate::{ScrollPhase, ScrollState, SETTLE_DURATION}; - -/// Call from the document scroll handler. -/// -/// Updates the scroll signal and sends the phase via `phase_tx` so the settle -/// detector task wakes immediately on scroll activity. -pub fn on_scroll_event( - mut scroll: Signal, - new_top_px: f64, - phase_tx: &watch::Sender, -) { - scroll.write().on_scroll(new_top_px); - let _ = phase_tx.send(scroll.peek().phase); -} - -/// Hook: spawn the settle-detector task exactly once per document view. -/// -/// `phase_tx` is the caller-owned sender (e.g. `RendererState::phase_tx`) that -/// every [`on_scroll_event`] call posts to. The detector `subscribe()`s to -/// *that same* channel — previously it minted a throwaway channel whose sender -/// was dropped each render, so `on_settle` could never fire (audit F5). -/// -/// The spawn is guarded by [`use_hook`] so a single task lives for the -/// component's lifetime instead of one per render, and [`use_drop`] cancels it -/// on unmount. The task debounces scroll-Active signals: after -/// [`SETTLE_DURATION`] elapses without a new Active signal it calls `on_settle` -/// exactly once. -pub fn use_settle_detector(phase_tx: &watch::Sender, on_settle: impl Fn() + 'static) { - let mut rx = phase_tx.subscribe(); - - let task = use_hook(move || { - spawn(async move { - // EVENT-DRIVEN: the watch channel parks this task until the phase - // changes; no polling loop. - loop { - if rx.changed().await.is_err() { - break; - } - if *rx.borrow() != ScrollPhase::Active { - continue; - } - // Debounce: wait SETTLE_DURATION after the last Active signal. - loop { - match tokio::time::timeout(SETTLE_DURATION, rx.changed()).await { - Err(_timeout) => { - // No new changes in SETTLE_DURATION → settled. - on_settle(); - break; - } - Ok(Err(_closed)) => return, - Ok(Ok(())) => { - if *rx.borrow() != ScrollPhase::Active { - break; - } - // Still Active — restart the debounce window. - } - } - } - } - }) - }); - - use_drop(move || task.cancel()); -} diff --git a/appthere-canvas/src/font_cache.rs b/appthere-canvas/src/font_cache.rs index feb9e952..8fa65169 100644 --- a/appthere-canvas/src/font_cache.rs +++ b/appthere-canvas/src/font_cache.rs @@ -114,3 +114,100 @@ fn strip_sfnt_bitmap_tables(data: &mut [u8], offset: usize) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a minimal single-font sfnt: a 12-byte offset table (with + /// `numTables` set) followed by one 16-byte directory record per tag. + /// Only the tag bytes of each record carry meaning for the strip logic; + /// the remaining 12 bytes per record are zero-filled. + fn sfnt_with_tags(tags: &[&[u8; 4]]) -> Vec { + let mut data = vec![0u8; 12]; + data[0..4].copy_from_slice(&[0x00, 0x01, 0x00, 0x00]); // sfnt version 1.0 + data[4..6].copy_from_slice(&(tags.len() as u16).to_be_bytes()); + for tag in tags { + data.extend_from_slice(*tag); + data.extend_from_slice(&[0u8; 12]); // checksum + offset + length + } + data + } + + fn tag_at(data: &[u8], table_index: usize) -> [u8; 4] { + let start = 12 + table_index * 16; + data[start..start + 4].try_into().unwrap() + } + + #[test] + fn strips_each_bitmap_table_tag() { + for tag in [b"EBLC", b"EBDT", b"EBSC", b"CBLC", b"CBDT", b"sbix"] { + let mut data = sfnt_with_tags(&[tag]); + strip_sfnt_bitmap_tables(&mut data, 0); + assert_eq!( + &tag_at(&data, 0), + &[b'X', tag[1], tag[2], tag[3]], + "first byte of {:?} should be renamed to 'X'", + std::str::from_utf8(tag).unwrap(), + ); + } + } + + #[test] + fn leaves_non_bitmap_tables_untouched() { + let mut data = sfnt_with_tags(&[b"glyf", b"EBDT", b"cmap"]); + strip_sfnt_bitmap_tables(&mut data, 0); + assert_eq!(&tag_at(&data, 0), b"glyf"); + assert_eq!(&tag_at(&data, 1), b"XBDT"); // EBDT renamed + assert_eq!(&tag_at(&data, 2), b"cmap"); + } + + #[test] + fn truncated_buffers_do_not_panic() { + // Shorter than the 12-byte offset table. + let mut tiny = vec![0u8; 8]; + strip_sfnt_bitmap_tables(&mut tiny, 0); + // Header claims 4 tables but the directory bytes are missing. + let mut header_only = vec![0u8; 12]; + header_only[4..6].copy_from_slice(&4u16.to_be_bytes()); + strip_sfnt_bitmap_tables(&mut header_only, 0); + strip_bitmap_tables(&mut header_only); + } + + #[test] + fn strips_inside_ttc_collection() { + // Build one inner sfnt and wrap it in a `ttcf` header pointing at it. + let inner = sfnt_with_tags(&[b"EBDT"]); + let header_size = 12 + 4; // ttcf header + one 4-byte offset + let mut data = vec![0u8; header_size]; + data[0..4].copy_from_slice(b"ttcf"); + data[8..12].copy_from_slice(&1u32.to_be_bytes()); // numFonts = 1 + data[12..16].copy_from_slice(&(header_size as u32).to_be_bytes()); // offset + data.extend_from_slice(&inner); + + strip_bitmap_tables(&mut data); + // The inner sfnt's EBDT tag (at header_size + 12) must be renamed. + assert_eq!(&data[header_size + 12], &b'X'); + } + + #[cfg(feature = "font-cache")] + #[test] + fn get_coords_is_empty_for_non_font_bytes() { + let mut cache = FontDataCache::new(); + let data = Arc::new(vec![0u8, 1, 2, 3]); + assert!(cache.get_coords(&data, 0).is_empty()); + } + + #[cfg(feature = "font-cache")] + #[test] + fn get_or_insert_caches_by_pointer_identity() { + let mut cache = FontDataCache::new(); + let a = Arc::new(vec![0u8; 4]); + let b = Arc::new(vec![0u8; 4]); // identical bytes, distinct allocation + cache.get_or_insert(&a, 0); + cache.get_or_insert(&a, 0); // same Arc → reuses entry + assert_eq!(cache.entries.len(), 1); + cache.get_or_insert(&b, 0); // distinct allocation → new entry + assert_eq!(cache.entries.len(), 2); + } +} diff --git a/appthere-canvas/src/lib.rs b/appthere-canvas/src/lib.rs index 5d33bfec..375b5224 100644 --- a/appthere-canvas/src/lib.rs +++ b/appthere-canvas/src/lib.rs @@ -7,32 +7,23 @@ //! //! Feature flags: //! - `gpu` — enables wgpu texture utilities and [`PageSource`]. -//! - `dioxus` — enables Dioxus scroll helpers in [`crate::dioxus`]. //! - `font-cache` — enables [`FontDataCache`]. -// The page-render cache, tier policy, scroll state, key trait, and GPU texture -// utilities are the canonical implementation in `loki-render-cache`, re-exported -// here so existing `appthere_canvas::*` paths keep working unchanged. This crate -// adds the Dioxus scroll driver and font cache on top. -pub use loki_render_cache::{ - assign_tier, CacheKey, CacheTier, CachedPage, PageCache, PageGeometry, PageIndex, RenderError, - RetierResult, ScrollPhase, ScrollState, SETTLE_DURATION, -}; +// The page-source trait, key trait, and GPU texture handle are the canonical +// implementation in `loki-render-cache`, re-exported here so existing +// `appthere_canvas::*` paths keep working unchanged. This crate adds the font +// cache on top. +pub use loki_render_cache::{CacheKey, PageIndex, RenderError}; // Re-export the `texture` module too, so module-qualified paths such as // `appthere_canvas::texture::GpuTexture` continue to resolve. #[cfg(feature = "gpu")] pub use loki_render_cache::texture; #[cfg(feature = "gpu")] -pub use loki_render_cache::{ - allocate_texture, downsample_texture, BlitPipeline, GpuTexture, PageSource, -}; +pub use loki_render_cache::{GpuTexture, PageSource}; #[cfg(feature = "font-cache")] pub mod font_cache; -#[cfg(feature = "dioxus")] -pub mod dioxus; - #[cfg(feature = "font-cache")] pub use font_cache::FontDataCache; diff --git a/appthere-ui/src/components/icons.rs b/appthere-ui/src/components/icons.rs index 3bcea2a0..67eb2678 100644 --- a/appthere-ui/src/components/icons.rs +++ b/appthere-ui/src/components/icons.rs @@ -44,6 +44,14 @@ pub const LUCIDE_SUBSCRIPT: &str = "M4 5l8 8M12 5l-8 8m14.5 6.5V19h-4l4-4.5"; pub const LUCIDE_SAVE: &str = "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2zM17 21v-8H7v8M7 3v5h8"; +/// Lucide `download` — tray with a downward arrow. Used for "Save As" to +/// visually distinguish it from the plain floppy-disk Save. +pub const LUCIDE_DOWNLOAD: &str = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"; + +/// Lucide `layout-template` — a wide bar above two smaller panes. Used for +/// "Save as Template" to distinguish it from the plain Save / Save As actions. +pub const LUCIDE_LAYOUT_TEMPLATE: &str = "M3 3h18v7H3zM3 14h9v7H3zM16 14h5v7h-5z"; + /// Lucide `align-left` — three lines, all left-aligned. pub const LUCIDE_ALIGN_LEFT: &str = "M15 12H3M17 6H3M13 18H3"; diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index 601a35be..2fdbba5a 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -32,8 +32,9 @@ pub mod tokens; pub use components::icons::{ AtIcon, LUCIDE_ALIGN_CENTER, LUCIDE_ALIGN_JUSTIFY, LUCIDE_ALIGN_LEFT, LUCIDE_ALIGN_RIGHT, - LUCIDE_BOLD, LUCIDE_ITALIC, LUCIDE_PILCROW, LUCIDE_REDO, LUCIDE_SAVE, LUCIDE_STRIKETHROUGH, - LUCIDE_SUBSCRIPT, LUCIDE_SUPERSCRIPT, LUCIDE_UNDERLINE, LUCIDE_UNDO, + LUCIDE_BOLD, LUCIDE_DOWNLOAD, LUCIDE_ITALIC, LUCIDE_LAYOUT_TEMPLATE, LUCIDE_PILCROW, + LUCIDE_REDO, LUCIDE_SAVE, LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, LUCIDE_SUPERSCRIPT, + LUCIDE_UNDERLINE, LUCIDE_UNDO, }; pub use components::ribbon::{ AtRibbon, AtRibbonGroup, AtRibbonIconButton, AtRibbonSelect, RibbonTabDesc, RibbonTabIndex, diff --git a/appthere-ui/src/theme.rs b/appthere-ui/src/theme.rs index 76db0d9d..3ce8231a 100644 --- a/appthere-ui/src/theme.rs +++ b/appthere-ui/src/theme.rs @@ -55,3 +55,18 @@ impl Default for AtThemeContext { pub fn use_theme() -> AtThemeContext { use_context::() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_variant_defaults_to_dark() { + assert_eq!(ThemeVariant::default(), ThemeVariant::Dark); + } + + #[test] + fn theme_context_defaults_to_dark_variant() { + assert_eq!(AtThemeContext::default().variant, ThemeVariant::Dark); + } +} diff --git a/appthere-ui/src/tokens/colors.rs b/appthere-ui/src/tokens/colors.rs index 7963c179..e11ae9e9 100644 --- a/appthere-ui/src/tokens/colors.rs +++ b/appthere-ui/src/tokens/colors.rs @@ -132,3 +132,68 @@ pub const COLOR_STATUS_ERROR_TEXT: &str = "#C62828"; /// Error banner border color. pub const COLOR_STATUS_ERROR_BORDER: &str = "#BB4433"; + +#[cfg(test)] +mod tests { + use super::*; + + fn is_hex6(s: &str) -> bool { + let Some(rest) = s.strip_prefix('#') else { + return false; + }; + rest.len() == 6 && rest.bytes().all(|b| b.is_ascii_hexdigit()) + } + + #[test] + fn all_hex_color_tokens_are_well_formed() { + // Every solid-fill token is a `#RRGGBB` string (Flat Design: no + // gradients, no shorthand). A typo here ships an invalid CSS color. + let hex_tokens: &[(&str, &str)] = &[ + ("COLOR_SURFACE_PAGE", COLOR_SURFACE_PAGE), + ("COLOR_SURFACE_BASE", COLOR_SURFACE_BASE), + ("COLOR_SURFACE_CHROME", COLOR_SURFACE_CHROME), + ("COLOR_SURFACE_1", COLOR_SURFACE_1), + ("COLOR_SURFACE_2", COLOR_SURFACE_2), + ("COLOR_SURFACE_3", COLOR_SURFACE_3), + ("COLOR_BORDER_DEFAULT", COLOR_BORDER_DEFAULT), + ("COLOR_BORDER_CHROME", COLOR_BORDER_CHROME), + ("COLOR_ACCENT_PRIMARY", COLOR_ACCENT_PRIMARY), + ("COLOR_ACCENT_PRIMARY_HOVER", COLOR_ACCENT_PRIMARY_HOVER), + ("COLOR_TEXT_PRIMARY", COLOR_TEXT_PRIMARY), + ("COLOR_TEXT_SECONDARY", COLOR_TEXT_SECONDARY), + ("COLOR_TEXT_ON_CHROME", COLOR_TEXT_ON_CHROME), + ( + "COLOR_TEXT_ON_CHROME_SECONDARY", + COLOR_TEXT_ON_CHROME_SECONDARY, + ), + ("COLOR_TEXT_ACCENT", COLOR_TEXT_ACCENT), + ("COLOR_TAB_ACTIVE_BG", COLOR_TAB_ACTIVE_BG), + ("COLOR_TAB_ACTIVE_INDICATOR", COLOR_TAB_ACTIVE_INDICATOR), + ("COLOR_TAB_INACTIVE_HOVER", COLOR_TAB_INACTIVE_HOVER), + ("COLOR_CONTEXTUAL_TAB", COLOR_CONTEXTUAL_TAB), + ("COLOR_ICON_DISABLED", COLOR_ICON_DISABLED), + ("CANVAS_PAGE_BG", CANVAS_PAGE_BG), + ("CANVAS_MARGIN_BG", CANVAS_MARGIN_BG), + ("COLOR_STATUS_ERROR_BG", COLOR_STATUS_ERROR_BG), + ("COLOR_STATUS_ERROR_TEXT", COLOR_STATUS_ERROR_TEXT), + ("COLOR_STATUS_ERROR_BORDER", COLOR_STATUS_ERROR_BORDER), + ]; + for (name, value) in hex_tokens { + assert!(is_hex6(value), "{name} = {value:?} is not a valid #RRGGBB"); + } + } + + #[test] + fn rgba_and_opacity_tokens_have_expected_form() { + for token in [ + COLOR_SCROLLBAR_THUMB, + COLOR_SCROLLBAR_THUMB_HOVER, + COLOR_SCROLLBAR_TRACK, + ] { + assert!(token.starts_with("rgba(") && token.ends_with(')')); + } + // OPACITY_DISABLED is a bare CSS opacity number in [0, 1]. + let opacity: f32 = OPACITY_DISABLED.parse().expect("opacity parses as f32"); + assert!((0.0..=1.0).contains(&opacity)); + } +} diff --git a/appthere-ui/src/tokens/spacing.rs b/appthere-ui/src/tokens/spacing.rs index 5b449379..e3c6652d 100644 --- a/appthere-ui/src/tokens/spacing.rs +++ b/appthere-ui/src/tokens/spacing.rs @@ -70,3 +70,42 @@ pub const ICON_SIZE_LG: f32 = 24.0; /// 32 px — extra-large icon (app icon, empty-state illustration). pub const ICON_SIZE_XL: f32 = 32.0; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn touch_target_meets_wcag_minimum() { + // WCAG 2.5.8 requires a 44×44 CSS-px minimum touch target. + assert_eq!(TOUCH_MIN, 44.0); + } + + #[test] + fn spacing_scale_uses_a_4px_base_unit() { + // Each SPACE_ token is n × the 4 px base. + assert_eq!(SPACE_1, 4.0); + assert_eq!(SPACE_2, 2.0 * SPACE_1); + assert_eq!(SPACE_3, 3.0 * SPACE_1); + assert_eq!(SPACE_4, 4.0 * SPACE_1); + assert_eq!(SPACE_5, 5.0 * SPACE_1); + assert_eq!(SPACE_6, 6.0 * SPACE_1); + assert_eq!(SPACE_8, 8.0 * SPACE_1); + assert_eq!(SPACE_10, 10.0 * SPACE_1); + } + + #[test] + fn radius_scale_is_strictly_increasing() { + assert!(RADIUS_SM < RADIUS_MD); + assert!(RADIUS_MD < RADIUS_LG); + assert!(RADIUS_LG < RADIUS_XL); + assert!(RADIUS_XL < RADIUS_FULL); + } + + #[test] + fn icon_sizes_are_strictly_increasing() { + assert!(ICON_SIZE_SM < ICON_SIZE_MD); + assert!(ICON_SIZE_MD < ICON_SIZE_LG); + assert!(ICON_SIZE_LG < ICON_SIZE_XL); + } +} diff --git a/docs/audit-2026-06.md b/docs/audit-2026-06.md new file mode 100644 index 00000000..cb82b8bc --- /dev/null +++ b/docs/audit-2026-06.md @@ -0,0 +1,612 @@ +# Loki Workspace Audit — 2026-06-21 + +A full-workspace audit covering security/robustness, performance, code smells, +dead code, test coverage, and process/tooling. Each finding has a **location**, +the **issue**, a **proposed fix**, and a **priority** (P0 = do first) so it can +be picked up in a future session. + +## How to read this + +- **Verified** findings were confirmed by reading the cited code during this audit. +- **Reported** findings come from analysis passes and are plausible but the exact + line/site should be re-confirmed before editing (noted inline). +- Scope: the 22 workspace crates (`loki-*`, `appthere-*`). Vendored `patches/*` + and `target/` are excluded. +- Scale at audit time: **452 `.rs` files, ~84,400 lines.** + +The code-review-graph knowledge graph was **not available** this session (empty / +build timed out at the 60 s MCP cap), so this audit used direct search + reading. +Rebuilding the graph would make a follow-up cheaper. + +--- + +## Top priorities (start here) + +> **Update 2026-06-21 (remediation pass):** the four P0 items below were +> actioned. #1 was **withdrawn as a false positive** after deeper analysis +> (see S-1); #2/#3/#4 were fixed. The P1+ items remain open. + +| # | Area | Finding | Priority | Status | +|---|------|---------|----------|--------| +| 1 | Security | ~~OOXML reader has no nesting-depth guard → stack-exhaustion DoS~~ — **false positive**, the import path does not recurse unboundedly (see S-1) | ~~P0~~ | Withdrawn | +| 2 | Tests | `appthere-ui`, `appthere-canvas`, `loki-fonts` have **zero** tests | **P0** | ✅ Fixed | +| 3 | Tests | ODT export is an unimplemented stub but CLAUDE.md claims it ships | **P0** | ✅ Docs corrected | +| 4 | Process | CI runs only `build` + `test` — no `clippy -D warnings`, no `fmt --check` | **P0** | ✅ Fixed | +| 5 | Perf | `loki-vello` clones every `PositionedItem` on the paint path (`scene.rs:490`) | **P1** | ✅ Fixed | +| 6 | Quality | 301 `let _ =` discard write Results in the OOXML writers (silent error swallowing) | **P1** | Re-assessed — see P-1b below | +| 7 | Quality | 43 production files exceed the 300-line ceiling (16 > 600; `flow.rs` = 1612) | **P1** | Tracked (CLAUDE.md fixed; split-pass open) | +| 8 | Quality | ~17 near-duplicate app-shell modules copy-pasted across the 3 binaries | **P1** | ✅ First slice done (`loki-app-shell`: tabs/untitled/new_document/recent_documents); UI/route modules still open | +| 9 | Perf | Per-keystroke style/CharProps cloning in the layout hot path | **P1** | ✅ Primary site fixed (CharProps); StyleSpan clone deferred | +| 10 | Dead code | 32 `#[allow(dead_code)]` sites (OOXML model/util) masking parsed-but-unused fields | **P2** | Open | + +> **Update 2026-06-21 (P1 pass):** #5 fixed (clone removed from the paint path); +> #7 the CLAUDE.md tech-debt table was corrected to match reality (43 files) and +> points here for the split backlog. #6 was re-assessed and **downgraded** — the +> discarded Results are writes to an in-memory `Vec` (infallible), so threading +> `?` through 301 sites is churn for errors that cannot occur (see the §2/Quality +> note P-1b). #8, #9, #10 remain open as dedicated efforts (rationale in their +> sections). + +--- + +## 1. Security & robustness + +**Overall posture is good.** The untrusted-input defenses that matter most are +present and verified. The gaps are an asymmetry between the ODF and OOXML readers +plus a few robustness papercuts. + +### Verified mitigations (keep / don't regress) + +- **Zip-bomb caps** — `loki-opc/src/zip/limits.rs`: 256 MiB per entry + (`MAX_ENTRY_DECOMPRESSED_BYTES`), 1 GiB aggregate + (`MAX_PACKAGE_DECOMPRESSED_BYTES`), `saturating_add` accumulation, and an + entry-count cap via `.take(entry_limit.saturating_add(1))` in `zip/read.rs`. +- **Zip-slip / path traversal** — `loki-opc/src/part/name.rs`: rejects `.`/`..` + segments and `%2f`/`%5c` encodings; enforces leading `/`, no trailing `/`. +- **ODF nesting guard** — `loki-odf/src/limits.rs:67` `MAX_NESTING_DEPTH = 100`, + enforced in `odt/reader/document.rs`. +- **ODS cell-explosion caps** — `loki-odf/src/ods/import.rs`: + `MAX_MATERIALIZED_REPEAT`, `MAX_MATERIALIZED_CELLS_TOTAL`, `MAX_SHEET_ROWS/COLS`. +- **XLSX cell-ref arithmetic** uses `checked_mul/add/sub` (overflow-safe). +- **Image decoding** goes through the `image` crate's internal bounds. + +### S-1 — OOXML "missing nesting-depth guard" — WITHDRAWN (false positive) *(Re-verified 2026-06-21)* + +- **Original claim:** the OOXML reader recurses through `parse_*` for nested + structures with no global depth bound, so a hostile DOCX could exhaust the + stack (a DoS that ODF guards against and OOXML does not). +- **Why it's wrong:** a closer read of `loki-ooxml/src/docx/reader/document.rs` + shows the import path does **not** recurse on user-controllable nesting: + - `parse_table_cell` handles only `tcPr` and `p` — it does **not** call + `parse_table`, so nested tables are dropped, not recursed (depth is fixed at + document → table → row → cell → paragraph → run). + - `parse_hyperlink_runs` / `parse_tracked_runs` collect runs only; they do not + re-enter themselves on nested `hyperlink`/`ins`/`del`. + - PPTX group shapes (`p:grpSp`) hit the `other =>` arm in `pptx/shapes.rs` and + are `skip_subtree`'d, not recursed. + - The skip helpers `skip_element` (`document.rs:1045`) and `skip_subtree` + (`pptx/mod.rs:66`) are **iterative** (depth counter + single loop). + - quick-xml's `read_event_into` is a pull parser — deep nesting does not + recurse it. Input size is additionally bounded by the OPC 256 MiB/1 GiB + decompression caps. + Maximum call depth is therefore a small constant independent of input nesting. + No guard is needed, and adding one would be dead defensive code implying a + vulnerability that does not exist. +- **Lesson:** the original pass confirmed the recursive *call structure* existed + but not that it was *cyclic/unbounded*. Verify the back-edge, not just the + forward calls. + +#### S-1b — Nested tables are silently dropped/mis-parsed *(Verified · correctness, not security · P2 · NEW)* + +- **Location:** `loki-ooxml/src/docx/reader/document.rs:868` `parse_table_cell`. +- **Issue:** A `` inside a `` falls into the ignored `_ => {}` arm; + worse, the nested table's first `` matches the cell loop's End arm and + breaks the *parent* cell early, so the remaining nested structure is misparsed. + Nested tables are a real DOCX feature (TC-DOCX-007) — this is a fidelity/data + bug, not a DoS. +- **Proposed fix:** Support nested tables in the cell body (add a `tbl` arm + calling `parse_table`, and a nested-table field on `DocxTableCell`). **If** that + recursion is added, it reintroduces the depth concern from S-1, so pair it with + a `MAX_NESTING_DEPTH` guard at that point. + +### S-2 — Unsanitized dimension values from file → unit conversion *(Verified · Low · P2)* + +- **Location:** `loki-ooxml/src/xml_util.rs:109` `emu_to_points(emu: i64)` → + `Points::new(emu as f64 / EMUS_PER_PT)`; same shape for twips. +- **Issue:** No sanity bound on the incoming value. `f64` won't *overflow* + (so this is **not** the integer-overflow the analysis first suggested), but an + absurd dimension (e.g. `cx="9223372036854775807"`) flows into layout. Risk is + only material if such a value later sizes an allocation or an unbounded layout + loop. Confirm downstream bounding before deciding severity. +- **Proposed fix:** Clamp to a generous sane maximum (e.g. ±100 m worth of EMU) + at the conversion boundary and emit a warning, so one bad attribute can't drive + pathological layout. + +### S-3 — UTF-16 transcode silently drops a trailing byte *(Verified · Low · P2)* + +- **Location:** `loki-opc/src/zip/read.rs:196` `transcode_utf16_to_utf8` + (`chunks_exact(2)` at 208/213, then `String::from_utf16_lossy`). +- **Issue:** `chunks_exact(2)` discards a final odd byte, so an odd-length UTF-16 + part is silently truncated rather than reported. Data-integrity papercut, not a + memory-safety issue. +- **Proposed fix:** If `(len - bom) % 2 != 0`, return `None`/error instead of + silently dropping the remainder. + +### S-4 — No spreadsheet formula-injection signal *(Reported · Low · P2)* + +- **Location:** XLSX/ODS import (`loki-ooxml/src/xlsx/import.rs`, `loki-sheet-model`). +- **Issue:** Formulas are stored verbatim. Loki does not execute them (good), but + re-export of `=cmd|…`-style payloads propagates a classic spreadsheet-injection + vector to whoever opens the file in Excel. Best-practice hardening, not a Loki + RCE. +- **Proposed fix:** Optionally flag formulas beginning with `= + - @` / tab as a + warning during import; leave stripping to a policy flag. + +### S-5 — Document the XXE posture *(Verified-by-absence · Low · P3)* + +- **Issue:** quick-xml (0.36 today; 0.40 is a deferred upgrade — see §6) does not + resolve external entities or DTDs by default, so XXE risk is inherently low. But + nothing in the code states this, so a future reader/maintainer could enable DTD + processing without realising the implication. +- **Proposed fix:** Add a one-line comment at each `Reader::from_reader` site (or + a module note) recording the "no external entities by default" guarantee. + +--- + +## 2. Performance + +These are the hot paths (per-keystroke layout, per-frame paint, CRDT +reconstruction). Cold one-time import/export costs are deliberately deprioritised. +Line numbers for the paint/layout flagships were re-confirmed; treat the rest as +"confirm exact site when fixing." + +### P-1 — Per-paint clone of every positioned item *(Verified · P1 · ✅ Fixed 2026-06-21)* + +- **Location:** `loki-vello/src/scene.rs` `paint_items`. +- **Issue:** Every `PositionedItem` was cloned solely to apply a translation + before painting — on a large page, a full copy of the item list per paint. +- **Fixed:** `paint_items` now fast-paths the heap-allocating variants without + cloning — a `GlyphRun` (carries a `Vec`) is painted via a new + `offset` parameter on `paint_glyph_run`/`paint_link_hint`, and the groups + (`ClippedGroup`/`RotatedGroup`, which carry a child `Vec`) offset their + coordinates inline and pass the offset to their recursive `paint_items` call. + The remaining leaf variants are small all-`Copy` structs, so their clone is a + stack copy and is left as-is (keeps `paint_filled_rect`'s 8 page-background + callers untouched). The transformation is arithmetically identical + (`(coord + offset) * scale`), and loki-vello's lib tests (14) pass. + +#### P-1b — OOXML writer `let _ =` (audit #6) — re-assessed, downgraded *(2026-06-21)* + +The 301 `let _ =` sites discard the `Result` of `quick_xml` writes that target an +**in-memory `Vec`**, which is infallible in practice — there is no I/O that +can fail. Mechanically threading `?` through all 301 sites adds error plumbing +for errors that cannot occur, against negligible benefit. If this is addressed +later, prefer a single thin write-wrapper that records the first error rather +than a 301-site churn. Downgraded from P1 to a low-priority cleanup. + +### P-2 — Per-keystroke style-span & CharProps cloning *(Verified · P1 · ✅ primary site fixed 2026-06-21)* + +- **Locations:** `loki-layout/src/resolve.rs` `walk_inlines` cloned `effective` + `CharProps` for each `Strong/Emph/Underline/Strikeout/Super/Subscript/SmallCaps` + nesting (and the footnote mark); `loki-layout/src/para.rs` `clean_text_and_spans` + clones each `StyleSpan`. +- **Fixed (the larger, nested one):** `walk_inlines` now takes `&mut CharProps` + and uses mutate-and-restore — each formatting variant sets its one field via + `Option::replace`, recurses, then restores the prior value — instead of cloning + the whole `CharProps` (3 heap `Option` font names) per styled run. This + removes the O(spans × nesting) allocations on the resolve hot path. Two + regression tests lock in the restore semantics (siblings after a styled run are + unaffected; nested styles accumulate then unwind). All 126 loki-layout lib tests + pass; clippy/fmt clean. +- **Left as a smaller follow-up:** the `StyleSpan` clone in `clean_text_and_spans`. + It is entangled with the shaping cache (callers pass `&[StyleSpan]` that the + cache borrows, so the Vec can't be consumed), and the one heap field + (`font_name`) is immediately overwritten by `resolve_font_name` right after — so + a clean fix means reworking that font-resolution handoff, not a local change. + `StyleSpan`'s other fields are all `Copy`, so the residual cost is one + `Option` clone per span (not per nesting level). + +### P-3 — Glyph-run style lookups are linear scans *(Reported · P2)* + +- **Location:** `loki-layout/src/para.rs` glyph-run emission (~lines 920-975): + `span_link_url_for_range` / `_vertical_align_` / `_highlight_` / `_has_shadow` + each re-scan `clean_spans` per run (with a `range.clone()` each call). +- **Issue:** O(glyph-runs × spans) per paragraph — quadratic on dense formatting. +- **Proposed fix:** Build a byte-index → span-index map once per paragraph and do + O(1) lookups in the emission loop. + +### P-4 — Incremental Loro reconstruction was section-0-only *(P1 · ✅ Fixed 2026-06-21 · original framing corrected)* + +- **Location:** `loki-doc-model/src/loro_bridge/incremental.rs`. +- **Original claim (corrected):** the audit said documents *with footnotes* and + *any* edit outside section 0 fall back to a full O(document) rebuild "every + keystroke." On closer inspection that was overstated: + - **Footnotes do not cause per-keystroke fallback.** They live inside block + content (no separate top-level container), so editing the main text never + touches a footnote container — the diff only contains the edited block. + - **Section-1+ edits were unreachable via the editing path anyway**, because + the mutation helpers (`get_loro_text_for_block`, `get_block_map_and_list`) + are themselves hardcoded to `sections.get(0)`. So single-user typing only + ever touched section 0, where the fast path already worked. + The real limitation: `try_incremental` could only map changes to **section 0**, + so a *collaborative/remote* edit (or a future multi-section editing feature) + reaching section 1+ forced a full rebuild. +- **Fixed:** generalised `try_incremental` to all sections — it collects every + section's blocks-list container up front and maps each changed container to a + `(section, block)` pair (via the blocks-list position + the following `Seq` + index), patching `cached.sections[s].blocks[n]`. Structural changes (to the + sections list or any blocks list) and changes outside any block still fall back. + Added a `last_update_was_incremental()` observability hook so the fast path is + testable, plus tests that prove it engages for a section-1 edit (previously a + full rebuild) and for cross-section edits — and that structural edits report a + fallback. 12 incremental tests pass (was 8); full doc-model suite green; + clippy/fmt clean. +- **Follow-up (done 2026-06-21): multi-section editing.** The mutation layer and + layout were section-0-only, so single-user editing could not reach section 1+ + at all (and would corrupt edits in a multi-section document, since layout's + section-relative `block_index` collided across sections). This was fixed + separately: block indices are now **document-global** — layout assigns + cumulative indices across sections (continuous + paginated), and the + `loro_mutation` helpers resolve a global index to `(section, local block)` via + `resolve_section_blocks`. The editor needed no changes (it is section-agnostic). + Cross-section merge (backspace at the very start of a section's first + paragraph) is rejected with `MutationError::CrossSectionMerge` rather than + silently removing the section break — a deliberate, documented limitation. New + tests cover insert/split/merge in a non-zero section, the cross-section-merge + rejection, and the global layout index. The path-resolution micro-caching the + audit suggested remains unnecessary (keystroke diffs touch 1–2 containers). + +### P-5 — Coarse render-cache invalidation *(Reported · P2)* + +- **Location:** `loki-render-cache/src/page_cache.rs:59` `mark_all_dirty`. +- **Issue:** Per-page `mark_dirty` exists (line 52), but if callers reach for + `mark_all_dirty` on any document change, every visible page re-renders even when + one paragraph changed. *Confirm who calls `mark_all_dirty` and whether it's too + coarse* before investing. +- **Proposed fix:** Ensure edit paths call `mark_dirty(page)` for the affected + page(s); reserve `mark_all_dirty` for structural/zoom/theme changes. + +### P-6 — Cold-path allocations *(Reported · P3)* + +- Checkpoint snapshots clone the list-counter `HashMap` per page boundary + (`loki-layout/src/flow.rs` `snapshot_checkpoint`); list rendering clones the + first paragraph per list item and `format!`s a marker each iteration. Consider + `Arc`-sharing the counters and pre-sizing inline vectors. Low impact (load-time), + fix opportunistically when touching `flow.rs`. + +--- + +## 3. Code smells & maintainability + +### Q-1 — 300-line file ceiling widely violated *(Verified · P1 · in progress 2026-06-21: **10 of 43 files done**, ~33 remain. `odt/mapper/props.rs` got a full directory split; nine others (`block.rs`, `docx/mapper/{paragraph,numbering,mod,table}.rs`, `odt/import.rs`, `odt/mapper/lists.rs`, `layout/result.rs`, `renderer/render_layout.rs`) were over the ceiling only because of a large inline test module, so they were made compliant by the safest possible move — relocating `#[cfg(test)] mod tests` to a sibling `*_tests.rs` (zero production-code change). All code files < 300, behaviour identical, full suites + clippy + fmt green. The remaining ~33 mostly need real directory splits like props.rs.)* + +- **Data:** 43 production `.rs` files exceed the CLAUDE.md 300-line ceiling; 16 + exceed 600. Worst offenders: + + | Lines | File | + |------:|------| + | 1612 | `loki-layout/src/flow.rs` | + | 1441 | `loki-odf/src/odt/reader/styles.rs` | + | 1428 | `loki-odf/src/odt/reader/document.rs` | + | 1278 | `loki-layout/src/para.rs` | + | 1241 | `loki-spreadsheet/src/routes/editor/editor_inner.rs` | + | 1169 | `loki-ooxml/src/docx/write/document.rs` | + | 1126 | `loki-ooxml/src/docx/reader/document.rs` | + | 1094 | `loki-odf/src/odt/mapper/document.rs` | + | 968 | `loki-text/src/routes/editor/editor_inner.rs` | + | 925 | `loki-odf/src/odt/mapper/props.rs` | + +- **Issue:** CLAUDE.md's tech-debt table lists only 3 ceiling violations; reality + is 43. The convention is effectively unenforced. +- **Proposed fix:** (a) Run a dedicated split pass on the worst files (group by + sub-responsibility — e.g. `flow.rs` → tables / lists / pagination / checkpoints). + (b) Update the CLAUDE.md tech-debt table to match reality. (c) Optionally add a + CI check that fails on new files over the ceiling so it stops growing. + +### Q-2 — App-shell duplication across the three binaries *(Verified · P1 · ✅ first slice done 2026-06-21)* + +- **Location:** `loki-text`, `loki-spreadsheet`, `loki-presentation` each carry + near-identical `app.rs`, `recent_documents.rs`, `new_document.rs`, + `routes/home.rs`, `routes/shell.rs`, `tabs.rs`, `utils.rs`, and editor + scaffolding (~17 shared module names). +- **Issue:** Three copies of the same shell logic means three places to fix every + bug — a maintainability hazard. +- **Done:** Created the **`loki-app-shell`** crate and moved the cohesive, + byte-identical cluster into it: the `OpenTab` model (`tabs`), the `untitled-` + path scheme + `is_untitled` (`untitled`), blank-tab creation (`new_document`), + and the persisted recent-documents store (`recent_documents`). The store's only + per-app difference — the JSON file name — is now a `#[serde(skip)]` field set by + `RecentDocuments::load(recent_file)`, so `save()`/`record()` call sites are + unchanged. Each binary keeps a thin re-export shim (so `crate::tabs::OpenTab`, + `crate::new_document::is_untitled`, etc. still resolve) plus its own + `RECENT_FILE` constant. Net: ~3 × 110 lines of duplication collapsed to one + tested crate (8 unit tests). All three binaries compile; clippy/fmt clean. +- **Still open (larger, riskier):** the duplicated **UI/route** modules + (`routes/home.rs` ~290-320 lines each, `routes/shell.rs`, `error.rs`, + `utils.rs`). These differ more between apps and are RSX/route logic; extracting + them (parameterised over the app's document model) is a separate, bigger pass. + +### Q-3 — Silent error swallowing in the OOXML writers *(Verified · downgraded 2026-06-21 — see P-1b: in-memory writes are infallible, so the 301-site `?` change is churn with no benefit)* + +- **Data:** 301 `let _ = …` in `loki-ooxml/src` — 171 in `docx/write/document.rs` + alone, plus `write/styles.rs` (57), `write/numbering.rs` (28), `write/footnotes.rs` + (26), `pptx/write_slide.rs` (15). Each discards a `write_event`/`write!` `Result`. +- **Issue:** Every XML write error is dropped. Writes target an in-memory buffer + so failures are rare, but the pattern violates CLAUDE.md's "no swallowing an + error" rule and would hide a real serialization fault (e.g. a future streaming + writer) with no signal. +- **Proposed fix:** Have the write helpers return `OoxmlResult<()>` and propagate + with `?` (the writers already run in fallible functions), or at minimum funnel + writes through a wrapper that records the first error. This also removes 300+ + lines of `let _ =` noise. + +### Q-4 — ~100 `#[allow]` lint suppressions *(Verified · P2)* + +- **Data (by lint):** 32 `dead_code`, 14 `clippy::too_many_arguments`, + 11 `clippy::too_many_lines`, 8 `cast_precision_loss`, 6 `unused_variables`, + 6 `clippy::ptr_arg`, 6 `cast_possible_truncation`, 4 `unreachable_code`, plus a + long tail. `clippy --workspace -- -D warnings` passes *because* of these. +- **Issue:** `too_many_arguments`/`too_many_lines` corroborate Q-1 (oversized, + over-parameterised functions). The cast suppressions overlap with the numeric + concerns in §1. `unreachable_code` (4×, in `recent_documents.rs` / `loki-fonts`) + are cfg-platform returns — benign but worth a glance. +- **Proposed fix:** Treat the `#[allow]` inventory as a refactor backlog: collapse + argument lists into structs (kills `too_many_arguments`), split functions (kills + `too_many_lines`), and either wire up or delete the `dead_code` items (Q-5). + +### Q-5 — Deferred-work markers *(Verified · informational)* + +- ~90 `TODO()` markers (well-disciplined per CLAUDE.md) plus ~20 generic + `TODO`. Clusters: `TODO(partial-render)` ×4, `TODO(loro-bridge)` ×4, + `TODO(link-click)` ×4, plus `TODO(shadow/icons/font/tabs/super-sub)`. These are + intentional debt, not bugs — listed so a future session can triage them. + +--- + +## 4. Dead code + +### D-1 — `#[allow(dead_code)]` clusters in OOXML model/util *(Verified · P2)* + +- **Location:** `loki-ooxml/src/docx/model/{paragraph,footnotes,styles,fields, + numbering}.rs` (12 sites) and `loki-ooxml/src/xml_util.rs` (6 sites), + `docx/reader/util.rs` (1). +- **Issue:** These are fields/enums parsed from DOCX but never mapped downstream + (e.g. `DocxRunChild::{FldChar, InstrText}`) and utility fns never called. They + represent the round-trip gaps CLAUDE.md already tracks, but the `#[allow]` hides + them from the compiler so they accumulate silently. +- **Proposed fix:** For each: either wire it into the mapper (closing a real + round-trip gap) or delete it. The unused `xml_util.rs` helpers (6) are the + safest to remove first. A graph-backed `refactor_tool mode=dead_code` pass would + enumerate the rest once the graph is rebuilt. + +### D-2 — Stray-dependency hygiene *(context)* + +- The prior dependency pass already removed two genuinely-unused deps + (`read-fonts`, `skrifa` from `loki-vello`). Worth a periodic `cargo`-level + unused-dependency sweep (e.g. `cargo machete`/`cargo-udeps`) since several + crates declared deps used only in tests. + +--- + +## 5. Test coverage + +**Strong** on core import/model/layout; **weak** on UI, exporters, and CRDT +edge-cases. Test-function counts (`#[test]`/`#[tokio::test]`): loki-odf 176, +loki-ooxml 174, loki-doc-model 139, loki-layout 130, then a steep drop-off. + +### T-1 — Zero-test crates *(Verified · P0/P2 · ✅ Addressed 2026-06-21)* + +> **Fixed:** added unit tests to all three — `appthere-canvas` (sfnt bitmap-table +> stripping incl. TTC + truncation, and the `font-cache` coord/cache paths), +> `appthere-ui` (theme default, spacing/WCAG touch-target/radius invariants, color +> token validity), and `loki-fonts` (the non-Android empty-CSS contract). Full +> component-render tests still await a Dioxus test harness. + + +- `appthere-ui` (0) and `appthere-canvas` (0) — the shared design system and + canvas infra — have **no tests**. Any pure logic (token math, layout helpers, + coordinate transforms, hit detection in `appthere-canvas`) is untested. **P0.** +- `loki-fonts` (0) — mostly `include_bytes!` data; low risk, but the + Android-conditional CSS generation is untested. **P2.** +- **Proposed fix:** Add unit tests for the non-RSX logic in `appthere-canvas` + (transforms, font-axis decode in `font_cache.rs`) and for `appthere-ui` token + helpers. Full component rendering can stay manual until a Dioxus test harness + exists. + +### T-2 — ODT export is an unimplemented stub *(Verified · P0 · ✅ Docs corrected 2026-06-21)* + +> **Addressed (docs):** CLAUDE.md and the `loki-odf` crate description now state +> ODT export is not yet implemented (only ODS export ships). **Implementing** the +> ODT exporter + round-trip test remains open (a feature, not a doc fix). + + +- **Location:** `loki-odf/src/odt/export.rs:48` returns + `OdfError::NotImplemented`. +- **Issue:** CLAUDE.md and the crate docs describe loki-odf as doing "ODT/ODS + **export**", but ODT export does not exist — so no ODT round-trip is possible + and the capability claim is misleading (see §7). +- **Proposed fix:** Implement ODT export (mirroring the DOCX writer structure) and + add `loki-odf/tests/odt_round_trip.rs`. Until then, correct the docs. + +### T-3 — Exporters under-tested vs importers *(Verified/Reported · P1 · ◑ Partly addressed 2026-06-21)* + +> **Fixed (integration test dirs):** added the three missing suites, each +> re-opening the exporter's output and validating its structure rather than just +> substring presence: +> - `loki-ooxml/tests/pptx_round_trip.rs` (gated on the `pptx` feature, 5 tests): +> a **multi-slide** deck through model → export → re-import (count/order/size), +> idempotent second round trip, and an OPC re-open asserting one slide part per +> slide + correct presentation→slide relationships. (The inline +> `export_tests.rs` only covered a single slide.) +> - `loki-epub/tests/epub_structure.rs` (7 tests): re-opens the OCF ZIP, asserts +> `mimetype` is first and **stored**, parses every XML part for +> well-formedness via `quick-xml`, checks manifest/spine/nav consistency +> (nav lists both chapter headings), and drives a table + embedded image +> through the full export, re-opening the ZIP to confirm the packaged image and +> manifest entry. +> - `loki-pdf/tests/pdf_structure.rs` (6 tests): multi-page export with the +> page-tree `/Count` checked against the actual leaf `/Page` objects, the +> `startxref` offset verified to point at the `xref` table, trailer `/Root` + +> `/ID`, and all three PDF/X levels validated structurally. +> +> **CI gap closed:** the `lint`, `build`, and `test` jobs now run with +> `--all-features`, so the feature-gated `pptx`/`xlsx` code and integration +> suites (previously compiled away by the default `cargo test --workspace`) are +> actually linted and executed. (Fixed a latent `clippy::collapsible_if` in +> `loki-opc` that the `strict` feature exposed — the only first-party blocker; +> the vendored `patches/blitz-dom` emits a dependency-level warning that `-D +> warnings` does not deny.) +> +> **EPUB tables/images (done):** these were no longer placeholders — basic +> ``/`` rendering already shipped — but the table renderer dropped +> the caption, column widths, and alignment. It now emits `` with fixed/proportional widths, and resolved horizontal/vertical +> cell alignment (cell override → column default), with a base table/figure/img +> stylesheet. (The stale `lib.rs` "TODO placeholders" note and a dead +> `.loki-table-placeholder` CSS class were removed.) +> +> **Still open:** the DOCX/XLSX P0 per-case `import → export → re-import` +> assertions (TC-DOCX-021/023/029, TC-XLSX-009/011) are not yet added. + + +- **PPTX:** only inline `export_tests.rs`; no `tests/pptx_round_trip.rs`. 29 ACID + cases catalogued but unrunnable (see T-5). **P1.** +- **PDF** (`loki-pdf`, 12 inline, no `tests/`) and **EPUB** (`loki-epub`, 17 + inline, no `tests/`): no integration/round-trip validation; EPUB tables/images + are still TODO placeholders. +- **DOCX/XLSX export:** good baseline, but P0 fidelity cases lack round-trip + assertions — float-image wrap modes (TC-DOCX-023), RTL/bidi (TC-DOCX-029), + bookmarks/cross-refs (TC-DOCX-021), XLSX dynamic-array spill (TC-XLSX-009), + modern functions/XLOOKUP (TC-XLSX-011). +- **Proposed fix:** Add an `import → export → re-import` assertion per P0 case; + create the missing `tests/pptx_round_trip.rs`, `loki-pdf/tests/`, and + `loki-epub/tests/` (validate by re-opening the output ZIP/PDF structure). + +### T-4 — Non-asserting & thin tests *(Verified · P2)* + +- `loki-ooxml/tests/diagnose_import.rs` has **0 assertions** (println diagnostics + only) — it can never fail. Either convert to assertions or move it to an + `examples/`/binary so it isn't counted as a test. +- `loki-opc/tests/package_tests.rs` is `#[cfg(feature = "serde")]`-gated — verify + CI enables that feature, else it silently no-ops. +- One `#[ignore]` (`loki-acid` golden-pixel, host-font-dependent) is justified. + +### T-5 — ACID harness gaps *(Reported · P1/P2 · ◑ Partly addressed 2026-06-21)* + +> **Fixed (PPTX fixture + harness path):** supplied `acid_pptx.pptx` and wired a +> presentation path through the harness — `Fixture::Pptx`, +> `Imported::Presentation`, `PptxImport` dispatch, and a `slide_count` field on +> `FixtureReport`. A new `presentation_fixture_has_slides` structural canary +> asserts it imports with the expected slide count, so the PPTX format is no +> longer dark. The fixture is **self-generated** by Loki's own exporter +> (`examples/gen_acid_pptx.rs`, regenerable) — a round-trip fixture covering only +> what Loki can emit (text/run formatting, preset geometries, placeholders, +> multiple slides), explicitly documented as a placeholder rather than a fidelity +> oracle. +> +> **Still open:** the harder catalogued PPTX cases (gradients, SmartArt, charts, +> animations, grouped-shape child transforms) need a PowerPoint-authored deck; +> golden-pixel references remain unpopulated (await the external GPU-raster step); +> ODP/ODG still have fixtures but no importers. + + +- `loki-acid` catalogs ~127 `TC-*` cases but the **`acid_pptx.pptx` fixture is + missing** (verified — assets dir has docx/odt/xlsx/ods/odp/odg only), so the 29 + PPTX cases can't run. Golden-pixel references are unpopulated (await an external + GPU-raster step). ODP/ODG have fixtures but no importers. +- **Proposed fix:** Supply `acid_pptx.pptx`; document the golden-pixel reference + generation procedure; gate the catalogued-but-unrunnable cases clearly so they + read as "pending fixture" rather than passing. + +### T-6 — CRDT bridge edge-cases *(Verified · P1 · ✅ Addressed 2026-06-21)* + +> **Fixed:** added a `loro_concurrency_tests.rs` integration suite to all three +> bridge crates (`loki-doc-model`, `loki-sheet-model`, and +> `loki-presentation-model` — which previously had **no `tests/` dir**). Each +> suite forks a replica, applies divergent edits, exchanges updates both ways, +> and asserts byte-identical convergence on `LoroDoc::get_deep_value()`: +> - **doc-model** (10 tests): concurrent same-/different-block inserts, insert +> vs. delete, split/merge vs. text edit, three-way order-independent +> convergence, undo+redo, snapshot-into-fresh-doc, idempotent re-import. +> - **sheet-model** (6 tests): disjoint-cell merges, same-cell deterministic +> register, metadata, snapshot, idempotency. +> - **presentation-model** (6 tests): metadata/slide-append merges plus a +> characterization test pinning the coarse-grained snapshot register +> (same-key edits are last-writer-wins) so a future fine-grained bridge is a +> visible, intentional behaviour change. +> +> Fine-grained slide-reorder/group CRDT tests remain a follow-up tied to the +> planned per-shape bridge (the model still stores `drawing`/`placeholders` as +> JSON snapshots). + + +- `loki-doc-model` / `loki-sheet-model` round-trips are tested, but + `loki-presentation-model` has only 7 inline tests and **no `tests/` dir**, and + none of the three cover concurrent-edit merge, undo/redo, or pathological CRDT + states. +- **Proposed fix:** Add concurrency/merge tests (two Loro instances, conflicting + edits → assert merged result) and slide reorder/group tests for the presentation + model. + +--- + +## 6. Process / CI / tooling + +### C-1 — CI does not enforce the project's own gates *(Verified · P0 · ✅ Addressed 2026-06-21)* + +> **Fixed:** `.github/workflows/rust.yml` now has a `lint` job running +> `cargo fmt --all --check` and `cargo clippy --workspace -- -D warnings`, plus a +> `build-and-test` job, both with `Swatinem/rust-cache`. Coverage, a pinned +> toolchain, and an Android-target smoke build remain as follow-ups (P1/P2). + + +- **Location:** `.github/workflows/rust.yml` — only `cargo build --verbose` and + `cargo test --verbose` on `ubuntu-latest`. +- **Issue:** CLAUDE.md mandates `cargo clippy --workspace -- -D warnings` and + `cargo fmt --all --check` as the completion gate, but **neither runs in CI**. + There is also no coverage tooling, no dependency caching, no pinned toolchain, + and no Android-target build (despite Android being a first-class target with + dedicated patches). +- **Proposed fix:** Add CI steps for `fmt --all --check`, `clippy --workspace -- -D + warnings`, a `--all-targets` test run, Swatinem/rust-cache, and a pinned + toolchain. Consider a coverage job (`cargo-llvm-cov`) and an Android + `cargo check --target aarch64-linux-android` smoke build. + +--- + +## 7. Documentation drift + +CLAUDE.md / crate docs have fallen behind the code in a few places. Keeping them +honest matters because they're the contract future sessions trust. + +- **D7-1 (P1):** loki-odf is described as doing "ODT/ODS export" but ODT export is + a stub (§T-2). Correct the capability statement. +- **D7-2 (P1):** CLAUDE.md's "300-line ceiling violations" tech-debt table lists 3 + files; the real count is 43 (§Q-1). Update it (or regenerate from a script). +- **D7-3 (P3):** `docs/fidelity-status.md` had a stale "Parley 0.6" reference + (fixed in the prior dependency pass); audit other version-specific notes for + similar drift after the parley 0.8→0.10 upgrade. + +--- + +## Appendix — raw metrics (2026-06-21) + +- Files / lines (excl. patches, target): **452 / ~84,420**. +- Production files > 300 lines: **43**; > 600 lines: **16**. +- `#[test]`/`#[tokio::test]` by crate (top): odf 176, ooxml 174, doc-model 139, + layout 130, text 47, spreadsheet 32, render-cache 27, opc 27, acid 21, + primitives 20, epub 17, vello 15, sheet-model 14, graphics 14, renderer 12, + pdf 12, presentation-model 7, presentation 7, i18n 6; **fonts 0, appthere-ui 0, + appthere-canvas 0**. +- `#[allow(...)]`: ~100 total (32 dead_code, 14 too_many_arguments, + 11 too_many_lines, 8 cast_precision_loss, 6 cast_possible_truncation, …). +- `let _ =` discarded Results in loki-ooxml: **301** (171 in `docx/write/document.rs`). +- `unsafe` in workspace src: only the Android FFI (`init_android`, + `#[unsafe(no_mangle)]`) in the 3 binary crates — justified platform glue. +- `forbid(unsafe_code)`: present in all library crates; absent (correctly) in the + 3 binaries that need Android FFI. + +*Note:* `unwrap`/`expect` counts in the format parsers are non-trivial but most +sit in inline `#[cfg(test)]` modules; the input-reachable ones are folded into the +§1 robustness work rather than counted as a separate metric (the raw grep +over-counts test modules). diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 0839033f..1997cae7 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -10,14 +10,20 @@ This is the living source of truth documenting which document features, characte | :--- | :---: | :---: | :---: | :--- | | **Page Size & Margins** | Yes | Yes | Yes | Page sizes (A4, Letter) and margins resolved and written. | | **Section Breaks** | Yes | Yes | Yes | Supports multiple sections with different page layouts. | -| **Headers & Footers** | Yes | Yes | Partial | Dynamic assignment supported; export lacks full plumbing. | +| **Document Metadata** | Yes | — | Yes | Core properties (title, subject, keywords, description, creator, last-modified-by, created/modified dates, language, revision) round-trip in both formats — DOCX via `docProps/core.xml` (OPC `CoreProperties`; the `serde` feature on `loki-opc` is enabled so the reader/writer are real, not stubs), ODT via `meta.xml`. **Extended Dublin Core** (publisher, contributors, rights, license, identifier, identifier-scheme, type, format, source, relation, coverage, issued, bibliographic-citation) round-trips too: `dc:identifier` uses the native core.xml element; the rest are carried as DOCX `docProps/custom.xml` custom properties / ODT `meta:user-defined` entries under reserved `dcmi:` names. The flattening is shared via `DublinCoreMeta::to_named_pairs`/`from_named_pairs` in `loki-doc-model`. Tested by `metadata_round_trip.rs` (DOCX) and `extended_dublin_core_round_trips` (ODT). Not yet written: custom user properties, `meta:editing-duration`, OOXML `docProps/app.xml`. | +| **Multi-column Sections** | Yes | Yes | Yes | Column count, gap, and separator line round-trip in both formats — DOCX `w:cols` (`@w:num`/`@w:space`/`@w:sep`) and ODF `style:columns` (`fo:column-count`/`fo:column-gap` + `style:column-sep`). At **layout time** (paginated mode) the flow engine divides the content area into equal columns and fills each top-to-bottom before advancing to the next column and then the page (`flow_columns.rs`); the requested separator line is drawn between used columns. Continuous/reflow modes ignore columns by design. Tested by `export_columns.rs` / `multi_column_section_round_trips` (round-trip) and `text_flows_down_columns_before_paging` / `column_separator_line_is_drawn` (layout). Known limitations: columns are balanced only implicitly (fill-first, no height balancing), and footnotes still flow in the last active column. | +| **Headers & Footers** | Yes | Yes | Yes | DOCX export writes all three variants — default, first-page (`w:titlePg`), and even-page (`word/settings.xml` with `w:evenAndOddHeaders`) — as `w:hdr`/`w:ftr` parts with full block content; ODT export writes them into the master page. Round-trip tested (`even_page_headers_footers_round_trip`). | | **Footnotes & Endnotes** | Yes | Yes | Yes | Rendered at end of section with separator rules. | -| **Dynamic Fields** | Yes | Partial | No | PAGE / NUMPAGES fields in headers & footers render real per-page values (per-page re-layout in `assign_headers_footers`). Body-text fields still render snapshots. Post-layout computation (ADR-0008) is proposed. | +| **Dynamic Fields** | Yes | Partial | Yes | Import reads **both** field encodings: complex fields (`w:fldChar`/`w:instrText` state machine) and the compact `w:fldSimple` form (`@w:instr` + cached-result runs, including the self-closing variant) — both map to an identical `Inline::Field` (`fld_simple.rs`). Export writes body-text fields as complex fields (with `separate`+result when a snapshot is cached) — PAGE, NUMPAGES, DATE/TIME (incl. `\@` format switch), TITLE, AUTHOR, SUBJECT, FILENAME, NUMWORDS, REF/PAGEREF cross-references, and `Raw` instructions round-trip (`export_fields.rs`). PAGE / NUMPAGES in headers & footers render real per-page values (per-page re-layout in `assign_headers_footers`); body-text fields still *render* snapshots (post-layout computation, ADR-0008, is proposed). | | **Line-boundary Splitting** | — | Yes | — | Paragraphs split cleanly across pages using `ClippedGroup` masks. | | **keep-together** | Yes | Yes | Yes | Prevents paragraph line breaks across pages when enabled. | | **keep-with-next** | Yes | Yes | Yes | Scans forward up to 5 blocks to place headings and body together. | | **Widow/Orphan Control** | Yes | No | No | Ignored at layout time (Word defaults to "on"). | | **Bookmarks** | Yes | No | Yes | Bookmarks are written/parsed but do not affect layout. | +| **Comments (annotations)** | Yes | Yes | Yes | Round-trip in both formats. The commented range is carried as `Inline::Comment` start/end anchors in the content flow; bodies (author, date, **multi-paragraph block content**) live in `Document.comments`. DOCX uses `w:commentRangeStart`/`w:commentRangeEnd` + a `CommentReference` run, with bodies in `word/comments.xml`; ODF uses inline `office:annotation` (body + `dc:creator`/`dc:date`) / `office:annotation-end`. **Rendered** in paginated mode as a **margin comment panel**: each anchored comment becomes a tinted card (author line + body) stacked in a gutter to the right of the page (`flow_comments.rs`; painted by `loki-vello`, and `loki-text` widens the canvas by `COMMENT_GUTTER_WIDTH` when comments are present). **Persisted through the Loro CRDT** as a JSON snapshot (`loro_bridge::comments`), so comment edits are durable and undoable. Tested by `comments_round_trip.rs` (DOCX), `comments_round_trip` (ODT), `loro_bridge::comments` (CRDT), and `comment_panel_renders_in_gutter` (layout). Limits: inline formatting inside a comment is flattened to plain text; the panel renders on full relayout (not incremental-reuse pages); cross-paragraph comment ranges anchor at the start paragraph. | +| **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. 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). 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. | --- @@ -27,6 +33,7 @@ This is the living source of truth documenting which document features, characte | Property | Import | Layout/Render | Export | Notes | | :--- | :---: | :---: | :---: | :--- | | **Bold / Italic** | Yes | Yes | Yes | Fully supported. | +| **Numeric Font Weight** | Partial | Yes | Partial | `CharProps.font_weight` (OpenType 1–1000) renders via Parley `FontWeight`, superseding boolean `bold` when set. Editable per-style in the style editor's weight selector (Thin…Black). Import: ODF `fo:font-weight` numeric; OOXML `w:b` is boolean only. Export: DOCX collapses to bold/not-bold (≥ 600 ⇒ bold). | | **Underline** | Yes | Yes | Yes | Style varieties (single, double, dotted, dash, wave, thick) mapped. | | **Strikethrough** | Yes | Yes | Yes | Single and double strikethrough variants mapped. | | **Font Family / Size** | Yes | Yes | Yes | Resolves against style catalog and font resources. | @@ -53,8 +60,9 @@ This is the living source of truth documenting which document features, characte | **Borders** | Yes | Yes | Yes | Top, bottom, left, right borders supported. | | **Tab Stops** | Yes | Yes | Yes | Position-sorted tab stops supported. | | **Background Color** | Yes | Yes | Yes | Paragraph background shading supported. | +| **Named / Custom Paragraph Styles** | Yes | Yes | Yes | The full style catalog round-trips through DOCX `styles.xml`: custom styles created in the style editor, plus edits to built-in `Normal`/`Heading1`–`6`, persist across save/reload. Font family, weight (→ bold), size, alignment, indentation (incl. first-line), spacing, line height, `basedOn`, `next`-style, outline level, and the custom flag are all written and read back. In-session, the catalog round-trips through the Loro CRDT (`loro_bridge::styles`, a JSON snapshot like metadata), so style-editor edits are durable across rebuilds and **undoable** with Ctrl+Z/Ctrl+Y. | | **border_between** | Yes | No | No | Rules between adjacent same-styled paragraphs ignored. | -| **bidi / RTL** | Yes | No | No | RTL paragraphs ignored due to Parley 0.6 API limitations. | +| **bidi / RTL** | Yes | No | No | RTL paragraphs ignored due to Parley API limitations. | --- @@ -167,7 +175,7 @@ OCF (ZIP) container. | **EPUB 3.3 container** | `loki-epub` | Yes | OCF ZIP with `mimetype` stored first, `META-INF/container.xml`, package document, navigation document, one XHTML content document, a stylesheet, and packaged image resources. | | **EPUB package metadata** | `loki-epub` | Yes | Required `dc:identifier` (synthesised UUID when absent) / `dc:title` / `dc:language` / `dcterms:modified`, plus all available Dublin Core fields. | | **EPUB content** | `loki-epub` | Yes | Paragraphs, headings (with a derived TOC `nav`), lists, blockquotes, code, rules, definition lists, **tables** (``/``/`` with `colspan`/`rowspan`), inline formatting, and **images** (`data:` URIs packaged as `EPUB/images/*` resources and listed in the manifest; external URLs referenced in place). Math, fields, and comments are dropped. | -| **Dublin Core metadata editor** | `loki-text` | Yes | Publish-tab **Metadata** button edits the DCMES + DCMI Terms fields (`DocumentMeta` + `DublinCoreMeta`). Edits are persisted **through the Loro CRDT** (`loro_bridge::write_document_meta`, stored as a JSON snapshot under the metadata map), so they survive incremental rebuilds, participate in undo/redo, and round-trip through Loro import/export. Metadata is **not** yet written back to DOCX/ODT on export. | +| **Dublin Core metadata editor** | `loki-text` | Yes | Publish-tab **Metadata** button edits the DCMES + DCMI Terms fields (`DocumentMeta` + `DublinCoreMeta`). Edits are persisted **through the Loro CRDT** (`loro_bridge::write_document_meta`, stored as a JSON snapshot under the metadata map), so they survive incremental rebuilds, participate in undo/redo, and round-trip through Loro import/export. Metadata is now also written back to **both DOCX and ODT** on export (see the Document Metadata row). | --- diff --git a/docs/memory-audit-2026-06-12.md b/docs/memory-audit-2026-06-12.md index d6665270..8eebb1d7 100644 --- a/docs/memory-audit-2026-06-12.md +++ b/docs/memory-audit-2026-06-12.md @@ -16,7 +16,8 @@ identified here are independent of that. | 3 | Inactive-tab sessions retain the full preserved layout (+ Loro + undo) | Medium | Recommended | | 4 | Paragraph shaping cache accumulated across documents; generous cap | Medium | **Fixed** | | 5 | Per-tile `FontDataCache` duplicates interned font bytes across tiles | Low | Recommended | -| 6 | Loro oplog grows with edit history and never compacts | Low | Recommended | +| 6 | Loro oplog grows with edit history and never compacts | Medium (edit-driven, not idle) | Instrumented 2026-06-21; compaction proposed | +| 7 | Static `` tiles force a continuous **idle** render loop (`is_animating`), churning per-frame GPU resources → >3 GB at idle | **Critical** | **Fixed 2026-06-21** | ## Finding 1 — All pages render at Hot tier until you scroll (FIXED) @@ -109,3 +110,65 @@ Android Studio's Memory Profiler) and watch: 2. Scroll to the bottom and back → memory should stay bounded (tiers demote). 3. Open several documents in tabs → only the active document should hold page textures; inactive tabs hold CPU state only. + +## 2026-06-21 update — Finding 7: idle render loop is the >3 GB grower (FIXED) + +The >3 GB growth was first attributed to Finding 6 (Loro history), but the user +then clarified the app was **idle** — not being typed in — when it ballooned past +3 GB. That rules out edit-driven history growth and points to a per-frame leak. + +**Root cause (Finding 7):** `blitz-dom::is_animating()` returns +`has_canvas | has_active_animations`, and blitz-shell's `redraw()` re-requests a +redraw **every frame** while it is true. Loki paints every document page as a +`` custom-paint tile, so `has_canvas` is permanently true → the app +ran a **continuous render loop at idle** (vsync-rate redraws of an unchanged +scene). The page-texture reuse guard prevents re-registering textures, so this is +not a Rust-side growing collection; it is the per-frame GPU/driver resource churn +(command buffers, drawables) accumulating under sustained idle rendering, plus +the obvious CPU/battery waste. + +**Fix:** added `BaseDocument::needs_animation_tick()` (only `has_active_animations`) +and switched blitz-shell's `redraw()` re-arm to it, so a static canvas no longer +forces per-frame redraws. Loki's canvas content always updates via a DOM mutation +(the tile's `data-cursor`/generation attribute, scroll remounts, resize), each of +which already schedules a redraw, so updates stay correct while idle frames stop. +See `docs/patches.md` (blitz-dom item 6). Needs on-device confirmation: idle CPU +drops to ~0 and RSS plateaus. + +## Finding 6 — Loro history still grows with *editing* (separate, lower) + +Finding 6 remains real but is **not** the idle grower: every keystroke appends +ops to the Loro oplog, and deleted characters leave tombstones in the rich-text +CRDT tree; neither is ever compacted, so resident memory grows with edit +*history* during active editing. The `UndoManager` is bounded +(`max_undo_steps(100)`). + +**Diagnosis aid (added):** `apply_mutation_and_relayout` now logs throttled +counters under the `loki_text::mem` target: + +```text +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. diff --git a/docs/patches.md b/docs/patches.md index 8dde831b..db9d8040 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -14,45 +14,6 @@ temporary and has a documented removal condition. ## Active patches -### fontique — 0.8.0 - -**Source:** `patches/fontique/` (local), vendored from upstream commit -`8dbecc0545a0c97eb605937b928bc186d2d1295c` in -[linebender/parley](https://github.com/linebender/parley) (`fontique/` path -in that monorepo). - -**Fixes:** Two related issues with the crates.io publication of fontique 0.8.0: - -1. **Missing package alias.** The crates.io publication lost the - `fontconfig_sys = { package = "yeslogic-fontconfig-sys", ... }` alias - during Cargo normalization. The source in - `src/backend/fontconfig.rs` uses `use fontconfig_sys::…` and requires - this alias to compile. - -2. **Workspace feature-unification conflict.** `blitz-dom` depends on - fontique 0.6.0 and activates `yeslogic-fontconfig-sys/dlopen` for the - entire build graph. Without the patch, fontique 0.8.0 is built without - dlopen and fails on static-C imports because the two versions of the - yeslogic-fontconfig-sys feature cannot agree. The patch enables - `fontconfig-dlopen` on 0.8.0 so both versions use the same linkage mode. - -**Root cause:** Bug in the fontique 0.8.0 crates.io publish pipeline (package -alias dropped during Cargo manifest normalisation). Compounded by Cargo -feature-unification behaviour when two semver-incompatible versions of -fontique coexist in the dependency graph. - -**Upstream status:** No upstream issue filed as of 2026-05-03. Upstream -repository is [linebender/parley](https://github.com/linebender/parley). - -**Removal condition:** Remove when a post-0.8.0 fontique release on crates.io -restores the `fontconfig_sys` package alias and the dlopen/static linkage -conflict is resolved (either fontique 0.8 is no longer paired with blitz-dom's -fontique 0.6, or upstream aligns feature flags). - -**Added:** 2026-04-13 (introduced in the loki-text scaffold commit). - ---- - ### dioxus-native-dom — 0.7.9 **Version pin:** the whole dioxus family is pinned to `=0.7.9` in the root @@ -454,13 +415,32 @@ file picking additionally requires a Gradle build with `FilePickerActivity.kt`. overruns the document, or starts over the ribbon, does nothing rather than jiggling the UI by the sub-pixel root/window slack. +6. **Static canvases don't force a per-frame redraw (PATCH(loki), 2026-06-21).** + `is_animating()` returns `has_canvas | has_active_animations`, and the shell's + redraw loop re-requests a redraw every frame while it is true. Loki paints + every document page as a `` custom-paint tile, so `has_canvas` is + permanently true — the app spun in a **continuous idle render loop**: high + CPU/battery, and per-frame GPU resource churn that grew RSS without bound even + with the app untouched (observed climbing past 3 GB at idle). A new + `BaseDocument::needs_animation_tick()` returns only `has_active_animations` + (genuine CSS animations/transitions), and blitz-shell's `redraw()` re-arms on + that instead of `is_animating()`. Loki's canvas tiles are static between + events — their content only changes via DOM mutations (the tile's + `data-cursor`/generation attribute, scroll remounts, viewport resize), each of + which already schedules a redraw — so dropping the canvas-forced loop leaves + updates correct while idle frames stop. (`is_animating()` is left intact for + any other consumer.) + **Removal condition:** Upstream blitz-dom implements tabindex focus-on-click -for non-input elements, dispatches scroll events to embedders, and exposes an -absolute node-scroll API. +for non-input elements, dispatches scroll events to embedders, exposes an +absolute node-scroll API, and stops treating a static canvas as perpetually +animating (e.g. a per-source "needs animation" signal). -**Added:** 2026-05-18 (focus); extended 2026-06-10 (scroll events) and -2026-06-11 (absolute scroll), together with matching changes in the blitz-shell -and dioxus-native(-dom) patches. +**Added:** 2026-05-18 (focus); extended 2026-06-10 (scroll events), +2026-06-11 (absolute scroll), and 2026-06-21 (`needs_animation_tick` — stop the +idle canvas redraw loop, paired with the blitz-shell `redraw()` change), +together with matching changes in the blitz-shell and dioxus-native(-dom) +patches. --- @@ -503,6 +483,31 @@ release with the Android `num_init_threads` default. Re-test with **Added:** 2026-06-10 +**Additional fix (texture release on teardown):** `CustomPaintSource` gained a +`fn release(&mut self, ctx: CustomPaintCtx)` method (default no-op), and +`VelloWindowRenderer::unregister_custom_paint_source` now calls it (while the +renderer is `Active`) before suspending and dropping the source. + +- *Root cause:* a texture handed to the renderer via + `CustomPaintCtx::register_texture` lives in the renderer's texture registry + until `unregister_texture` is called. The only teardown hook a source had was + `suspend()`, which takes no `CustomPaintCtx` and so cannot unregister. When a + paint source is unregistered (e.g. a virtualized page tile scrolling out of + view), its last-registered full-resolution texture (~10+ MB) leaked in the + registry. Scrolling a long document top→bottom→top grew RSS unboundedly + (observed ~500 MB → ~1.3 GB) and never recovered. App-level `suspend()` did + not leak because the whole renderer is recreated on resume; only per-source + unregister was affected. +- *Loki consumer:* `loki-renderer/src/page_paint_source.rs` (`LokiPageSource`) + implements `release` to `unregister_texture` its page texture. +- *Upstream status:* candidate upstream fix — the custom-paint API has no other + way to release per-source textures on teardown. +- *Removal condition:* an anyrender_vello release whose custom-paint teardown + releases a source's registered textures (e.g. an equivalent `release`/`drop` + hook), at which point `LokiPageSource::release` can target the upstream API. + +**Updated:** 2026-06-21 + --- ## Upgrading Dioxus @@ -604,3 +609,23 @@ Before removing a patch: 3. Run `cargo check --workspace` and `cargo test --workspace`. 4. Remove the patch source directory (`patches//`). 5. Update or remove the corresponding entry in this file. + +## Removed patches + +### fontique — removed 2026-06-21 (was 0.8.0) + +The `patches/fontique` patch (added 2026-04-13) worked around two issues with +the crates.io publication of **fontique 0.8.0**: (1) a missing +`fontconfig_sys = { package = "yeslogic-fontconfig-sys", … }` alias dropped +during the publish pipeline, and (2) a dlopen/static feature-unification +conflict with blitz-dom's fontique 0.6. + +Removed when Loki's own crates moved from fontique 0.8 to **fontique 0.10** +(alongside the parley 0.8 → 0.10 upgrade). fontique 0.10 restores the +`fontconfig_sys` alias, so issue (1) no longer applies. Issue (2) is now +handled without a patch by enabling the `fontconfig-dlopen` feature directly on +`loki-layout`'s fontique dependency (fontique is re-exported through parley, so +this turns dlopen on wherever fontique appears — including crates such as +`loki-vello` whose graph does not contain blitz-dom). blitz-dom's own fontique +0.6 continues to enable `yeslogic-fontconfig-sys/dlopen`, so both fontique +generations agree on linkage mode. diff --git a/loki-acid/Cargo.toml b/loki-acid/Cargo.toml index 10e0d168..5b20213d 100644 --- a/loki-acid/Cargo.toml +++ b/loki-acid/Cargo.toml @@ -9,10 +9,11 @@ publish = false [dependencies] loki-doc-model = { path = "../loki-doc-model" } -loki-ooxml = { path = "../loki-ooxml", features = ["docx", "xlsx"] } +loki-ooxml = { path = "../loki-ooxml", features = ["docx", "xlsx", "pptx"] } loki-odf = { path = "../loki-odf" } loki-layout = { path = "../loki-layout" } loki-sheet-model = { path = "../loki-sheet-model" } +loki-presentation-model = { path = "../loki-presentation-model" } thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -20,3 +21,7 @@ image = { version = "0.25", default-features = false, features = ["png"] } [dev-dependencies] loki-doc-model = { path = "../loki-doc-model" } +# The `gen_acid_pptx` example builds the fixture from raw shapes. +loki-graphics = { path = "../loki-graphics", features = ["serde"] } +loki-presentation-model = { path = "../loki-presentation-model" } +loki-primitives = { path = "../loki-primitives" } diff --git a/loki-acid/assets/acid_pptx.pptx b/loki-acid/assets/acid_pptx.pptx new file mode 100644 index 00000000..c3a08ea2 Binary files /dev/null and b/loki-acid/assets/acid_pptx.pptx differ diff --git a/loki-acid/examples/gen_acid_pptx.rs b/loki-acid/examples/gen_acid_pptx.rs new file mode 100644 index 00000000..39a62f2c --- /dev/null +++ b/loki-acid/examples/gen_acid_pptx.rs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Generates `loki-acid/assets/acid_pptx.pptx`, the PPTX acid fixture. +//! +//! Run with `cargo run -p loki-acid --example gen_acid_pptx`. +//! +//! # Provenance — read before trusting this fixture +//! +//! Unlike `acid_docx.docx` / `acid_odt.odt` (authored in real Office / LibreOffice +//! to test *import* fidelity against third-party output), this deck is written by +//! Loki's **own** `PptxExport`. It is therefore a **round-trip** fixture: it +//! covers only constructs Loki can already emit (text boxes with run formatting, +//! preset geometries with fill/stroke, placeholders, multiple slides). It does +//! **not** exercise the harder catalogued cases (gradients, SmartArt, charts, +//! animations, grouped-shape child transforms), which need a PowerPoint-authored +//! deck. Treat it as a placeholder that unblocks the import/pagination canaries +//! for the 29 PPTX cases, not as a fidelity oracle for them. + +use std::io::Cursor; +use std::path::Path; + +use loki_graphics::{ + Fill, Geometry, PresetShape, RectF, Shape, Stroke, TextAlign, TextBody, TextParagraph, TextRun, + TextRunProps, +}; +use loki_ooxml::pptx::export::PptxExport; +use loki_presentation_model::{PlaceholderKind, Presentation, Slide}; +use loki_primitives::color::DocumentColor; + +fn color(hex: &str) -> DocumentColor { + DocumentColor::from_hex(hex).expect("valid hex") +} + +/// A title slide: a centred, bold, coloured title text box + a placeholder. +fn title_slide(p: &Presentation) -> Slide { + let mut slide = Slide::new("slide1", p.slide_size); + let body = TextBody { + paragraphs: vec![TextParagraph { + runs: vec![TextRun { + text: "Loki ACID — Presentation".to_string(), + props: TextRunProps { + bold: true, + font_size_pt: Some(40.0), + color: Some(color("#1A1A2E")), + ..Default::default() + }, + }], + align: TextAlign::Center, + level: 0, + }], + ..Default::default() + }; + let mut title = Shape::text_box("2", RectF::new(60.0, 40.0, 840.0, 120.0), body); + title.name = Some("Title 1".to_string()); + slide.drawing.push(title); + slide.add_placeholder(PlaceholderKind::Title, "2"); + slide +} + +/// A content slide: a left-aligned bulleted body plus a filled, stroked shape. +fn content_slide(p: &Presentation) -> Slide { + let mut slide = Slide::new("slide2", p.slide_size); + + let body = TextBody { + paragraphs: vec![ + TextParagraph { + runs: vec![TextRun { + text: "First point".to_string(), + props: TextRunProps::default(), + }], + align: TextAlign::Left, + level: 0, + }, + TextParagraph { + runs: vec![TextRun { + text: "Second point, indented".to_string(), + props: TextRunProps { + italic: true, + ..Default::default() + }, + }], + align: TextAlign::Left, + level: 1, + }, + ], + ..Default::default() + }; + let mut content = Shape::text_box("2", RectF::new(60.0, 60.0, 480.0, 360.0), body); + content.name = Some("Content".to_string()); + slide.drawing.push(content); + + let rect = Shape::geometry( + "3", + RectF::new(580.0, 120.0, 300.0, 200.0), + Geometry::Preset(PresetShape::Rectangle), + Fill::Solid(color("#0F3460")), + Some(Stroke::solid(color("#E94560"), 3.0)), + ); + slide.drawing.push(rect); + slide +} + +/// A shapes slide: a couple of preset geometries with different fills. +fn shapes_slide(p: &Presentation) -> Slide { + let mut slide = Slide::new("slide3", p.slide_size); + let ellipse = Shape::geometry( + "2", + RectF::new(80.0, 100.0, 240.0, 240.0), + Geometry::Preset(PresetShape::Ellipse), + Fill::Solid(color("#16C79A")), + Some(Stroke::solid(color("#11999E"), 2.0)), + ); + slide.drawing.push(ellipse); + + let triangle = Shape::geometry( + "3", + RectF::new(420.0, 100.0, 240.0, 240.0), + Geometry::Preset(PresetShape::Triangle), + Fill::Solid(color("#FFD460")), + None, + ); + slide.drawing.push(triangle); + slide +} + +fn build_deck() -> Presentation { + let mut p = Presentation::new(Presentation::WIDESCREEN_16_9); + p.meta.title = Some("Loki ACID Presentation Fixture".to_string()); + p.meta.author = Some("Loki ACID harness".to_string()); + + let s1 = title_slide(&p); + let s2 = content_slide(&p); + let s3 = shapes_slide(&p); + p.add_slide(s1); + p.add_slide(s2); + p.add_slide(s3); + p +} + +fn main() { + let deck = build_deck(); + + let mut buf = Cursor::new(Vec::new()); + PptxExport::export(&deck, &mut buf).expect("export acid pptx"); + let bytes = buf.into_inner(); + + let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/acid_pptx.pptx"); + std::fs::write(&out, &bytes).expect("write fixture"); + println!( + "wrote {} ({} bytes, {} slides)", + out.display(), + bytes.len(), + deck.slide_count() + ); +} diff --git a/loki-acid/src/catalog/pptx.rs b/loki-acid/src/catalog/pptx.rs index db4025b9..02352e14 100644 --- a/loki-acid/src/catalog/pptx.rs +++ b/loki-acid/src/catalog/pptx.rs @@ -3,8 +3,12 @@ //! PPTX acid cases (TEST_PLAN.md §3). //! -//! Note: no `acid_pptx.pptx` fixture has been supplied yet, so these cases are -//! catalogued but not yet exercised by the harness. +//! A self-generated `acid_pptx.pptx` fixture is now supplied (see +//! `examples/gen_acid_pptx.rs`), so the import/pagination canaries run for the +//! PPTX format. The fixture is written by Loki's own exporter and therefore only +//! covers constructs Loki can already emit — the catalogued cases below that +//! depend on gradients, SmartArt, charts, animations, or grouped-shape child +//! transforms still await a PowerPoint-authored deck and a golden render. use super::{Format::Pptx, TestCase, tc}; use crate::severity::Severity::{P0, P1, P2}; diff --git a/loki-acid/src/fixtures.rs b/loki-acid/src/fixtures.rs index 953384f5..5e0ed2f0 100644 --- a/loki-acid/src/fixtures.rs +++ b/loki-acid/src/fixtures.rs @@ -11,7 +11,9 @@ use std::io::Cursor; use loki_doc_model::Document; use loki_doc_model::io::DocumentImport; use loki_odf::{OdsImport, OdsImportOptions, OdtImport, OdtImportOptions}; +use loki_ooxml::pptx::import::{PptxImport, PptxImportOptions}; use loki_ooxml::{DocxImport, DocxImportOptions, XlsxImport, XlsxImportOptions}; +use loki_presentation_model::Presentation; use loki_sheet_model::workbook::Workbook; use crate::catalog::Format; @@ -25,6 +27,8 @@ pub enum Fixture { Odt, /// `acid_xlsx.xlsx` — spreadsheet. Xlsx, + /// `acid_pptx.pptx` — presentation. + Pptx, /// `acid_ods.ods` — OpenDocument Spreadsheet. Ods, /// `acid_odp.odp` — OpenDocument Presentation (no importer yet). @@ -39,6 +43,8 @@ pub enum Imported { Document(Box), /// A spreadsheet workbook. Workbook(Box), + /// A slide presentation. + Presentation(Box), } impl Fixture { @@ -49,6 +55,7 @@ impl Fixture { Fixture::Docx, Fixture::Odt, Fixture::Xlsx, + Fixture::Pptx, Fixture::Ods, Fixture::Odp, Fixture::Odg, @@ -62,6 +69,7 @@ impl Fixture { Fixture::Docx => Format::Docx, Fixture::Odt => Format::Odt, Fixture::Xlsx => Format::Xlsx, + Fixture::Pptx => Format::Pptx, Fixture::Ods => Format::Ods, Fixture::Odp => Format::Odp, Fixture::Odg => Format::Odg, @@ -75,6 +83,7 @@ impl Fixture { Fixture::Docx => "acid_docx.docx", Fixture::Odt => "acid_odt.odt", Fixture::Xlsx => "acid_xlsx.xlsx", + Fixture::Pptx => "acid_pptx.pptx", Fixture::Ods => "acid_ods.ods", Fixture::Odp => "acid_odp.odp", Fixture::Odg => "acid_odg.odg", @@ -88,6 +97,7 @@ impl Fixture { Fixture::Docx => include_bytes!("../assets/acid_docx.docx"), Fixture::Odt => include_bytes!("../assets/acid_odt.odt"), Fixture::Xlsx => include_bytes!("../assets/acid_xlsx.xlsx"), + Fixture::Pptx => include_bytes!("../assets/acid_pptx.pptx"), Fixture::Ods => include_bytes!("../assets/acid_ods.ods"), Fixture::Odp => include_bytes!("../assets/acid_odp.odp"), Fixture::Odg => include_bytes!("../assets/acid_odg.odg"), @@ -99,7 +109,7 @@ impl Fixture { pub fn has_importer(self) -> bool { matches!( self, - Fixture::Docx | Fixture::Odt | Fixture::Xlsx | Fixture::Ods + Fixture::Docx | Fixture::Odt | Fixture::Xlsx | Fixture::Pptx | Fixture::Ods ) } @@ -122,6 +132,9 @@ impl Fixture { Fixture::Ods => OdsImport::import(cursor, OdsImportOptions::default()) .map(|w| Imported::Workbook(Box::new(w))) .map_err(|e| e.to_string()), + Fixture::Pptx => PptxImport::import(cursor, PptxImportOptions::default()) + .map(|p| Imported::Presentation(Box::new(p))) + .map_err(|e| e.to_string()), Fixture::Odp | Fixture::Odg => Err("no importer yet for this ODF format".to_string()), } } diff --git a/loki-acid/src/report.rs b/loki-acid/src/report.rs index 5aaf558a..6d0569d6 100644 --- a/loki-acid/src/report.rs +++ b/loki-acid/src/report.rs @@ -26,6 +26,8 @@ pub struct FixtureReport { pub page_count: Option, /// Worksheet count (spreadsheets only). pub sheet_count: Option, + /// Slide count (presentations only). + pub slide_count: Option, /// Glyph coverage (word-processing documents only). pub glyph_coverage: Option, /// Number of catalogued acid cases targeting this format. @@ -45,6 +47,7 @@ pub fn analyze_fixture(fixture: Fixture) -> FixtureReport { import_error: None, page_count: None, sheet_count: None, + slide_count: None, glyph_coverage: None, catalogued_cases: catalog::cases_for(format).len(), }; @@ -60,6 +63,10 @@ pub fn analyze_fixture(fixture: Fixture) -> FixtureReport { report.import_ok = true; report.sheet_count = Some(wb.sheets.len()); } + Ok(Imported::Presentation(p)) => { + report.import_ok = true; + report.slide_count = Some(p.slide_count()); + } Err(e) => report.import_error = Some(e), } report diff --git a/loki-acid/tests/structural.rs b/loki-acid/tests/structural.rs index 4546bf0e..a66ece61 100644 --- a/loki-acid/tests/structural.rs +++ b/loki-acid/tests/structural.rs @@ -71,6 +71,26 @@ fn spreadsheet_fixtures_have_sheets() { } } +#[test] +fn presentation_fixture_has_slides() { + let Ok(Imported::Presentation(p)) = Fixture::Pptx.import() else { + panic!("acid_pptx.pptx did not import as a presentation"); + }; + assert!( + p.slide_count() >= 1, + "acid_pptx.pptx imported with no slides" + ); + // The aggregate report must surface the slide count for the PPTX fixture. + let report = report::run(); + let pptx = report + .fixtures + .iter() + .find(|f| f.format == loki_acid::Format::Pptx) + .expect("pptx fixture in report"); + assert_eq!(pptx.slide_count, Some(p.slide_count())); + assert!(pptx.import_ok, "pptx fixture failed structural import"); +} + #[test] fn aggregate_report_covers_all_fixtures() { let report = report::run(); diff --git a/loki-app-shell/Cargo.toml b/loki-app-shell/Cargo.toml new file mode 100644 index 00000000..62143fee --- /dev/null +++ b/loki-app-shell/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "loki-app-shell" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Shared application-shell logic (tabs, blank documents, recent list) for the Loki suite binaries" +repository = "https://github.com/appthere/loki" +keywords = ["document", "shell", "loki"] +categories = ["gui"] + +[dependencies] +loki-i18n = { path = "../loki-i18n" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/loki-app-shell/src/lib.rs b/loki-app-shell/src/lib.rs new file mode 100644 index 00000000..48df3d49 --- /dev/null +++ b/loki-app-shell/src/lib.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Shared application-shell logic for the Loki suite binaries +//! (`loki-text`, `loki-spreadsheet`, `loki-presentation`). +//! +//! Each application is an independent Dioxus binary, but their shell chrome +//! shares the same plumbing: an [`tabs::OpenTab`] model, blank-document creation +//! ([`new_document::new_blank_tab`]) using the `untitled-` path scheme +//! ([`untitled`]), and a persisted recent-documents list +//! ([`recent_documents::RecentDocuments`]). +//! +//! This crate holds the format-neutral, app-agnostic parts of that plumbing so +//! the three binaries do not each carry a copy. Application-specific bits — the +//! document model, routing, ribbon content, and the recent-list file name — stay +//! in each binary. + +#![forbid(unsafe_code)] + +pub mod new_document; +pub mod recent_documents; +pub mod tabs; +pub mod untitled; + +pub use untitled::{ + NewDocSource, UNTITLED_SCHEME, import_path, is_untitled, parse_new_doc_source, template_path, +}; diff --git a/loki-app-shell/src/new_document.rs b/loki-app-shell/src/new_document.rs new file mode 100644 index 00000000..f39b8ca4 --- /dev/null +++ b/loki-app-shell/src/new_document.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Blank-document tab creation shared across the Loki editor shells. +//! +//! Every new blank document gets a unique `untitled-N` path (see [`crate::untitled`]) +//! for its session lifetime. The path is never persisted or added to the +//! recent-documents list. + +use std::sync::atomic::{AtomicU32, Ordering}; + +use loki_i18n::fl; + +use crate::tabs::OpenTab; +use crate::untitled::UNTITLED_SCHEME; + +/// Session-scoped counter — incremented once per [`new_blank_tab`] call. +/// +/// Each Loki application is a separate process, so although this static is +/// compiled into a shared crate every running binary has its own instance. +static UNTITLED_COUNTER: AtomicU32 = AtomicU32::new(1); + +/// Creates a new blank-document tab with a unique `untitled-N` path. +/// +/// The tab is pre-marked `is_dirty: true` because the document has never been +/// saved; the title bar will show the unsaved-changes indicator immediately. +/// +/// Title convention matches macOS: the first blank document is "Untitled", +/// subsequent ones are "Untitled 2", "Untitled 3", … +pub fn new_blank_tab() -> OpenTab { + let n = UNTITLED_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = format!("{}{}", UNTITLED_SCHEME, n); + let title = if n == 1 { + fl!("editor-untitled") + } else { + fl!("editor-untitled-n", n = n as i64) + }; + OpenTab { + title, + path, + is_dirty: true, + is_discarded: false, + } +} + +/// Creates a new tab seeded from bundled template `template_id`. +/// +/// Like a blank tab the document is untitled (its `untitled-N-tpl-…` path keeps +/// it out of recents and forces Save As), but `load_document` reconstructs the +/// template's content from the path. `title` is the template's display name. +pub fn new_template_tab(template_id: &str, title: String) -> OpenTab { + let n = UNTITLED_COUNTER.fetch_add(1, Ordering::Relaxed); + OpenTab { + title, + path: crate::untitled::template_path(n, template_id), + is_dirty: true, + is_discarded: false, + } +} + +/// Creates a new tab that imports external `token` as a fresh, detached document +/// (the template-file open flow). Untitled, so saving prompts Save As rather +/// than overwriting the source file. `title` is typically the file's stem. +pub fn new_import_tab(token: &str, title: String) -> OpenTab { + let n = UNTITLED_COUNTER.fetch_add(1, Ordering::Relaxed); + OpenTab { + title, + path: crate::untitled::import_path(n, token), + is_dirty: true, + is_discarded: false, + } +} diff --git a/loki-app-shell/src/recent_documents.rs b/loki-app-shell/src/recent_documents.rs new file mode 100644 index 00000000..de2fe41a --- /dev/null +++ b/loki-app-shell/src/recent_documents.rs @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Recent-documents list — persisted as JSON in the platform data directory. +//! +//! Each application shell supplies its own relative file name (e.g. +//! `AppThere/Loki/recent.json`) to [`RecentDocuments::load`]; the name is +//! remembered on the value so [`RecentDocuments::save`] takes no argument. The +//! list is capped at [`MAX_RECENT`] entries and `untitled-N` paths +//! (see [`crate::untitled`]) are never recorded. + +use std::path::PathBuf; +#[cfg(target_os = "android")] +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; + +use crate::untitled::is_untitled; + +// ── Android data-dir override ───────────────────────────────────────────────── +// On Android, dirs::data_dir() returns None. android_main() calls +// set_android_data_dir() with the value from AndroidApp::internal_data_path() +// before Dioxus launches, giving recent_file_path() a writable location. + +#[cfg(target_os = "android")] +static ANDROID_DATA_DIR: OnceLock = OnceLock::new(); + +/// Store the Android internal data path before `dioxus::launch`. +/// Safe to call multiple times (ignored after the first call). +#[cfg(target_os = "android")] +pub fn set_android_data_dir(path: PathBuf) { + let _ = ANDROID_DATA_DIR.set(path); +} + +/// Maximum number of entries retained in the recent-documents list. +const MAX_RECENT: usize = 20; + +/// A single entry in the recent-documents list. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RecentEntry { + /// The serialised file-access token used as the editor route path. + pub path: String, + /// Human-readable document title (filename stem). + pub title: String, + /// ISO 8601 date of the last open, e.g. `"2026-05-13"`. + pub modified_at: String, +} + +/// In-memory recent-documents list. +/// +/// Inject as a `Signal` at the app root so all components can +/// read and mutate the list reactively. Construct with [`RecentDocuments::load`] +/// (which records the persistence file name); the name is not serialised. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RecentDocuments { + /// The recent entries, most-recent first. + pub entries: Vec, + /// Relative persistence path under the platform data dir, e.g. + /// `"AppThere/Loki/recent.json"`. Set by [`load`](RecentDocuments::load); + /// skipped during (de)serialisation so the on-disk format is just + /// `{ "entries": [...] }`. + #[serde(skip)] + recent_file: &'static str, +} + +impl RecentDocuments { + /// Load from the platform data directory, or return an empty list on any + /// error (missing file, parse failure, permissions). + /// + /// `recent_file` is the relative path under the platform data dir; it is + /// stored on the returned value so [`save`](Self::save) needs no argument. + pub fn load(recent_file: &'static str) -> Self { + let mut docs: Self = recent_file_path(recent_file) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + docs.recent_file = recent_file; + docs + } + + /// Persist to the platform data directory. Errors are silently ignored + /// (disk full, read-only FS, etc.) — the app continues without crashing. + /// A no-op if the value was not produced by [`load`](Self::load). + pub fn save(&self) { + let Some(path) = recent_file_path(self.recent_file) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, json); + } + } + + /// Record a document open. + /// + /// Moves the entry to the front if already present; otherwise inserts at the + /// front. Caps the list at [`MAX_RECENT`] entries. `untitled-N` paths are + /// silently ignored. + pub fn record(&mut self, path: String, title: String) { + if is_untitled(&path) { + return; + } + self.entries.retain(|e| e.path != path); + self.entries.insert( + 0, + RecentEntry { + path, + title, + modified_at: today_iso(), + }, + ); + self.entries.truncate(MAX_RECENT); + } + + /// Remove a recent entry by path (e.g. after a "remove from list" action). + pub fn remove(&mut self, path: &str) { + self.entries.retain(|e| e.path != path); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// On Android CPU the cfg-gated return is always taken; the desktop fallback is +// unreachable on that target but reachable on desktop/GPU. +#[allow(unreachable_code)] +fn recent_file_path(recent_file: &str) -> Option { + #[cfg(target_os = "android")] + return ANDROID_DATA_DIR.get().map(|d| d.join(recent_file)); + + dirs::data_dir().map(|d| d.join(recent_file)) +} + +/// Returns today's date as `"YYYY-MM-DD"` using only `std`. +fn today_iso() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let (y, m, d) = days_to_ymd(secs / 86400); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Civil-calendar conversion from days-since-Unix-epoch to (year, month, day). +/// +/// Algorithm: Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms" +/// +fn days_to_ymd(days: u64) -> (u64, u64, u64) { + let z = days + 719_468; + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(path: &str) -> RecentEntry { + RecentEntry { + path: path.to_string(), + title: path.to_string(), + modified_at: String::new(), + } + } + + #[test] + fn record_inserts_at_front() { + let mut docs = RecentDocuments::default(); + docs.record("a".into(), "A".into()); + docs.record("b".into(), "B".into()); + let paths: Vec<&str> = docs.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!(paths, ["b", "a"], "most-recent first"); + } + + #[test] + fn record_deduplicates_and_promotes() { + let mut docs = RecentDocuments::default(); + docs.record("a".into(), "A".into()); + docs.record("b".into(), "B".into()); + docs.record("a".into(), "A".into()); // re-open a + let paths: Vec<&str> = docs.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!(paths, ["a", "b"], "re-opening promotes without duplicating"); + } + + #[test] + fn record_ignores_untitled_paths() { + let mut docs = RecentDocuments::default(); + docs.record("untitled-1".into(), "Untitled".into()); + assert!(docs.entries.is_empty(), "untitled paths are never recorded"); + } + + #[test] + fn record_caps_at_max_recent() { + let mut docs = RecentDocuments::default(); + for i in 0..(MAX_RECENT + 5) { + docs.record(format!("doc-{i}"), format!("Doc {i}")); + } + assert_eq!(docs.entries.len(), MAX_RECENT); + // The newest entry is at the front; the oldest were dropped. + assert_eq!(docs.entries[0].path, format!("doc-{}", MAX_RECENT + 4)); + } + + #[test] + fn remove_drops_matching_entry() { + let mut docs = RecentDocuments::default(); + docs.entries = vec![entry("a"), entry("b"), entry("c")]; + docs.remove("b"); + let paths: Vec<&str> = docs.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!(paths, ["a", "c"]); + } + + #[test] + fn entries_round_trip_through_json_without_the_file_name() { + let mut docs = RecentDocuments::load("AppThere/Loki/recent.json"); + docs.entries = vec![entry("a")]; + let json = serde_json::to_string(&docs).unwrap(); + assert!( + !json.contains("recent_file"), + "the persistence file name must not be serialised" + ); + let back: RecentDocuments = serde_json::from_str(&json).unwrap(); + assert_eq!(back.entries, docs.entries); + } +} diff --git a/loki-app-shell/src/tabs.rs b/loki-app-shell/src/tabs.rs new file mode 100644 index 00000000..5ee6ad7e --- /dev/null +++ b/loki-app-shell/src/tabs.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Open-tab state shared across the Loki editor shells. + +/// Represents a single open document tab. +/// +/// Injected into Dioxus context at each application's `App` root as a +/// `Signal>`, alongside a `Signal` for the active tab index +/// (`0` = the Home tab). +#[derive(Clone, PartialEq)] +pub struct OpenTab { + /// Display title shown in the tab bar (filename stem, decoded). + pub title: String, + /// The serialised file access token / path used by the editor. + pub path: String, + /// Whether the document has unsaved changes. + pub is_dirty: bool, + /// Whether this tab has been discarded from memory. + pub is_discarded: bool, +} diff --git a/loki-app-shell/src/untitled.rs b/loki-app-shell/src/untitled.rs new file mode 100644 index 00000000..b25a5c9d --- /dev/null +++ b/loki-app-shell/src/untitled.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The `untitled-` path scheme for unsaved blank documents. +//! +//! Every new blank document gets a unique `untitled-N` path for its session +//! lifetime. The path is never persisted or added to the recent-documents list. +//! +//! ## Why not `untitled://`? +//! +//! Dioxus Router's `PATH_ASCII_SET` does not encode `:` or `/`, so a path like +//! `"untitled://1"` would serialise to the URL `/editor/untitled://1`. The +//! router splits URLs by `/` and would see four segments instead of the two that +//! `#[route("/editor/:path")]` expects, causing a match failure. `"untitled-1"` +//! is a plain alphanumeric string that is safe as a URL segment. + +/// Path prefix for unsaved blank documents. +/// +/// Produces paths like `"untitled-1"`, `"untitled-2"` — URL-safe alphanumeric +/// strings with a hyphen separator. +pub const UNTITLED_SCHEME: &str = "untitled-"; + +/// Returns `true` if `path` refers to an unsaved (untitled) document — blank, +/// created from a bundled template, or imported from an external template file. +pub fn is_untitled(path: &str) -> bool { + path.starts_with(UNTITLED_SCHEME) +} + +/// Marker separating the counter from a bundled-template id in an untitled path. +const MARKER_TPL: &str = "-tpl-"; +/// Marker separating the counter from an imported file's token in an untitled path. +const MARKER_IMP: &str = "-imp-"; + +/// How a new (untitled) document's initial content is produced. +/// +/// Encoded in the path *after* the counter so [`is_untitled`] stays true and the +/// path remains a single URL-safe segment that survives router round-trips and +/// session restore (the content is always reproducible from the path alone). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NewDocSource { + /// Empty document with the built-in heading styles. + Blank, + /// A bundled template identified by its short id (e.g. `"apa"`). + Template(String), + /// An external file (a serialized file token) opened as a fresh, detached + /// document — saving prompts Save As rather than overwriting the source. + Import(String), +} + +/// Builds the untitled path for bundled template `id` with counter `n`. +#[must_use] +pub fn template_path(n: u32, id: &str) -> String { + format!("{UNTITLED_SCHEME}{n}{MARKER_TPL}{id}") +} + +/// Builds the untitled path that imports external `token` as a fresh document. +#[must_use] +pub fn import_path(n: u32, token: &str) -> String { + format!("{UNTITLED_SCHEME}{n}{MARKER_IMP}{token}") +} + +/// Parses how to build a new document from its untitled `path`. +/// +/// Returns `None` when `path` is not an untitled path (i.e. a real file path). +/// The counter is the leading run of digits, so the source marker — when present +/// — begins immediately after it, making the parse unambiguous regardless of the +/// payload's own contents (an imported token may itself contain hyphens). +#[must_use] +pub fn parse_new_doc_source(path: &str) -> Option { + let rest = path.strip_prefix(UNTITLED_SCHEME)?; + let digits_end = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + let after = &rest[digits_end..]; + if let Some(id) = after.strip_prefix(MARKER_TPL) { + return Some(NewDocSource::Template(id.to_string())); + } + if let Some(token) = after.strip_prefix(MARKER_IMP) { + return Some(NewDocSource::Import(token.to_string())); + } + Some(NewDocSource::Blank) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_untitled_paths() { + assert!(is_untitled("untitled-1")); + assert!(is_untitled("untitled-42")); + } + + #[test] + fn rejects_real_paths() { + assert!(!is_untitled("/path/to/doc.docx")); + assert!(!is_untitled("file://token")); + assert!(!is_untitled("")); + assert!(!is_untitled("untitle")); // prefix not complete + } + + #[test] + fn parses_blank_source() { + assert_eq!( + parse_new_doc_source("untitled-1"), + Some(NewDocSource::Blank) + ); + assert_eq!( + parse_new_doc_source("untitled-42"), + Some(NewDocSource::Blank) + ); + assert_eq!(parse_new_doc_source("/real/file.docx"), None); + } + + #[test] + fn round_trips_template_and_import_paths() { + let tp = template_path(3, "apa"); + assert!(is_untitled(&tp)); + assert_eq!( + parse_new_doc_source(&tp), + Some(NewDocSource::Template("apa".into())) + ); + + // An imported token may itself contain hyphens — parsing anchors on the + // marker right after the counter, so the whole token is recovered. + let token = "file-token-abc-123"; + let ip = import_path(7, token); + assert!(is_untitled(&ip)); + assert_eq!( + parse_new_doc_source(&ip), + Some(NewDocSource::Import(token.into())) + ); + } +} diff --git a/loki-doc-model/src/content/annotation/comment.rs b/loki-doc-model/src/content/annotation/comment.rs index 25918fe7..b6d0eb53 100644 --- a/loki-doc-model/src/content/annotation/comment.rs +++ b/loki-doc-model/src/content/annotation/comment.rs @@ -72,13 +72,9 @@ pub struct Comment { /// The comment body as a sequence of block nodes. /// - /// Stored as raw bytes to avoid a circular dependency between the - /// annotation module and the block/inline modules. Consumers should - /// parse these blocks using the crate's Block type. - /// - /// In practice, comment bodies are plain paragraphs. The raw storage - /// avoids importing Block here, which would create a module cycle. - pub body_raw: Vec, + /// Comment bodies are usually one or more plain paragraphs, but may carry + /// full block content (multiple paragraphs, inline formatting, lists). + pub body: Vec, } impl Comment { @@ -89,9 +85,22 @@ impl Comment { id: id.into(), author: None, date: None, - body_raw: Vec::new(), + body: Vec::new(), } } + + /// Builder: set the comment body from a single plain-text string, splitting + /// on `\n` into paragraphs. + #[must_use] + pub fn with_plain_body(mut self, text: &str) -> Self { + use crate::content::block::Block; + use crate::content::inline::Inline; + self.body = text + .split('\n') + .map(|line| Block::Para(vec![Inline::Str(line.to_string())])) + .collect(); + self + } } #[cfg(test)] diff --git a/loki-doc-model/src/content/block.rs b/loki-doc-model/src/content/block.rs index 7dcccea5..462a8e87 100644 --- a/loki-doc-model/src/content/block.rs +++ b/loki-doc-model/src/content/block.rs @@ -283,37 +283,5 @@ pub enum Block { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn heading_level_one() { - let block = Block::Heading(1, NodeAttr::default(), vec![Inline::Str("Title".into())]); - assert!(matches!(block, Block::Heading(1, _, _))); - } - - #[test] - fn styled_para_with_style_ref() { - let para = StyledParagraph { - style_id: Some(StyleId("Normal".into())), - direct_para_props: None, - direct_char_props: None, - inlines: vec![Inline::Str("Hello".into())], - attr: NodeAttr::default(), - }; - let block = Block::StyledPara(para); - if let Block::StyledPara(p) = &block { - assert_eq!(p.style_id, Some(StyleId("Normal".into()))); - assert_eq!(p.inlines.len(), 1); - } else { - panic!("expected StyledPara"); - } - } - - #[test] - fn ordered_list_attributes_default() { - let attrs = ListAttributes::default(); - assert_eq!(attrs.start_number, 1); - assert_eq!(attrs.style, ListNumberStyle::Decimal); - } -} +#[path = "block_tests.rs"] +mod tests; diff --git a/loki-doc-model/src/content/block_tests.rs b/loki-doc-model/src/content/block_tests.rs new file mode 100644 index 00000000..fc4d9a30 --- /dev/null +++ b/loki-doc-model/src/content/block_tests.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `block`. + +use super::*; + +#[test] +fn heading_level_one() { + let block = Block::Heading(1, NodeAttr::default(), vec![Inline::Str("Title".into())]); + assert!(matches!(block, Block::Heading(1, _, _))); +} + +#[test] +fn styled_para_with_style_ref() { + let para = StyledParagraph { + style_id: Some(StyleId("Normal".into())), + direct_para_props: None, + direct_char_props: None, + inlines: vec![Inline::Str("Hello".into())], + attr: NodeAttr::default(), + }; + let block = Block::StyledPara(para); + if let Block::StyledPara(p) = &block { + assert_eq!(p.style_id, Some(StyleId("Normal".into()))); + assert_eq!(p.inlines.len(), 1); + } else { + panic!("expected StyledPara"); + } +} + +#[test] +fn ordered_list_attributes_default() { + let attrs = ListAttributes::default(); + assert_eq!(attrs.start_number, 1); + assert_eq!(attrs.style, ListNumberStyle::Decimal); +} diff --git a/loki-doc-model/src/content/inline.rs b/loki-doc-model/src/content/inline.rs index f8d878bc..17e44d39 100644 --- a/loki-doc-model/src/content/inline.rs +++ b/loki-doc-model/src/content/inline.rs @@ -184,6 +184,11 @@ pub enum Inline { LineBreak, /// Mathematical content. Corresponds to pandoc `Math`. + /// + /// The string holds a self-contained MathML `` document — + /// the W3C interchange standard and ODF's native math representation. + /// OOXML import/export converts to and from OMML (`m:oMath`); ODF embeds + /// the MathML directly as a formula object. Math(MathType, String), /// Raw inline in a specific format. Corresponds to pandoc `RawInline`. diff --git a/loki-doc-model/src/document.rs b/loki-doc-model/src/document.rs index 2e109624..75605f29 100644 --- a/loki-doc-model/src/document.rs +++ b/loki-doc-model/src/document.rs @@ -52,6 +52,7 @@ use loki_primitives::units::Points; /// styles: StyleCatalog::default(), /// sections: vec![section], /// settings: None, +/// comments: Vec::new(), /// source: None, /// }; /// @@ -79,6 +80,11 @@ pub struct Document { /// OOXML: `word/settings.xml`; ODF: `settings.xml`. pub settings: Option, + /// Document comments (annotations), keyed by id. The content flow carries + /// only [`crate::content::annotation::CommentRef`] anchors; the comment + /// bodies live here. OOXML: `word/comments.xml`; ODF: `office:annotation`. + pub comments: Vec, + /// Format and version provenance from the source file, if loaded from one. /// /// `None` for programmatically constructed documents. @@ -94,6 +100,7 @@ impl Document { styles: StyleCatalog::default(), sections: vec![Section::new()], settings: None, + comments: Vec::new(), source: None, } } @@ -150,6 +157,7 @@ impl Document { styles, sections: vec![section], settings: None, + comments: Vec::new(), source: None, } } diff --git a/loki-doc-model/src/lib.rs b/loki-doc-model/src/lib.rs index 13bb7e6e..2d51ac9a 100644 --- a/loki-doc-model/src/lib.rs +++ b/loki-doc-model/src/lib.rs @@ -130,6 +130,7 @@ //! styles, //! sections: vec![section], //! settings: None, +//! comments: Vec::new(), //! source: None, //! }; //! diff --git a/loki-doc-model/src/loro_bridge/comments.rs b/loki-doc-model/src/loro_bridge/comments.rs new file mode 100644 index 00000000..33558721 --- /dev/null +++ b/loki-doc-model/src/loro_bridge/comments.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Document comments in the Loro CRDT. +//! +//! Comments (annotation bodies, keyed by id) are stored as a lossless `serde` +//! JSON snapshot under [`KEY_COMMENTS_JSON`] in the comments map — the same +//! strategy used for metadata and the style catalog. The in-flow +//! [`crate::content::annotation::CommentRef`] anchors live in the block text +//! and round-trip with the blocks; only the bodies need separate storage. + +use loro::{LoroDoc, LoroMap}; + +use super::BridgeError; +use crate::content::annotation::Comment; +use crate::loro_schema::{KEY_COMMENTS, KEY_COMMENTS_JSON}; + +/// Writes `comments` into the comments `map` as a JSON snapshot. +pub(super) fn write_comments(comments: &[Comment], map: &LoroMap) -> Result<(), BridgeError> { + write_comments_json(comments, map); + Ok(()) +} + +#[cfg(feature = "serde")] +fn write_comments_json(comments: &[Comment], map: &LoroMap) { + match serde_json::to_string(comments) { + Ok(json) => { + if let Err(err) = map.insert(KEY_COMMENTS_JSON, json) { + tracing::warn!("loro bridge: failed to store comments snapshot: {err}"); + } + } + // Unreachable in practice: Comment derives Serialize. + Err(err) => tracing::warn!("loro bridge: failed to serialize comments: {err}"), + } +} + +#[cfg(not(feature = "serde"))] +fn write_comments_json(_comments: &[Comment], _map: &LoroMap) { + tracing::warn!( + "loro bridge: comments not persisted — build loki-doc-model with the \ + `serde` feature (default) to round-trip document comments" + ); +} + +/// Reads the comments from the comments `map`. Empty when absent. +pub(super) fn read_comments(map: &LoroMap) -> Vec { + read_comments_json(map).unwrap_or_default() +} + +#[cfg(feature = "serde")] +fn read_comments_json(map: &LoroMap) -> Option> { + let json = map + .get(KEY_COMMENTS_JSON) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_string().ok())?; + serde_json::from_str(&json).ok() +} + +#[cfg(not(feature = "serde"))] +fn read_comments_json(_map: &LoroMap) -> Option> { + None +} + +/// Overwrites the document-level comments stored in `loro` with `comments`. +/// +/// Used by the editor to persist comment edits as a CRDT mutation, so they +/// survive incremental rebuilds, undo/redo, and Loro import/export. The caller +/// is responsible for committing. +pub fn write_document_comments(loro: &LoroDoc, comments: &[Comment]) -> Result<(), BridgeError> { + let map = loro.get_map(KEY_COMMENTS); + write_comments(comments, &map) +} + +/// Reads the document-level comments stored in `loro`. +#[must_use] +pub fn read_document_comments(loro: &LoroDoc) -> Vec { + let map = loro.get_map(KEY_COMMENTS); + read_comments(&map) +} + +#[cfg(all(test, feature = "serde"))] +mod tests { + use super::*; + use crate::content::block::Block; + use crate::content::inline::Inline; + + #[test] + fn comments_round_trip_through_loro() { + let doc = LoroDoc::new(); + let mut comment = Comment::new("c1").with_plain_body("First line\nSecond line"); + comment.author = Some("Reviewer".into()); + let comments = vec![comment]; + + write_document_comments(&doc, &comments).unwrap(); + let back = read_document_comments(&doc); + + assert_eq!(back.len(), 1); + assert_eq!(back[0].id, "c1"); + assert_eq!(back[0].author.as_deref(), Some("Reviewer")); + assert_eq!(back[0].body.len(), 2, "two body paragraphs"); + assert!( + matches!(&back[0].body[0], Block::Para(i) if matches!(&i[0], Inline::Str(s) if s == "First line")) + ); + } + + #[test] + fn empty_when_absent() { + let doc = LoroDoc::new(); + assert!(read_document_comments(&doc).is_empty()); + } +} diff --git a/loki-doc-model/src/loro_bridge/incremental.rs b/loki-doc-model/src/loro_bridge/incremental.rs index c71122fc..b577a408 100644 --- a/loki-doc-model/src/loro_bridge/incremental.rs +++ b/loki-doc-model/src/loro_bridge/incremental.rs @@ -9,8 +9,7 @@ //! reconstructed [`Document`] together with the Loro version it reflects; on //! [`IncrementalReader::update`] it diffs the new version against the old and, //! when every change is confined to the text / marks / properties of existing -//! blocks in section 0 (the common typing case), re-reconstructs only those -//! blocks. +//! blocks (in any section), re-reconstructs only those blocks. //! //! Anything else — a block insert/delete/move, a section/layout/metadata change, //! a diff error, or a change it cannot map to a single block — falls back to a @@ -21,13 +20,16 @@ //! //! Loro's `get_path_to_container` returns the path root→target as //! `(container_id, index_of_that_container_within_its_parent)` tuples. For the -//! text of block *N* in section 0 the path passes through -//! `(blocks_list, Key("blocks"))` immediately followed by -//! `(block_map, Seq(N))`, so the block index is the `Seq` of the element right -//! after the blocks-list element. A change to the blocks list itself (an insert -//! or delete) has no such following element and is treated as structural. +//! text of block *N* in section *S* the path passes through +//! `(sections_list, Key("sections"))`, `(section_map, Seq(S))`, +//! `(blocks_list, Key("blocks"))`, then `(block_map, Seq(N))`. Each section's +//! blocks-list container id is collected up front, so the element immediately +//! following a known blocks list yields the block index `N`, and the matched +//! blocks list yields the section index `S`. A change *to* a blocks list (an +//! insert or delete) or to the sections list itself is treated as structural and +//! triggers a full rebuild. -use loro::{ContainerID, ContainerTrait, Frontiers, Index, LoroDoc, LoroMovableList}; +use loro::{ContainerID, ContainerTrait, Frontiers, Index, LoroDoc, LoroList, LoroMovableList}; use super::BridgeError; use super::read::map_loro_block; @@ -39,6 +41,7 @@ use crate::loro_schema::{KEY_BLOCKS, KEY_SECTIONS}; pub struct IncrementalReader { cached: Document, version: Frontiers, + last_was_incremental: bool, } impl IncrementalReader { @@ -51,6 +54,7 @@ impl IncrementalReader { Ok(Self { cached, version: loro.state_frontiers(), + last_was_incremental: false, }) } @@ -60,75 +64,111 @@ impl IncrementalReader { loro.commit(); let now = loro.state_frontiers(); if now == self.version { + // No change since the last call — neither a patch nor a full rebuild. + self.last_was_incremental = true; return Ok(&self.cached); } - if !self.try_incremental(loro, &now) { + if self.try_incremental(loro, &now) { + self.last_was_incremental = true; + } else { self.cached = super::loro_to_document(loro)?; + self.last_was_incremental = false; } self.version = now; Ok(&self.cached) } + /// Whether the most recent [`update`](Self::update) used the block-local fast + /// path (`true`) rather than a full rebuild (`false`). + /// + /// Primarily for instrumentation and tests; a no-op update (no version + /// change) reports `true` because it does no full rebuild. + pub fn last_update_was_incremental(&self) -> bool { + self.last_was_incremental + } + /// Attempts a block-local incremental update. Returns `false` to request a /// full rebuild (leaving `self.cached` untouched or only partially patched — /// the caller overwrites it entirely in that case). fn try_incremental(&mut self, loro: &LoroDoc, now: &Frontiers) -> bool { - let Some(blocks_list) = section0_blocks_list(loro) else { + let sections = loro.get_list(KEY_SECTIONS); + let sections_id = sections.id(); + + // The blocks-list container of every section, in section order. Their + // ids let us map a changed container back to `(section, block)`. + let mut blocks_lists: Vec = Vec::with_capacity(sections.len()); + for s in 0..sections.len() { + let Some(list) = section_blocks_list(§ions, s) else { + return false; + }; + blocks_lists.push(list); + } + if blocks_lists.is_empty() { return false; - }; - let blocks_id = blocks_list.id(); + } let Ok(diff) = loro.diff(&self.version, now) else { return false; }; - // Collect the distinct section-0 block indices touched by this diff. - let mut dirty: Vec = Vec::new(); + // Collect the distinct `(section, block)` pairs touched by this diff. + let mut dirty: Vec<(usize, usize)> = Vec::new(); for (cid, _diff) in diff.iter() { - if *cid == blocks_id { - return false; // structural change to the block list itself + if *cid == sections_id { + return false; // structural change to the sections list itself } - match block_index_for(loro, cid, &blocks_id) { - Some(n) => { - if !dirty.contains(&n) { - dirty.push(n); + if blocks_lists.iter().any(|l| l.id() == *cid) { + return false; // structural change to a section's block list + } + match locate_block(loro, cid, &blocks_lists) { + Some(sb) => { + if !dirty.contains(&sb) { + dirty.push(sb); } } - None => return false, // change outside section-0 block contents + None => return false, // change outside any section's block contents } } if dirty.is_empty() { return false; } - let Some(section) = self.cached.sections.first_mut() else { - return false; - }; // Index space must match: text/mark/prop edits never change block count. - if dirty.iter().any(|&n| n >= section.blocks.len()) { - return false; + for &(s, n) in &dirty { + match self.cached.sections.get(s) { + Some(section) if n < section.blocks.len() => {} + _ => return false, + } } - for n in dirty { - let Some(block_map) = blocks_list + + for (s, n) in dirty { + let Some(block_map) = blocks_lists[s] .get(n) .and_then(|v| v.into_container().ok()) .and_then(|c| c.into_map().ok()) else { return false; }; - match map_loro_block(&block_map) { - Ok(block) => section.blocks[n] = block, - Err(_) => return false, - } + let Ok(block) = map_loro_block(&block_map) else { + return false; + }; + let Some(slot) = self + .cached + .sections + .get_mut(s) + .and_then(|section| section.blocks.get_mut(n)) + else { + return false; + }; + *slot = block; } true } } -/// Resolves section 0's blocks movable list, if present. -fn section0_blocks_list(loro: &LoroDoc) -> Option { - let sections = loro.get_list(KEY_SECTIONS); - let section = sections.get(0)?.into_container().ok()?.into_map().ok()?; +/// Resolves section `s`'s blocks movable list, if present. +fn section_blocks_list(sections: &LoroList, s: usize) -> Option { + let section = sections.get(s)?.into_container().ok()?.into_map().ok()?; section .get(KEY_BLOCKS)? .into_container() @@ -137,23 +177,28 @@ fn section0_blocks_list(loro: &LoroDoc) -> Option { .ok() } -/// Returns the section-0 block index that `cid` lives inside, or `None` when -/// `cid` is not a descendant of the blocks list (or is the list itself). +/// Returns the `(section_index, block_index)` that `cid` lives inside, or `None` +/// when `cid` is not a descendant of any section's blocks list. /// /// The block index is the `Seq` of the path element immediately following the -/// blocks-list element (see the module docs). -fn block_index_for(loro: &LoroDoc, cid: &ContainerID, blocks_id: &ContainerID) -> Option { +/// blocks-list element; the section index is the position of that blocks list in +/// `blocks_lists` (see the module docs). +fn locate_block( + loro: &LoroDoc, + cid: &ContainerID, + blocks_lists: &[LoroMovableList], +) -> Option<(usize, usize)> { let path = loro.get_path_to_container(cid)?; - let mut after_blocks = false; + let mut pending_section: Option = None; for (container, index) in &path { - if after_blocks { + if let Some(section) = pending_section { return match index { - Index::Seq(n) => Some(*n), + Index::Seq(n) => Some((section, *n)), _ => None, }; } - if container == blocks_id { - after_blocks = true; + if let Some(section) = blocks_lists.iter().position(|l| l.id() == *container) { + pending_section = Some(section); } } None diff --git a/loki-doc-model/src/loro_bridge/incremental_tests.rs b/loki-doc-model/src/loro_bridge/incremental_tests.rs index 1bce6dab..f254c6f7 100644 --- a/loki-doc-model/src/loro_bridge/incremental_tests.rs +++ b/loki-doc-model/src/loro_bridge/incremental_tests.rs @@ -162,3 +162,116 @@ fn no_mutation_returns_cached() { let derived = reader.update(&loro).expect("update").clone(); assert_matches_full(&derived, &loro); } + +// ── Fast-path engagement (not just correctness) ──────────────────────────────── + +/// Builds a document of several sections, each with the given number of simple +/// paragraphs. +fn doc_with_sections(section_sizes: &[usize]) -> Document { + let sections = section_sizes + .iter() + .enumerate() + .map(|(s, &n)| { + let blocks: Vec = (0..n) + .map(|i| Block::Para(vec![Inline::Str(format!("s{s} paragraph {i}"))])) + .collect(); + Section::with_layout_and_blocks(Default::default(), blocks) + }) + .collect(); + let mut doc = Document::new(); + doc.sections = sections; + doc +} + +/// Section-aware analogue of [`insert_text`] (which only targets section 0): +/// navigates to block `block` of section `sec` and inserts `text` at `offset`. +/// Used to simulate a multi-section / remote edit that reaches a non-zero +/// section, which the production mutation helpers do not currently produce. +fn insert_in_section(loro: &loro::LoroDoc, sec: usize, block: usize, offset: usize, text: &str) { + use crate::loro_schema::{KEY_BLOCKS, KEY_CONTENT, KEY_SECTIONS}; + let content = loro + .get_list(KEY_SECTIONS) + .get(sec) + .and_then(|v| v.into_container().ok()) + .and_then(|c| c.into_map().ok()) + .and_then(|m| m.get(KEY_BLOCKS)) + .and_then(|v| v.into_container().ok()) + .and_then(|c| c.into_movable_list().ok()) + .and_then(|list| list.get(block)) + .and_then(|v| v.into_container().ok()) + .and_then(|c| c.into_map().ok()) + .and_then(|m| m.get(KEY_CONTENT)) + .and_then(|v| v.into_container().ok()) + .and_then(|c| c.into_text().ok()) + .expect("navigate to section/block text"); + content.insert_utf8(offset, text).expect("insert"); +} + +#[test] +fn text_edit_uses_incremental_fast_path() { + let loro = document_to_loro(&doc_with_paras(6)).expect("to loro"); + let mut reader = IncrementalReader::seed(&loro).expect("seed"); + + insert_text(&loro, 3, 0, "X").expect("insert"); + reader.update(&loro).expect("update"); + + assert!( + reader.last_update_was_incremental(), + "a plain text edit must take the block-local fast path" + ); +} + +#[test] +fn structural_edit_reports_non_incremental() { + let loro = document_to_loro(&doc_with_paras(4)).expect("to loro"); + let mut reader = IncrementalReader::seed(&loro).expect("seed"); + + split_block(&loro, 1, 4).expect("split"); + reader.update(&loro).expect("update"); + + assert!( + !reader.last_update_was_incremental(), + "a block split changes the block count and must fall back to a full rebuild" + ); +} + +#[test] +fn edit_in_second_section_is_incremental() { + // A multi-section document (or a remote peer's edit) reaching section 1 must + // patch that section incrementally rather than rebuilding the whole document. + let loro = document_to_loro(&doc_with_sections(&[3, 3])).expect("to loro"); + let mut reader = IncrementalReader::seed(&loro).expect("seed"); + + insert_in_section(&loro, 1, 2, 0, "Z"); + let derived = reader.update(&loro).expect("update").clone(); + + assert!( + reader.last_update_was_incremental(), + "an edit confined to a section-1 block must take the fast path" + ); + assert_matches_full(&derived, &loro); + if let Some(Block::Para(inlines)) = derived.sections[1].blocks.get(2) { + assert!( + matches!(inlines.first(), Some(Inline::Str(s)) if s.starts_with('Z')), + "the edited section-1 paragraph should reflect the insert" + ); + } else { + panic!("expected Para at section 1, block 2"); + } +} + +#[test] +fn edits_across_sections_stay_incremental_and_consistent() { + let loro = document_to_loro(&doc_with_sections(&[2, 2, 2])).expect("to loro"); + let mut reader = IncrementalReader::seed(&loro).expect("seed"); + + for (sec, block, ch) in [(0usize, 1usize, "a"), (2, 0, "b"), (1, 1, "c")] { + insert_in_section(&loro, sec, block, 0, ch); + let derived = reader.update(&loro).expect("update").clone(); + assert!( + reader.last_update_was_incremental(), + "edit to section {sec} block {block} should be incremental" + ); + assert_matches_full(&derived, &loro); + } +} diff --git a/loki-doc-model/src/loro_bridge/inlines.rs b/loki-doc-model/src/loro_bridge/inlines.rs index b7b1067c..9c338c53 100644 --- a/loki-doc-model/src/loro_bridge/inlines.rs +++ b/loki-doc-model/src/loro_bridge/inlines.rs @@ -124,7 +124,9 @@ pub(super) fn extract_plain_text(inlines: &[Inline]) -> String { Inline::Str(s) => out.push_str(s), Inline::Space => out.push(' '), Inline::SoftBreak | Inline::LineBreak => out.push('\n'), - Inline::Code(_, s) | Inline::Math(_, s) => out.push_str(s), + Inline::Code(_, s) => out.push_str(s), + // Math holds MathML markup, not display text; a block containing it + // is preserved as an opaque snapshot rather than flat text. Inline::StyledRun(run) => out.push_str(&extract_plain_text(&run.content)), Inline::Emph(inner) | Inline::Strong(inner) diff --git a/loki-doc-model/src/loro_bridge/mod.rs b/loki-doc-model/src/loro_bridge/mod.rs index 4d884ab3..df1f2960 100644 --- a/loki-doc-model/src/loro_bridge/mod.rs +++ b/loki-doc-model/src/loro_bridge/mod.rs @@ -9,6 +9,7 @@ //! - `read` — deserialization (Loro → Loki) //! - `inlines` — inline content helpers shared by both directions +mod comments; mod decode; mod incremental; mod inlines; @@ -17,10 +18,13 @@ mod meta; mod opaque; mod props_read; mod read; +mod styles; mod write; +pub use comments::{read_document_comments, write_document_comments}; pub use incremental::IncrementalReader; pub use meta::{read_document_meta, write_document_meta}; +pub use styles::{read_document_styles, write_document_styles}; use crate::document::Document; use crate::layout::header_footer::HeaderFooter; @@ -151,6 +155,11 @@ pub fn document_to_loro(doc: &Document) -> Result { let meta_map = loro_doc.get_map(KEY_METADATA); meta::write_meta(&doc.meta, &meta_map)?; + // Style catalog — lossless JSON snapshot, so style edits are durable and + // undoable rather than carried forward by cloning the previous document. + let styles_map = loro_doc.get_map(KEY_STYLE_CATALOG); + styles::write_styles(&doc.styles, &styles_map)?; + // Sections let sections_list = loro_doc.get_list(KEY_SECTIONS); for (s_idx, section) in doc.sections.iter().enumerate() { @@ -164,6 +173,10 @@ pub fn document_to_loro(doc: &Document) -> Result { map_blocks_to_list(§ion.blocks, &blocks_list)?; } + // Comments (annotation bodies) — JSON snapshot, like metadata. + let comments_map = loro_doc.get_map(KEY_COMMENTS); + comments::write_comments(&doc.comments, &comments_map)?; + Ok(loro_doc) } @@ -178,6 +191,10 @@ pub fn loro_to_document(loro: &LoroDoc) -> Result { let meta_map: loro::LoroMap = loro.get_map(KEY_METADATA); doc.meta = meta::read_meta(&meta_map); + // Style catalog — from the JSON snapshot (empty when absent). + let styles_map: loro::LoroMap = loro.get_map(KEY_STYLE_CATALOG); + doc.styles = styles::read_styles(&styles_map); + // Sections let sections_list = loro.get_list(KEY_SECTIONS); doc.sections.clear(); @@ -216,6 +233,10 @@ pub fn loro_to_document(loro: &LoroDoc) -> Result { doc.sections.push(crate::layout::section::Section::new()); } + // Comments (annotation bodies). + let comments_map: loro::LoroMap = loro.get_map(KEY_COMMENTS); + doc.comments = comments::read_comments(&comments_map); + Ok(doc) } diff --git a/loki-doc-model/src/loro_bridge/styles.rs b/loki-doc-model/src/loro_bridge/styles.rs new file mode 100644 index 00000000..0457e57c --- /dev/null +++ b/loki-doc-model/src/loro_bridge/styles.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Style catalog round-trip through the Loro CRDT. +//! +//! The whole [`StyleCatalog`] — paragraph, character, table, and list styles — +//! is stored as a single JSON string under [`KEY_STYLE_CATALOG_JSON`] in the +//! style-catalog map. This mirrors the metadata strategy ([`super::meta`]): a +//! lossless snapshot via the model's `serde` derives, rather than a +//! hand-written field-by-field CRDT mapping that would have to grow with every +//! new style property. +//! +//! Storing the catalog in the CRDT (rather than carrying it forward by cloning +//! the previous [`Document`]) makes style-editor edits durable across +//! incremental rebuilds and — crucially — **undoable**: an edit is a committed +//! Loro transaction, so `UndoManager` reverts it like any text edit. + +use loro::{LoroDoc, LoroMap}; + +use super::BridgeError; +use crate::loro_schema::{KEY_STYLE_CATALOG, KEY_STYLE_CATALOG_JSON}; +use crate::style::catalog::StyleCatalog; + +/// Writes `catalog` into the style-catalog `map` as a JSON snapshot. +pub(super) fn write_styles(catalog: &StyleCatalog, map: &LoroMap) -> Result<(), BridgeError> { + write_styles_json(catalog, map); + Ok(()) +} + +#[cfg(feature = "serde")] +fn write_styles_json(catalog: &StyleCatalog, map: &LoroMap) { + match serde_json::to_string(catalog) { + Ok(json) => { + if let Err(err) = map.insert(KEY_STYLE_CATALOG_JSON, json) { + tracing::warn!("loro bridge: failed to store style catalog snapshot: {err}"); + } + } + // Unreachable in practice: StyleCatalog derives Serialize. + Err(err) => tracing::warn!("loro bridge: failed to serialize style catalog: {err}"), + } +} + +#[cfg(not(feature = "serde"))] +fn write_styles_json(_catalog: &StyleCatalog, _map: &LoroMap) { + tracing::warn!( + "loro bridge: style catalog not persisted — build loki-doc-model with the \ + `serde` feature (default) to round-trip styles" + ); +} + +/// Reads the [`StyleCatalog`] from the style-catalog `map`. +/// +/// Falls back to an empty catalog when the snapshot is missing (e.g. a document +/// written before style round-tripping existed, or one with no styles). +pub(super) fn read_styles(map: &LoroMap) -> StyleCatalog { + read_styles_json(map).unwrap_or_default() +} + +#[cfg(feature = "serde")] +fn read_styles_json(map: &LoroMap) -> Option { + let json = map + .get(KEY_STYLE_CATALOG_JSON) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_string().ok())?; + serde_json::from_str(&json).ok() +} + +#[cfg(not(feature = "serde"))] +fn read_styles_json(_map: &LoroMap) -> Option { + None +} + +/// Overwrites the style catalog stored in `loro` with `catalog`. +/// +/// Used by the editor to persist style-editor edits as a CRDT mutation, so they +/// survive incremental rebuilds, undo/redo, and Loro import/export. The caller +/// is responsible for committing. +pub fn write_document_styles(loro: &LoroDoc, catalog: &StyleCatalog) -> Result<(), BridgeError> { + let map = loro.get_map(KEY_STYLE_CATALOG); + write_styles(catalog, &map) +} + +/// Reads the style catalog stored in `loro`. +#[must_use] +pub fn read_document_styles(loro: &LoroDoc) -> StyleCatalog { + let map = loro.get_map(KEY_STYLE_CATALOG); + read_styles(&map) +} + +#[cfg(all(test, feature = "serde"))] +mod tests { + use super::*; + use crate::style::catalog::StyleId; + use crate::style::para_style::ParagraphStyle; + use crate::style::props::char_props::CharProps; + use crate::style::props::para_props::{ParaProps, ParagraphAlignment}; + use loki_primitives::units::Points; + + fn sample_catalog() -> StyleCatalog { + let mut catalog = StyleCatalog::new(); + let style = ParagraphStyle { + id: StyleId::new("MyQuote"), + display_name: Some("My Quote".into()), + parent: Some(StyleId::new("Normal")), + linked_char_style: None, + next_style_id: Some("Normal".into()), + para_props: ParaProps { + alignment: Some(ParagraphAlignment::Justify), + indent_start: Some(Points::new(36.0)), + ..Default::default() + }, + char_props: CharProps { + font_name: Some("Arial".into()), + bold: Some(true), + font_weight: Some(700), + ..Default::default() + }, + is_default: false, + is_custom: true, + extensions: Default::default(), + }; + catalog + .paragraph_styles + .insert(StyleId::new("MyQuote"), style); + catalog + } + + #[test] + fn round_trips_catalog() { + let loro = LoroDoc::new(); + let catalog = sample_catalog(); + write_document_styles(&loro, &catalog).expect("write"); + loro.commit(); + let read = read_document_styles(&loro); + let original = catalog.paragraph_styles.get(&StyleId::new("MyQuote")); + let restored = read.paragraph_styles.get(&StyleId::new("MyQuote")); + assert_eq!(restored, original); + assert!(restored.is_some_and(|s| s.is_custom)); + } + + #[test] + fn missing_snapshot_falls_back_to_empty() { + let loro = LoroDoc::new(); + let read = read_document_styles(&loro); + assert!(read.paragraph_styles.is_empty()); + } +} diff --git a/loki-doc-model/src/loro_mutation/block.rs b/loki-doc-model/src/loro_mutation/block.rs index 1347d33c..34ff4b85 100644 --- a/loki-doc-model/src/loro_mutation/block.rs +++ b/loki-doc-model/src/loro_mutation/block.rs @@ -11,6 +11,7 @@ use crate::loro_schema::{ use super::{ MutationError, copy_map_primitive_values, get_block_map_and_list, get_loro_text_for_block, + resolve_section_blocks, }; /// Splits the block at `block_index` at `byte_offset`, inserting a new block @@ -37,7 +38,7 @@ pub fn split_block( block_index: usize, byte_offset: usize, ) -> Result<(), MutationError> { - let (blocks_list, block_map) = get_block_map_and_list(loro, block_index)?; + let (blocks_list, block_map, local) = get_block_map_and_list(loro, block_index)?; // Get the LoroText for the source block. let text_container = block_map @@ -62,8 +63,9 @@ pub fn split_block( text_container.delete_utf8(byte_offset, tail.len())?; } - // Insert a new LoroMap at block_index + 1 in the blocks list. - let new_map = blocks_list.insert_container(block_index + 1, LoroMap::new())?; + // Insert a new LoroMap right after the source block, within its section's + // blocks list (`local` is the source block's index in that section). + let new_map = blocks_list.insert_container(local + 1, LoroMap::new())?; // Copy KEY_TYPE (required; default to empty string if absent). let block_type = block_map @@ -125,6 +127,9 @@ pub fn split_block( /// # Errors /// /// - [`MutationError::NoPreviousBlock`] — `block_index` is 0 (no predecessor). +/// - [`MutationError::CrossSectionMerge`] — `block_index` is the first block of +/// its section, so its predecessor lives in an earlier section. Merging across +/// a section break (which would remove the break) is not supported. /// - [`MutationError::BlockIndexOutOfRange`] — either `block_index` or /// `block_index - 1` is out of range (e.g. the document is empty). /// - [`MutationError::TextNotFound`] — one of the blocks has no `LoroText`. @@ -135,11 +140,19 @@ pub fn merge_block(loro: &LoroDoc, block_index: usize) -> Result Result= blocks_list.len() { + // Remove block N from its section's list, by its index within that section. + if local >= blocks_list.len() { return Err(MutationError::BlockIndexOutOfRange(block_index)); } - blocks_list.delete(block_index, 1)?; + blocks_list.delete(local, 1)?; Ok(merged_offset) } diff --git a/loki-doc-model/src/loro_mutation/mod.rs b/loki-doc-model/src/loro_mutation/mod.rs index 91a46872..9a5e484c 100644 --- a/loki-doc-model/src/loro_mutation/mod.rs +++ b/loki-doc-model/src/loro_mutation/mod.rs @@ -13,12 +13,14 @@ //! [`get_block_text`] //! - [`block`] — block-level mutations: [`split_block`], [`merge_block`] //! -//! # Container Path +//! # Block addressing //! -//! Text for block N in section 0 lives at: -//! ```text -//! sections[0].blocks[N]["content"] (LoroText) -//! ``` +//! Block indices are **document-global**: section 0's blocks occupy the first +//! `sections[0].blocks.len()` indices, section 1's the next, and so on — the same +//! flat index space the layout assigns to paragraphs and the editor's cursor +//! uses. [`resolve_section_blocks`] maps a global index to the containing +//! section's blocks list and the block's local index, i.e. +//! `sections[S].blocks[local]["content"]` (a `LoroText`). //! //! # Byte Offsets //! @@ -35,7 +37,7 @@ pub use self::style::{ }; pub use self::text::{delete_text, get_block_text, get_mark_at, insert_text, mark_text}; -use loro::{LoroDoc, LoroMap, LoroMovableList, LoroText}; +use loro::{LoroDoc, LoroList, LoroMap, LoroMovableList, LoroText}; use crate::loro_schema::{KEY_BLOCKS, KEY_CONTENT, KEY_SECTIONS}; @@ -57,6 +59,11 @@ pub enum MutationError { /// `merge_block` was called on block 0, which has no predecessor. #[error("Cannot merge: no block before block 0")] NoPreviousBlock, + /// `merge_block` would merge across a section break — the previous block + /// lives in an earlier section. Merging across a section boundary (which + /// would remove the break) is not supported. + #[error("Cannot merge across a section break")] + CrossSectionMerge, } impl From for MutationError { @@ -67,97 +74,79 @@ impl From for MutationError { // ── Shared internal helpers ─────────────────────────────────────────────────── -/// Navigate to the `LoroText` container for `block_index` in section 0. -pub(crate) fn get_loro_text_for_block( - loro: &LoroDoc, - block_index: usize, -) -> Result { - let sections_list = loro.get_list(KEY_SECTIONS); - let sec_val = sections_list - .get(0) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - let sec_map = sec_val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - - let blocks_val = sec_map - .get(KEY_BLOCKS) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - let blocks_list = blocks_val +/// Resolves section `s`'s blocks movable list, if present. +fn section_blocks_list(sections: &LoroList, s: usize) -> Option { + let section = sections.get(s)?.into_container().ok()?.into_map().ok()?; + section + .get(KEY_BLOCKS)? .into_container() + .ok()? + .into_movable_list() .ok() - .and_then(|c| c.into_movable_list().ok()) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; +} - if block_index >= blocks_list.len() { - return Err(MutationError::BlockIndexOutOfRange(block_index)); +/// Resolves a document-global `block_index` to its section's blocks list and the +/// block's index *within that section*. +/// +/// Editor block indices are global (document order across every section); each +/// section consumes `blocks.len()` of that index space, in section order. This +/// mirrors the index space the layout assigns to `PageParagraphData::block_index`, +/// so a cursor/hit-test index resolves to the correct section. Returns +/// [`MutationError::BlockIndexOutOfRange`] when `block_index` is past the last +/// block of the last section. +pub(crate) fn resolve_section_blocks( + loro: &LoroDoc, + block_index: usize, +) -> Result<(LoroMovableList, usize), MutationError> { + let sections = loro.get_list(KEY_SECTIONS); + let mut base = 0usize; + for s in 0..sections.len() { + let Some(blocks_list) = section_blocks_list(§ions, s) else { + continue; // a malformed section contributes no addressable blocks + }; + let len = blocks_list.len(); + if block_index < base + len { + return Ok((blocks_list, block_index - base)); + } + base += len; } + Err(MutationError::BlockIndexOutOfRange(block_index)) +} - let block_val = blocks_list - .get(block_index) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - let block_map = block_val - .into_container() - .ok() +/// Navigate to the `LoroText` content container for the global `block_index`. +pub(crate) fn get_loro_text_for_block( + loro: &LoroDoc, + block_index: usize, +) -> Result { + let (blocks_list, local) = resolve_section_blocks(loro, block_index)?; + let block_map = blocks_list + .get(local) + .and_then(|v| v.into_container().ok()) .and_then(|c| c.into_map().ok()) .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - - let content_val = block_map + block_map .get(KEY_CONTENT) - .ok_or(MutationError::TextNotFound(block_index))?; - let text = content_val - .into_container() - .ok() + .and_then(|v| v.into_container().ok()) .and_then(|c| c.into_text().ok()) - .ok_or(MutationError::TextNotFound(block_index))?; - - Ok(text) + .ok_or(MutationError::TextNotFound(block_index)) } -/// Navigate to the `LoroMovableList` of blocks and the `LoroMap` for -/// `block_index` in section 0. +/// Navigate to the section's `LoroMovableList` of blocks and the `LoroMap` for +/// the global `block_index`, plus the block's index *within that section*. /// -/// Returns both so callers can insert/delete from the list after reading -/// the block map. +/// Returns the list and the local index so callers can insert/delete relative to +/// the correct section, and the map so they can read/write block properties. pub(crate) fn get_block_map_and_list( loro: &LoroDoc, block_index: usize, -) -> Result<(LoroMovableList, LoroMap), MutationError> { - let sections_list = loro.get_list(KEY_SECTIONS); - let sec_val = sections_list - .get(0) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - let sec_map = sec_val - .into_container() - .ok() +) -> Result<(LoroMovableList, LoroMap, usize), MutationError> { + let (blocks_list, local) = resolve_section_blocks(loro, block_index)?; + let block_map = blocks_list + .get(local) + .and_then(|v| v.into_container().ok()) .and_then(|c| c.into_map().ok()) .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - - let blocks_val = sec_map - .get(KEY_BLOCKS) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - let blocks_list = blocks_val - .into_container() - .ok() - .and_then(|c| c.into_movable_list().ok()) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - - if block_index >= blocks_list.len() { - return Err(MutationError::BlockIndexOutOfRange(block_index)); - } - - let block_val = blocks_list - .get(block_index) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - let block_map = block_val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or(MutationError::BlockIndexOutOfRange(block_index))?; - - Ok((blocks_list, block_map)) + Ok((blocks_list, block_map, local)) } /// Copies primitive (non-container) key-value pairs from `src` to `dst`. diff --git a/loki-doc-model/src/loro_mutation/style.rs b/loki-doc-model/src/loro_mutation/style.rs index eeaddb94..a9de8dcf 100644 --- a/loki-doc-model/src/loro_mutation/style.rs +++ b/loki-doc-model/src/loro_mutation/style.rs @@ -23,7 +23,7 @@ use crate::loro_schema::{ /// Returns an empty string when `block_index` is out of range or the block /// cannot be read, so callers can treat `""` as "no cursor / no block." pub fn get_block_style_name(loro: &LoroDoc, block_index: usize) -> String { - let Ok((_, block_map)) = get_block_map_and_list(loro, block_index) else { + let Ok((_, block_map, _)) = get_block_map_and_list(loro, block_index) else { return String::new(); }; @@ -76,7 +76,7 @@ pub fn get_block_style_name(loro: &LoroDoc, block_index: usize) -> String { /// - [`MutationError::BlockIndexOutOfRange`] if `block_index` is out of range. /// - [`MutationError::Loro`] for underlying Loro errors. pub fn set_block_type_para(loro: &LoroDoc, block_index: usize) -> Result<(), MutationError> { - let (_, block_map) = get_block_map_and_list(loro, block_index)?; + let (_, block_map, _) = get_block_map_and_list(loro, block_index)?; block_map.insert(KEY_TYPE, BLOCK_TYPE_PARA)?; Ok(()) } @@ -95,7 +95,7 @@ pub fn set_block_type_heading( block_index: usize, level: u8, ) -> Result<(), MutationError> { - let (_, block_map) = get_block_map_and_list(loro, block_index)?; + let (_, block_map, _) = get_block_map_and_list(loro, block_index)?; block_map.insert(KEY_TYPE, BLOCK_TYPE_HEADING)?; block_map.insert(KEY_HEADING_LEVEL, level as i64)?; Ok(()) @@ -119,7 +119,7 @@ pub fn set_block_style( block_index: usize, style_id: &str, ) -> Result<(), MutationError> { - let (_, block_map) = get_block_map_and_list(loro, block_index)?; + let (_, block_map, _) = get_block_map_and_list(loro, block_index)?; let block_type = block_map .get(KEY_TYPE) @@ -154,7 +154,7 @@ pub fn set_block_style( /// /// Returns `"Left"` if no alignment is stored (the default). pub fn get_block_alignment(loro: &LoroDoc, block_index: usize) -> String { - let Ok((_, block_map)) = get_block_map_and_list(loro, block_index) else { + let Ok((_, block_map, _)) = get_block_map_and_list(loro, block_index) else { return "Left".to_string(); }; let Some(props_map) = block_map @@ -186,7 +186,7 @@ pub fn set_block_alignment( block_index: usize, alignment: &str, ) -> Result<(), MutationError> { - let (_, block_map) = get_block_map_and_list(loro, block_index)?; + let (_, block_map, _) = get_block_map_and_list(loro, block_index)?; let props_map = if let Some(existing) = block_map .get(KEY_PARA_PROPS) .and_then(|v| v.into_container().ok()) diff --git a/loki-doc-model/src/loro_schema.rs b/loki-doc-model/src/loro_schema.rs index 3df0fbbd..c9dde0df 100644 --- a/loki-doc-model/src/loro_schema.rs +++ b/loki-doc-model/src/loro_schema.rs @@ -21,6 +21,11 @@ pub const KEY_META_TITLE: &str = "title"; /// Key for the Document style catalog map. pub const KEY_STYLE_CATALOG: &str = "style_catalog"; +/// Key (within the style-catalog map) for the JSON snapshot of the whole +/// [`crate::style::catalog::StyleCatalog`]. A lossless serde snapshot, mirroring +/// [`KEY_META_JSON`], rather than a field-by-field CRDT mapping. +pub const KEY_STYLE_CATALOG_JSON: &str = "catalog_json"; + /// Key for the legacy document header/footer map (superseded by KEY_LAYOUT slots). pub const KEY_HEADER_FOOTER: &str = "header_footer"; @@ -30,6 +35,13 @@ pub const KEY_SECTIONS: &str = "sections"; /// Key for the Document blocks movable list. pub const KEY_BLOCKS: &str = "blocks"; +/// Key for the Document comments map (annotation bodies). +pub const KEY_COMMENTS: &str = "comments"; + +/// Key for the comments JSON snapshot inside the comments map. Like metadata +/// and the style catalog, comments are stored as a lossless `serde` snapshot. +pub const KEY_COMMENTS_JSON: &str = "comments_json"; + /// Key for the Block type discriminator. pub const KEY_TYPE: &str = "type"; diff --git a/loki-doc-model/src/meta/dublin_core.rs b/loki-doc-model/src/meta/dublin_core.rs index 0f78cc96..d6b85acc 100644 --- a/loki-doc-model/src/meta/dublin_core.rs +++ b/loki-doc-model/src/meta/dublin_core.rs @@ -115,8 +115,94 @@ impl DublinCoreMeta { pub fn dc_type_or_default(&self) -> &str { self.dc_type.as_deref().unwrap_or(DCMI_TYPE_TEXT) } + + /// Flattens the set fields into `(name, value)` pairs under reserved + /// `dcmi:` names, in a stable order. + /// + /// This is the canonical flattening used by formats that have no native + /// element for these fields: OOXML carries them as `docProps/custom.xml` + /// custom properties, ODF as `meta:user-defined` entries. Repeatable + /// `contributors` become `dcmi:contributor.{i}`. The inverse is + /// [`Self::from_named_pairs`]. + #[must_use] + pub fn to_named_pairs(&self) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + let mut push = |name: &str, value: &Option| { + if let Some(v) = value { + pairs.push((name.to_string(), v.clone())); + } + }; + push(DC_PUBLISHER, &self.publisher); + push(DC_RIGHTS, &self.rights); + push(DC_LICENSE, &self.license); + push(DC_IDENTIFIER, &self.identifier); + push(DC_IDENTIFIER_SCHEME, &self.identifier_scheme); + push(DC_TYPE, &self.dc_type); + push(DC_FORMAT, &self.format); + push(DC_SOURCE, &self.source); + push(DC_RELATION, &self.relation); + push(DC_COVERAGE, &self.coverage); + push(DC_ISSUED, &self.issued); + push(DC_BIBLIOGRAPHIC_CITATION, &self.bibliographic_citation); + for (i, c) in self.contributors.iter().enumerate() { + pairs.push((format!("{DC_CONTRIBUTOR_PREFIX}{i}"), c.clone())); + } + pairs + } + + /// Rebuilds the fields from `(name, value)` pairs produced by + /// [`Self::to_named_pairs`]. Unknown names are ignored; `contributors` + /// are ordered by their numeric suffix. + #[must_use] + pub fn from_named_pairs(pairs: &[(String, String)]) -> Self { + let mut dc = Self::default(); + let mut contributors: Vec<(usize, String)> = Vec::new(); + for (name, value) in pairs { + let v = || Some(value.clone()); + match name.as_str() { + DC_PUBLISHER => dc.publisher = v(), + DC_RIGHTS => dc.rights = v(), + DC_LICENSE => dc.license = v(), + DC_IDENTIFIER => dc.identifier = v(), + DC_IDENTIFIER_SCHEME => dc.identifier_scheme = v(), + DC_TYPE => dc.dc_type = v(), + DC_FORMAT => dc.format = v(), + DC_SOURCE => dc.source = v(), + DC_RELATION => dc.relation = v(), + DC_COVERAGE => dc.coverage = v(), + DC_ISSUED => dc.issued = v(), + DC_BIBLIOGRAPHIC_CITATION => dc.bibliographic_citation = v(), + other => { + if let Some(idx) = other + .strip_prefix(DC_CONTRIBUTOR_PREFIX) + .and_then(|n| n.parse::().ok()) + { + contributors.push((idx, value.clone())); + } + } + } + } + contributors.sort_by_key(|(i, _)| *i); + dc.contributors = contributors.into_iter().map(|(_, c)| c).collect(); + dc + } } +// Reserved `dcmi:` property names shared by the OOXML and ODF metadata writers. +const DC_PUBLISHER: &str = "dcmi:publisher"; +const DC_RIGHTS: &str = "dcmi:rights"; +const DC_LICENSE: &str = "dcmi:license"; +const DC_IDENTIFIER: &str = "dcmi:identifier"; +const DC_IDENTIFIER_SCHEME: &str = "dcmi:identifier-scheme"; +const DC_TYPE: &str = "dcmi:type"; +const DC_FORMAT: &str = "dcmi:format"; +const DC_SOURCE: &str = "dcmi:source"; +const DC_RELATION: &str = "dcmi:relation"; +const DC_COVERAGE: &str = "dcmi:coverage"; +const DC_ISSUED: &str = "dcmi:issued"; +const DC_BIBLIOGRAPHIC_CITATION: &str = "dcmi:bibliographic-citation"; +const DC_CONTRIBUTOR_PREFIX: &str = "dcmi:contributor."; + #[cfg(test)] mod tests { use super::*; diff --git a/loki-doc-model/src/style/props/char_props.rs b/loki-doc-model/src/style/props/char_props.rs index 9430a973..4b537800 100644 --- a/loki-doc-model/src/style/props/char_props.rs +++ b/loki-doc-model/src/style/props/char_props.rs @@ -122,6 +122,12 @@ pub struct CharProps { /// Bold. ODF `fo:font-weight bold`; OOXML `w:b`. pub bold: Option, + /// Numeric font weight in the CSS/OpenType 1–1000 range (400 = Regular, + /// 700 = Bold). `None` falls back to [`Self::bold`] (700 when bold, else + /// 400). ODF `fo:font-weight` (numeric); OOXML has no native numeric weight + /// (`w:b` is boolean), so a DOCX round-trip collapses this to bold/not-bold. + pub font_weight: Option, + /// Italic. ODF `fo:font-style italic`; OOXML `w:i`. pub italic: Option, @@ -219,6 +225,7 @@ impl CharProps { inherit!(font_size); inherit!(font_size_complex); inherit!(bold); + inherit!(font_weight); inherit!(italic); inherit!(underline); inherit!(strikethrough); diff --git a/loki-doc-model/tests/loro_concurrency_tests.rs b/loki-doc-model/tests/loro_concurrency_tests.rs new file mode 100644 index 00000000..690b403a --- /dev/null +++ b/loki-doc-model/tests/loro_concurrency_tests.rs @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Concurrent-edit, merge-convergence, undo/redo, and pathological-state tests +//! for the `loki-doc-model` Loro CRDT bridge and mutation layer (audit T-6). +//! +//! The bridge round-trip is covered by `loro_bridge_tests.rs`; the single-peer +//! mutation semantics by `loro_mutation_tests.rs`. This file exercises the +//! property that actually justifies using a CRDT: two peers that diverge and +//! then exchange their updates **converge to byte-identical state**, regardless +//! of the order edits were applied. +//! +//! Convergence is asserted on `LoroDoc::get_deep_value()` — the fully resolved +//! document value. Two CRDT replicas that have seen the same set of operations +//! must produce equal deep values; this is a stronger check than comparing +//! reconstructed `Document`s (which `loro_to_document` could normalise). + +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::{ + delete_text, get_block_text, insert_text, merge_block, split_block, +}; +use loro::{ExportMode, LoroDoc, UndoManager}; + +/// Builds a document whose section 0 holds `paras` paragraphs, the i-th +/// paragraph containing the text `"Block{i}"`. +fn doc_with_paras(paras: usize) -> Document { + let mut doc = Document::new(); + if let Some(sec) = doc.first_section_mut() { + for i in 0..paras { + sec.blocks + .push(Block::Para(vec![Inline::Str(format!("Block{i}"))])); + } + } + doc +} + +/// Exchanges all operations between two replicas so both observe the full +/// op set, then commits each. After this returns the two docs must converge. +fn sync(a: &LoroDoc, b: &LoroDoc) { + a.commit(); + b.commit(); + let a_updates = a.export(ExportMode::all_updates()).expect("export a"); + let b_updates = b.export(ExportMode::all_updates()).expect("export b"); + a.import(&b_updates).expect("a imports b"); + b.import(&a_updates).expect("b imports a"); + a.commit(); + b.commit(); +} + +/// Forks `base` into an independent replica that shares the same history. +fn fork(base: &LoroDoc) -> LoroDoc { + base.commit(); + base.fork() +} + +// ── Convergence: concurrent edits to the same block ─────────────────────────── + +#[test] +fn concurrent_inserts_same_block_converge() { + let a = document_to_loro(&doc_with_paras(1)).expect("to loro"); + let b = fork(&a); + + // Both peers prepend different text to the same block, concurrently. + insert_text(&a, 0, 0, "AAA").expect("insert a"); + insert_text(&b, 0, 0, "BBB").expect("insert b"); + + sync(&a, &b); + + // Convergence: identical resolved state on both replicas. + assert_eq!( + a.get_deep_value(), + b.get_deep_value(), + "replicas must converge after exchanging concurrent inserts" + ); + // Both insertions survive (CRDT keeps both, never silently drops one). + let text = get_block_text(&a, 0); + assert!(text.contains("AAA"), "lost peer A's insert: {text:?}"); + assert!(text.contains("BBB"), "lost peer B's insert: {text:?}"); + assert!(text.contains("Block0"), "lost the base text: {text:?}"); +} + +#[test] +fn concurrent_insert_and_delete_converge() { + let a = document_to_loro(&doc_with_paras(1)).expect("to loro"); // "Block0" + let b = fork(&a); + + // A inserts at the end; B deletes the leading "Block" (5 bytes). + insert_text(&a, 0, "Block0".len(), "!").expect("insert a"); + delete_text(&b, 0, 0, "Block".len()).expect("delete b"); + + sync(&a, &b); + + assert_eq!(a.get_deep_value(), b.get_deep_value()); + // A's "!" and B's deletion compose: "Block0" -> "0" -> "0!". + assert_eq!(get_block_text(&a, 0), "0!"); +} + +// ── Convergence: concurrent edits to different blocks ───────────────────────── + +#[test] +fn concurrent_inserts_different_blocks_converge() { + let a = document_to_loro(&doc_with_paras(2)).expect("to loro"); + let b = fork(&a); + + insert_text(&a, 0, 0, "X").expect("edit block 0 on a"); + insert_text(&b, 1, 0, "Y").expect("edit block 1 on b"); + + sync(&a, &b); + + assert_eq!(a.get_deep_value(), b.get_deep_value()); + assert_eq!(get_block_text(&a, 0), "XBlock0"); + assert_eq!(get_block_text(&a, 1), "YBlock1"); +} + +// ── Convergence: concurrent structural + text edits ─────────────────────────── + +#[test] +fn concurrent_split_and_text_edit_converge() { + let a = document_to_loro(&doc_with_paras(2)).expect("to loro"); + let b = fork(&a); + + // A splits block 0 in the middle ("Block" | "0"); B appends to block 1. + split_block(&a, 0, "Block".len()).expect("split on a"); + insert_text(&b, 1, "Block1".len(), "Z").expect("append on b"); + + sync(&a, &b); + + assert_eq!( + a.get_deep_value(), + b.get_deep_value(), + "structural split must converge with a concurrent text edit" + ); + // A's split added a block; B's edit landed on the (formerly) second block. + let doc = loro_to_document(&a).expect("from loro"); + assert_eq!(doc.sections[0].blocks.len(), 3); +} + +#[test] +fn concurrent_merge_and_text_edit_converge() { + let a = document_to_loro(&doc_with_paras(3)).expect("to loro"); + let b = fork(&a); + + // A merges block 1 into block 0; B edits block 2 (untouched by the merge). + merge_block(&a, 1).expect("merge on a"); + insert_text(&b, 2, 0, "Q").expect("edit on b"); + + sync(&a, &b); + + assert_eq!(a.get_deep_value(), b.get_deep_value()); + let doc = loro_to_document(&a).expect("from loro"); + assert_eq!(doc.sections[0].blocks.len(), 2); + assert_eq!(get_block_text(&a, 0), "Block0Block1"); +} + +// ── Three-way convergence (order independence) ──────────────────────────────── + +#[test] +fn three_replicas_converge_regardless_of_merge_order() { + let a = document_to_loro(&doc_with_paras(1)).expect("to loro"); + let b = fork(&a); + let c = fork(&a); + + insert_text(&a, 0, 0, "1").expect("a"); + insert_text(&b, 0, 0, "2").expect("b"); + insert_text(&c, 0, 0, "3").expect("c"); + + // Merge in a deliberately lopsided order: b<-c, a<-b, c<-a, then settle. + sync(&b, &c); + sync(&a, &b); + sync(&c, &a); + sync(&a, &c); + + assert_eq!(a.get_deep_value(), b.get_deep_value()); + assert_eq!(b.get_deep_value(), c.get_deep_value()); + let text = get_block_text(&a, 0); + for needle in ["1", "2", "3", "Block0"] { + assert!(text.contains(needle), "lost {needle:?} in {text:?}"); + } +} + +// ── Undo / redo ─────────────────────────────────────────────────────────────── + +#[test] +fn undo_then_redo_restores_insert() { + let loro = document_to_loro(&doc_with_paras(1)).expect("to loro"); + let mut um = UndoManager::new(&loro); + + insert_text(&loro, 0, 0, "typed ").expect("insert"); + loro.commit(); + um.record_new_checkpoint().expect("checkpoint"); + assert_eq!(get_block_text(&loro, 0), "typed Block0"); + + assert!( + um.can_undo(), + "an edit was recorded, so undo must be available" + ); + um.undo().expect("undo"); + loro.commit(); + assert_eq!( + get_block_text(&loro, 0), + "Block0", + "undo must revert the insert" + ); + + assert!(um.can_redo(), "after an undo, redo must be available"); + um.redo().expect("redo"); + loro.commit(); + assert_eq!( + get_block_text(&loro, 0), + "typed Block0", + "redo must re-apply the insert" + ); +} + +// ── Pathological / edge states ──────────────────────────────────────────────── + +#[test] +fn snapshot_import_into_fresh_doc_preserves_state() { + let src = document_to_loro(&doc_with_paras(2)).expect("to loro"); + insert_text(&src, 0, 0, "edited ").expect("insert"); + src.commit(); + + // Round-trip through a binary snapshot into a brand-new, empty LoroDoc. + let snapshot = src.export(ExportMode::snapshot()).expect("snapshot"); + let fresh = LoroDoc::new(); + fresh.import(&snapshot).expect("import snapshot"); + + assert_eq!( + src.get_deep_value(), + fresh.get_deep_value(), + "a snapshot imported into a fresh doc must reproduce the state exactly" + ); + assert_eq!(get_block_text(&fresh, 0), "edited Block0"); +} + +#[test] +fn fork_of_empty_document_converges() { + // Document::new() has one empty section with no blocks — the degenerate + // starting point. Forking and editing it must still converge. + let a = document_to_loro(&Document::new()).expect("to loro"); + let b = fork(&a); + + // Neither peer can edit a non-existent block; assert the helper reports the + // empty state rather than panicking, then converge the (no-op) histories. + assert_eq!(get_block_text(&a, 0), ""); + assert!( + insert_text(&b, 0, 0, "x").is_err(), + "no block 0 to edit yet" + ); + + sync(&a, &b); + assert_eq!(a.get_deep_value(), b.get_deep_value()); +} + +#[test] +fn idempotent_reimport_is_a_noop() { + let a = document_to_loro(&doc_with_paras(1)).expect("to loro"); + let b = fork(&a); + insert_text(&b, 0, 0, "once ").expect("insert"); + sync(&a, &b); + + let before = a.get_deep_value(); + // Re-importing the same updates a second time must not duplicate ops. + let again = b.export(ExportMode::all_updates()).expect("re-export"); + a.import(&again).expect("re-import"); + a.commit(); + + assert_eq!(before, a.get_deep_value(), "re-import must be idempotent"); + assert_eq!(get_block_text(&a, 0), "once Block0"); +} diff --git a/loki-doc-model/tests/loro_mutation_tests.rs b/loki-doc-model/tests/loro_mutation_tests.rs index 2a3cd000..da4c9934 100644 --- a/loki-doc-model/tests/loro_mutation_tests.rs +++ b/loki-doc-model/tests/loro_mutation_tests.rs @@ -588,3 +588,130 @@ fn split_block_with_char_props_inherits_direct_char_props() { } } } + +// ── Multi-section editing ────────────────────────────────────────────────────── + +/// Build a multi-section `Document`; each inner slice is one section's +/// paragraphs. Editor block indices are global across sections (section 0's +/// blocks occupy `0..a`, section 1's `a..a+b`, and so on). +fn make_doc_with_sections(sections: &[&[&str]]) -> Document { + let mut doc = Document::new(); + doc.sections.clear(); + for paras in sections { + let mut section = Section::new(); + for text in *paras { + section.blocks.push(Block::StyledPara(StyledParagraph { + style_id: Some(StyleId::new("Normal")), + direct_para_props: None, + direct_char_props: None, + inlines: vec![Inline::Str((*text).into())], + attr: NodeAttr::default(), + })); + } + doc.sections.push(section); + } + doc +} + +/// Plain-text content of a paragraph/heading block (for assertions). +fn block_text(block: &Block) -> String { + let inlines = match block { + Block::StyledPara(sp) => &sp.inlines, + Block::Para(inlines) => inlines, + _ => return String::new(), + }; + inlines + .iter() + .filter_map(|i| match i { + Inline::Str(s) => Some(s.as_str()), + _ => None, + }) + .collect() +} + +#[test] +fn insert_text_targets_the_correct_section() { + // Global index 2 is the first block of section 1. + let ldoc = document_to_loro(&make_doc_with_sections(&[&["a0", "a1"], &["b0", "b1"]])) + .expect("to loro"); + + insert_text(&ldoc, 2, 0, "X").expect("insert into section 1"); + + assert_eq!(get_block_text(&ldoc, 2), "Xb0", "edit lands in section 1"); + assert_eq!( + get_block_text(&ldoc, 0), + "a0", + "section 0 block 0 untouched" + ); + assert_eq!( + get_block_text(&ldoc, 1), + "a1", + "section 0 block 1 untouched" + ); + + let doc = loki_doc_model::loro_bridge::loro_to_document(&ldoc).expect("rebuild"); + assert_eq!(doc.sections.len(), 2); + assert_eq!(block_text(&doc.sections[1].blocks[0]), "Xb0"); + assert_eq!(block_text(&doc.sections[0].blocks[0]), "a0"); +} + +#[test] +fn split_block_in_second_section_only_affects_that_section() { + // Global: 0="a0"; 1="b0"; 2="b1". + let ldoc = + document_to_loro(&make_doc_with_sections(&[&["a0"], &["b0", "b1"]])).expect("to loro"); + + split_block(&ldoc, 1, 1).expect("split b0 -> 'b' + '0'"); + + let doc = loki_doc_model::loro_bridge::loro_to_document(&ldoc).expect("rebuild"); + assert_eq!(doc.sections[0].blocks.len(), 1, "section 0 unchanged"); + assert_eq!(doc.sections[1].blocks.len(), 3, "section 1 gained a block"); + assert_eq!(block_text(&doc.sections[1].blocks[0]), "b"); + assert_eq!(block_text(&doc.sections[1].blocks[1]), "0"); + assert_eq!(block_text(&doc.sections[1].blocks[2]), "b1"); +} + +#[test] +fn merge_within_a_section_works() { + // Global: 0="a0"; 1="b0"; 2="b1". Merge b1 into b0. + let ldoc = + document_to_loro(&make_doc_with_sections(&[&["a0"], &["b0", "b1"]])).expect("to loro"); + + let offset = merge_block(&ldoc, 2).expect("merge within section 1"); + assert_eq!(offset, 2, "join offset is the former byte length of 'b0'"); + + let doc = loki_doc_model::loro_bridge::loro_to_document(&ldoc).expect("rebuild"); + assert_eq!(doc.sections[0].blocks.len(), 1); + assert_eq!(doc.sections[1].blocks.len(), 1, "section 1 lost a block"); + assert_eq!(block_text(&doc.sections[1].blocks[0]), "b0b1"); +} + +#[test] +fn merge_across_a_section_break_is_rejected() { + // Global index 1 is the first block of section 1, so its predecessor lives + // in section 0 — a cross-section merge, which is not supported. + let ldoc = document_to_loro(&make_doc_with_sections(&[&["a0"], &["b0"]])).expect("to loro"); + + let err = merge_block(&ldoc, 1).expect_err("cross-section merge must be rejected"); + assert!( + matches!(err, MutationError::CrossSectionMerge), + "got {err:?}" + ); + + // Nothing changed. + let doc = loki_doc_model::loro_bridge::loro_to_document(&ldoc).expect("rebuild"); + assert_eq!(doc.sections[0].blocks.len(), 1); + assert_eq!(doc.sections[1].blocks.len(), 1); + assert_eq!(block_text(&doc.sections[1].blocks[0]), "b0"); +} + +#[test] +fn global_index_past_the_last_section_errors() { + let ldoc = document_to_loro(&make_doc_with_sections(&[&["a0"], &["b0"]])).expect("to loro"); + // Only global indices 0 and 1 exist (one block per section). + let err = insert_text(&ldoc, 2, 0, "X").expect_err("index past last block"); + assert!( + matches!(err, MutationError::BlockIndexOutOfRange(2)), + "got {err:?}" + ); +} diff --git a/loki-doc-model/tests/loro_styles_round_trip.rs b/loki-doc-model/tests/loro_styles_round_trip.rs new file mode 100644 index 00000000..1fb47350 --- /dev/null +++ b/loki-doc-model/tests/loro_styles_round_trip.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Verifies the style catalog round-trips through the Loro CRDT bridge +//! (`document_to_loro` → `loro_to_document`) and that an in-place +//! `write_document_styles` mutation is picked up on the next re-derive — the +//! mechanism that makes style-editor edits durable and undoable. + +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document, write_document_styles}; +use loki_doc_model::style::ParagraphStyle; +use loki_doc_model::style::catalog::StyleId; +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::para_props::{ParaProps, ParagraphAlignment}; +use loki_doc_model::{Document, loki_primitives::units::Points}; + +fn custom_style(id: &str, size: f64) -> ParagraphStyle { + ParagraphStyle { + id: StyleId::new(id), + display_name: Some(format!("{id} Display")), + parent: Some(StyleId::new("Normal")), + linked_char_style: None, + next_style_id: Some("Normal".into()), + para_props: ParaProps { + alignment: Some(ParagraphAlignment::Justify), + indent_start: Some(Points::new(36.0)), + ..Default::default() + }, + char_props: CharProps { + font_name: Some("Arial".into()), + font_size: Some(Points::new(size)), + bold: Some(true), + ..Default::default() + }, + is_default: false, + is_custom: true, + extensions: Default::default(), + } +} + +#[test] +fn catalog_survives_document_to_loro_round_trip() { + let mut doc = Document::new(); + doc.styles + .paragraph_styles + .insert(StyleId::new("MyQuote"), custom_style("MyQuote", 14.0)); + + let loro = document_to_loro(&doc).expect("to loro"); + let restored = loro_to_document(&loro).expect("from loro"); + + let s = restored + .styles + .paragraph_styles + .get(&StyleId::new("MyQuote")) + .expect("custom style must survive the Loro round-trip"); + assert_eq!(s.display_name.as_deref(), Some("MyQuote Display")); + assert_eq!(s.char_props.font_size, Some(Points::new(14.0))); + assert!(s.is_custom); +} + +#[test] +fn in_place_style_mutation_is_rederived() { + let doc = Document::new(); + let loro = document_to_loro(&doc).expect("to loro"); + + // Simulate a style-editor Apply: clone the catalog, add a style, write it + // back to Loro, and commit (the editor's `commit_style_to_loro` path). + let mut catalog = loro_to_document(&loro).expect("derive").styles; + catalog + .paragraph_styles + .insert(StyleId::new("Callout"), custom_style("Callout", 18.0)); + write_document_styles(&loro, &catalog).expect("write styles"); + loro.commit(); + + // A fresh re-derive (what `apply_mutation_and_relayout` does) sees the edit. + let restored = loro_to_document(&loro).expect("re-derive"); + assert!( + restored + .styles + .paragraph_styles + .contains_key(&StyleId::new("Callout")), + "style written via write_document_styles must appear on re-derive" + ); +} diff --git a/loki-epub/Cargo.toml b/loki-epub/Cargo.toml index 694385b3..5be1f041 100644 --- a/loki-epub/Cargo.toml +++ b/loki-epub/Cargo.toml @@ -20,5 +20,11 @@ version = "0.4" default-features = false features = ["std", "clock"] +[dev-dependencies] +# Integration tests re-open the exported OCF ZIP and assert that each XML part +# is well-formed, so they need a ZIP reader and an XML parser of their own. +zip = { version = "2", default-features = false, features = ["deflate"] } +quick-xml = "0.36" + [package.metadata.docs.rs] all-features = true diff --git a/loki-epub/src/container.rs b/loki-epub/src/container.rs index a524e10c..d9829ddc 100644 --- a/loki-epub/src/container.rs +++ b/loki-epub/src/container.rs @@ -41,4 +41,9 @@ body { margin: 5%; line-height: 1.4; }\n\ h1, h2, h3, h4, h5, h6 { font-family: sans-serif; line-height: 1.2; }\n\ pre { white-space: pre-wrap; font-family: monospace; }\n\ blockquote { margin-left: 1.5em; font-style: italic; }\n\ -.loki-table-placeholder { color: #666; font-style: italic; }\n"; +table { border-collapse: collapse; margin: 1em 0; }\n\ +th, td { border: 1px solid #999; padding: 0.3em 0.5em; }\n\ +caption { font-style: italic; margin-bottom: 0.3em; }\n\ +figure { margin: 1em 0; text-align: center; }\n\ +figcaption { font-size: 0.9em; color: #555; }\n\ +img { max-width: 100%; height: auto; }\n"; diff --git a/loki-epub/src/lib.rs b/loki-epub/src/lib.rs index e011a56c..c5b2f7b4 100644 --- a/loki-epub/src/lib.rs +++ b/loki-epub/src/lib.rs @@ -17,9 +17,11 @@ //! - The package declares EPUB version 3.0 with the mandatory //! `dc:identifier` / `dc:title` / `dc:language` / `dcterms:modified` //! metadata (EPUB 3.3 §5.4). -//! - A single reflowable content document is emitted. Tables and images are -//! currently rendered as placeholders / alt text (see the `TODO`s in -//! `content.rs`); fixed-layout and media overlays are out of scope. +//! - A single reflowable content document is emitted. Tables render as real +//! `
`, a +> `
`s (caption, `` widths, `colspan`/`rowspan`, resolved +//! cell alignment); embedded `data:` images are decoded, packaged as manifest +//! resources, and referenced with `` (external URLs are referenced but +//! not packaged). Fixed-layout and media overlays are out of scope. #![forbid(unsafe_code)] #![warn(missing_docs)] diff --git a/loki-epub/src/tables.rs b/loki-epub/src/tables.rs index a986f328..6006686f 100644 --- a/loki-epub/src/tables.rs +++ b/loki-epub/src/tables.rs @@ -2,22 +2,40 @@ // Copyright 2026 AppThere Loki contributors //! Table serialisation to XHTML `
` for the EPUB content document. +//! +//! Carries the structural model (head/body/foot rows, `colspan`/`rowspan`) plus +//! the presentation the reflowable target can honour: the caption, a +//! `` with per-column widths, and resolved horizontal/vertical cell +//! alignment (cell override falling back to the column default). +use loki_doc_model::content::table::col::{ColAlignment, ColSpec, ColWidth, TableWidth}; use loki_doc_model::content::table::core::Table; -use loki_doc_model::content::table::row::{Cell, Row}; +use loki_doc_model::content::table::row::{Cell, CellVerticalAlign, Row}; use crate::content::RenderCtx; impl RenderCtx { - /// Renders a [`Table`] as an XHTML `
` with optional ``, - /// ``, and `` sections. Cell `colspan`/`rowspan` are honoured. + /// Renders a [`Table`] as an XHTML `
` with an optional ``, and ``/``/`` sections. Cell + /// `colspan`/`rowspan`, column widths, and alignment are honoured. pub(crate) fn render_table(&mut self, table: &Table, out: &mut String) { - out.push_str("
`, + /// `
\n"); + match table_width_style(table.width) { + Some(style) => out.push_str(&format!("
\n")), + None => out.push_str("
\n"), + } + + if !table.caption.full.is_empty() { + out.push_str("\n"); + } + + render_colgroup(&table.col_specs, out); if !table.head.rows.is_empty() { out.push_str("\n"); for row in &table.head.rows { - self.render_row(row, "th", out); + self.render_row(row, "th", &table.col_specs, out); } out.push_str("\n"); } @@ -26,10 +44,10 @@ impl RenderCtx { for body in &table.bodies { // Per-body head rows repeat as header cells. for row in &body.head_rows { - self.render_row(row, "th", out); + self.render_row(row, "th", &table.col_specs, out); } for row in &body.body_rows { - self.render_row(row, "td", out); + self.render_row(row, "td", &table.col_specs, out); } } out.push_str("\n"); @@ -37,7 +55,7 @@ impl RenderCtx { if !table.foot.rows.is_empty() { out.push_str("\n"); for row in &table.foot.rows { - self.render_row(row, "td", out); + self.render_row(row, "td", &table.col_specs, out); } out.push_str("\n"); } @@ -45,15 +63,27 @@ impl RenderCtx { out.push_str("
"); + self.render_inlines(&table.caption.full, out); + out.push_str("
\n"); } - fn render_row(&mut self, row: &Row, cell_tag: &str, out: &mut String) { + fn render_row(&mut self, row: &Row, cell_tag: &str, col_specs: &[ColSpec], out: &mut String) { out.push_str(""); + // A simple left-to-right column cursor. It advances by each cell's + // `col_span`; it does not model rowspan occupancy carried from earlier + // rows, so column-default alignment may be approximate in tables that mix + // row spans with per-column alignment — the common cases resolve exactly. + let mut col = 0usize; for cell in &row.cells { - self.render_cell(cell, cell_tag, out); + self.render_cell(cell, cell_tag, col_specs.get(col), out); + col += cell.col_span.max(1) as usize; } out.push_str("\n"); } - fn render_cell(&mut self, cell: &Cell, tag: &str, out: &mut String) { + fn render_cell( + &mut self, + cell: &Cell, + tag: &str, + col_spec: Option<&ColSpec>, + out: &mut String, + ) { let mut attrs = String::new(); if cell.col_span > 1 { attrs.push_str(&format!(" colspan=\"{}\"", cell.col_span)); @@ -61,6 +91,9 @@ impl RenderCtx { if cell.row_span > 1 { attrs.push_str(&format!(" rowspan=\"{}\"", cell.row_span)); } + if let Some(style) = cell_style(cell, col_spec) { + attrs.push_str(&format!(" style=\"{style}\"")); + } out.push_str(&format!("<{tag}{attrs}>")); for block in &cell.blocks { self.render_block(block, out); @@ -69,46 +102,107 @@ impl RenderCtx { } } -#[cfg(test)] -mod tests { - use super::*; - use crate::content::render_content; - use loki_doc_model::Document; - use loki_doc_model::content::block::Block; - use loki_doc_model::content::inline::Inline; - use loki_doc_model::content::table::core::{Table, TableBody, TableFoot, TableHead}; - - #[test] - fn renders_table_with_header_and_body() { - let header = Row::new(vec![Cell::simple(vec![Block::Para(vec![Inline::Str( - "H".into(), - )])])]); - let body = Row::new(vec![Cell::simple(vec![Block::Para(vec![Inline::Str( - "C".into(), - )])])]); - let table = Table { - attr: Default::default(), - caption: Default::default(), - width: None, - col_specs: Vec::new(), - head: TableHead { - attr: Default::default(), - rows: vec![header], - }, - bodies: vec![TableBody::from_rows(vec![body])], - foot: TableFoot::empty(), - }; - - let mut doc = Document::new(); - let sec = doc.first_section_mut().unwrap(); - sec.blocks.clear(); - sec.blocks.push(Block::Table(Box::new(table))); - - let rendered = render_content(&doc); - assert!(rendered.body.contains("")); - assert!(rendered.body.contains("")); - assert!(rendered.body.contains("` carrying each column's width, when any column declares +/// one. Proportional widths are normalised against the sum of all proportional +/// shares so they become CSS percentages. +fn render_colgroup(col_specs: &[ColSpec], out: &mut String) { + if col_specs.is_empty() { + return; + } + let proportional_total: f32 = col_specs + .iter() + .filter_map(|c| match c.width { + ColWidth::Proportional(share) => Some(share), + _ => None, + }) + .sum(); + + let mut any_width = false; + let mut cols = String::new(); + for spec in col_specs { + match col_width_style(spec.width, proportional_total) { + Some(style) => { + any_width = true; + cols.push_str(&format!("\n")); + } + None => cols.push_str("\n"), + } + } + // Only bother emitting the group if at least one column carries a width; + // a colgroup of bare s would be inert. + if any_width { + out.push_str("\n"); + out.push_str(&cols); + out.push_str("\n"); + } +} + +/// CSS `width` declaration for a single column, or `None` for content-sized. +fn col_width_style(width: ColWidth, proportional_total: f32) -> Option { + match width { + ColWidth::Fixed(pts) => Some(format!("width:{:.2}pt", pts.value())), + ColWidth::Proportional(share) if proportional_total > 0.0 => { + Some(format!("width:{:.2}%", share / proportional_total * 100.0)) + } + _ => None, + } +} + +/// CSS `width` declaration for the whole table, or `None` for auto. +fn table_width_style(width: Option) -> Option { + match width { + Some(TableWidth::Fixed(pts)) => Some(format!("width:{pts:.2}pt")), + Some(TableWidth::Percent(p)) => Some(format!("width:{p:.2}%")), + _ => None, + } +} + +/// Builds the combined `style` value for a cell from its resolved horizontal +/// alignment (cell override → column default) and vertical alignment. +fn cell_style(cell: &Cell, col_spec: Option<&ColSpec>) -> Option { + let mut parts: Vec = Vec::new(); + + let align = if cell.alignment != ColAlignment::Default { + cell.alignment + } else { + col_spec.map(|c| c.alignment).unwrap_or_default() + }; + if let Some(css) = horizontal_align_css(align) { + parts.push(format!("text-align:{css}")); + } + + if let Some(css) = vertical_align_css(cell.props.vertical_align) { + parts.push(format!("vertical-align:{css}")); + } + + if parts.is_empty() { + None + } else { + Some(parts.join(";")) } } + +/// Maps a horizontal alignment to its CSS keyword, or `None` for the default. +fn horizontal_align_css(align: ColAlignment) -> Option<&'static str> { + match align { + ColAlignment::Left => Some("left"), + ColAlignment::Right => Some("right"), + ColAlignment::Center => Some("center"), + // `Default` and any future variant carry no explicit alignment. + _ => None, + } +} + +/// Maps a vertical alignment to its CSS keyword, or `None` for the default top. +fn vertical_align_css(align: Option) -> Option<&'static str> { + match align { + Some(CellVerticalAlign::Middle) => Some("middle"), + Some(CellVerticalAlign::Bottom) => Some("bottom"), + // `Top` is the default; `None` and any future variant emit nothing. + _ => None, + } +} + +#[cfg(test)] +#[path = "tables_tests.rs"] +mod tests; diff --git a/loki-epub/src/tables_tests.rs b/loki-epub/src/tables_tests.rs new file mode 100644 index 00000000..341fb031 --- /dev/null +++ b/loki-epub/src/tables_tests.rs @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for table serialisation. + +use crate::content::render_content; +use loki_doc_model::Document; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::content::table::col::{ColAlignment, ColSpec, ColWidth}; +use loki_doc_model::content::table::core::{Table, TableBody, TableCaption, TableFoot, TableHead}; +use loki_doc_model::content::table::row::{Cell, CellVerticalAlign, Row}; +use loki_primitives::units::Points; + +/// Wraps a single block-bearing table into a one-section document and renders it. +fn render_table(table: Table) -> String { + let mut doc = Document::new(); + let sec = doc.first_section_mut().unwrap(); + sec.blocks.clear(); + sec.blocks.push(Block::Table(Box::new(table))); + render_content(&doc).body +} + +fn cell(text: &str) -> Cell { + Cell::simple(vec![Block::Para(vec![Inline::Str(text.into())])]) +} + +fn bare_table(rows: Vec) -> Table { + Table { + attr: Default::default(), + caption: Default::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(rows)], + foot: TableFoot::empty(), + } +} + +#[test] +fn renders_table_with_header_and_body() { + let table = Table { + head: TableHead { + attr: Default::default(), + rows: vec![Row::new(vec![cell("H")])], + }, + ..bare_table(vec![Row::new(vec![cell("C")])]) + }; + + let body = render_table(table); + assert!(body.contains("

H

")); - assert!(rendered.body.contains("

C

")); - assert!(rendered.body.contains("")); +/// Emits a `
")); + assert!(body.contains("")); + assert!(body.contains(""), + "caption missing: {body}" + ); +} + +#[test] +fn renders_colgroup_with_fixed_and_proportional_widths() { + let table = Table { + col_specs: vec![ + ColSpec::fixed(Points::new(72.0)), + ColSpec::proportional(1.0), + ColSpec::proportional(3.0), + ], + ..bare_table(vec![Row::new(vec![cell("a"), cell("b"), cell("c")])]) + }; + let body = render_table(table); + assert!(body.contains(""), "colgroup missing: {body}"); + assert!( + body.contains("width:72.00pt"), + "fixed width missing: {body}" + ); + // 1.0 / (1.0 + 3.0) = 25%, 3.0 / 4.0 = 75%. + assert!(body.contains("width:25.00%"), "1-share width wrong: {body}"); + assert!(body.contains("width:75.00%"), "3-share width wrong: {body}"); +} + +#[test] +fn colgroup_omitted_when_no_widths() { + // All-default widths must not emit an inert colgroup of bare s. + let table = Table { + col_specs: vec![ + ColSpec { + alignment: ColAlignment::Center, + width: ColWidth::Default, + }, + ColSpec { + alignment: ColAlignment::Default, + width: ColWidth::Default, + }, + ], + ..bare_table(vec![Row::new(vec![cell("a"), cell("b")])]) + }; + let body = render_table(table); + assert!(!body.contains(""), "unexpected colgroup: {body}"); +} + +#[test] +fn cell_inherits_column_alignment() { + let table = Table { + col_specs: vec![ + ColSpec { + alignment: ColAlignment::Right, + width: ColWidth::Default, + }, + ColSpec { + alignment: ColAlignment::Center, + width: ColWidth::Default, + }, + ], + ..bare_table(vec![Row::new(vec![cell("a"), cell("b")])]) + }; + let body = render_table(table); + assert!( + body.contains("

H

")); + assert!(body.contains("

C

")); + assert!(body.contains("")); +} + +#[test] +fn renders_caption() { + let table = Table { + caption: TableCaption { + short: None, + full: vec![Inline::Str("Table 1: Results".into())], + }, + ..bare_table(vec![Row::new(vec![cell("C")])]) + }; + let body = render_table(table); + assert!( + body.contains("
Table 1: Results

a

"), + "column-right alignment not applied: {body}" + ); + assert!( + body.contains("

b

"), + "column-center alignment not applied: {body}" + ); +} + +#[test] +fn cell_alignment_overrides_column_default() { + let mut overridden = cell("x"); + overridden.alignment = ColAlignment::Left; + let table = Table { + col_specs: vec![ColSpec { + alignment: ColAlignment::Right, + width: ColWidth::Default, + }], + ..bare_table(vec![Row::new(vec![overridden])]) + }; + let body = render_table(table); + assert!( + body.contains("text-align:left"), + "cell override should win over column default: {body}" + ); + assert!( + !body.contains("text-align:right"), + "stale column align: {body}" + ); +} + +#[test] +fn cell_vertical_alignment_is_emitted() { + let mut c = cell("v"); + c.props.vertical_align = Some(CellVerticalAlign::Middle); + let body = render_table(bare_table(vec![Row::new(vec![c])])); + assert!( + body.contains("vertical-align:middle"), + "vertical align missing: {body}" + ); +} + +#[test] +fn alignment_tracks_column_index_through_colspan() { + // A 2-col-spanning first cell must push the second cell onto column index 2. + let mut spanning = cell("wide"); + spanning.col_span = 2; + let table = Table { + col_specs: vec![ + ColSpec { + alignment: ColAlignment::Default, + width: ColWidth::Default, + }, + ColSpec { + alignment: ColAlignment::Default, + width: ColWidth::Default, + }, + ColSpec { + alignment: ColAlignment::Right, + width: ColWidth::Default, + }, + ], + ..bare_table(vec![Row::new(vec![spanning, cell("tail")])]) + }; + let body = render_table(table); + assert!( + body.contains("colspan=\"2\""), + "colspan not emitted: {body}" + ); + assert!( + body.contains("text-align:right"), + "second cell should inherit column 2's right alignment: {body}" + ); +} + +#[test] +fn renders_table_width() { + let table = Table { + width: Some(loki_doc_model::content::table::col::TableWidth::Percent( + 80.0, + )), + ..bare_table(vec![Row::new(vec![cell("c")])]) + }; + let body = render_table(table); + assert!( + body.contains(""), + "table width missing: {body}" + ); +} diff --git a/loki-epub/tests/epub_structure.rs b/loki-epub/tests/epub_structure.rs new file mode 100644 index 00000000..064a52dc --- /dev/null +++ b/loki-epub/tests/epub_structure.rs @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Integration tests for EPUB export (audit T-3): export a document, re-open the +//! OCF ZIP container, and validate its structure — that every XML part is +//! well-formed and that the package's manifest / spine / navigation relationships +//! hold together. The inline unit tests in `lib.rs` cover substring presence; +//! these tests verify the package is actually a coherent, parseable EPUB. + +use std::io::{Cursor, Read}; + +use loki_doc_model::Document; +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::io::DocumentExport; +use loki_epub::{EpubExport, EpubOptions}; +use quick_xml::Reader; +use quick_xml::events::Event; +use zip::ZipArchive; + +/// A multi-section document with two headings (so the nav gets a real TOC) and +/// body paragraphs under each. +fn sample_book() -> Document { + let mut doc = Document::new(); + doc.meta.title = Some("The Loki Compendium".into()); + doc.meta.creator = Some("Ada Lovelace".into()); + let sec = doc.first_section_mut().unwrap(); + sec.blocks.clear(); + sec.blocks.push(Block::Heading( + 1, + NodeAttr::default(), + vec![Inline::Str("Chapter One".into())], + )); + sec.blocks + .push(Block::Para(vec![Inline::Str("The opening words.".into())])); + sec.blocks.push(Block::Heading( + 1, + NodeAttr::default(), + vec![Inline::Str("Chapter Two".into())], + )); + sec.blocks + .push(Block::Para(vec![Inline::Str("The closing words.".into())])); + doc +} + +fn export_bytes(doc: &Document) -> Vec { + let mut buf = Cursor::new(Vec::new()); + EpubExport::export(doc, &mut buf, EpubOptions::default()).expect("export"); + buf.into_inner() +} + +/// Reads a named entry from the archive into a String. +fn read_entry(archive: &mut ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + archive + .by_name(name) + .unwrap_or_else(|_| panic!("missing entry {name}")) + .read_to_string(&mut s) + .unwrap_or_else(|_| panic!("entry {name} not UTF-8")); + s +} + +/// Drives quick-xml over the whole document, panicking on the first parse error. +/// A clean run to `Eof` proves the part is well-formed XML. +fn assert_well_formed(label: &str, xml: &str) { + let mut reader = Reader::from_str(xml); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(_) => {} + Err(e) => panic!("{label} is not well-formed XML: {e}"), + } + } +} + +#[test] +fn mimetype_is_first_and_stored_uncompressed() { + let bytes = export_bytes(&sample_book()); + let mut archive = ZipArchive::new(Cursor::new(bytes)).expect("open ocf zip"); + + // OCF §4.3: the mimetype entry must be first. + assert_eq!(archive.file_names().next(), Some("mimetype")); + + let entry = archive.by_name("mimetype").expect("mimetype entry"); + assert_eq!( + entry.compression(), + zip::CompressionMethod::Stored, + "mimetype must be stored uncompressed" + ); +} + +#[test] +fn every_xml_part_is_well_formed() { + let bytes = export_bytes(&sample_book()); + let mut archive = ZipArchive::new(Cursor::new(bytes)).expect("open ocf zip"); + + for name in [ + "META-INF/container.xml", + "EPUB/package.opf", + "EPUB/nav.xhtml", + "EPUB/content.xhtml", + ] { + let xml = read_entry(&mut archive, name); + assert_well_formed(name, &xml); + } +} + +#[test] +fn package_manifest_and_spine_are_consistent() { + let bytes = export_bytes(&sample_book()); + let mut archive = ZipArchive::new(Cursor::new(bytes)).expect("open ocf zip"); + let opf = read_entry(&mut archive, "EPUB/package.opf"); + + // Mandatory EPUB 3.3 metadata. + assert!(opf.contains("The Loki Compendium")); + assert!(opf.contains("Ada Lovelace")); + assert!(opf.contains("dc:identifier"), "missing dc:identifier"); + assert!(opf.contains("dc:language"), "missing dc:language"); + assert!(opf.contains("dcterms:modified"), "missing dcterms:modified"); + + // The nav document must be declared with the `nav` property, and the content + // document must appear in both the manifest and the spine. + assert!( + opf.contains("properties=\"nav\""), + "nav document not flagged in manifest" + ); + assert!( + opf.contains("href=\"content.xhtml\""), + "content doc missing from manifest" + ); + assert!(opf.contains(" base64 "SGk="). + let target = LinkTarget::new("data:image/png;base64,SGk="); + sec.blocks.push(Block::Para(vec![Inline::Image( + NodeAttr::default(), + vec![Inline::Str("Chart".into())], + target, + )])); + + let bytes = export_bytes(&doc); + let mut archive = ZipArchive::new(Cursor::new(bytes)).expect("open ocf zip"); + + // The content document must carry real table markup and stay well-formed. + let content = read_entry(&mut archive, "EPUB/content.xhtml"); + assert_well_formed("EPUB/content.xhtml (with table + image)", &content); + assert!(content.contains("Quarterly figures"), + "caption dropped: {content}" + ); + assert!(content.contains("text-align:right"), "column align lost"); + assert!( + content.contains("\"Chart\"/"), + "image reference missing: {content}" + ); + + // The image bytes must be packaged and declared in the manifest. + let mut img = Vec::new(); + archive + .by_name("EPUB/images/img0.png") + .expect("image entry packaged") + .read_to_end(&mut img) + .unwrap(); + assert_eq!(img, b"Hi", "packaged image bytes wrong"); + + let opf = read_entry(&mut archive, "EPUB/package.opf"); + assert!( + opf.contains("href=\"images/img0.png\"") && opf.contains("media-type=\"image/png\""), + "image not in manifest: {opf}" + ); +} + +#[test] +fn untitled_document_still_produces_a_valid_package() { + // A blank document (no title/creator) must still yield a spec-valid package + // with a synthesised identifier and the fallback title. + let doc = Document::new(); + let bytes = export_bytes(&doc); + let mut archive = ZipArchive::new(Cursor::new(bytes)).expect("open ocf zip"); + + let opf = read_entry(&mut archive, "EPUB/package.opf"); + assert_well_formed("EPUB/package.opf", &opf); + assert!(opf.contains("dc:identifier"), "identifier is mandatory"); + assert!(opf.contains("dc:title"), "title is mandatory"); +} diff --git a/loki-fonts/fonts/AtkinsonHyperlegibleNext-VF.ttf b/loki-fonts/fonts/AtkinsonHyperlegibleNext-VF.ttf new file mode 100644 index 00000000..92a35755 Binary files /dev/null and b/loki-fonts/fonts/AtkinsonHyperlegibleNext-VF.ttf differ diff --git a/loki-fonts/src/lib.rs b/loki-fonts/src/lib.rs index fae57740..d3fb104f 100644 --- a/loki-fonts/src/lib.rs +++ b/loki-fonts/src/lib.rs @@ -25,6 +25,57 @@ #![forbid(unsafe_code)] +use std::sync::OnceLock; + +/// Returns a self-contained `@font-face` block for the **Atkinson Hyperlegible +/// Next** UI variable font, embedded as a `data:font/truetype;base64,…` URI. +/// +/// Embedded on **all** platforms (the single variable font is ~112 KB). Unlike +/// the `dioxus:///assets/...` URL the apps previously used — which resolves +/// relative to the executable and fails to load on Android/ChromeOS (and +/// silently relies on a system-installed copy on desktop) — the `data:` URI is +/// decoded by `blitz_net` on every platform, so the UI chrome renders in the +/// intended face everywhere. Built once and cached for the process lifetime. +pub fn ui_face_css() -> &'static str { + static UI_FACE_CSS: OnceLock = OnceLock::new(); + UI_FACE_CSS.get_or_init(|| { + const FONT: &[u8] = include_bytes!("../fonts/AtkinsonHyperlegibleNext-VF.ttf"); + let b64 = base64_encode(FONT); + format!( + "@font-face{{font-family:'Atkinson Hyperlegible Next';\ + font-weight:100 900;font-style:normal;\ + src:url('data:font/truetype;base64,{b64}') format('truetype');}}" + ) + }) +} + +/// Standard base64 encoder (no line wrapping), shared by [`ui_face_css`] and the +/// Android fallback-font CSS. Dependency-free to keep this crate `include_bytes` +/// only. +pub(crate) fn base64_encode(input: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + out.push(if chunk.len() > 1 { + T[((n >> 6) & 63) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + T[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + /// Returns a complete `@font-face` CSS block for all bundled fonts, with each /// font embedded as a `data:font/truetype;base64,…` URI. /// @@ -41,6 +92,34 @@ pub fn face_css() -> &'static str { "" } +/// 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. +/// +/// Android-only: on desktop the layout engine discovers these fonts from the +/// executable-relative `assets/fonts/` directory, but that path does not resolve +/// on Android, so the bytes must be registered directly (otherwise a document's +/// Calibri/Arial/Times text falls back to an Android system font with different +/// metrics — e.g. wider digit advances). Returns `&[]` would be the desktop +/// shape, but the function is simply not compiled there. +#[cfg(target_os = "android")] +pub fn fallback_font_blobs() -> &'static [&'static [u8]] { + imp::fallback_font_blobs() +} + +#[cfg(all(test, not(target_os = "android")))] +mod tests { + use super::*; + + #[test] + fn face_css_is_empty_on_non_android() { + // Desktop and Android-GPU builds must not embed any @font-face CSS + // (the ~7 MB of font bytes are android-cpu-only). This is the documented + // no-op contract relied on by the shared App component. + assert_eq!(face_css(), ""); + } +} + // ── Android-only implementation ─────────────────────────────────────────────── // The entire font-data and CSS-generation block is compiled only on Android so // that desktop binaries do not embed ~7 MB of font bytes. @@ -178,18 +257,27 @@ mod imp { FACE_CSS.get_or_init(build_css) } + /// Raw bytes of every bundled fallback face, for direct registration into a + /// font collection (e.g. Parley's). Used by the document layout engine on + /// Android, where the executable-relative asset path that loads these on + /// desktop is unavailable. + pub(super) fn fallback_font_blobs() -> &'static [&'static [u8]] { + static BLOBS: OnceLock> = OnceLock::new(); + BLOBS.get_or_init(|| FACES.iter().map(|f| f.bytes).collect()) + } + fn build_css() -> String { use std::fmt::Write as _; let total_bytes: usize = FACES.iter().map(|f| f.bytes.len()).sum(); let mut css = String::with_capacity(total_bytes * 4 / 3 + FACES.len() * 256); for face in FACES { - let b64 = base64_encode(face.bytes); - write!( + let b64 = crate::base64_encode(face.bytes); + writeln!( css, "@font-face{{font-family:'{family}';font-weight:{weight};\ font-style:{style};src:url('data:font/truetype;base64,{b64}')\ - format('truetype');}}\n", + format('truetype');}}", family = face.family, weight = face.weight, style = face.style, @@ -198,28 +286,4 @@ mod imp { } css } - - fn base64_encode(input: &[u8]) -> String { - const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity((input.len() + 2) / 3 * 4); - for chunk in input.chunks(3) { - let b0 = chunk[0] as u32; - let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; - let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; - let n = (b0 << 16) | (b1 << 8) | b2; - out.push(T[((n >> 18) & 63) as usize] as char); - out.push(T[((n >> 12) & 63) as usize] as char); - out.push(if chunk.len() > 1 { - T[((n >> 6) & 63) as usize] as char - } else { - '=' - }); - out.push(if chunk.len() > 2 { - T[(n & 63) as usize] as char - } else { - '=' - }); - } - out - } } diff --git a/loki-i18n/i18n/en-US/editor.ftl b/loki-i18n/i18n/en-US/editor.ftl index 73838ce9..09c69fbd 100644 --- a/loki-i18n/i18n/en-US/editor.ftl +++ b/loki-i18n/i18n/en-US/editor.ftl @@ -49,6 +49,7 @@ editor-font-dismiss = Dismiss # Save editor-save-success = Document saved +editor-save-template-success = Template saved editor-save-error = Could not save: { $reason } editor-save-untitled-hint = Use File → Save As to save new documents editor-dismiss-aria = Dismiss @@ -60,6 +61,31 @@ editor-style-editor-close-aria = Close style editor editor-style-based-on-label = Based on editor-style-next-style-label = Next style editor-style-align-label = Align +editor-style-name-label = Name +editor-style-new = + New +editor-style-font-label = Font +editor-style-size-label = Size (pt) +editor-style-weight-label = Weight +editor-style-weight-thin = Thin +editor-style-weight-light = Light +editor-style-weight-regular = Regular +editor-style-weight-medium = Medium +editor-style-weight-semibold = Semibold +editor-style-weight-bold = Bold +editor-style-weight-black = Black +editor-style-align-left = Left +editor-style-align-center = Center +editor-style-align-right = Right +editor-style-align-justify = Justify +editor-style-indent-label = Indent +editor-style-indent-left = Left +editor-style-indent-right = Right +editor-style-indent-first = First line +editor-style-indent-hanging = Hanging +editor-style-spacing-label = Spacing +editor-style-spacing-before = Before +editor-style-spacing-after = After +editor-style-line-spacing = Line × # View mode (paginated vs reflowed) toggle in the status bar editor-view-paginated = Paginated diff --git a/loki-i18n/i18n/en-US/home.ftl b/loki-i18n/i18n/en-US/home.ftl index b8eb43ef..61deafb6 100644 --- a/loki-i18n/i18n/en-US/home.ftl +++ b/loki-i18n/i18n/en-US/home.ftl @@ -8,18 +8,18 @@ home-recent-heading = Recent home-template-blank = Blank home-template-blank-description = Empty document home-template-blank-format = DOCX -home-template-letter = Letter -home-template-letter-description = Formal letter template -home-template-letter-format = DOCX -home-template-report = Report -home-template-report-description = Multi-section report -home-template-report-format = DOCX +home-template-markdown = Markdown +home-template-markdown-description = Clean styles inspired by Markdown +home-template-apa = APA Paper +home-template-apa-description = APA 7th edition format +home-template-mla = MLA Paper +home-template-mla-description = MLA 9th edition format +home-template-screenplay = Screenplay +home-template-screenplay-description = Hollywood screenplay format home-template-resume = Résumé -home-template-resume-description = Single-page résumé -home-template-resume-format = DOCX -home-template-invoice = Invoice -home-template-invoice-description = Simple invoice template -home-template-invoice-format = DOCX +home-template-resume-description = Basic single-page résumé +# Shared format label for the bundled document templates. +home-template-format-dotx = DOTX home-browse-templates = Browse templates… diff --git a/loki-i18n/i18n/en-US/ribbon.ftl b/loki-i18n/i18n/en-US/ribbon.ftl index 9e7fd626..13243ab6 100644 --- a/loki-i18n/i18n/en-US/ribbon.ftl +++ b/loki-i18n/i18n/en-US/ribbon.ftl @@ -2,13 +2,6 @@ # Tab labels ribbon-tab-home = Home -ribbon-tab-insert = Insert -ribbon-tab-format = Format -ribbon-tab-review = Review -ribbon-tab-view = View - -# Placeholder content (shown while ribbon is not yet implemented) -ribbon-coming-soon = Ribbon content coming soon # Inline formatting group (Home tab) ribbon-group-inline = Inline formatting @@ -28,6 +21,7 @@ ribbon-redo-aria = Redo (Ctrl+Y) ribbon-group-document = Document ribbon-save-aria = Save (Ctrl+S) ribbon-save-as-aria = Save As… +ribbon-save-as-template-aria = Save as Template… # Styles group (Home tab) ribbon-group-styles = Styles diff --git a/loki-layout/Cargo.toml b/loki-layout/Cargo.toml index 68176ea2..08f553c7 100644 --- a/loki-layout/Cargo.toml +++ b/loki-layout/Cargo.toml @@ -12,14 +12,27 @@ categories = ["graphics", "text-processing"] loki-doc-model = { path = "../loki-doc-model" } loki-primitives = { path = "../loki-primitives" } appthere-color = "0.1.1" -parley = "0.8" -fontique = "0.8" +parley = "0.10" +# Force fontconfig-dlopen so fontique loads libfontconfig at runtime instead of +# requiring static C symbols at link time. This must be enabled wherever fontique +# is pulled (parley re-exports it) so crates that build without the blitz stack in +# their graph (e.g. loki-vello) still link — blitz-dom otherwise enables +# yeslogic-fontconfig-sys/dlopen globally, but it is not always present. +fontique = { version = "0.10", features = ["fontconfig-dlopen"] } thiserror = "2" tracing = "0.1" +# Android only: the bundled metric-compatible fallback fonts (Carlito/Caladea/ +# Arimo/Cousine/Tinos). On desktop these are discovered from the executable's +# `assets/fonts/` directory, but that path does not resolve on Android, so the +# bytes are registered directly in `FontResources::new`. Gated to the Android +# target so desktop builds neither depend on loki-fonts nor embed the ~7 MB. +[target.'cfg(target_os = "android")'.dependencies] +loki-fonts.workspace = true + [dev-dependencies] # Statistical benchmark harness (layout + edit-path scaling). -criterion = "0.5" +criterion = "0.8" # Drives real CRDT mutations through the edit-path benchmark / report. loro = "1.11.1" diff --git a/loki-layout/src/flow.rs b/loki-layout/src/flow.rs index 00e9383e..c9f194b3 100644 --- a/loki-layout/src/flow.rs +++ b/loki-layout/src/flow.rs @@ -11,6 +11,10 @@ //! Paragraph placement, splitting, and keep-with-next chain logic live in //! the `para_impl` submodule (`flow_para.rs`). +#[path = "flow_columns.rs"] +mod columns_impl; +#[path = "flow_comments.rs"] +mod comments_impl; #[path = "flow_para.rs"] mod para_impl; @@ -159,6 +163,28 @@ pub(super) struct FlowState<'a> { /// used by incremental relayout. Populated only by [`run_paginated_loop`]; /// nested flows (tables, cells, headers) never call it, so it stays empty. pub(super) checkpoints: Vec, + /// Number of text columns for this section (`1` = single column). When + /// `> 1`, [`content_width`](Self::content_width) holds the *column* width and + /// content flows down each column before moving to the next, then the page. + pub(super) columns: u8, + /// Gap between adjacent columns in points (meaningful only when `columns > 1`). + pub(super) column_gap: f32, + /// Whether to draw a separator line between columns. + pub(super) column_separator: bool, + /// 0-based index of the column currently being filled. + pub(super) col_index: u8, + /// Index into `current_items` at which the current column's items begin, so + /// they can be shifted to the column's horizontal offset when it is finished. + pub(super) column_item_start: usize, + /// Index into `current_paragraphs` at which the current column's editing + /// data begins (parallel to `column_item_start`). + pub(super) column_para_start: usize, + /// Document comments (annotation bodies), looked up by id when laying out + /// the comment panel. Empty in nested flows (headers/footers, cells). + pub(super) comments: &'a [loki_doc_model::content::annotation::Comment], + /// Comment anchors (`id`, content-local `y`) recorded on the current page, + /// consumed by [`finish_page`] to lay out the gutter comment panel. + pub(super) pending_comment_anchors: Vec<(String, f32)>, } impl FlowState<'_> { @@ -208,6 +234,7 @@ fn new_flow_state<'a>( mode: &'a LayoutMode, display_scale: f32, options: &'a LayoutOptions, + comments: &'a [loki_doc_model::content::annotation::Comment], ) -> FlowState<'a> { let pl = §ion.layout; let page_w = pts_to_f32(pl.page_size.width); @@ -218,10 +245,24 @@ fn new_flow_state<'a>( bottom: pts_to_f32(pl.margins.bottom), left: pts_to_f32(pl.margins.left), }; - let content_width = match mode { + let full_content_width = match mode { LayoutMode::Reflow { available_width } => *available_width, _ => (page_w - margins.horizontal()).max(0.0), }; + // Multi-column layout is a paginated-print feature: divide the content area + // into `count` equal columns separated by `gap`. `content_width` then holds + // the per-column width and the flow fills each column top-to-bottom before + // advancing to the next (and finally the next page). Reflow/Pageless ignore + // columns (continuous scroll has no fixed page height to break columns at). + let (columns, column_gap, column_separator, content_width) = match &pl.columns { + Some(c) if c.count >= 2 && mode.is_paginated() => { + let n = f32::from(c.count); + let gap = pts_to_f32(c.gap); + let col_w = ((full_content_width - (n - 1.0) * gap) / n).max(0.0); + (c.count, gap, c.separator, col_w) + } + _ => (1, 0.0, false, full_content_width), + }; FlowState { resources, catalog, @@ -244,6 +285,14 @@ fn new_flow_state<'a>( pending_footnotes: Vec::new(), current_paragraphs: Vec::new(), checkpoints: Vec::new(), + columns, + column_gap, + column_separator, + col_index: 0, + column_item_start: 0, + column_para_start: 0, + comments, + pending_comment_anchors: Vec::new(), } } @@ -311,7 +360,17 @@ pub(crate) fn flow_section_resume( resync: impl FnMut(usize, &FlowCheckpoint) -> bool, ) -> crate::incremental::ResumedFlow { let mode = LayoutMode::Paginated; - let mut state = new_flow_state(resources, section, catalog, &mode, display_scale, options); + // Incremental resume does not render comment panels on reused pages; the + // full relayout path does. Pass an empty comment set here. + let mut state = new_flow_state( + resources, + section, + catalog, + &mode, + display_scale, + options, + &[], + ); state.page_number = seed.page_number; state.list_counters = seed.list_counters.clone(); state.prev_list_id = seed.prev_list_id.clone(); @@ -351,8 +410,17 @@ pub fn flow_section( mode: &LayoutMode, display_scale: f32, options: &LayoutOptions, + comments: &[loki_doc_model::content::annotation::Comment], ) -> FlowOutput { - let mut state = new_flow_state(resources, section, catalog, mode, display_scale, options); + let mut state = new_flow_state( + resources, + section, + catalog, + mode, + display_scale, + options, + comments, + ); if mode.is_paginated() { // Top-level paginated flow: record checkpoints, never resync. @@ -489,6 +557,17 @@ fn flow_block(state: &mut FlowState, block: &Block, idx: usize) { // ── Page management ─────────────────────────────────────────────────────────── pub(super) fn finish_page(state: &mut FlowState) { + // Position the final column's content, draw separators for the columns that + // were used, then reset the column tracker so the next page starts at 0. + columns_impl::position_current_column(state); + columns_impl::emit_column_separators(state); + state.col_index = 0; + state.column_item_start = 0; + state.column_para_start = 0; + + // Lay out the gutter comment panel for any comments anchored on this page. + let comment_items = comments_impl::layout_comment_panel(state); + let page = LayoutPage { page_number: state.page_number, page_size: state.page_size, @@ -496,6 +575,7 @@ pub(super) fn finish_page(state: &mut FlowState) { content_items: std::mem::take(&mut state.current_items), header_items: vec![], footer_items: vec![], + comment_items, header_height: 0.0, footer_height: 0.0, editing_data: if state.options.preserve_for_editing { @@ -550,6 +630,7 @@ fn layout_blocks_reflow( &mode, display_scale, &options, + &[], ) { FlowOutput::Canvas { items, height, .. } => (items, height), FlowOutput::Pages { .. } => unreachable!("Reflow mode always returns Canvas"), @@ -1088,6 +1169,16 @@ fn measure_cell_height( pending_footnotes: Vec::new(), current_paragraphs: Vec::new(), checkpoints: Vec::new(), + // Table cells are always laid out single-column. + columns: 1, + column_gap: 0.0, + column_separator: false, + col_index: 0, + column_item_start: 0, + column_para_start: 0, + // Cells never render the comment gutter panel. + comments: &[], + pending_comment_anchors: Vec::new(), }; for block in &cell.blocks { @@ -1204,6 +1295,16 @@ fn flow_cell_blocks( pending_footnotes: Vec::new(), current_paragraphs: Vec::new(), checkpoints: Vec::new(), + // Table cells are always laid out single-column. + columns: 1, + column_gap: 0.0, + column_separator: false, + col_index: 0, + column_item_start: 0, + column_para_start: 0, + // Cells never render the comment gutter panel. + comments: &[], + pending_comment_anchors: Vec::new(), }; for block in blocks { @@ -1299,7 +1400,9 @@ fn flow_table( if state.mode.is_paginated() { let remaining_h = state.page_content_height - state.cursor_y; if row_max_h > remaining_h && row_max_h <= state.page_content_height { - finish_page(state); + // A whole row that fits in a band but not the remaining space + // moves to the next column (or page). + columns_impl::break_column(state); } } diff --git a/loki-layout/src/flow_columns.rs b/loki-layout/src/flow_columns.rs new file mode 100644 index 00000000..9517665c --- /dev/null +++ b/loki-layout/src/flow_columns.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Multi-column flow helpers for the paginated engine. +//! +//! A section with `columns > 1` flows content down each column (a full-height +//! band of width [`FlowState::content_width`]) before advancing to the next +//! column, and only starts a new page once the last column is full. Items are +//! placed with x relative to the *current column's* left edge and shifted to the +//! column's absolute offset when the column is finished — see +//! [`position_current_column`]. + +use super::{FlowState, finish_page}; +use crate::color::LayoutColor; +use crate::geometry::LayoutRect; +use crate::items::{PositionedItem, PositionedRect}; + +/// Horizontal offset of column `state.col_index`'s left edge from the +/// content-area left. Column 0 is at offset 0; each later column is shifted by +/// one column width plus the gap. Returns 0 for single-column flows. +fn column_x_offset(state: &FlowState) -> f32 { + if state.columns <= 1 { + return 0.0; + } + f32::from(state.col_index) * (state.content_width + state.column_gap) +} + +/// Shifts the items and editing data placed in the current column (everything +/// from `column_item_start` / `column_para_start` onward) to that column's +/// horizontal offset. A no-op for column 0 and for single-column flows. +pub(super) fn position_current_column(state: &mut FlowState) { + let off = column_x_offset(state); + if off == 0.0 { + return; + } + for item in &mut state.current_items[state.column_item_start..] { + item.translate(off, 0.0); + } + for para in &mut state.current_paragraphs[state.column_para_start..] { + para.origin.0 += off; + } +} + +/// Called when the current column has run out of vertical space. Advances to the +/// next column on the same page when one remains; otherwise finishes the page. +/// +/// This is what mid-flow space-exhaustion uses (instead of [`finish_page`]) so +/// that text fills every column before a page break. Explicit page breaks +/// (`page_break_before`/`after`) still call [`finish_page`] directly to skip any +/// remaining columns. For single-column flows this is exactly [`finish_page`]. +pub(super) fn break_column(state: &mut FlowState) { + if state.columns > 1 && u16::from(state.col_index) + 1 < u16::from(state.columns) { + position_current_column(state); + state.col_index += 1; + state.column_item_start = state.current_items.len(); + state.column_para_start = state.current_paragraphs.len(); + state.cursor_y = 0.0; + } else { + finish_page(state); + } +} + +/// Emits a vertical separator line in each inter-column gap that has content on +/// this page (gaps `0..col_index`). Lines run the full content height; a no-op +/// unless the section requested separators and at least two columns were used. +pub(super) fn emit_column_separators(state: &mut FlowState) { + if state.columns <= 1 || !state.column_separator || state.col_index == 0 { + return; + } + const SEP_WIDTH: f32 = 0.75; + let col_w = state.content_width; + let height = state.page_content_height; + for gap_idx in 0..state.col_index { + let center = + f32::from(gap_idx) * (col_w + state.column_gap) + col_w + state.column_gap / 2.0; + state + .current_items + .push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(center - SEP_WIDTH / 2.0, 0.0, SEP_WIDTH, height), + color: LayoutColor::BLACK, + })); + } +} diff --git a/loki-layout/src/flow_comments.rs b/loki-layout/src/flow_comments.rs new file mode 100644 index 00000000..e08f2ac1 --- /dev/null +++ b/loki-layout/src/flow_comments.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Margin comment panel layout for the paginated engine. +//! +//! Each `Inline::Comment` start anchor records a (comment id, content-local y) +//! pair on the current page. When the page is finished, every anchored comment +//! is laid out as a card in a gutter to the right of the page: a tinted +//! background, the author line, then the comment body. Cards are stacked +//! top-to-bottom and pushed down so they never overlap. + +use loki_doc_model::NodeAttr; +use loki_doc_model::content::annotation::CommentRefKind; +use loki_doc_model::content::block::{Block, StyledParagraph}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::style::props::char_props::CharProps; +use loki_primitives::units::Points; + +use super::FlowState; +use crate::color::LayoutColor; +use crate::geometry::LayoutRect; +use crate::items::{PositionedItem, PositionedRect}; + +/// Gap between the page's right edge and the comment gutter. +const GUTTER_GAP: f32 = 12.0; +/// Width of the comment gutter (card area). Derived so the gap + width match +/// the public [`crate::COMMENT_GUTTER_WIDTH`] the host reserves. +const GUTTER_WIDTH: f32 = crate::COMMENT_GUTTER_WIDTH - GUTTER_GAP; +/// Inner padding inside each comment card. +const CARD_PADDING: f32 = 6.0; +/// Vertical gap between stacked cards. +const CARD_GAP: f32 = 8.0; + +/// Records the start anchors of any comments in `inlines` at the current cursor +/// position. Paginated mode only — the gutter panel is a print-layout feature. +pub(super) fn record_comment_anchors(state: &mut FlowState, inlines: &[Inline]) { + if !state.mode.is_paginated() || state.comments.is_empty() { + return; + } + for inline in inlines { + if let Inline::Comment(c) = inline + && c.kind == CommentRefKind::Start + { + state + .pending_comment_anchors + .push((c.id.clone(), state.cursor_y)); + } + } +} + +/// Lays out the comment cards for the anchors recorded on the page being +/// finished and returns the gutter items (page-local coordinates). Clears the +/// pending anchors. +pub(super) fn layout_comment_panel(state: &mut FlowState) -> Vec { + let anchors = std::mem::take(&mut state.pending_comment_anchors); + if anchors.is_empty() { + return Vec::new(); + } + let card_x = state.page_size.width + GUTTER_GAP; + let inner_width = (GUTTER_WIDTH - 2.0 * CARD_PADDING).max(1.0); + // Comment-card background tint (light yellow). + let card_fill = LayoutColor::new(1.0, 0.97, 0.80, 1.0); + + let mut items = Vec::new(); + // Next free y in content-local coordinates (cards stack downward). + let mut next_free_y = 0.0f32; + for (id, anchor_y) in anchors { + let Some(comment) = state.comments.iter().find(|c| c.id == id) else { + continue; + }; + + // Author line (bold) followed by the comment body. + let mut blocks: Vec = vec![Block::StyledPara(author_paragraph( + comment.author.as_deref().unwrap_or("Comment"), + ))]; + blocks.extend(comment.body.iter().cloned()); + + let (mut card_items, height) = super::layout_blocks_reflow( + state.resources, + &blocks, + state.catalog, + inner_width, + state.display_scale, + None, + ); + + // Stack: never above the anchor, never overlapping the previous card. + let top = anchor_y.max(next_free_y); + let page_top = top + state.margins.top; + + items.push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(card_x, page_top, GUTTER_WIDTH, height + 2.0 * CARD_PADDING), + color: card_fill, + })); + for item in &mut card_items { + item.translate(card_x + CARD_PADDING, page_top + CARD_PADDING); + } + items.append(&mut card_items); + + next_free_y = top + height + 2.0 * CARD_PADDING + CARD_GAP; + } + items +} + +/// Builds a small bold paragraph carrying the comment author's name. +fn author_paragraph(author: &str) -> StyledParagraph { + StyledParagraph { + style_id: None, + direct_para_props: None, + direct_char_props: Some(Box::new(CharProps { + bold: Some(true), + font_size: Some(Points::new(9.0)), + ..Default::default() + })), + inlines: vec![Inline::Str(author.to_string())], + attr: NodeAttr::default(), + } +} diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index 1723bc5c..6318c0db 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -59,6 +59,7 @@ use crate::para::{ParagraphLayout, ResolvedParaProps, format_list_marker, layout use crate::resolve::{emu_to_pt, flatten_paragraph, pts_to_f32, resolve_para_props}; use crate::result::PageParagraphData; +use super::columns_impl::break_column; use super::{FlowState, LayoutWarning, finish_page}; /// Maximum keep-with-next chain length before truncation (ADR 004 §4). @@ -144,6 +145,10 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc finish_page(state); } + // Record comment start anchors at the paragraph's top (on the final page, + // after any page break above) for the gutter comment panel. + super::comments_impl::record_comment_anchors(state, &effective_para.inlines); + let mut para_layout = layout_paragraph( state.resources, &text, @@ -246,10 +251,10 @@ pub(super) fn place_paragraph_layout( } else { let available = state.page_content_height - state.cursor_y; if needed > available && state.cursor_y > 0.0 { - finish_page(state); + break_column(state); state.cursor_y += resolved.space_before; } - // Fits on current (or freshly flushed) page. + // Fits on current (or freshly flushed) column/page. let dy = state.cursor_y; let dx = state.current_indent; if state.options.preserve_for_editing { @@ -343,7 +348,7 @@ pub(super) fn flow_keep_with_next_chain( let available = state.page_content_height - state.cursor_y; if total_h > available && state.cursor_y > 0.0 { - finish_page(state); + break_column(state); } place_chain_blocks(state, chain, start); @@ -405,6 +410,8 @@ fn build_chain_layouts<'s>( parley_layout: None, orig_to_clean: vec![0], clean_to_orig: vec![0], + indent_start: 0.0, + indent_hanging: 0.0, }, ) } @@ -465,7 +472,7 @@ fn place_chain_too_tall( ); if state.cursor_y > 0.0 { - finish_page(state); + break_column(state); } let consumed = last_fits - start + 1; @@ -560,9 +567,10 @@ fn split_and_place_loop( match split_k { None if state.cursor_y > 0.0 && !flushed_without_progress => { - // No lines of this fragment fit on the current page; flush and retry. - // Re-apply space_before on the fresh page (ADR 004 §3 retry). - finish_page(state); + // No lines of this fragment fit in the current column; advance to + // the next column (or page) and retry. Re-apply space_before on + // the fresh column (ADR 004 §3 retry). + break_column(state); state.cursor_y += resolved.space_before; flushed_without_progress = true; } @@ -605,7 +613,7 @@ fn split_and_place_loop( split_y, dx, ); - finish_page(state); + break_column(state); frag_start = split_y; flushed_without_progress = false; } @@ -621,7 +629,7 @@ fn split_and_place_loop( split_y, dx, ); - finish_page(state); + break_column(state); frag_start = split_y; flushed_without_progress = false; // space_before is NOT re-applied between split fragments; only diff --git a/loki-layout/src/flow_tests.rs b/loki-layout/src/flow_tests.rs index a933b13e..53ae987c 100644 --- a/loki-layout/src/flow_tests.rs +++ b/loki-layout/src/flow_tests.rs @@ -12,7 +12,7 @@ use loki_doc_model::content::table::col::{ColAlignment, ColSpec, ColWidth}; use loki_doc_model::content::table::core::{Table, TableBody, TableFoot, TableHead}; use loki_doc_model::content::table::row::{Cell, CellProps, Row}; use loki_doc_model::layout::Section; -use loki_doc_model::layout::page::{PageLayout, PageMargins, PageSize}; +use loki_doc_model::layout::page::{PageLayout, PageMargins, PageSize, SectionColumns}; use loki_doc_model::style::catalog::StyleCatalog; use loki_doc_model::style::list_style::{ BulletChar, LabelAlignment, ListId, ListLevel, ListLevelKind, ListStyle, NumberingScheme, @@ -40,6 +40,7 @@ fn flow_pageless( &LayoutMode::Pageless, 1.0, &LayoutOptions::default(), + &[], ) { FlowOutput::Canvas { items, @@ -64,6 +65,7 @@ fn flow_paginated( &LayoutMode::Paginated, 1.0, &LayoutOptions::default(), + &[], ) { FlowOutput::Pages { pages, warnings, .. @@ -116,8 +118,170 @@ fn tiny_layout() -> PageLayout { } } +/// Collects the x-origins of every glyph run in a page's content items. +fn glyph_x_origins(page: &crate::result::LayoutPage) -> Vec { + page.content_items + .iter() + .filter_map(|i| { + if let PositionedItem::GlyphRun(run) = i { + Some(run.origin.x) + } else { + None + } + }) + .collect() +} + // ── tests ───────────────────────────────────────────────────────────────────── +#[test] +fn comment_panel_renders_in_gutter() { + use loki_doc_model::content::annotation::{Comment, CommentRef, CommentRefKind}; + use loki_doc_model::content::block::Block; + + let mut r = test_resources(); + // A paragraph with a comment start anchor on it. + let para = Block::Para(vec![ + Inline::Str("Commented text".into()), + Inline::Comment(CommentRef::new("c1", CommentRefKind::Start)), + ]); + let section = Section::with_layout_and_blocks(tiny_layout(), vec![para]); + + let comments = vec![Comment::new("c1").with_plain_body("Fix this")]; + let catalog = StyleCatalog::new(); + + let FlowOutput::Pages { pages, .. } = flow_section( + &mut r, + §ion, + &catalog, + &LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + &comments, + ) else { + panic!("expected paginated pages"); + }; + + // The first page must carry a comment card in the gutter (x ≥ page width). + let card = pages[0].comment_items.iter().find_map(|i| { + if let PositionedItem::FilledRect(rect) = i { + (rect.rect.origin.x >= 200.0).then_some(rect.rect) + } else { + None + } + }); + assert!( + card.is_some(), + "expected a comment card past the page right edge; items: {}", + pages[0].comment_items.len() + ); + // The card must contain rendered text (author + body glyph runs). + let has_glyphs = pages[0] + .comment_items + .iter() + .any(|i| matches!(i, PositionedItem::GlyphRun(_))); + assert!(has_glyphs, "comment card should contain rendered text"); +} + +#[test] +fn no_comment_panel_without_anchors() { + let mut r = test_resources(); + let section = section_of(vec![make_para("Plain")], tiny_layout()); + let (pages, _) = flow_paginated(&mut r, §ion); + assert!( + pages.iter().all(|p| p.comment_items.is_empty()), + "no comment anchors ⇒ no comment panel" + ); +} + +#[test] +fn text_flows_down_columns_before_paging() { + let mut r = test_resources(); + // 12 short, left-aligned paragraphs: each line starts at the column's left + // edge, so a run's x-origin reveals which column it landed in. + let paras: Vec<_> = (0..12).map(|i| make_para(&format!("Line {i}"))).collect(); + + // tiny_layout: 200×100 pt, L/R margin 10 → content width 180, height 90. + // Two columns with an 18 pt gap ⇒ column width (180−18)/2 = 81, so the + // second column's left edge sits at x = 81 + 18 = 99 (content-local). + let two_col = PageLayout { + columns: Some(SectionColumns { + count: 2, + gap: Points::new(18.0), + separator: false, + }), + ..tiny_layout() + }; + let (col_pages, _) = flow_paginated(&mut r, §ion_of(paras.clone(), two_col)); + + // The same content single-column needs more pages (each column holds only + // ~90 pt; two columns roughly double per-page capacity). + let (plain_pages, _) = flow_paginated(&mut r, §ion_of(paras, tiny_layout())); + + assert!( + col_pages.len() < plain_pages.len(), + "two columns must fit more per page: {} cols vs {} plain", + col_pages.len(), + plain_pages.len() + ); + + // The first page must actually use the second column band (x ≥ 99) as well + // as the first (x near 0). + let xs = glyph_x_origins(&col_pages[0]); + assert!( + xs.iter().any(|&x| x < 50.0), + "first column (x≈0) must be used: {xs:?}" + ); + assert!( + xs.iter().any(|&x| x >= 99.0), + "second column (x≥99) must be used: {xs:?}" + ); +} + +#[test] +fn column_separator_line_is_drawn() { + let mut r = test_resources(); + let paras: Vec<_> = (0..12).map(|i| make_para(&format!("Line {i}"))).collect(); + let with_sep = PageLayout { + columns: Some(SectionColumns { + count: 2, + gap: Points::new(18.0), + separator: true, + }), + ..tiny_layout() + }; + let (pages, _) = flow_paginated(&mut r, §ion_of(paras, with_sep)); + + // A thin full-height FilledRect must sit in the gap centre (x ≈ 81 + 9 = 90). + let sep = pages[0].content_items.iter().find_map(|i| { + if let PositionedItem::FilledRect(r) = i { + let cx = r.rect.origin.x + r.rect.size.width / 2.0; + ((cx - 90.0).abs() < 1.0 && r.rect.size.height > 80.0).then_some(r.rect) + } else { + None + } + }); + assert!( + sep.is_some(), + "expected a full-height separator near x=90; items: {:?}", + pages[0].content_items.len() + ); +} + +#[test] +fn single_column_keeps_content_in_one_band() { + let mut r = test_resources(); + let paras: Vec<_> = (0..12).map(|i| make_para(&format!("Line {i}"))).collect(); + let (pages, _) = flow_paginated(&mut r, §ion_of(paras, tiny_layout())); + // No column layout: every run starts near the left margin (x≈0); nothing is + // shifted into a second band. + for page in &pages { + for x in glyph_x_origins(page) { + assert!(x < 50.0, "single-column run unexpectedly shifted to x={x}"); + } + } +} + #[test] fn continuous_cursor_advances() { let mut r = test_resources(); @@ -748,6 +912,7 @@ fn flow_with_catalog( &LayoutMode::Pageless, 1.0, &LayoutOptions::default(), + &[], ) { FlowOutput::Canvas { items, diff --git a/loki-layout/src/font.rs b/loki-layout/src/font.rs index 27ede532..45ebb908 100644 --- a/loki-layout/src/font.rs +++ b/loki-layout/src/font.rs @@ -59,6 +59,20 @@ impl FontResources { font_cx.collection.load_fonts_from_paths(vec![assets_fonts]); } } + + // Android: the executable-relative `assets/fonts/` path above does not + // resolve, so register the bundled metric-compatible fallbacks + // (Carlito/Caladea/Arimo/Cousine/Tinos) directly from embedded bytes. + // Without this a document's Calibri/Arial/Times text — which + // `resolve_font_name` substitutes to those families — would not be found + // in the collection and would fall back to an Android system font with + // different glyph metrics (e.g. wider digit advances). + #[cfg(target_os = "android")] + for blob in loki_fonts::fallback_font_blobs() { + font_cx + .collection + .register_fonts(parley::fontique::Blob::from(blob.to_vec()), None); + } tracing::info!( target: "loki_text::open", elapsed_ms = started.elapsed().as_secs_f64() * 1000.0, @@ -81,6 +95,24 @@ impl FontResources { self.para_cache.clear(); } + /// Returns the names of every font family available for layout — the + /// scanned system fonts plus any bundled or document-embedded faces — sorted + /// alphabetically (case-insensitive) and de-duplicated. + /// + /// Used by the style editor's font picker. Requires `&mut self` because + /// Fontique populates its family index lazily. + pub fn available_font_families(&mut self) -> Vec { + let mut names: Vec = self + .font_cx + .collection + .family_names() + .map(|s| s.to_string()) + .collect(); + names.sort_by_key(|s| s.to_lowercase()); + names.dedup(); + names + } + /// Registers additional font data (e.g. fonts embedded in the document). /// /// `data` must be valid font bytes (TTF / OTF / TTC). The font is added diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index a83aec9c..258ca7e2 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 math; pub mod mode; pub mod para; mod para_cache; @@ -65,6 +66,12 @@ pub use result::{ /// Minimum table row height in points. pub const MIN_ROW_HEIGHT: f32 = 0.0; +/// Total width (points) reserved to the right of the page for the comment +/// gutter panel (gap + card width). Hosts widen the scrollable/canvas area by +/// this much when a paginated layout contains comment items, so the panel is +/// reachable. See [`result::LayoutPage::comment_items`]. +pub const COMMENT_GUTTER_WIDTH: f32 = 192.0; + /// Options that control the layout pipeline's memory / feature trade-offs. /// /// Pass to [`layout_document`] or [`flow_section`]. The default (all fields @@ -115,6 +122,10 @@ pub fn layout_document( let mut all_paragraphs = Vec::new(); let mut total_height = 0.0; let mut max_width: f32 = 0.0; + // Running base so block indices are *global* (document order across + // every section), matching the index space the editor and the + // `loro_mutation` layer use to address blocks. + let mut block_base = 0usize; for section in &doc.sections { let FlowOutput::Canvas { @@ -129,6 +140,7 @@ pub fn layout_document( &mode, display_scale, options, + &doc.comments, ) else { unreachable!("flow_section in non-paginated mode always returns Canvas"); @@ -140,10 +152,12 @@ pub fn layout_document( } for para in &mut paragraphs { para.origin.1 += total_height; + para.block_index += block_base; } all_items.extend(items); all_paragraphs.extend(paragraphs); total_height += height; + block_base += section.blocks.len(); let pl = §ion.layout; let page_w = pts_to_f32(pl.page_size.width); @@ -179,6 +193,9 @@ pub fn layout_paginated_full( ) -> (PaginatedLayout, PaginatedReuse) { let mode = LayoutMode::Paginated; let mut global_page_count = 0; + // Running base so editing block indices are global across sections (see the + // continuous path and the `loro_mutation` resolver). + let mut block_base = 0usize; let mut first_page_size = None; let mut checkpoints: Vec = Vec::new(); @@ -206,6 +223,7 @@ pub fn layout_paginated_full( &mode, display_scale, options, + &doc.comments, ) else { unreachable!("flow_section in Paginated mode always returns Pages"); @@ -216,6 +234,13 @@ pub fn layout_paginated_full( let section_page_count = pages.len(); for page in &mut pages { page.page_number += global_page_count; + // Globalise editing block indices across sections so hit-test / + // cursor positions resolve to the right section's block. + if let Some(ed) = page.editing_data.as_mut() { + for para in &mut ed.paragraphs { + para.block_index += block_base; + } + } } // Lift the section-local checkpoints to document-global: tag the section // and offset page_index by the running page count (page_number inside @@ -228,6 +253,7 @@ pub fn layout_paginated_full( flowed.push((section, pages)); global_page_count += section_page_count; + block_base += section.blocks.len(); } // Pass 2: headers/footers, with the document-wide page total available for diff --git a/loki-layout/src/math/compose.rs b/loki-layout/src/math/compose.rs new file mode 100644 index 00000000..b670bfe5 --- /dev/null +++ b/loki-layout/src/math/compose.rs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Box composition for the math typesetter. +//! +//! Combines the baseline-relative [`MBox`]es produced by token shaping into +//! the structural MathML constructs: horizontal rows, fractions, scripts, and +//! radicals. Spacing constants follow TeX-ish proportions of the current font +//! size; they are deliberately simple (this is a first-pass typesetter, not a +//! full TeX math engine). + +use super::MBox; +use super::shape; +use crate::color::LayoutColor; +use crate::font::FontResources; +use crate::geometry::LayoutRect; +use crate::items::{PositionedItem, PositionedRect}; + +/// Lays out `boxes` left-to-right on a shared baseline, inserting `gap` between +/// adjacent boxes. The result's ascent/descent are the maxima of its parts. +pub(super) fn hbox(boxes: Vec<(MBox, f32)>) -> MBox { + let mut out = MBox::empty(); + let mut x = 0.0f32; + for (i, (mut b, gap)) in boxes.into_iter().enumerate() { + if i > 0 { + x += gap; + } + b.translate(x, 0.0); + out.ascent = out.ascent.max(b.ascent); + out.descent = out.descent.max(b.descent); + x += b.width; + out.items.extend(b.items); + } + out.width = x; + out +} + +/// Stacks `num` over `den` separated by a fraction bar (``). The bar is +/// centred on the math axis (≈ ¼ em above the baseline). +pub(super) fn frac(num: MBox, den: MBox, font_size: f32, color: LayoutColor) -> MBox { + let rule = (font_size * 0.055).max(0.6); + let axis = font_size * 0.25; + let gap = font_size * 0.18; + let pad = font_size * 0.15; + let width = num.width.max(den.width) + 2.0 * pad; + let bar_y = -axis; + + let mut num = num; + let mut den = den; + num.translate((width - num.width) / 2.0, bar_y - gap - num.descent); + den.translate((width - den.width) / 2.0, bar_y + gap + den.ascent); + + let mut items = vec![PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(0.0, bar_y - rule / 2.0, width, rule), + color, + })]; + let ascent = axis + gap + num.ascent + num.descent; + let descent = (den.ascent + den.descent + gap - axis).max(0.0); + items.extend(num.items); + items.extend(den.items); + MBox { + width, + ascent, + descent, + items, + } +} + +/// Attaches an optional super- and/or subscript to `base` +/// (``/``/``). Scripts are placed to the right of the base. +pub(super) fn scripts(base: MBox, sup: Option, sub: Option, font_size: f32) -> MBox { + let sup_shift = font_size * 0.45; + let sub_shift = font_size * 0.18; + let script_x = base.width; + + let mut width = base.width; + let mut ascent = base.ascent; + let mut descent = base.descent; + let mut items = base.items; + + if let Some(mut s) = sup { + s.translate(script_x, -sup_shift); + ascent = ascent.max(sup_shift + s.ascent); + width = width.max(script_x + s.width); + items.extend(s.items); + } + if let Some(mut s) = sub { + s.translate(script_x, sub_shift); + descent = descent.max(sub_shift + s.descent); + width = width.max(script_x + s.width); + items.extend(s.items); + } + + MBox { + width, + ascent, + descent, + items, + } +} + +/// Wraps `radicand` in a radical sign with an overbar (``/``). +/// +/// The surd is the `√` glyph **stretched** (via uniform scaling) so it spans the +/// radicand from the overbar down to the radicand's depth; the overbar is a rule +/// drawn across the top of the radicand. An optional `index` (for ``) is +/// placed above the surd. +pub(super) fn radical( + resources: &mut FontResources, + radicand: MBox, + index: Option, + font_size: f32, + color: LayoutColor, + display_scale: f32, +) -> MBox { + let rule = (font_size * 0.055).max(0.6); + let gap = font_size * 0.12; + + // Overbar sits `gap` above the radicand's top edge; the surd is scaled to + // reach from there down to the radicand's foot. + let bar_y = -(radicand.ascent + gap) - rule; + let target = (radicand.ascent + radicand.descent + gap).max(font_size); + let mut surd = shape::stretchy_glyph( + resources, + "\u{221A}", + font_size, + target, + color, + display_scale, + ); + // Lower the surd so its top reaches the overbar line. + surd.shift_v(bar_y + surd.ascent); + + let mut items: Vec = Vec::new(); + let mut x = 0.0f32; + + // Optional degree index, raised and to the left of the surd. + if let Some(mut idx) = index { + idx.shift_v(bar_y * 0.6); + idx.translate(x, 0.0); + x += idx.width; + items.extend(idx.items); + } + + surd.translate(x, 0.0); + let surd_right = x + surd.width; + let surd_ascent = surd.ascent; + let surd_descent = surd.descent; + items.extend(surd.items); + + let mut radicand = radicand; + radicand.translate(surd_right, 0.0); + let width = surd_right + radicand.width; + let rad_ascent = radicand.ascent; + let rad_descent = radicand.descent; + items.extend(radicand.items); + + items.push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(surd_right, bar_y, width - surd_right, rule), + color, + })); + + let ascent = (rad_ascent + gap + rule).max(surd_ascent).max(-bar_y); + let descent = rad_descent.max(surd_descent); + MBox { + width, + ascent, + descent, + items, + } +} + +/// Wraps `content` in stretchy `open`/`close` delimiter glyphs, each scaled to +/// the content height and vertically centred on the content's mid-line. +pub(super) fn delimiters( + resources: &mut FontResources, + open: &str, + content: MBox, + close: &str, + font_size: f32, + color: LayoutColor, + display_scale: f32, +) -> MBox { + let pad = font_size * 0.1; + let target = content.ascent + content.descent + pad; + // Content's vertical centre (y-down: negative is above the baseline). + let center = (content.descent - content.ascent) / 2.0; + let gap = font_size * 0.08; + + let mut left = shape::stretchy_glyph(resources, open, font_size, target, color, display_scale); + let mut right = + shape::stretchy_glyph(resources, close, font_size, target, color, display_scale); + left.shift_v(center - (left.descent - left.ascent) / 2.0); + right.shift_v(center - (right.descent - right.ascent) / 2.0); + + hbox(vec![(left, 0.0), (content, gap), (right, gap)]) +} diff --git a/loki-layout/src/math/math_tests.rs b/loki-layout/src/math/math_tests.rs new file mode 100644 index 00000000..fc196575 --- /dev/null +++ b/loki-layout/src/math/math_tests.rs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for the math typesetter. These assert structural layout properties +//! (box metrics, rule presence) rather than exact glyph positions, which depend +//! on the system font available at test time. + +use super::layout_math; +use crate::color::LayoutColor; +use crate::font::FontResources; +use crate::items::PositionedItem; + +const NS: &str = "http://www.w3.org/1998/Math/MathML"; + +fn render(mathml: &str) -> super::MathRender { + let mut fr = FontResources::new(); + layout_math(&mut fr, mathml, 12.0, LayoutColor::BLACK, 1.0) +} + +fn count_glyph_runs(items: &[PositionedItem]) -> usize { + items + .iter() + .filter(|i| matches!(i, PositionedItem::GlyphRun(_))) + .count() +} + +fn count_rects(items: &[PositionedItem]) -> usize { + items + .iter() + .filter(|i| matches!(i, PositionedItem::FilledRect(_))) + .count() +} + +/// Largest font size used by any glyph run — a proxy for how much a delimiter or +/// surd has been stretched (stretched glyphs are shaped at a larger size). +fn max_glyph_font_size(items: &[PositionedItem]) -> f32 { + items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(r) => Some(r.font_size), + _ => None, + }) + .fold(0.0, f32::max) +} + +#[test] +fn empty_or_invalid_is_zero() { + let r = render("not math at all"); + assert_eq!(r.width, 0.0); + assert!(r.items.is_empty()); +} + +#[test] +fn single_identifier_has_extent() { + let r = render(&format!("x")); + assert!(r.width > 0.0, "identifier should have width"); + assert!(r.ascent > 0.0, "identifier should have ascent"); + assert_eq!(count_glyph_runs(&r.items), 1); +} + +#[test] +fn fraction_draws_a_bar_and_two_operands() { + let r = render(&format!( + "12" + )); + // One rule (the fraction bar) plus a glyph run for each of 1 and 2. + assert_eq!(count_rects(&r.items), 1, "fraction bar rule"); + assert_eq!(count_glyph_runs(&r.items), 2, "numerator and denominator"); + // A fraction is taller than a single digit: ascent + descent exceed the + // numerator's own height. + assert!(r.ascent > 0.0 && r.descent > 0.0); +} + +#[test] +fn superscript_is_raised_and_narrower() { + let base = render(&format!("x")); + let sup = render(&format!( + "x2" + )); + // Superscript adds a (smaller) glyph and increases the box ascent. + assert_eq!(count_glyph_runs(&sup.items), 2); + assert!(sup.ascent >= base.ascent); + assert!(sup.width > base.width); +} + +#[test] +fn square_root_has_surd_and_overbar() { + let r = render(&format!( + "x" + )); + // The surd glyph + the radicand glyph, plus an overbar rule. + assert_eq!(count_rects(&r.items), 1, "overbar rule"); + assert!(count_glyph_runs(&r.items) >= 2, "surd and radicand"); + assert!(r.width > 0.0); +} + +#[test] +fn nth_root_adds_an_index_glyph() { + let sqrt = render(&format!( + "x" + )); + let root = render(&format!( + "x3" + )); + // The index contributes an extra glyph run and widens the box. + assert!(count_glyph_runs(&root.items) > count_glyph_runs(&sqrt.items)); + assert!(root.width > sqrt.width); +} + +#[test] +fn radical_stretches_to_a_tall_radicand() { + let short = render(&format!( + "x" + )); + let tall = render(&format!( + "12" + )); + assert!(tall.ascent > short.ascent, "tall radicand → taller box"); + // The surd over the fraction is scaled up noticeably more than the surd over + // a single glyph. + assert!( + max_glyph_font_size(&tall.items) > max_glyph_font_size(&short.items) + 2.0, + "surd should stretch further for a tall radicand ({} vs {})", + max_glyph_font_size(&tall.items), + max_glyph_font_size(&short.items), + ); +} + +#[test] +fn delimiters_stretch_around_tall_content() { + let bare = render(&format!( + "12" + )); + let fenced = render(&format!( + "(\ + 12)" + )); + assert!(fenced.width > bare.width, "delimiters add width"); + assert!( + max_glyph_font_size(&fenced.items) > 12.5, + "parentheses should be stretched larger than the base size" + ); +} + +#[test] +fn baseline_is_at_ascent() { + // After layout the baseline sits at y = ascent: every glyph run origin must + // be within the box's vertical extent [0, ascent + descent]. + let r = render(&format!( + "x2" + )); + let total = r.ascent + r.descent; + for item in &r.items { + if let PositionedItem::GlyphRun(run) = item { + assert!( + run.origin.y >= -0.01 && run.origin.y <= total + 0.01, + "glyph baseline {} outside [0, {}]", + run.origin.y, + total + ); + } + } +} diff --git a/loki-layout/src/math/mod.rs b/loki-layout/src/math/mod.rs new file mode 100644 index 00000000..212ce3dd --- /dev/null +++ b/loki-layout/src/math/mod.rs @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! A small math typesetter that lays out MathML into positioned draw items. +//! +//! The document model stores math as a MathML string +//! (`loki_doc_model::content::inline::Inline::Math`). [`layout_math`] parses it +//! and produces a [`MathRender`] — a box of [`PositionedItem`]s (shaped glyph +//! runs plus fraction/radical rules) with intrinsic metrics — which +//! [`crate::para`] places inline via a Parley inline box. +//! +//! # Covered constructs +//! +//! Identifiers/numbers/operators (`mi`/`mn`/`mo`/`mtext`), rows (`mrow`), +//! fractions (`mfrac`), scripts (`msup`/`msub`/`msubsup`), radicals +//! (`msqrt`/`mroot`), and fenced expressions (`mfenced`, or a row wrapped in +//! matching fence operators). Radical signs and delimiters **stretch** to their +//! content via uniform glyph scaling (an approximation of true extensible +//! glyphs — the sign also widens). Unknown elements lay out their children as a +//! row. This is still a first-pass typesetter: it does not balance spacing per +//! the full TeX `mathspacing` table, or handle matrices / n-ary operators. + +mod compose; +mod parse; +mod shape; + +use crate::color::LayoutColor; +use crate::font::FontResources; +use crate::items::PositionedItem; +use parse::{MNode, parse_mathml}; + +/// A laid-out math expression in its own coordinate box. +/// +/// `items` are positioned relative to the box's top-left corner `(0, 0)`; the +/// expression's baseline sits at `y = ascent`. `width`/`ascent`/`descent` give +/// the box metrics so the caller can place it inline on the text baseline. +pub(crate) struct MathRender { + /// Total advance width in points. + pub width: f32, + /// Height above the baseline in points. + pub ascent: f32, + /// Depth below the baseline in points. + pub descent: f32, + /// Draw items, relative to the box top-left (`baseline = ascent`). + pub items: Vec, +} + +/// Lays out a MathML string at `font_size`, returning a [`MathRender`]. +/// +/// Returns an empty render (zero width, no items) when the string has no +/// usable `math` content. +pub(crate) fn layout_math( + resources: &mut FontResources, + mathml: &str, + font_size: f32, + color: LayoutColor, + display_scale: f32, +) -> MathRender { + let Some(root) = parse_mathml(mathml) else { + return MathRender { + width: 0.0, + ascent: 0.0, + descent: 0.0, + items: Vec::new(), + }; + }; + let mut mb = layout_node(resources, &root, font_size, color, display_scale); + // Shift the baseline (currently y = 0) down to y = ascent so the box uses + // top-left origin coordinates. + let dy = mb.ascent; + mb.translate(0.0, dy); + MathRender { + width: mb.width, + ascent: mb.ascent, + descent: mb.descent, + items: mb.items, + } +} + +/// A baseline-relative math box: draw items with `y = 0` on the baseline, +/// positive `ascent` above and positive `descent` below. +pub(super) struct MBox { + pub width: f32, + pub ascent: f32, + pub descent: f32, + pub items: Vec, +} + +impl MBox { + fn empty() -> Self { + Self { + width: 0.0, + ascent: 0.0, + descent: 0.0, + items: Vec::new(), + } + } + + /// Translates every draw item by `(dx, dy)`. + fn translate(&mut self, dx: f32, dy: f32) { + for item in &mut self.items { + item.translate(dx, dy); + } + } + + /// Shifts the box vertically by `dy` (positive = down), keeping its + /// ascent/descent consistent with the new position relative to the baseline. + fn shift_v(&mut self, dy: f32) { + self.translate(0.0, dy); + self.ascent -= dy; + self.descent += dy; + } +} + +/// Scale factor applied to script (super/sub/index) and nested content. +const SCRIPT_SCALE: f32 = 0.7; + +/// Lays out one MathML node into a baseline-relative [`MBox`]. +fn layout_node( + resources: &mut FontResources, + node: &MNode, + font_size: f32, + color: LayoutColor, + scale: f32, +) -> MBox { + let child = |i: usize| node.children.get(i); + let small = font_size * SCRIPT_SCALE; + match node.tag.as_str() { + "mi" => shape::shape_token( + resources, + &node.text, + font_size, + is_italic_identifier(&node.text), + color, + scale, + ), + "mn" | "mtext" | "mo" | "ms" => { + shape::shape_token(resources, &node.text, font_size, false, color, scale) + } + "mfrac" => { + let num = opt_node(resources, child(0), font_size, color, scale); + let den = opt_node(resources, child(1), font_size, color, scale); + compose::frac(num, den, font_size, color) + } + "msup" => { + let base = opt_node(resources, child(0), font_size, color, scale); + let sup = child(1).map(|c| layout_node(resources, c, small, color, scale)); + compose::scripts(base, sup, None, font_size) + } + "msub" => { + let base = opt_node(resources, child(0), font_size, color, scale); + let sub = child(1).map(|c| layout_node(resources, c, small, color, scale)); + compose::scripts(base, None, sub, font_size) + } + "msubsup" => { + let base = opt_node(resources, child(0), font_size, color, scale); + let sub = child(1).map(|c| layout_node(resources, c, small, color, scale)); + let sup = child(2).map(|c| layout_node(resources, c, small, color, scale)); + compose::scripts(base, sup, sub, font_size) + } + "msqrt" => { + let radicand = row(resources, &node.children, font_size, color, scale); + compose::radical(resources, radicand, None, font_size, color, scale) + } + "mroot" => { + let radicand = opt_node(resources, child(0), font_size, color, scale); + let index = child(1).map(|c| layout_node(resources, c, small, color, scale)); + compose::radical(resources, radicand, index, font_size, color, scale) + } + // A fenced expression: lay out the children, then wrap in stretchy + // delimiters. `` open/close attributes are not retained by the + // parser, so the default parentheses are used. + "mfenced" => { + let content = row(resources, &node.children, font_size, color, scale); + compose::delimiters(resources, "(", content, ")", font_size, color, scale) + } + // math, mrow, mstyle, semantics, mpadded, and unknowns: lay out children + // as a horizontal row. + _ => row(resources, &node.children, font_size, color, scale), + } +} + +/// Lays out `child` if present, else an empty box. +fn opt_node( + resources: &mut FontResources, + child: Option<&MNode>, + font_size: f32, + color: LayoutColor, + scale: f32, +) -> MBox { + match child { + Some(c) => layout_node(resources, c, font_size, color, scale), + None => MBox::empty(), + } +} + +/// Lays out a sequence of nodes as a horizontal row, adding operator spacing +/// around `` atoms. +fn row( + resources: &mut FontResources, + nodes: &[MNode], + font_size: f32, + color: LayoutColor, + scale: f32, +) -> MBox { + // Stretchy delimiters: a row that opens and closes with matching fence + // operators is laid out as its inner content wrapped in delimiters scaled to + // that content's height. + if nodes.len() >= 2 { + let (first, last) = (&nodes[0], &nodes[nodes.len() - 1]); + if first.tag == "mo" + && last.tag == "mo" + && is_open_fence(&first.text) + && is_close_fence(&last.text) + { + let content = row( + resources, + &nodes[1..nodes.len() - 1], + font_size, + color, + scale, + ); + return compose::delimiters( + resources, + &first.text, + content, + &last.text, + font_size, + color, + scale, + ); + } + } + + let op_gap = font_size * 0.17; + let mut boxes: Vec<(MBox, f32)> = Vec::new(); + let mut prev_mo = false; + for node in nodes { + let b = layout_node(resources, node, font_size, color, scale); + if b.width == 0.0 && b.items.is_empty() { + continue; + } + let is_mo = node.tag == "mo"; + let gap = if (is_mo || prev_mo) && !boxes.is_empty() { + op_gap + } else { + 0.0 + }; + boxes.push((b, gap)); + prev_mo = is_mo; + } + compose::hbox(boxes) +} + +/// MathML convention: a single-letter identifier is rendered italic, a +/// multi-letter one (e.g. a function name like `sin`) upright. +fn is_italic_identifier(text: &str) -> bool { + let mut chars = text.chars(); + matches!((chars.next(), chars.next()), (Some(c), None) if c.is_alphabetic()) +} + +/// Whether `s` is a single opening fence character (paren/bracket/brace/bar or +/// the floor/ceiling/angle openers) that should stretch to its content. +fn is_open_fence(s: &str) -> bool { + matches!( + s, + "(" | "[" | "{" | "|" | "\u{2016}" | "\u{230A}" | "\u{2308}" | "\u{27E8}" | "\u{2329}" + ) +} + +/// Whether `s` is a single closing fence character. +fn is_close_fence(s: &str) -> bool { + matches!( + s, + ")" | "]" | "}" | "|" | "\u{2016}" | "\u{230B}" | "\u{2309}" | "\u{27E9}" | "\u{232A}" + ) +} + +#[cfg(test)] +#[path = "math_tests.rs"] +mod tests; diff --git a/loki-layout/src/math/parse.rs b/loki-layout/src/math/parse.rs new file mode 100644 index 00000000..6ec3621c --- /dev/null +++ b/loki-layout/src/math/parse.rs @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Minimal MathML parser for the layout engine. +//! +//! Parses the model's canonical MathML string (see +//! `loki_doc_model::content::inline::Inline::Math`) into a lightweight element +//! tree. A hand-rolled scanner is used instead of pulling an XML crate into the +//! otherwise dependency-light `loki-layout`; the input is always well-formed, +//! compact MathML produced by the importers. Only structure and token text are +//! retained — attributes are skipped. + +/// A parsed MathML element. +#[derive(Debug, Clone, Default, PartialEq)] +pub(super) struct MNode { + /// Local element name (prefix stripped), e.g. `"mfrac"`, `"mi"`. + pub tag: String, + /// Character data for token elements (`mi`/`mn`/`mo`/`mtext`). + pub text: String, + /// Child elements, in document order. + pub children: Vec, +} + +/// Parses a MathML string, returning the first element (the `` root), or +/// `None` if there is no element. +pub(super) fn parse_mathml(s: &str) -> Option { + let mut p = Parser { + b: s.as_bytes(), + pos: 0, + }; + p.next_element() +} + +struct Parser<'a> { + b: &'a [u8], + pos: usize, +} + +impl Parser<'_> { + /// Scans forward to the next element start and parses it, skipping XML + /// declarations (``) and comments/doctypes (``). + fn next_element(&mut self) -> Option { + loop { + while self.pos < self.b.len() && self.b[self.pos] != b'<' { + self.pos += 1; + } + if self.pos >= self.b.len() { + return None; + } + match self.b.get(self.pos + 1) { + Some(b'?') | Some(b'!') => { + self.skip_to_gt(); + } + _ => return self.parse_element(), + } + } + } + + /// Parses an element whose `<` is at `self.pos`. + fn parse_element(&mut self) -> Option { + self.pos += 1; // consume '<' + let name = self.read_name(); + let self_closing = self.skip_attrs(); + let mut node = MNode { + tag: local(&name), + ..Default::default() + }; + if self_closing { + return Some(node); + } + loop { + let text = self.read_text(); + if !text.is_empty() { + node.text.push_str(&unescape(&text)); + } + if self.pos >= self.b.len() { + break; + } + // At '<': either a closing tag or a child element. + if self.b.get(self.pos + 1) == Some(&b'/') { + self.skip_to_gt(); + break; + } + match self.parse_element() { + Some(child) => node.children.push(child), + None => break, + } + } + Some(node) + } + + /// Reads an element/attribute name (until whitespace, `/`, or `>`). + fn read_name(&mut self) -> String { + let start = self.pos; + while self.pos < self.b.len() { + match self.b[self.pos] { + b' ' | b'\t' | b'\n' | b'\r' | b'/' | b'>' => break, + _ => self.pos += 1, + } + } + String::from_utf8_lossy(&self.b[start..self.pos]).into_owned() + } + + /// Skips attributes up to the end of the start tag. Returns `true` for a + /// self-closing (`/>`) tag. Handles quoted attribute values. + fn skip_attrs(&mut self) -> bool { + let mut quote: Option = None; + while self.pos < self.b.len() { + let c = self.b[self.pos]; + match quote { + Some(q) => { + if c == q { + quote = None; + } + } + None => match c { + b'"' | b'\'' => quote = Some(c), + b'>' => { + self.pos += 1; + return false; + } + b'/' if self.b.get(self.pos + 1) == Some(&b'>') => { + self.pos += 2; + return true; + } + _ => {} + }, + } + self.pos += 1; + } + false + } + + /// Reads character data up to the next `<` (or end of input). + fn read_text(&mut self) -> String { + let start = self.pos; + while self.pos < self.b.len() && self.b[self.pos] != b'<' { + self.pos += 1; + } + String::from_utf8_lossy(&self.b[start..self.pos]).into_owned() + } + + /// Skips to just past the next `>`. + fn skip_to_gt(&mut self) { + while self.pos < self.b.len() && self.b[self.pos] != b'>' { + self.pos += 1; + } + if self.pos < self.b.len() { + self.pos += 1; + } + } +} + +/// Strips a namespace prefix from a qualified name. +fn local(name: &str) -> String { + name.rsplit(':').next().unwrap_or(name).to_string() +} + +/// Decodes the five XML predefined entities in token text. +fn unescape(s: &str) -> String { + if !s.contains('&') { + return s.to_string(); + } + s.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} diff --git a/loki-layout/src/math/shape.rs b/loki-layout/src/math/shape.rs new file mode 100644 index 00000000..9687b20a --- /dev/null +++ b/loki-layout/src/math/shape.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Token shaping for the math typesetter. +//! +//! Each MathML token (`mi`/`mn`/`mo`/`mtext`) is shaped as a one-off Parley +//! layout so its glyphs and metrics can be composed manually by +//! [`super::compose`]. The extracted glyph run mirrors the main paragraph +//! extraction in [`crate::para`], but the run origin is placed on the box +//! baseline (`y = 0`) so composition can stack and shift boxes freely. + +use std::sync::Arc; + +use parley::{FontStyle, PositionedLayoutItem, StyleProperty}; + +use super::MBox; +use crate::color::LayoutColor; +use crate::font::FontResources; +use crate::geometry::LayoutPoint; +use crate::items::{GlyphEntry, GlyphSynthesis, PositionedGlyphRun, PositionedItem}; + +/// Shapes `text` at `font_size` into a baseline-relative [`MBox`] (a single +/// glyph atom). `italic` selects the italic face (used for identifiers). +pub(super) fn shape_token( + resources: &mut FontResources, + text: &str, + font_size: f32, + italic: bool, + color: LayoutColor, + display_scale: f32, +) -> MBox { + if text.is_empty() { + return MBox::empty(); + } + + let mut builder = + resources + .layout_cx + .ranged_builder(&mut resources.font_cx, text, display_scale, true); + builder.push_default(StyleProperty::Brush(color)); + builder.push_default(StyleProperty::FontSize(font_size)); + if italic { + builder.push_default(StyleProperty::FontStyle(FontStyle::Italic)); + } + let mut layout = builder.build(text); + layout.break_all_lines(None); + + let width = layout.width(); + let mut ascent = 0.0f32; + let mut descent = 0.0f32; + let mut items: Vec = Vec::new(); + + if let Some(line) = layout.lines().next() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + let run = glyph_run.run(); + let metrics = run.metrics(); + ascent = ascent.max(metrics.ascent); + descent = descent.max(metrics.descent); + + let run_offset = glyph_run.offset(); + let run_baseline = glyph_run.baseline(); + let raw: &[u8] = run.font().data.data(); + let font_data = resources + .font_data_cache + .entry(raw.as_ptr() as u64) + .or_insert_with(|| Arc::new(raw.to_vec())) + .clone(); + let synthesis = run.synthesis(); + let glyphs: Vec = glyph_run + .positioned_glyphs() + .map(|g| GlyphEntry { + id: g.id as u16, + x: g.x - run_offset, + y: g.y - run_baseline, + advance: g.advance, + }) + .collect(); + + items.push(PositionedItem::GlyphRun(PositionedGlyphRun { + // Baseline sits at y = 0; composition shifts the whole box. + origin: LayoutPoint { + x: run_offset, + y: 0.0, + }, + font_data, + font_index: run.font().index, + font_size: run.font_size(), + glyphs, + color, + synthesis: GlyphSynthesis { + bold: synthesis.embolden(), + italic: synthesis.skew().is_some(), + }, + link_url: None, + })); + } + } + + MBox { + width, + ascent, + descent, + items, + } +} + +/// Shapes `ch` scaled up so its visual height is at least `target_height`, +/// producing a "stretched" delimiter or radical sign. Uniform glyph scaling is +/// an approximation of a true extensible glyph — the sign also widens — but it +/// keeps the symbol's shape and needs no special renderer support. The glyph is +/// never shrunk below its natural size, and the scale is capped to avoid +/// pathological growth. +pub(super) fn stretchy_glyph( + resources: &mut FontResources, + ch: &str, + font_size: f32, + target_height: f32, + color: LayoutColor, + display_scale: f32, +) -> MBox { + let base = shape_token(resources, ch, font_size, false, color, display_scale); + let natural = base.ascent + base.descent; + if natural <= 0.0 || target_height <= natural * 1.05 { + return base; + } + let factor = (target_height / natural).min(6.0); + shape_token( + resources, + ch, + font_size * factor, + false, + color, + display_scale, + ) +} diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index 48a96722..655ffba6 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -16,8 +16,8 @@ use loki_doc_model::style::list_style::{ BulletChar, ListId, ListLevel, ListLevelKind, NumberingScheme, }; use parley::{ - Alignment, AlignmentOptions, Cursor, FontFamily, FontStyle, FontWeight, InlineBox, LineHeight, - PositionedLayoutItem, RangedBuilder, Selection, StyleProperty, + Alignment, AlignmentOptions, Cursor, FontFamily, FontStyle, FontWeight, InlineBox, + InlineBoxKind, LineHeight, PositionedLayoutItem, RangedBuilder, Selection, StyleProperty, }; use crate::color::LayoutColor; @@ -141,8 +141,13 @@ pub struct StyleSpan { pub font_name: Option, /// Font size in points. pub font_size: f32, - /// Bold weight. + /// Bold weight (legacy boolean; retained for synthesis fallback). Prefer + /// [`Self::weight`] for the effective numeric weight. pub bold: bool, + /// Effective numeric font weight (1–1000; 400 = Regular, 700 = Bold). This + /// is the value pushed to Parley, so it supersedes `bold` when set from a + /// `font_weight` style. + pub weight: u16, /// Italic style. pub italic: bool, /// Text colour. @@ -191,6 +196,12 @@ pub struct StyleSpan { /// children. Used to render a visual link hint and (eventually) hit-test /// regions. TODO(link-click): interactive hit-testing deferred. pub link_url: Option, + /// MathML markup for an [`Inline::Math`][loki_doc_model::content::inline::Inline::Math] + /// placeholder. When `Some`, this span has an empty `range` marking the + /// insertion point of an equation; [`layout_paragraph`] typesets it (see + /// [`crate::math`]) and places it inline via a Parley inline box. All other + /// span fields supply the base font size / colour for the math. + pub math: Option>, } /// Resolved paragraph-level properties passed to [`layout_paragraph`]. @@ -349,6 +360,15 @@ pub struct ParagraphLayout { pub orig_to_clean: Vec, /// Cleaned to original byte index mappings. pub clean_to_orig: Vec, + /// Paragraph start (left) indent in points, applied to drawn glyphs. + /// + /// Retained so cursor / hit-test / selection geometry can include the same + /// horizontal offset the glyph runs use (the Parley layout itself is built + /// in an un-indented coordinate space). See [`Self::line_indent`]. + pub indent_start: f32, + /// Hanging indent in points (first line starts this far left of + /// `indent_start`). `0.0` = no hanging. + pub indent_hanging: f32, } impl std::fmt::Debug for ParagraphLayout { @@ -378,7 +398,18 @@ impl ParagraphLayout { /// layout was produced with `preserve_for_editing: false` (read-only mode). pub fn hit_test_point(&self, x: f32, y: f32) -> Option { let layout = self.parley_layout.as_deref()?; - let cursor = Cursor::from_point(layout, x, y); + // Derive the line index from `line_boundaries`: find the first line + // whose `max_coord` is strictly above the hit y, or clamp to the last line. + let line_index = self + .line_boundaries + .iter() + .position(|&(_, max_y)| y < max_y) + .unwrap_or_else(|| self.line_boundaries.len().saturating_sub(1)); + // Glyphs are drawn shifted right by the line's indent, but the Parley + // layout is un-indented — remove the indent before hit-testing so a + // click on the visible text maps to the right offset. + let local_x = x - self.line_indent(line_index); + let cursor = Cursor::from_point(layout, local_x, y); let byte_offset = cursor.index(); let mapped_offset = self .clean_to_orig @@ -389,13 +420,6 @@ impl ParagraphLayout { parley::Affinity::Upstream => Affinity::Upstream, parley::Affinity::Downstream => Affinity::Downstream, }; - // Derive the line index from `line_boundaries`: find the first line - // whose `max_coord` is strictly above the hit y, or clamp to the last line. - let line_index = self - .line_boundaries - .iter() - .position(|&(_, max_y)| y < max_y) - .unwrap_or_else(|| self.line_boundaries.len().saturating_sub(1)); Some(HitTestResult { byte_offset: mapped_offset, affinity, @@ -473,8 +497,17 @@ impl ParagraphLayout { let bb = cursor.geometry(layout, 1.0); let y = bb.y0 as f32; let height = (bb.y1 - bb.y0) as f32; + // Add the line's indent so the caret sits with the drawn glyphs (the + // Parley layout is built in an un-indented coordinate space). The line is + // located from the caret's vertical centre, matching `hit_test_point`. + let probe_y = y + height * 0.5; + let line_index = self + .line_boundaries + .iter() + .position(|&(_, max_y)| probe_y < max_y) + .unwrap_or_else(|| self.line_boundaries.len().saturating_sub(1)); Some(CursorRect { - x: bb.x0 as f32, + x: bb.x0 as f32 + self.line_indent(line_index), y, height, }) @@ -506,9 +539,9 @@ impl ParagraphLayout { Selection::new(anchor, focus) .geometry(layout) .into_iter() - .map(|(bb, _line)| { + .map(|(bb, line)| { LayoutRect::new( - bb.x0 as f32, + bb.x0 as f32 + self.line_indent(line), bb.y0 as f32, (bb.x1 - bb.x0) as f32, (bb.y1 - bb.y0) as f32, @@ -516,6 +549,19 @@ impl ParagraphLayout { }) .collect() } + + /// Horizontal indent (points) applied to the drawn glyphs of visual line + /// `line_index`, matching the `indent_x` used when emitting glyph runs: the + /// first line of a hanging-indent paragraph starts `indent_hanging` to the + /// left of `indent_start`. Editing geometry adds this so cursor, hit-test, + /// and selection coordinates line up with the rendered text. + fn line_indent(&self, line_index: usize) -> f32 { + if line_index == 0 && self.indent_hanging > 0.0 { + self.indent_start - self.indent_hanging + } else { + self.indent_start + } + } } // ── Tab stop helpers (gap #7) ───────────────────────────────────────────────── @@ -542,6 +588,30 @@ fn next_tab_stop(stops: &[ResolvedTabStop], x: f32, indent_hanging: f32) -> f32 /// Push paragraph-level defaults and per-span character styles onto `builder`. /// +/// Pushes one Parley inline box per typeset math placeholder, sized to the +/// equation's intrinsic box so the surrounding text flows around it. Ids are +/// offset by [`MATH_ID_BASE`] so the post-layout pass can recognise them. +/// +/// The box height is the equation's **ascent** only: Parley aligns an inline +/// box's bottom to the text baseline (counting its whole height as ascent), so +/// reserving just the ascent lands the box top at `baseline − ascent`. Drawing +/// the equation there puts its baseline on the text baseline; the descent then +/// hangs below into the line's descent region, exactly like inline text. +fn push_math_inline_boxes( + builder: &mut RangedBuilder<'_, LayoutColor>, + math_boxes: &[(usize, crate::math::MathRender)], +) { + for (i, (index, render)) in math_boxes.iter().enumerate() { + builder.push_inline_box(InlineBox { + id: MATH_ID_BASE + i as u64, + kind: InlineBoxKind::InFlow, + index: *index, + width: render.width, + height: render.ascent, + }); + } +} + /// Extracted so the same styles can be applied in both the probe pass (pass 1) /// and the final pass (pass 2) of the two-pass tab stop expansion. fn push_para_styles( @@ -566,6 +636,12 @@ fn push_para_styles( } for span in style_spans { + // Math placeholder spans have an empty range and no text — they are + // typeset separately and reserved via an inline box, so they push no + // text styles here. + if span.math.is_some() { + continue; + } let r = span.range.clone(); // For super/subscript (gap #3), reduce font size to 58 %. // TODO(super-sub): Parley does not expose baseline-shift. @@ -576,8 +652,14 @@ fn push_para_styles( }; builder.push(StyleProperty::FontSize(effective_font_size), r.clone()); builder.push(StyleProperty::Brush(span.color), r.clone()); - if span.bold { - builder.push(StyleProperty::FontWeight(FontWeight::BOLD), r.clone()); + // Push the effective numeric weight. `weight` already folds in `bold` + // (700 when bold, else 400) plus any explicit `font_weight` style, so a + // non-default weight is honoured even when the bold flag is unset. + if span.weight != 400 { + builder.push( + StyleProperty::FontWeight(FontWeight::new(span.weight as f32)), + r.clone(), + ); } if span.italic { builder.push(StyleProperty::FontStyle(FontStyle::Italic), r.clone()); @@ -679,6 +761,10 @@ fn clean_text_and_spans( (clean_text, clean_spans, orig_to_clean, clean_to_orig) } +/// Inline-box id base for math placeholders, kept clear of the tab-stop ids +/// (which count up from 0) so the two can coexist in one paragraph. +const MATH_ID_BASE: u64 = 1 << 40; + /// Lay out a single paragraph using Parley. /// /// `text_content` is the flattened text from all inline runs. `style_spans` @@ -740,7 +826,7 @@ fn layout_paragraph_uncached( display_scale: f32, preserve_for_editing: bool, ) -> ParagraphLayout { - let (clean_text, mut clean_spans, orig_to_clean, clean_to_orig) = + let (mut clean_text, mut clean_spans, orig_to_clean, clean_to_orig) = clean_text_and_spans(text_content, style_spans); for span in &mut clean_spans { @@ -749,6 +835,31 @@ fn layout_paragraph_uncached( } } + // ── Inline math (gap) ───────────────────────────────────────────────────── + // Typeset each `Inline::Math` placeholder span (empty range, `math: Some`) + // into its own box. Done before the tab/final passes so its intrinsic size + // can size a Parley inline box, reserving inline space for the equation. + let mut math_boxes: Vec<(usize, crate::math::MathRender)> = Vec::new(); + for span in &clean_spans { + if let Some(mathml) = &span.math { + let render = crate::math::layout_math( + resources, + mathml, + span.font_size, + span.color, + display_scale, + ); + if render.width > 0.0 { + math_boxes.push((span.range.start, render)); + } + } + } + // A paragraph that contains only math has empty text; give Parley a single + // space so it still produces a line to anchor the inline box(es). + if clean_text.is_empty() && !math_boxes.is_empty() { + clean_text = " ".to_string(); + } + if clean_text.is_empty() { if !preserve_for_editing { return ParagraphLayout { @@ -761,6 +872,8 @@ fn layout_paragraph_uncached( parley_layout: None, orig_to_clean, clean_to_orig, + indent_start: para_props.indent_start, + indent_hanging: para_props.indent_hanging, }; } // Build a phantom single-space layout so cursor_rect can return a @@ -790,6 +903,8 @@ fn layout_paragraph_uncached( parley_layout: Some(Arc::new(phantom)), orig_to_clean, clean_to_orig, + indent_start: para_props.indent_start, + indent_hanging: para_props.indent_hanging, }; } @@ -821,11 +936,13 @@ fn layout_paragraph_uncached( for (idx, &pos) in tab_char_positions.iter().enumerate() { probe.push_inline_box(InlineBox { id: idx as u64, + kind: InlineBoxKind::InFlow, index: pos, width: 0.0, height: 0.0, }); } + push_math_inline_boxes(&mut probe, &math_boxes); let mut probe_layout = probe.build(&clean_text); probe_layout.break_all_lines(Some(line_w)); @@ -862,19 +979,17 @@ fn layout_paragraph_uncached( let width = tab_inline_widths.get(idx).copied().unwrap_or(0.0); builder.push_inline_box(InlineBox { id: idx as u64, + kind: InlineBoxKind::InFlow, index: pos, width, height: 0.0, }); } + push_math_inline_boxes(&mut builder, &math_boxes); let mut layout = builder.build(&clean_text); layout.break_all_lines(Some(line_w)); - layout.align( - Some(line_w), - para_props.alignment, - AlignmentOptions::default(), - ); + layout.align(para_props.alignment, AlignmentOptions::default()); let total_height = layout.height(); let total_width = layout.width(); @@ -890,11 +1005,16 @@ fn layout_paragraph_uncached( .unwrap_or(0.0); let line_boundaries: Vec<(f32, f32)> = layout .lines() - .map(|l| (l.metrics().min_coord, l.metrics().max_coord)) + .map(|l| (l.metrics().block_min_coord, l.metrics().block_max_coord)) .collect(); let mut items: Vec = Vec::new(); let mut line_index: usize = 0; + // Track the lowest point reached by any inline equation. Its baseline is on + // the text baseline, so a deep denominator can hang below the line's own + // descent; we grow the paragraph height to cover it so the next block does + // not overlap it. + let mut content_bottom = total_height; for line in layout.lines() { // Hanging indent: the first line shifts left so the marker is visible to @@ -905,6 +1025,24 @@ fn layout_paragraph_uncached( para_props.indent_start }; for item in line.items() { + // Math inline box: emit the typeset equation's draw items, offset to + // the box's resolved position on the line. + if let PositionedLayoutItem::InlineBox(pib) = &item { + if pib.id >= MATH_ID_BASE { + let mi = (pib.id - MATH_ID_BASE) as usize; + if let Some((_, render)) = math_boxes.get(mi) { + for prim in &render.items { + let mut prim = prim.clone(); + prim.translate(pib.x + indent_x, pib.y); + items.push(prim); + } + // The box top is at `pib.y` and its baseline at + // `pib.y + ascent`; the descent hangs below that. + content_bottom = content_bottom.max(pib.y + render.ascent + render.descent); + } + } + continue; + } let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue; }; @@ -1081,7 +1219,9 @@ fn layout_paragraph_uncached( }; ParagraphLayout { - height: total_height, + // `content_bottom` ≥ `total_height`; it is larger only when an inline + // equation hangs below the last line (see above). + height: content_bottom, width: total_width, items, first_baseline, @@ -1090,6 +1230,8 @@ fn layout_paragraph_uncached( parley_layout, orig_to_clean, clean_to_orig, + indent_start: para_props.indent_start, + indent_hanging: para_props.indent_hanging, } } diff --git a/loki-layout/src/para_cache.rs b/loki-layout/src/para_cache.rs index 1cb60a7c..3dde8940 100644 --- a/loki-layout/src/para_cache.rs +++ b/loki-layout/src/para_cache.rs @@ -141,6 +141,7 @@ mod tests { font_name: None, font_size: 12.0, bold: false, + weight: 400, italic: false, color: LayoutColor::BLACK, underline: None, @@ -153,6 +154,7 @@ mod tests { word_spacing: None, shadow: false, link_url: None, + math: None, } } diff --git a/loki-layout/src/para_tests.rs b/loki-layout/src/para_tests.rs index 539a6603..443a3d8c 100644 --- a/loki-layout/src/para_tests.rs +++ b/loki-layout/src/para_tests.rs @@ -33,6 +33,7 @@ fn single_span(text: &str, font_size: f32) -> StyleSpan { font_name: None, font_size, bold: false, + weight: 400, italic: false, color: LayoutColor::BLACK, underline: None, @@ -45,11 +46,113 @@ fn single_span(text: &str, font_size: f32) -> StyleSpan { word_spacing: None, shadow: false, link_url: None, + math: None, } } +/// A math placeholder span (empty range carrying MathML) at byte `at`. +fn math_span(at: usize, mathml: &str) -> StyleSpan { + let mut s = single_span("", 12.0); + s.range = at..at; + s.math = Some(std::sync::Arc::from(mathml)); + s +} + // ── tests ───────────────────────────────────────────────────────────────────── +#[test] +fn inline_math_emits_typeset_items() { + const NS: &str = "http://www.w3.org/1998/Math/MathML"; + let mut r = test_resources(); + let text = "x = "; + let mathml = format!("12"); + + // Baseline: same paragraph without the equation. + let plain = [single_span(text, 12.0)]; + let plain_layout = layout_paragraph( + &mut r, + text, + &plain, + &ResolvedParaProps::default(), + 400.0, + 1.0, + false, + ); + + let spans = [single_span(text, 12.0), math_span(text.len(), &mathml)]; + let result = layout_paragraph( + &mut r, + text, + &spans, + &ResolvedParaProps::default(), + 400.0, + 1.0, + false, + ); + + // The fraction contributes a bar rule and at least the two numerator/ + // denominator glyph runs on top of the paragraph's own text runs. + let rects = result + .items + .iter() + .filter(|i| matches!(i, PositionedItem::FilledRect(_))) + .count(); + let glyph_runs = result + .items + .iter() + .filter(|i| matches!(i, PositionedItem::GlyphRun(_))) + .count(); + let plain_runs = plain_layout + .items + .iter() + .filter(|i| matches!(i, PositionedItem::GlyphRun(_))) + .count(); + + assert_eq!(rects, 1, "fraction bar should be drawn"); + assert!( + glyph_runs > plain_runs, + "math adds glyph runs beyond the plain text ({glyph_runs} vs {plain_runs})" + ); +} + +#[test] +fn inline_math_baseline_aligns_with_text() { + const NS: &str = "http://www.w3.org/1998/Math/MathML"; + let mut r = test_resources(); + let text = "x = "; + let mathml = format!("y"); + let spans = [single_span(text, 12.0), math_span(text.len(), &mathml)]; + let result = layout_paragraph( + &mut r, + text, + &spans, + &ResolvedParaProps::default(), + 400.0, + 1.0, + false, + ); + + // A lone identifier sits on the math baseline, which the inline box places + // on the text baseline — so every glyph run (the "x = " text and the math + // "y") shares one baseline `y`. + let baselines: Vec = result + .items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(g) => Some(g.origin.y), + _ => None, + }) + .collect(); + assert!(baselines.len() >= 2, "expected text + math glyph runs"); + let first = baselines[0]; + for b in &baselines { + assert!( + (b - first).abs() < 0.6, + "math baseline {b} should match text baseline {first}" + ); + } +} + #[test] fn plain_paragraph_non_empty() { let mut r = test_resources(); @@ -81,6 +184,7 @@ fn bold_span_produces_items() { StyleSpan { range: 6..10, bold: true, + weight: 700, ..single_span(text, 12.0) }, StyleSpan { @@ -586,6 +690,49 @@ fn editing_paragraph(text: &str) -> ParagraphLayout { ) } +#[test] +fn left_indent_included_in_cursor_and_hit_test() { + // Regression: paragraphs with a left indent (e.g. screenplay Character / + // parenthetical blocks) drew glyphs shifted right by `indent_start`, but the + // caret and hit-testing read un-indented Parley coordinates — so the cursor + // showed at the page's content-left instead of at the text. + let mut r = test_resources(); + let text = "Hello"; + let spans = [single_span(text, 12.0)]; + let props = ResolvedParaProps { + indent_start: 100.0, + ..Default::default() + }; + let layout = layout_paragraph(&mut r, text, &spans, &props, 400.0, 1.0, true); + + // The caret at the start of the text sits at the indent, not at x = 0. + let c0 = layout.cursor_rect(0).expect("cursor at start"); + assert!( + (c0.x - 100.0).abs() < 1.0, + "start caret x {} should be ~100 (the left indent)", + c0.x + ); + + // A click on the visible (indented) text maps to the right offset, not to + // the end of the line as it would if the indent were ignored. + let mid_y = c0.y + c0.height * 0.5; + let hit = layout + .hit_test_point(102.0, mid_y) + .expect("hit near indented start"); + assert_eq!( + hit.byte_offset, 0, + "click just inside the indented text should map to offset 0" + ); + + // Caret / hit-test are mutually consistent under the indent. + let c3 = layout.cursor_rect(3).expect("cursor mid"); + assert!(c3.x > 100.0, "mid caret should be right of the indent"); + let hit3 = layout + .hit_test_point(c3.x, mid_y) + .expect("hit at mid caret"); + assert_eq!(hit3.byte_offset, 3); +} + #[test] fn hit_test_read_only_mode_returns_none() { let mut r = test_resources(); diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index a37dc18a..cbdad785 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -228,13 +228,17 @@ pub fn flatten_paragraph( Some(direct) => direct.as_ref().clone().merged_with_parent(&base), None => base, }; + // `walk_inlines` takes `&mut` and mutates this in place (restoring after each + // styled run) to avoid cloning `CharProps` per formatting span; `base` is a + // throwaway local, so the mutation is not observable outside this call. + let mut base = base; let mut buf = String::new(); let mut spans: Vec = Vec::new(); let mut images: Vec = Vec::new(); let mut notes: Vec = Vec::new(); walk_inlines( &block.inlines, - &base, + &mut base, catalog, &mut buf, &mut spans, @@ -370,11 +374,14 @@ fn char_props_to_style_span(props: &CharProps, range: Range) -> StyleSpan None }; + let bold = props.bold.unwrap_or(false); StyleSpan { range, font_name: props.font_name.clone(), font_size: props.font_size.map(pts_to_f32).unwrap_or(12.0), - bold: props.bold.unwrap_or(false), + bold, + // Explicit numeric weight wins; otherwise derive from the bold flag. + weight: props.font_weight.unwrap_or(if bold { 700 } else { 400 }), italic: props.italic.unwrap_or(false), color: resolve_color(props.color.as_ref()), underline, @@ -387,6 +394,7 @@ fn char_props_to_style_span(props: &CharProps, range: Range) -> StyleSpan word_spacing: props.word_spacing.map(pts_to_f32), // gap #22 shadow: props.shadow.unwrap_or(false), // gap #24 link_url: None, // set by walk_inlines when inside Inline::Link (gap #11) + math: None, // set by walk_inlines for Inline::Math placeholders } } @@ -463,7 +471,7 @@ fn push_text( #[allow(clippy::too_many_arguments)] fn walk_inlines( inlines: &[Inline], - effective: &CharProps, + effective: &mut CharProps, catalog: &StyleCatalog, buf: &mut String, spans: &mut Vec, @@ -480,10 +488,10 @@ fn walk_inlines( Inline::LineBreak => push_text(buf, spans, "\n", effective, active_link_url), Inline::Code(_, s) => push_text(buf, spans, s, effective, active_link_url), Inline::StyledRun(run) => { - let p = effective_run_char_props(run, catalog, effective); + let mut p = effective_run_char_props(run, catalog, effective); walk_inlines( &run.content, - &p, + &mut p, catalog, buf, spans, @@ -494,11 +502,13 @@ fn walk_inlines( ); } Inline::Strong(ch) => { - let mut p = effective.clone(); - p.bold = Some(true); + // Toggle the single flag in place and restore it after recursing, + // instead of cloning the whole CharProps (which heap-allocates its + // font-name Strings) for every formatting span. + let prev = effective.bold.replace(true); walk_inlines( ch, - &p, + effective, catalog, buf, spans, @@ -507,13 +517,13 @@ fn walk_inlines( note_counter, notes, ); + effective.bold = prev; } Inline::Emph(ch) => { - let mut p = effective.clone(); - p.italic = Some(true); + let prev = effective.italic.replace(true); walk_inlines( ch, - &p, + effective, catalog, buf, spans, @@ -522,13 +532,13 @@ fn walk_inlines( note_counter, notes, ); + effective.italic = prev; } Inline::Underline(ch) => { - let mut p = effective.clone(); - p.underline = Some(DocUnderlineStyle::Single); + let prev = effective.underline.replace(DocUnderlineStyle::Single); walk_inlines( ch, - &p, + effective, catalog, buf, spans, @@ -537,13 +547,15 @@ fn walk_inlines( note_counter, notes, ); + effective.underline = prev; } Inline::Strikeout(ch) => { - let mut p = effective.clone(); - p.strikethrough = Some(DocStrikethroughStyle::Single); + let prev = effective + .strikethrough + .replace(DocStrikethroughStyle::Single); walk_inlines( ch, - &p, + effective, catalog, buf, spans, @@ -552,14 +564,16 @@ fn walk_inlines( note_counter, notes, ); + effective.strikethrough = prev; } // Superscript (gap #3): set vertical_align on the effective props. Inline::Superscript(ch) => { - let mut p = effective.clone(); - p.vertical_align = Some(DocVerticalAlign::Superscript); + let prev = effective + .vertical_align + .replace(DocVerticalAlign::Superscript); walk_inlines( ch, - &p, + effective, catalog, buf, spans, @@ -568,14 +582,16 @@ fn walk_inlines( note_counter, notes, ); + effective.vertical_align = prev; } // Subscript (gap #3): set vertical_align on the effective props. Inline::Subscript(ch) => { - let mut p = effective.clone(); - p.vertical_align = Some(DocVerticalAlign::Subscript); + let prev = effective + .vertical_align + .replace(DocVerticalAlign::Subscript); walk_inlines( ch, - &p, + effective, catalog, buf, spans, @@ -584,14 +600,14 @@ fn walk_inlines( note_counter, notes, ); + effective.vertical_align = prev; } // SmallCaps (gap #15): set small_caps so StyleSpan gets FontVariant::SmallCaps. Inline::SmallCaps(ch) => { - let mut p = effective.clone(); - p.small_caps = Some(true); + let prev = effective.small_caps.replace(true); walk_inlines( ch, - &p, + effective, catalog, buf, spans, @@ -600,6 +616,7 @@ fn walk_inlines( note_counter, notes, ); + effective.small_caps = prev; } Inline::Quoted(_, ch) | Inline::Span(_, ch) => { walk_inlines( @@ -700,17 +717,28 @@ fn walk_inlines( Inline::Note(kind, blocks) => { *note_counter += 1; let mark = superscript_mark(*note_counter); - let mut mark_props = effective.clone(); - mark_props.vertical_align = Some(DocVerticalAlign::Superscript); - push_text(buf, spans, &mark, &mark_props, active_link_url); + let prev = effective + .vertical_align + .replace(DocVerticalAlign::Superscript); + push_text(buf, spans, &mark, effective, active_link_url); + effective.vertical_align = prev; notes.push(CollectedNote { number: *note_counter, kind: *kind, blocks: blocks.clone(), }); } - // Math, RawInline, Comment, Bookmark, and any - // future #[non_exhaustive] variants are not text runs — skip. + // Math (gap): record an empty-range placeholder span carrying the + // MathML; `layout_paragraph` typesets it and places it inline via a + // Parley inline box. No text is emitted into `buf`. + Inline::Math(_, mathml) => { + let at = buf.len(); + let mut span = char_props_to_style_span(effective, at..at); + span.math = Some(std::sync::Arc::from(mathml.as_str())); + spans.push(span); + } + // RawInline, Comment, Bookmark, and any future #[non_exhaustive] + // variants are not text runs — skip. _ => {} } } diff --git a/loki-layout/src/resolve_tests.rs b/loki-layout/src/resolve_tests.rs index c6b831ec..7cd86638 100644 --- a/loki-layout/src/resolve_tests.rs +++ b/loki-layout/src/resolve_tests.rs @@ -102,6 +102,56 @@ fn flatten_emph_sets_italic() { assert!(spans[0].italic, "Emph should produce italic=true"); } +#[test] +fn formatting_does_not_leak_to_following_siblings() { + // Regression for the `walk_inlines` mutate-and-restore optimisation: a plain + // run *after* a Strong must not inherit bold (the effective props must be + // restored once the styled run's children are processed). + let catalog = StyleCatalog::new(); + let para = empty_para(vec![ + Inline::Strong(vec![Inline::Str("a".into())]), + Inline::Str("b".into()), + ]); + let (text, spans, _images, _notes) = flatten_paragraph(¶, &catalog, &mut 0u32); + assert_eq!(text, "ab"); + let bold_a = spans + .iter() + .find(|s| s.range == (0..1)) + .expect("span for 'a'"); + let plain_b = spans + .iter() + .find(|s| s.range == (1..2)) + .expect("span for 'b'"); + assert!(bold_a.bold, "'a' inside Strong must be bold"); + assert!( + !plain_b.bold, + "'b' after Strong must NOT be bold (effective props restored)" + ); +} + +#[test] +fn nested_formatting_accumulates_and_restores() { + // Strong( Emph("ab") + "cd" ) + "ef": + // "ab" → bold + italic, "cd" → bold only (Emph restored), + // "ef" → unformatted (Strong restored). + let catalog = StyleCatalog::new(); + let para = empty_para(vec![ + Inline::Strong(vec![ + Inline::Emph(vec![Inline::Str("ab".into())]), + Inline::Str("cd".into()), + ]), + Inline::Str("ef".into()), + ]); + let (text, spans, _images, _notes) = flatten_paragraph(¶, &catalog, &mut 0u32); + assert_eq!(text, "abcdef"); + let ab = spans.iter().find(|s| s.range == (0..2)).expect("span 'ab'"); + let cd = spans.iter().find(|s| s.range == (2..4)).expect("span 'cd'"); + let ef = spans.iter().find(|s| s.range == (4..6)).expect("span 'ef'"); + assert!(ab.bold && ab.italic, "'ab' must be bold + italic"); + assert!(cd.bold && !cd.italic, "'cd' must stay bold but not italic"); + assert!(!ef.bold && !ef.italic, "'ef' must be unformatted"); +} + #[test] fn flatten_styled_run_applies_direct_props() { let catalog = StyleCatalog::new(); diff --git a/loki-layout/src/result.rs b/loki-layout/src/result.rs index af5c7c24..c05aa3a3 100644 --- a/loki-layout/src/result.rs +++ b/loki-layout/src/result.rs @@ -118,6 +118,10 @@ pub struct LayoutPage { pub header_items: Vec, /// Items in the footer area. Origins are page-local (top-left of page). pub footer_items: Vec, + /// Comment-panel items rendered in the gutter to the right of the page. + /// Origins are page-local; their x extends past `page_size.width`. Empty + /// when the page has no anchored comments. + pub comment_items: Vec, /// Rendered height of the header content in points (0.0 if no header). pub header_height: f32, /// Rendered height of the footer content in points (0.0 if no footer). @@ -253,207 +257,5 @@ impl ContinuousLayout { } #[cfg(test)] -mod tests { - use super::*; - use crate::color::LayoutColor; - use crate::geometry::LayoutRect; - use crate::items::PositionedRect; - - fn make_filled(x: f32) -> PositionedItem { - PositionedItem::FilledRect(PositionedRect { - rect: LayoutRect::new(x, 0.0, 10.0, 10.0), - color: LayoutColor::BLACK, - }) - } - - #[test] - fn continuous_all_items_count() { - let layout = DocumentLayout::Continuous(ContinuousLayout { - content_width: 500.0, - total_height: 200.0, - items: vec![make_filled(0.0), make_filled(20.0), make_filled(40.0)], - paragraphs: vec![], - }); - assert_eq!(layout.all_items().count(), 3); - } - - fn para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagraphData { - use crate::font::FontResources; - use crate::para::{ResolvedParaProps, StyleSpan, layout_paragraph}; - let mut resources = FontResources::new(); - let layout = layout_paragraph( - &mut resources, - text, - &[StyleSpan { - range: 0..text.len(), - font_name: None, - font_size: 12.0, - bold: false, - 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, - }], - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - PageParagraphData { - block_index, - layout: Arc::new(layout), - origin, - } - } - - fn two_para_continuous() -> ContinuousLayout { - let p0 = para("Hello world", 0, (0.0, 0.0)); - let h0 = p0.layout.height; - let p1 = para("Second line here", 1, (0.0, h0)); - ContinuousLayout { - content_width: 400.0, - total_height: h0 + p1.layout.height, - items: vec![], - paragraphs: vec![p0, p1], - } - } - - #[test] - fn selection_rects_collapsed_is_empty() { - let cl = two_para_continuous(); - assert!(cl.selection_rects((0, 3), (0, 3)).is_empty()); - } - - #[test] - fn selection_rects_within_paragraph() { - let cl = two_para_continuous(); - let rects = cl.selection_rects((0, 0), (0, 5)); - assert!(!rects.is_empty(), "expected highlight rects"); - // Confined to the first paragraph (origin y = 0, near the top). - assert!(rects.iter().all(|r| r.origin.y < 30.0)); - } - - #[test] - fn selection_rects_span_two_paragraphs() { - let cl = two_para_continuous(); - // Split at the boundary midpoint; line ascent puts a rect's top a point - // or so above the nominal paragraph origin, so an exact `>= origin` - // comparison is too strict. - let mid = cl.paragraphs[1].origin.1 / 2.0; - let rects = cl.selection_rects((0, 6), (1, 6)); - // Endpoint order is normalised, so reversing gives the same result. - let rev = cl.selection_rects((1, 6), (0, 6)); - assert_eq!(rects.len(), rev.len()); - assert!(rects.iter().any(|r| r.origin.y < mid)); // first paragraph - assert!(rects.iter().any(|r| r.origin.y > mid)); // second paragraph - } - - #[test] - fn hit_test_and_cursor_round_trip() { - let cl = two_para_continuous(); - // A click on the second paragraph resolves to block 1. - let (block, _byte) = cl - .hit_test(2.0, cl.paragraphs[1].origin.1 + 2.0) - .expect("hit"); - assert_eq!(block, 1); - // Caret for the second paragraph sits at/after its canvas origin. - let cr = cl.cursor_rect_canvas(1, 0).expect("caret"); - assert!(cr.y >= cl.paragraphs[1].origin.1 - 1.0); - } - - #[test] - fn paginated_all_items_across_pages() { - let page1 = LayoutPage { - page_number: 1, - page_size: LayoutSize::new(595.0, 842.0), - margins: LayoutInsets::uniform(72.0), - content_items: vec![make_filled(0.0), make_filled(10.0)], - header_items: vec![make_filled(20.0)], - footer_items: vec![], - header_height: 0.0, - footer_height: 0.0, - editing_data: None, - }; - let page2 = LayoutPage { - page_number: 2, - page_size: LayoutSize::new(595.0, 842.0), - margins: LayoutInsets::uniform(72.0), - content_items: vec![make_filled(0.0)], - header_items: vec![], - footer_items: vec![make_filled(30.0)], - header_height: 0.0, - footer_height: 0.0, - editing_data: None, - }; - let layout = DocumentLayout::Paginated(PaginatedLayout { - page_size: LayoutSize::new(595.0, 842.0), - pages: vec![Arc::new(page1), Arc::new(page2)], - }); - // page1: 2 content + 1 header = 3; page2: 1 content + 1 footer = 2 → total 5 - assert_eq!(layout.all_items().count(), 5); - } - - #[test] - fn layout_page_content_rect() { - let page = LayoutPage { - page_number: 1, - page_size: LayoutSize::new(595.0, 842.0), - margins: LayoutInsets { - top: 72.0, - right: 72.0, - bottom: 72.0, - left: 72.0, - }, - content_items: vec![], - header_items: vec![], - footer_items: vec![], - header_height: 0.0, - footer_height: 0.0, - editing_data: None, - }; - let cr = page.content_rect(); - assert_eq!(cr.x(), 72.0); - assert_eq!(cr.y(), 72.0); - assert_eq!(cr.width(), 595.0 - 144.0); - assert_eq!(cr.height(), 842.0 - 144.0); - } - - #[test] - fn document_layout_total_height_paginated() { - let make_page = |n: usize| LayoutPage { - page_number: n, - page_size: LayoutSize::new(595.0, 842.0), - margins: LayoutInsets::uniform(72.0), - content_items: vec![], - header_items: vec![], - footer_items: vec![], - header_height: 0.0, - footer_height: 0.0, - editing_data: None, - }; - let layout = DocumentLayout::Paginated(PaginatedLayout { - page_size: LayoutSize::new(595.0, 842.0), - pages: vec![Arc::new(make_page(1)), Arc::new(make_page(2))], - }); - assert_eq!(layout.total_height(), 842.0 * 2.0); - } - - #[test] - fn document_layout_content_width_continuous() { - let layout = DocumentLayout::Continuous(ContinuousLayout { - content_width: 480.0, - total_height: 100.0, - items: vec![], - paragraphs: vec![], - }); - assert_eq!(layout.content_width(), 480.0); - } -} +#[path = "result_tests.rs"] +mod tests; diff --git a/loki-layout/src/result_tests.rs b/loki-layout/src/result_tests.rs new file mode 100644 index 00000000..715b94fd --- /dev/null +++ b/loki-layout/src/result_tests.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `result`. + +use super::*; +use crate::color::LayoutColor; +use crate::geometry::LayoutRect; +use crate::items::PositionedRect; + +fn make_filled(x: f32) -> PositionedItem { + PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(x, 0.0, 10.0, 10.0), + color: LayoutColor::BLACK, + }) +} + +#[test] +fn continuous_all_items_count() { + let layout = DocumentLayout::Continuous(ContinuousLayout { + content_width: 500.0, + total_height: 200.0, + items: vec![make_filled(0.0), make_filled(20.0), make_filled(40.0)], + paragraphs: vec![], + }); + assert_eq!(layout.all_items().count(), 3); +} + +fn para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagraphData { + use crate::font::FontResources; + use crate::para::{ResolvedParaProps, StyleSpan, layout_paragraph}; + let mut resources = FontResources::new(); + let layout = 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, + }], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + ); + PageParagraphData { + block_index, + layout: Arc::new(layout), + origin, + } +} + +fn two_para_continuous() -> ContinuousLayout { + let p0 = para("Hello world", 0, (0.0, 0.0)); + let h0 = p0.layout.height; + let p1 = para("Second line here", 1, (0.0, h0)); + ContinuousLayout { + content_width: 400.0, + total_height: h0 + p1.layout.height, + items: vec![], + paragraphs: vec![p0, p1], + } +} + +#[test] +fn selection_rects_collapsed_is_empty() { + let cl = two_para_continuous(); + assert!(cl.selection_rects((0, 3), (0, 3)).is_empty()); +} + +#[test] +fn selection_rects_within_paragraph() { + let cl = two_para_continuous(); + let rects = cl.selection_rects((0, 0), (0, 5)); + assert!(!rects.is_empty(), "expected highlight rects"); + // Confined to the first paragraph (origin y = 0, near the top). + assert!(rects.iter().all(|r| r.origin.y < 30.0)); +} + +#[test] +fn selection_rects_span_two_paragraphs() { + let cl = two_para_continuous(); + // Split at the boundary midpoint; line ascent puts a rect's top a point + // or so above the nominal paragraph origin, so an exact `>= origin` + // comparison is too strict. + let mid = cl.paragraphs[1].origin.1 / 2.0; + let rects = cl.selection_rects((0, 6), (1, 6)); + // Endpoint order is normalised, so reversing gives the same result. + let rev = cl.selection_rects((1, 6), (0, 6)); + assert_eq!(rects.len(), rev.len()); + assert!(rects.iter().any(|r| r.origin.y < mid)); // first paragraph + assert!(rects.iter().any(|r| r.origin.y > mid)); // second paragraph +} + +#[test] +fn hit_test_and_cursor_round_trip() { + let cl = two_para_continuous(); + // A click on the second paragraph resolves to block 1. + let (block, _byte) = cl + .hit_test(2.0, cl.paragraphs[1].origin.1 + 2.0) + .expect("hit"); + assert_eq!(block, 1); + // Caret for the second paragraph sits at/after its canvas origin. + let cr = cl.cursor_rect_canvas(1, 0).expect("caret"); + assert!(cr.y >= cl.paragraphs[1].origin.1 - 1.0); +} + +#[test] +fn paginated_all_items_across_pages() { + let page1 = LayoutPage { + page_number: 1, + page_size: LayoutSize::new(595.0, 842.0), + margins: LayoutInsets::uniform(72.0), + content_items: vec![make_filled(0.0), make_filled(10.0)], + header_items: vec![make_filled(20.0)], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: None, + }; + let page2 = LayoutPage { + page_number: 2, + page_size: LayoutSize::new(595.0, 842.0), + margins: LayoutInsets::uniform(72.0), + content_items: vec![make_filled(0.0)], + header_items: vec![], + footer_items: vec![make_filled(30.0)], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: None, + }; + let layout = DocumentLayout::Paginated(PaginatedLayout { + page_size: LayoutSize::new(595.0, 842.0), + pages: vec![Arc::new(page1), Arc::new(page2)], + }); + // page1: 2 content + 1 header = 3; page2: 1 content + 1 footer = 2 → total 5 + assert_eq!(layout.all_items().count(), 5); +} + +#[test] +fn layout_page_content_rect() { + let page = LayoutPage { + page_number: 1, + page_size: LayoutSize::new(595.0, 842.0), + margins: LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: None, + }; + let cr = page.content_rect(); + assert_eq!(cr.x(), 72.0); + assert_eq!(cr.y(), 72.0); + assert_eq!(cr.width(), 595.0 - 144.0); + assert_eq!(cr.height(), 842.0 - 144.0); +} + +#[test] +fn document_layout_total_height_paginated() { + let make_page = |n: usize| LayoutPage { + page_number: n, + page_size: LayoutSize::new(595.0, 842.0), + margins: LayoutInsets::uniform(72.0), + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: None, + }; + let layout = DocumentLayout::Paginated(PaginatedLayout { + page_size: LayoutSize::new(595.0, 842.0), + pages: vec![Arc::new(make_page(1)), Arc::new(make_page(2))], + }); + assert_eq!(layout.total_height(), 842.0 * 2.0); +} + +#[test] +fn document_layout_content_width_continuous() { + let layout = DocumentLayout::Continuous(ContinuousLayout { + content_width: 480.0, + total_height: 100.0, + items: vec![], + paragraphs: vec![], + }); + assert_eq!(layout.content_width(), 480.0); +} diff --git a/loki-layout/tests/multi_section_tests.rs b/loki-layout/tests/multi_section_tests.rs new file mode 100644 index 00000000..967b2405 --- /dev/null +++ b/loki-layout/tests/multi_section_tests.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Multi-section layout: block indices must be **global** (document order across +//! every section). The editor and the `loro_mutation` layer address blocks by a +//! single flat index, so a hit-test / cursor position must resolve to the right +//! section's block. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::{Block, StyledParagraph}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::layout::Section; + +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; + +fn resources() -> FontResources { + let mut r = FontResources::new(); + for p in [ + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ] { + if let Ok(data) = std::fs::read(p) { + r.register_font(data); + break; + } + } + r +} + +fn section(texts: &[&str]) -> Section { + let mut s = Section::new(); + for t in texts { + s.blocks.push(Block::StyledPara(StyledParagraph { + style_id: None, + direct_para_props: None, + direct_char_props: None, + inlines: vec![Inline::Str((*t).into())], + attr: NodeAttr::default(), + })); + } + s +} + +#[test] +fn block_index_is_global_across_sections() { + let mut doc = Document::new(); + doc.sections = vec![section(&["a0", "a1"]), section(&["b0", "b1", "b2"])]; + + let mut r = resources(); + let layout = layout_document( + &mut r, + &doc, + LayoutMode::Reflow { + available_width: 600.0, + }, + 1.0, + &LayoutOptions { + preserve_for_editing: true, + }, + ); + + let DocumentLayout::Continuous(cl) = layout else { + panic!("Reflow mode must yield a Continuous layout"); + }; + + // 2 blocks in section 0 (global indices 0, 1), 3 in section 1 (global 2, 3, + // 4). Without the global offset these would be [0, 1, 0, 1, 2] and section-1 + // edits would hit section 0. + let indices: Vec = cl.paragraphs.iter().map(|p| p.block_index).collect(); + assert_eq!( + indices, + vec![0, 1, 2, 3, 4], + "block indices must be global (cumulative) across sections" + ); +} diff --git a/loki-layout/tests/table_rotation_tests.rs b/loki-layout/tests/table_rotation_tests.rs index cc6e8efa..cffd1625 100644 --- a/loki-layout/tests/table_rotation_tests.rs +++ b/loki-layout/tests/table_rotation_tests.rs @@ -44,6 +44,7 @@ fn flow_pageless(r: &mut FontResources, section: &Section) -> (Vec (items, height), _ => panic!("expected Canvas output"), diff --git a/loki-layout/tests/table_tests.rs b/loki-layout/tests/table_tests.rs index a2881c51..bde5dd6e 100644 --- a/loki-layout/tests/table_tests.rs +++ b/loki-layout/tests/table_tests.rs @@ -63,6 +63,7 @@ fn flow_pageless(r: &mut FontResources, section: &Section) -> (Vec (items, height), _ => panic!("expected Canvas output"), diff --git a/loki-odf/Cargo.toml b/loki-odf/Cargo.toml index 41a73c76..185c7776 100644 --- a/loki-odf/Cargo.toml +++ b/loki-odf/Cargo.toml @@ -3,7 +3,7 @@ name = "loki-odf" version = "0.1.0" edition = "2024" license = "Apache-2.0" -description = "ODF (OpenDocument Text) import/export for the Loki document suite" +description = "ODF (OpenDocument) import + ODS export for the Loki document suite (ODT export pending)" repository = "https://github.com/appthere/loki" keywords = ["odf", "odt", "opendocument", "document", "loki"] categories = ["encoding", "parser-implementations", "text-processing"] diff --git a/loki-odf/src/constants.rs b/loki-odf/src/constants.rs index 6766568b..81c7e500 100644 --- a/loki-odf/src/constants.rs +++ b/loki-odf/src/constants.rs @@ -58,6 +58,18 @@ pub const MIME_ODT: &str = "application/vnd.oasis.opendocument.text"; /// uncompressed, with no trailing newline. ODF 1.3 §3.3. pub const MIME_ODS: &str = "application/vnd.oasis.opendocument.spreadsheet"; +/// MIME type for an OTT (`OpenDocument` Text **Template**) package. +/// +/// Structurally identical to an ODT; only the `mimetype` entry differs. ODF +/// 1.3 §3.3. Opening one yields a new (untitled) document based on the template. +pub const MIME_OTT: &str = "application/vnd.oasis.opendocument.text-template"; + +/// MIME type for an OTS (`OpenDocument` Spreadsheet Template) package. +/// +/// Structurally identical to an ODS; only the `mimetype` entry differs. ODF +/// 1.3 §3.3. +pub const MIME_OTS: &str = "application/vnd.oasis.opendocument.spreadsheet-template"; + // ── ODF version strings ──────────────────────────────────────────────────────── /// Version string for ODF 1.1 (ISO/IEC 26300:2006/Amd 1:2012). diff --git a/loki-odf/src/ods/import.rs b/loki-odf/src/ods/import.rs index c272ea5d..e30edfd9 100644 --- a/loki-odf/src/ods/import.rs +++ b/loki-odf/src/ods/import.rs @@ -118,10 +118,8 @@ impl OdsImport { in_cell = true; } } - b"p" => { - if in_cell { - in_p = true; - } + b"p" if in_cell => { + in_p = true; } _ => {} } @@ -129,40 +127,38 @@ impl OdsImport { Ok(Event::Empty(ref e)) => { let local = local_name(e); match local { - b"table-cell" => { - if in_row { - let style_name = local_attr_val(e, b"style-name"); - let cols_repeated = local_attr_val(e, b"number-columns-repeated") - .and_then(|s| s.parse::().ok()) - .unwrap_or(1) - .min(MAX_SHEET_COLS); - - let style = style_name.and_then(|name| styles.get(&name).cloned()); - if let Some(ref mut ws) = current_sheet { - if style.is_some() { - // Only materialize a bounded number of - // cells; the cursor still advances by the - // full clamped repeat count below. - let mat_rows = - current_row_repeated.min(MAX_MATERIALIZED_REPEAT); - let mat_cols = cols_repeated.min(MAX_MATERIALIZED_REPEAT); - fill_cells( - ws, - row_idx, - col_idx, - mat_rows, - mat_cols, - &mut materialized_cells, - &Cell { - value: String::new(), - formula: None, - style: style.clone(), - }, - ); - } + b"table-cell" if in_row => { + let style_name = local_attr_val(e, b"style-name"); + let cols_repeated = local_attr_val(e, b"number-columns-repeated") + .and_then(|s| s.parse::().ok()) + .unwrap_or(1) + .min(MAX_SHEET_COLS); + + let style = style_name.and_then(|name| styles.get(&name).cloned()); + if let Some(ref mut ws) = current_sheet { + if style.is_some() { + // Only materialize a bounded number of + // cells; the cursor still advances by the + // full clamped repeat count below. + let mat_rows = + current_row_repeated.min(MAX_MATERIALIZED_REPEAT); + let mat_cols = cols_repeated.min(MAX_MATERIALIZED_REPEAT); + fill_cells( + ws, + row_idx, + col_idx, + mat_rows, + mat_cols, + &mut materialized_cells, + &Cell { + value: String::new(), + formula: None, + style: style.clone(), + }, + ); } - col_idx = col_idx.saturating_add(cols_repeated); } + col_idx = col_idx.saturating_add(cols_repeated); } _ => {} } @@ -226,13 +222,11 @@ impl OdsImport { in_row = false; } } - b"table" => { - if in_table { - if let Some(ws) = current_sheet.take() { - sheets.push(ws); - } - in_table = false; + b"table" if in_table => { + if let Some(ws) = current_sheet.take() { + sheets.push(ws); } + in_table = false; } _ => {} } diff --git a/loki-odf/src/odt/export.rs b/loki-odf/src/odt/export.rs index 6d7d824d..55ee453a 100644 --- a/loki-odf/src/odt/export.rs +++ b/loki-odf/src/odt/export.rs @@ -1,52 +1,118 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 AppThere Loki contributors -//! ODT export — stub implementation. +//! ODT export. //! -//! [`OdtExport`] will write a [`loki_doc_model::Document`] to an ODT -//! (`OpenDocument` Text) ZIP package. The full implementation is deferred to a -//! later session; calling [`OdtExport::export`] currently returns -//! [`crate::error::OdfError::NotImplemented`]. +//! [`OdtExport`] writes a [`loki_doc_model::Document`] to an ODT +//! (`OpenDocument` Text) ZIP package: `mimetype`, `META-INF/manifest.xml`, +//! `content.xml`, `styles.xml`, and `meta.xml`. ODF 1.3 §3. use std::io::{Seek, Write}; use loki_doc_model::document::Document; use loki_doc_model::io::DocumentExport; +use zip::{CompressionMethod, ZipWriter, write::FileOptions}; +use crate::constants::{ + ENTRY_CONTENT, ENTRY_MANIFEST, ENTRY_META, ENTRY_MIMETYPE, ENTRY_STYLES, MIME_ODT, +}; use crate::error::{OdfError, OdfResult}; +use crate::odt::write::{MathPart, MediaPart, content_xml, meta_xml, styles_xml}; /// Options controlling ODT export behaviour. /// -/// Currently empty; reserved for future use (e.g. controlling whether -/// images are embedded or linked). -/// -/// ODF 1.3 §3 (package conventions). +/// Currently empty; reserved for future use (e.g. controlling whether images +/// are embedded or linked). ODF 1.3 §3. #[non_exhaustive] #[derive(Debug, Clone, Default)] pub struct OdtExportOptions {} /// Unit struct that implements [`DocumentExport`] for ODT files. -/// -/// Export is not yet implemented; all calls return -/// [`OdfError::NotImplemented`]. ODF 1.3 §3. pub struct OdtExport; impl DocumentExport for OdtExport { type Error = OdfError; type Options = OdtExportOptions; - /// Export a [`Document`] as an ODT file. - /// - /// **Not yet implemented.** Returns [`OdfError::NotImplemented`]. - /// - /// ODF 1.3 §3 (package conventions). - fn export( - _doc: &Document, - _writer: impl Write + Seek, - _options: Self::Options, - ) -> OdfResult<()> { - Err(OdfError::NotImplemented { - feature: "ODT export".into(), - }) + /// Exports a [`Document`] as an ODT package. ODF 1.3 §3. + fn export(doc: &Document, writer: impl Write + Seek, _options: Self::Options) -> OdfResult<()> { + let content = content_xml(doc); + let styles = styles_xml(doc); + + let mut zip = ZipWriter::new(writer); + + // 1. mimetype — first entry, stored (uncompressed), no trailing newline. + let stored = FileOptions::<()>::default().compression_method(CompressionMethod::Stored); + zip.start_file(ENTRY_MIMETYPE, stored)?; + zip.write_all(MIME_ODT.as_bytes())?; + + let deflated = FileOptions::<()>::default().compression_method(CompressionMethod::Deflated); + + // 2. manifest (listing every part, including embedded images from both + // the body and the master-page header/footer, plus any embedded + // formula objects). + zip.start_file(ENTRY_MANIFEST, deflated)?; + zip.write_all(manifest(&content.media, &styles.media, &content.objects).as_bytes())?; + + // 3. the three XML parts. + zip.start_file(ENTRY_CONTENT, deflated)?; + zip.write_all(content.xml.as_bytes())?; + + zip.start_file(ENTRY_STYLES, deflated)?; + zip.write_all(styles.xml.as_bytes())?; + + zip.start_file(ENTRY_META, deflated)?; + zip.write_all(meta_xml(doc).as_bytes())?; + + // 4. embedded image parts (already-compressed images stay stored to + // avoid double-compression overhead). + for part in content.media.iter().chain(styles.media.iter()) { + zip.start_file(&part.path, stored)?; + zip.write_all(&part.bytes)?; + } + + // 5. embedded formula objects: each `Object N/content.xml` sub-document. + for obj in &content.objects { + zip.start_file(format!("{}/content.xml", obj.dir), deflated)?; + zip.write_all(obj.content_xml.as_bytes())?; + } + + zip.finish()?; + Ok(()) + } +} + +/// Builds `META-INF/manifest.xml`, listing the fixed parts, every image +/// (from the body and the master-page header/footer), and every embedded +/// formula object sub-document. +fn manifest(body_media: &[MediaPart], styles_media: &[MediaPart], objects: &[MathPart]) -> String { + let mut m = String::from(concat!( + "\n", + "", + "", + "", + "", + "", + )); + for part in body_media.iter().chain(styles_media.iter()) { + m.push_str(&format!( + "", + part.path, part.media_type + )); + } + // Each formula object contributes a directory entry (with the formula + // media type) and its `content.xml` entry. ODF 1.3 §3.16. + for obj in objects { + m.push_str(&format!( + "\ + ", + obj.dir + )); } + m.push_str(""); + m } diff --git a/loki-odf/src/odt/import.rs b/loki-odf/src/odt/import.rs index d62d6c2d..8cef557a 100644 --- a/loki-odf/src/odt/import.rs +++ b/loki-odf/src/odt/import.rs @@ -195,6 +195,7 @@ impl OdtImporter { &stylesheet, odf_meta.as_ref(), &package.images, + &package.objects, &self.options, ); warnings.append(&mut mapper_warnings); @@ -260,95 +261,5 @@ fn raw_version_attr(content: &[u8]) -> Option { // ── Tests ────────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use std::io::{Cursor, Write}; - - use zip::CompressionMethod; - use zip::write::{FileOptions, ZipWriter}; - - use super::*; - use crate::constants::{ENTRY_CONTENT, ENTRY_MANIFEST, ENTRY_STYLES, MIME_ODT}; - - fn build_odt_zip(version: Option<&str>) -> Vec { - let ver_attr = match version { - Some(v) => format!(" office:version=\"{v}\""), - None => String::new(), - }; - let content = format!(r#""#); - - let mut buf = Vec::new(); - let mut zip = ZipWriter::new(Cursor::new(&mut buf)); - - let stored = FileOptions::<()>::default().compression_method(CompressionMethod::Stored); - zip.start_file("mimetype", stored).unwrap(); - zip.write_all(MIME_ODT.as_bytes()).unwrap(); - - let deflated = FileOptions::<()>::default().compression_method(CompressionMethod::Deflated); - zip.start_file(ENTRY_MANIFEST, deflated).unwrap(); - zip.write_all(b"").unwrap(); - zip.start_file(ENTRY_CONTENT, deflated).unwrap(); - zip.write_all(content.as_bytes()).unwrap(); - zip.start_file(ENTRY_STYLES, deflated).unwrap(); - zip.write_all(b"").unwrap(); - - zip.finish().unwrap(); - buf - } - - #[test] - fn run_returns_source_version_1_2() { - let zip = build_odt_zip(Some("1.2")); - let result = OdtImporter::new(OdtImportOptions::default()) - .run(Cursor::new(zip)) - .unwrap(); - assert_eq!(result.source_version, OdfVersion::V1_2); - assert_eq!( - result.document.source.as_ref().unwrap().version.as_deref(), - Some("1.2") - ); - } - - #[test] - fn run_absent_version_is_v1_1() { - let zip = build_odt_zip(None); - let result = OdtImporter::new(OdtImportOptions::default()) - .run(Cursor::new(zip)) - .unwrap(); - assert_eq!(result.source_version, OdfVersion::V1_1); - } - - #[test] - fn run_unknown_version_non_strict_emits_warning() { - let zip = build_odt_zip(Some("99.0")); - let result = OdtImporter::new(OdtImportOptions::default()) - .run(Cursor::new(zip)) - .unwrap(); - assert_eq!(result.source_version, OdfVersion::V1_3); - assert!( - result.warnings.iter().any(|w| matches!( - w, - OdfWarning::UnrecognisedVersion { version } - if version == "99.0" - )), - "expected UnrecognisedVersion warning" - ); - } - - #[test] - fn run_unknown_version_strict_returns_error() { - let zip = build_odt_zip(Some("99.0")); - let opts = OdtImportOptions { - strict_version: true, - ..Default::default() - }; - let result = OdtImporter::new(opts).run(Cursor::new(zip)); - assert!( - matches!( - result, - Err(OdfError::UnsupportedVersion { ref version }) - if version == "99.0" - ), - "expected UnsupportedVersion error, got {result:?}" - ); - } -} +#[path = "import_tests.rs"] +mod tests; diff --git a/loki-odf/src/odt/import_tests.rs b/loki-odf/src/odt/import_tests.rs new file mode 100644 index 00000000..899c9463 --- /dev/null +++ b/loki-odf/src/odt/import_tests.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `import`. + +use std::io::{Cursor, Write}; + +use zip::CompressionMethod; +use zip::write::{FileOptions, ZipWriter}; + +use super::*; +use crate::constants::{ENTRY_CONTENT, ENTRY_MANIFEST, ENTRY_STYLES, MIME_ODT, MIME_OTT}; + +fn build_odt_zip(version: Option<&str>) -> Vec { + let ver_attr = match version { + Some(v) => format!(" office:version=\"{v}\""), + None => String::new(), + }; + let content = format!(r#""#); + + let mut buf = Vec::new(); + let mut zip = ZipWriter::new(Cursor::new(&mut buf)); + + let stored = FileOptions::<()>::default().compression_method(CompressionMethod::Stored); + zip.start_file("mimetype", stored).unwrap(); + zip.write_all(MIME_ODT.as_bytes()).unwrap(); + + let deflated = FileOptions::<()>::default().compression_method(CompressionMethod::Deflated); + zip.start_file(ENTRY_MANIFEST, deflated).unwrap(); + zip.write_all(b"").unwrap(); + zip.start_file(ENTRY_CONTENT, deflated).unwrap(); + zip.write_all(content.as_bytes()).unwrap(); + zip.start_file(ENTRY_STYLES, deflated).unwrap(); + zip.write_all(b"").unwrap(); + + zip.finish().unwrap(); + buf +} + +/// Builds a minimal package with an arbitrary `mimetype` string (for template +/// variants), otherwise identical to [`build_odt_zip`]. +fn build_zip_with_mimetype(mimetype: &str) -> Vec { + let mut buf = Vec::new(); + let mut zip = ZipWriter::new(Cursor::new(&mut buf)); + let stored = FileOptions::<()>::default().compression_method(CompressionMethod::Stored); + zip.start_file("mimetype", stored).unwrap(); + zip.write_all(mimetype.as_bytes()).unwrap(); + let deflated = FileOptions::<()>::default().compression_method(CompressionMethod::Deflated); + zip.start_file(ENTRY_MANIFEST, deflated).unwrap(); + zip.write_all(b"").unwrap(); + zip.start_file(ENTRY_CONTENT, deflated).unwrap(); + zip.write_all(br#""#) + .unwrap(); + zip.start_file(ENTRY_STYLES, deflated).unwrap(); + zip.write_all(b"").unwrap(); + zip.finish().unwrap(); + buf +} + +#[test] +fn run_accepts_ott_template_mimetype() { + // An OTT (text template) is structurally an ODT; the importer must accept it + // so the editor can open it as a new document. + let zip = build_zip_with_mimetype(MIME_OTT); + let result = OdtImporter::new(OdtImportOptions::default()).run(Cursor::new(zip)); + assert!( + result.is_ok(), + "OTT template mimetype must import: {result:?}" + ); +} + +#[test] +fn run_rejects_unknown_mimetype() { + let zip = build_zip_with_mimetype("application/zip"); + let result = OdtImporter::new(OdtImportOptions::default()).run(Cursor::new(zip)); + assert!(result.is_err(), "non-ODF mimetype must still be rejected"); +} + +#[test] +fn run_returns_source_version_1_2() { + let zip = build_odt_zip(Some("1.2")); + let result = OdtImporter::new(OdtImportOptions::default()) + .run(Cursor::new(zip)) + .unwrap(); + assert_eq!(result.source_version, OdfVersion::V1_2); + assert_eq!( + result.document.source.as_ref().unwrap().version.as_deref(), + Some("1.2") + ); +} + +#[test] +fn run_absent_version_is_v1_1() { + let zip = build_odt_zip(None); + let result = OdtImporter::new(OdtImportOptions::default()) + .run(Cursor::new(zip)) + .unwrap(); + assert_eq!(result.source_version, OdfVersion::V1_1); +} + +#[test] +fn run_unknown_version_non_strict_emits_warning() { + let zip = build_odt_zip(Some("99.0")); + let result = OdtImporter::new(OdtImportOptions::default()) + .run(Cursor::new(zip)) + .unwrap(); + assert_eq!(result.source_version, OdfVersion::V1_3); + assert!( + result.warnings.iter().any(|w| matches!( + w, + OdfWarning::UnrecognisedVersion { version } + if version == "99.0" + )), + "expected UnrecognisedVersion warning" + ); +} + +#[test] +fn run_unknown_version_strict_returns_error() { + let zip = build_odt_zip(Some("99.0")); + let opts = OdtImportOptions { + strict_version: true, + ..Default::default() + }; + let result = OdtImporter::new(opts).run(Cursor::new(zip)); + assert!( + matches!( + result, + Err(OdfError::UnsupportedVersion { ref version }) + if version == "99.0" + ), + "expected UnsupportedVersion error, got {result:?}" + ); +} diff --git a/loki-odf/src/odt/mapper/document.rs b/loki-odf/src/odt/mapper/document.rs index 27772aea..f47a33f0 100644 --- a/loki-odf/src/odt/mapper/document.rs +++ b/loki-odf/src/odt/mapper/document.rs @@ -18,19 +18,24 @@ use std::collections::HashMap; +use loki_doc_model::content::annotation::{Comment, CommentRef, CommentRefKind}; use loki_doc_model::content::attr::{ExtensionBag, NodeAttr}; use loki_doc_model::content::block::{ Block, Caption, ListAttributes, ListDelimiter, ListNumberStyle, StyledParagraph, TableOfContentsBlock, }; use loki_doc_model::content::field::types::{CrossRefFormat, Field, FieldKind}; -use loki_doc_model::content::inline::{BookmarkKind, Inline, LinkTarget, NoteKind, StyledRun}; +use loki_doc_model::content::inline::{ + BookmarkKind, Inline, LinkTarget, MathType, NoteKind, StyledRun, +}; use loki_doc_model::content::table::col::{ColAlignment, ColSpec, ColWidth}; 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::header_footer::{HeaderFooter, HeaderFooterKind}; -use loki_doc_model::layout::page::{PageLayout, PageMargins, PageOrientation, PageSize}; +use loki_doc_model::layout::page::{ + PageLayout, PageMargins, PageOrientation, PageSize, SectionColumns, +}; use loki_doc_model::layout::section::Section; use loki_doc_model::meta::core::DocumentMeta; use loki_doc_model::style::catalog::{StyleCatalog, StyleId}; @@ -70,6 +75,9 @@ pub(crate) struct OdfMappingContext<'a> { /// Images extracted from the ODF package: ZIP-entry path → /// (media-type, raw bytes). pub images: &'a HashMap)>, + /// Embedded object sub-documents (e.g. formulas): object directory → + /// raw `content.xml` bytes. + pub objects: &'a HashMap>, /// Import options controlling heading emission, image embedding, etc. pub options: &'a OdtImportOptions, /// Column widths from `style:table-column-properties`: style name → points. @@ -84,6 +92,8 @@ pub(crate) struct OdfMappingContext<'a> { /// collected while mapping inline content. The caller flushes this after /// each paragraph or block. pub pending_figures: Vec, + /// Comment bodies collected from `office:annotation` start anchors. + pub comments: Vec, } // ── Public entry point ───────────────────────────────────────────────────────── @@ -101,6 +111,7 @@ pub(crate) fn map_document( stylesheet: &OdfStylesheet, meta: Option<&OdfMeta>, images: &HashMap)>, + objects: &HashMap>, options: &OdtImportOptions, ) -> (Document, Vec) { // ── 1. Map stylesheet + list styles ────────────────────────────────────── @@ -144,15 +155,17 @@ pub(crate) fn map_document( .map(|m| m.name.as_str()); // ── 4. Map body, detecting master page transitions → multiple sections ──── - let (sections, warnings) = { + let (sections, warnings, comments) = { let mut ctx = OdfMappingContext { styles: &catalog, images, + objects, options, col_style_widths: &col_style_widths, cell_style_props: &cell_style_props, warnings: Vec::new(), pending_figures: Vec::new(), + comments: Vec::new(), }; let mut current_master: Option = initial_master.map(str::to_string); @@ -193,7 +206,7 @@ pub(crate) fn map_document( let layout = resolve_page_layout_by_name(stylesheet, current_master.as_deref(), &mut ctx); sections.push(Section::with_layout_and_blocks(layout, current_blocks)); - (sections, ctx.warnings) + (sections, ctx.warnings, ctx.comments) }; // ── 5. Map metadata ─────────────────────────────────────────────────────── @@ -205,6 +218,7 @@ pub(crate) fn map_document( styles: catalog, sections, settings: None, + comments, source: None, }; @@ -316,6 +330,27 @@ fn map_inline(child: &OdfParagraphChild, ctx: &mut OdfMappingContext<'_>) -> Opt )) } OdfParagraphChild::LineBreak => Some(Inline::LineBreak), + OdfParagraphChild::Annotation { + name, + creator, + date, + body, + } => { + let id = name.clone().unwrap_or_default(); + let mut comment = Comment::new(id.clone()); + comment.author.clone_from(creator); + comment.date = date.as_deref().and_then(parse_datetime); + comment.body = body + .iter() + .map(|t| Block::Para(vec![Inline::Str(t.clone())])) + .collect(); + ctx.comments.push(comment); + Some(Inline::Comment(CommentRef::new(id, CommentRefKind::Start))) + } + OdfParagraphChild::AnnotationEnd { name } => Some(Inline::Comment(CommentRef::new( + name.clone().unwrap_or_default(), + CommentRefKind::End, + ))), } } @@ -456,6 +491,26 @@ fn map_frame(frame: &OdfFrame, ctx: &mut OdfMappingContext<'_>) -> Option { + // Resolve the embedded sub-document; if it is a MathML formula, map + // it to inline math. ODF does not distinguish display from inline + // math, so an embedded formula always maps to `MathType::InlineMath`. + // Any other (or unresolvable) object — OLE, chart, … — is dropped + // with a warning rather than silently lost. + let key = href.trim_start_matches("./").trim_end_matches('/'); + let mathml = ctx + .objects + .get(key) + .and_then(|bytes| crate::odt::math::extract_mathml(bytes)); + if let Some(mathml) = mathml { + Some(Inline::Math(MathType::InlineMath, mathml)) + } else { + ctx.warnings.push(OdfWarning::DroppedFrame { + name: frame.name.clone(), + }); + None + } + } OdfFrameKind::Other => { ctx.warnings.push(OdfWarning::DroppedFrame { name: frame.name.clone(), @@ -811,6 +866,21 @@ fn convert_page_layout(pl: &OdfPageLayout) -> PageLayout { _ => PageOrientation::Portrait, }; + // Multi-column layout is only meaningful for two or more columns. + let columns = pl + .columns + .as_ref() + .filter(|c| c.count >= 2) + .map(|c| SectionColumns { + count: u8::try_from(c.count.clamp(2, u32::from(u8::MAX))).unwrap_or(2), + gap: c + .gap + .as_deref() + .and_then(parse_length) + .unwrap_or_else(|| Points::new(18.0)), + separator: c.separator, + }); + PageLayout { page_size: PageSize { width, height }, margins: PageMargins { @@ -823,6 +893,7 @@ fn convert_page_layout(pl: &OdfPageLayout) -> PageLayout { gutter: zero, }, orientation, + columns, ..Default::default() } } @@ -846,6 +917,9 @@ fn map_meta(meta: &OdfMeta) -> DocumentMeta { created: meta.created.as_deref().and_then(parse_datetime), modified: meta.modified.as_deref().and_then(parse_datetime), revision: meta.editing_cycles, + dublin_core: loki_doc_model::meta::dublin_core::DublinCoreMeta::from_named_pairs( + &meta.user_defined, + ), ..Default::default() } } @@ -905,8 +979,14 @@ mod tests { #[test] fn empty_document_produces_empty_section() { let doc = empty_doc(vec![]); - let (result, warnings) = - map_document(&doc, &empty_stylesheet(), None, &HashMap::new(), &options()); + let (result, warnings) = map_document( + &doc, + &empty_stylesheet(), + None, + &HashMap::new(), + &HashMap::new(), + &options(), + ); assert!(warnings.is_empty()); assert_eq!(result.sections.len(), 1); assert!(result.sections[0].blocks.is_empty()); @@ -916,8 +996,14 @@ mod tests { fn heading_is_emitted_as_heading_block() { let para = text_paragraph("Title", true, Some(1)); let doc = empty_doc(vec![OdfBodyChild::Heading(para)]); - let (result, _) = - map_document(&doc, &empty_stylesheet(), None, &HashMap::new(), &options()); + let (result, _) = map_document( + &doc, + &empty_stylesheet(), + None, + &HashMap::new(), + &HashMap::new(), + &options(), + ); let blocks = &result.sections[0].blocks; assert_eq!(blocks.len(), 1); assert!( @@ -935,7 +1021,14 @@ mod tests { emit_heading_blocks: false, ..options() }; - let (result, _) = map_document(&doc, &empty_stylesheet(), None, &HashMap::new(), &opts); + let (result, _) = map_document( + &doc, + &empty_stylesheet(), + None, + &HashMap::new(), + &HashMap::new(), + &opts, + ); let blocks = &result.sections[0].blocks; assert_eq!(blocks.len(), 1); assert!( @@ -949,8 +1042,14 @@ mod tests { fn paragraph_is_emitted_as_styled_para() { let para = text_paragraph("Hello", false, None); let doc = empty_doc(vec![OdfBodyChild::Paragraph(para)]); - let (result, _) = - map_document(&doc, &empty_stylesheet(), None, &HashMap::new(), &options()); + let (result, _) = map_document( + &doc, + &empty_stylesheet(), + None, + &HashMap::new(), + &HashMap::new(), + &options(), + ); let blocks = &result.sections[0].blocks; assert!( matches!(blocks[0], Block::StyledPara(_)), @@ -963,8 +1062,14 @@ mod tests { fn text_content_preserved_in_heading() { let para = text_paragraph("Introduction", true, Some(1)); let doc = empty_doc(vec![OdfBodyChild::Heading(para)]); - let (result, _) = - map_document(&doc, &empty_stylesheet(), None, &HashMap::new(), &options()); + let (result, _) = map_document( + &doc, + &empty_stylesheet(), + None, + &HashMap::new(), + &HashMap::new(), + &options(), + ); if let Block::Heading(_, _, inlines) = &result.sections[0].blocks[0] { assert_eq!(inlines.len(), 1); assert!(matches!(&inlines[0], loki_doc_model::Inline::Str(s) if s == "Introduction")); @@ -989,6 +1094,7 @@ mod tests { &empty_stylesheet(), Some(&odf_meta), &HashMap::new(), + &HashMap::new(), &options(), ); assert_eq!(result.meta.title.as_deref(), Some("My Document")); diff --git a/loki-odf/src/odt/mapper/lists.rs b/loki-odf/src/odt/mapper/lists.rs index f6f2a3bd..8b2f728d 100644 --- a/loki-odf/src/odt/mapper/lists.rs +++ b/loki-odf/src/odt/mapper/lists.rs @@ -211,239 +211,5 @@ fn map_indentation( // ── Tests ────────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use crate::odt::model::list_styles::{OdfListLevel, OdfListLevelKind, OdfListStyle}; - use loki_doc_model::style::list_style::{BulletChar, ListLevelKind, NumberingScheme}; - - fn bullet_level(ch: &str, legacy_space: &str, legacy_width: &str) -> OdfListLevel { - OdfListLevel { - level: 0, - kind: OdfListLevelKind::Bullet { - char: ch.into(), - style_name: None, - }, - legacy_space_before: Some(legacy_space.into()), - legacy_min_label_width: Some(legacy_width.into()), - legacy_min_label_distance: None, - label_followed_by: None, - list_tab_stop_position: None, - text_indent: None, - margin_left: None, - text_props: None, - } - } - - fn number_level( - fmt: &str, - suffix: &str, - start: u32, - display_levels: u8, - margin: &str, - indent: &str, - ) -> OdfListLevel { - OdfListLevel { - level: 0, - kind: OdfListLevelKind::Number { - num_format: Some(fmt.into()), - num_prefix: None, - num_suffix: Some(suffix.into()), - start_value: Some(start), - display_levels, - style_name: None, - }, - legacy_space_before: None, - legacy_min_label_width: None, - legacy_min_label_distance: None, - label_followed_by: Some("listtab".into()), - list_tab_stop_position: None, - text_indent: Some(indent.into()), - margin_left: Some(margin.into()), - text_props: None, - } - } - - #[test] - fn bullet_char_bullet() { - let level = bullet_level("•", "0.25cm", "0.25cm"); - let ls = OdfListStyle { - name: "L1".into(), - levels: vec![level], - }; - let mut catalog = StyleCatalog::new(); - map_list_styles(&[ls], &mut catalog, OdfVersion::V1_1); - let style = catalog.list_styles.get(&ListId::new("L1")).unwrap(); - assert_eq!(style.levels.len(), 1); - match &style.levels[0].kind { - ListLevelKind::Bullet { - char: BulletChar::Char(c), - .. - } => { - assert_eq!(*c, '•'); - } - other => panic!("expected Bullet, got {:?}", other), - } - } - - #[test] - fn bullet_custom_char() { - let level = bullet_level("-", "0.5cm", "0.25cm"); - let ls = OdfListStyle { - name: "L2".into(), - levels: vec![level], - }; - let mut catalog = StyleCatalog::new(); - map_list_styles(&[ls], &mut catalog, OdfVersion::V1_1); - let style = catalog.list_styles.get(&ListId::new("L2")).unwrap(); - match &style.levels[0].kind { - ListLevelKind::Bullet { - char: BulletChar::Char(c), - .. - } => { - assert_eq!(*c, '-'); - } - other => panic!("expected Bullet, got {:?}", other), - } - } - - #[test] - fn number_decimal_with_suffix() { - let level = number_level("1", ".", 1, 1, "1.27cm", "-0.635cm"); - let ls = OdfListStyle { - name: "L3".into(), - levels: vec![level], - }; - let mut catalog = StyleCatalog::new(); - map_list_styles(&[ls], &mut catalog, OdfVersion::V1_2); - let style = catalog.list_styles.get(&ListId::new("L3")).unwrap(); - match &style.levels[0].kind { - ListLevelKind::Numbered { - scheme, - format, - start_value, - .. - } => { - assert_eq!(*scheme, NumberingScheme::Decimal); - assert_eq!(format, "%1."); - assert_eq!(*start_value, 1); - } - other => panic!("expected Numbered, got {:?}", other), - } - } - - #[test] - fn number_lower_alpha() { - let level = number_level("a", ")", 1, 1, "1.27cm", "-0.635cm"); - let ls = OdfListStyle { - name: "L4".into(), - levels: vec![level], - }; - let mut catalog = StyleCatalog::new(); - map_list_styles(&[ls], &mut catalog, OdfVersion::V1_2); - let style = catalog.list_styles.get(&ListId::new("L4")).unwrap(); - match &style.levels[0].kind { - ListLevelKind::Numbered { scheme, format, .. } => { - assert_eq!(*scheme, NumberingScheme::LowerAlpha); - assert_eq!(format, "%1)"); - } - other => panic!("expected Numbered, got {:?}", other), - } - } - - #[test] - fn odf12_label_alignment_indentation() { - let level = number_level("1", ".", 1, 1, "1.27cm", "-0.635cm"); - let ls = OdfListStyle { - name: "L5".into(), - levels: vec![level], - }; - let mut catalog = StyleCatalog::new(); - map_list_styles(&[ls], &mut catalog, OdfVersion::V1_2); - let style = catalog.list_styles.get(&ListId::new("L5")).unwrap(); - let ll = &style.levels[0]; - // margin_left = 1.27cm ≈ 36.0pt - assert!( - ll.indent_start.value() > 35.0 && ll.indent_start.value() < 37.0, - "indent_start={}", - ll.indent_start.value() - ); - // text_indent = -0.635cm ≈ 18pt, stored as positive hanging - assert!( - ll.hanging_indent.value() > 17.0 && ll.hanging_indent.value() < 19.0, - "hanging_indent={}", - ll.hanging_indent.value() - ); - } - - #[test] - fn odf11_legacy_indentation() { - // space_before=0.25cm, min_label_width=0.25cm - // → indent_start = 0.5cm, hanging = 0.25cm - let level = bullet_level("•", "0.25cm", "0.25cm"); - let ls = OdfListStyle { - name: "L6".into(), - levels: vec![level], - }; - let mut catalog = StyleCatalog::new(); - map_list_styles(&[ls], &mut catalog, OdfVersion::V1_1); - let style = catalog.list_styles.get(&ListId::new("L6")).unwrap(); - let ll = &style.levels[0]; - let expected_indent = parse_length("0.5cm").unwrap().value(); - let expected_hanging = parse_length("0.25cm").unwrap().value(); - assert!( - (ll.indent_start.value() - expected_indent).abs() < 1e-4, - "indent_start: expected {:.3}, got {:.3}", - expected_indent, - ll.indent_start.value() - ); - assert!( - (ll.hanging_indent.value() - expected_hanging).abs() < 1e-4, - "hanging: expected {:.3}, got {:.3}", - expected_hanging, - ll.hanging_indent.value() - ); - } - - #[test] - fn display_levels_two_format() { - // level=1 (0-indexed), display_levels=2 - // → format "%1.%2." - let level = OdfListLevel { - level: 1, // 0-indexed → level_num=2 - kind: OdfListLevelKind::Number { - num_format: Some("1".into()), - num_prefix: None, - num_suffix: Some(".".into()), - start_value: Some(1), - display_levels: 2, - style_name: None, - }, - legacy_space_before: None, - legacy_min_label_width: None, - legacy_min_label_distance: None, - label_followed_by: Some("listtab".into()), - list_tab_stop_position: None, - text_indent: Some("-0.5cm".into()), - margin_left: Some("1cm".into()), - text_props: None, - }; - let ls = OdfListStyle { - name: "L7".into(), - levels: vec![level], - }; - let mut catalog = StyleCatalog::new(); - map_list_styles(&[ls], &mut catalog, OdfVersion::V1_2); - let style = catalog.list_styles.get(&ListId::new("L7")).unwrap(); - match &style.levels[0].kind { - ListLevelKind::Numbered { - format, - display_levels, - .. - } => { - assert_eq!(format, "%1.%2."); - assert_eq!(*display_levels, 2); - } - other => panic!("expected Numbered, got {:?}", other), - } - } -} +#[path = "lists_tests.rs"] +mod tests; diff --git a/loki-odf/src/odt/mapper/lists_tests.rs b/loki-odf/src/odt/mapper/lists_tests.rs new file mode 100644 index 00000000..1ce8e0d5 --- /dev/null +++ b/loki-odf/src/odt/mapper/lists_tests.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `lists`. + +use super::*; +use crate::odt::model::list_styles::{OdfListLevel, OdfListLevelKind, OdfListStyle}; +use loki_doc_model::style::list_style::{BulletChar, ListLevelKind, NumberingScheme}; + +fn bullet_level(ch: &str, legacy_space: &str, legacy_width: &str) -> OdfListLevel { + OdfListLevel { + level: 0, + kind: OdfListLevelKind::Bullet { + char: ch.into(), + style_name: None, + }, + legacy_space_before: Some(legacy_space.into()), + legacy_min_label_width: Some(legacy_width.into()), + legacy_min_label_distance: None, + label_followed_by: None, + list_tab_stop_position: None, + text_indent: None, + margin_left: None, + text_props: None, + } +} + +fn number_level( + fmt: &str, + suffix: &str, + start: u32, + display_levels: u8, + margin: &str, + indent: &str, +) -> OdfListLevel { + OdfListLevel { + level: 0, + kind: OdfListLevelKind::Number { + num_format: Some(fmt.into()), + num_prefix: None, + num_suffix: Some(suffix.into()), + start_value: Some(start), + display_levels, + style_name: None, + }, + legacy_space_before: None, + legacy_min_label_width: None, + legacy_min_label_distance: None, + label_followed_by: Some("listtab".into()), + list_tab_stop_position: None, + text_indent: Some(indent.into()), + margin_left: Some(margin.into()), + text_props: None, + } +} + +#[test] +fn bullet_char_bullet() { + let level = bullet_level("•", "0.25cm", "0.25cm"); + let ls = OdfListStyle { + name: "L1".into(), + levels: vec![level], + }; + let mut catalog = StyleCatalog::new(); + map_list_styles(&[ls], &mut catalog, OdfVersion::V1_1); + let style = catalog.list_styles.get(&ListId::new("L1")).unwrap(); + assert_eq!(style.levels.len(), 1); + match &style.levels[0].kind { + ListLevelKind::Bullet { + char: BulletChar::Char(c), + .. + } => { + assert_eq!(*c, '•'); + } + other => panic!("expected Bullet, got {:?}", other), + } +} + +#[test] +fn bullet_custom_char() { + let level = bullet_level("-", "0.5cm", "0.25cm"); + let ls = OdfListStyle { + name: "L2".into(), + levels: vec![level], + }; + let mut catalog = StyleCatalog::new(); + map_list_styles(&[ls], &mut catalog, OdfVersion::V1_1); + let style = catalog.list_styles.get(&ListId::new("L2")).unwrap(); + match &style.levels[0].kind { + ListLevelKind::Bullet { + char: BulletChar::Char(c), + .. + } => { + assert_eq!(*c, '-'); + } + other => panic!("expected Bullet, got {:?}", other), + } +} + +#[test] +fn number_decimal_with_suffix() { + let level = number_level("1", ".", 1, 1, "1.27cm", "-0.635cm"); + let ls = OdfListStyle { + name: "L3".into(), + levels: vec![level], + }; + let mut catalog = StyleCatalog::new(); + map_list_styles(&[ls], &mut catalog, OdfVersion::V1_2); + let style = catalog.list_styles.get(&ListId::new("L3")).unwrap(); + match &style.levels[0].kind { + ListLevelKind::Numbered { + scheme, + format, + start_value, + .. + } => { + assert_eq!(*scheme, NumberingScheme::Decimal); + assert_eq!(format, "%1."); + assert_eq!(*start_value, 1); + } + other => panic!("expected Numbered, got {:?}", other), + } +} + +#[test] +fn number_lower_alpha() { + let level = number_level("a", ")", 1, 1, "1.27cm", "-0.635cm"); + let ls = OdfListStyle { + name: "L4".into(), + levels: vec![level], + }; + let mut catalog = StyleCatalog::new(); + map_list_styles(&[ls], &mut catalog, OdfVersion::V1_2); + let style = catalog.list_styles.get(&ListId::new("L4")).unwrap(); + match &style.levels[0].kind { + ListLevelKind::Numbered { scheme, format, .. } => { + assert_eq!(*scheme, NumberingScheme::LowerAlpha); + assert_eq!(format, "%1)"); + } + other => panic!("expected Numbered, got {:?}", other), + } +} + +#[test] +fn odf12_label_alignment_indentation() { + let level = number_level("1", ".", 1, 1, "1.27cm", "-0.635cm"); + let ls = OdfListStyle { + name: "L5".into(), + levels: vec![level], + }; + let mut catalog = StyleCatalog::new(); + map_list_styles(&[ls], &mut catalog, OdfVersion::V1_2); + let style = catalog.list_styles.get(&ListId::new("L5")).unwrap(); + let ll = &style.levels[0]; + // margin_left = 1.27cm ≈ 36.0pt + assert!( + ll.indent_start.value() > 35.0 && ll.indent_start.value() < 37.0, + "indent_start={}", + ll.indent_start.value() + ); + // text_indent = -0.635cm ≈ 18pt, stored as positive hanging + assert!( + ll.hanging_indent.value() > 17.0 && ll.hanging_indent.value() < 19.0, + "hanging_indent={}", + ll.hanging_indent.value() + ); +} + +#[test] +fn odf11_legacy_indentation() { + // space_before=0.25cm, min_label_width=0.25cm + // → indent_start = 0.5cm, hanging = 0.25cm + let level = bullet_level("•", "0.25cm", "0.25cm"); + let ls = OdfListStyle { + name: "L6".into(), + levels: vec![level], + }; + let mut catalog = StyleCatalog::new(); + map_list_styles(&[ls], &mut catalog, OdfVersion::V1_1); + let style = catalog.list_styles.get(&ListId::new("L6")).unwrap(); + let ll = &style.levels[0]; + let expected_indent = parse_length("0.5cm").unwrap().value(); + let expected_hanging = parse_length("0.25cm").unwrap().value(); + assert!( + (ll.indent_start.value() - expected_indent).abs() < 1e-4, + "indent_start: expected {:.3}, got {:.3}", + expected_indent, + ll.indent_start.value() + ); + assert!( + (ll.hanging_indent.value() - expected_hanging).abs() < 1e-4, + "hanging: expected {:.3}, got {:.3}", + expected_hanging, + ll.hanging_indent.value() + ); +} + +#[test] +fn display_levels_two_format() { + // level=1 (0-indexed), display_levels=2 + // → format "%1.%2." + let level = OdfListLevel { + level: 1, // 0-indexed → level_num=2 + kind: OdfListLevelKind::Number { + num_format: Some("1".into()), + num_prefix: None, + num_suffix: Some(".".into()), + start_value: Some(1), + display_levels: 2, + style_name: None, + }, + legacy_space_before: None, + legacy_min_label_width: None, + legacy_min_label_distance: None, + label_followed_by: Some("listtab".into()), + list_tab_stop_position: None, + text_indent: Some("-0.5cm".into()), + margin_left: Some("1cm".into()), + text_props: None, + }; + let ls = OdfListStyle { + name: "L7".into(), + levels: vec![level], + }; + let mut catalog = StyleCatalog::new(); + map_list_styles(&[ls], &mut catalog, OdfVersion::V1_2); + let style = catalog.list_styles.get(&ListId::new("L7")).unwrap(); + match &style.levels[0].kind { + ListLevelKind::Numbered { + format, + display_levels, + .. + } => { + assert_eq!(format, "%1.%2."); + assert_eq!(*display_levels, 2); + } + other => panic!("expected Numbered, got {:?}", other), + } +} diff --git a/loki-odf/src/odt/mapper/props.rs b/loki-odf/src/odt/mapper/props.rs deleted file mode 100644 index 8a03ec76..00000000 --- a/loki-odf/src/odt/mapper/props.rs +++ /dev/null @@ -1,925 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Paragraph and character property mappers. -//! -//! Converts [`OdfParaProps`] → [`ParaProps`] and -//! [`OdfTextProps`] → [`CharProps`]. -//! All ODF measurement values are length strings (e.g. `"2.5cm"`, `"12pt"`); -//! conversion uses [`crate::xml_util::parse_length`]. - -use loki_doc_model::content::table::row::{CellProps, CellTextDirection, CellVerticalAlign}; -use loki_doc_model::meta::LanguageTag; -use loki_doc_model::style::props::border::{Border, BorderStyle}; -use loki_doc_model::style::props::char_props::{ - CharProps, StrikethroughStyle, UnderlineStyle, VerticalAlign, -}; -use loki_doc_model::style::props::para_props::{ - LineHeight, ParaProps, ParagraphAlignment, Spacing, -}; -use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; -use loki_primitives::color::DocumentColor; -use loki_primitives::units::Points; - -use crate::odt::model::styles::{OdfCellProps, OdfParaProps, OdfTabStop, OdfTextProps}; -use crate::xml_util::parse_length; - -// ── Paragraph properties ─────────────────────────────────────────────────────── - -/// Convert [`OdfParaProps`] to the format-neutral [`ParaProps`]. -/// -/// All length values (margins, text-indent, line-height) are parsed from ODF -/// attribute strings via [`parse_length`]. Unmapped or unparseable values are -/// silently dropped (the corresponding field remains `None`). ODF 1.3 §17.6. -pub(crate) fn map_para_props(props: &OdfParaProps) -> ParaProps { - let mut out = ParaProps::default(); - - // ── Spacing ──────────────────────────────────────────────────────────── - if let Some(s) = props.margin_top.as_deref().and_then(parse_length) { - out.space_before = Some(Spacing::Exact(s)); - } - if let Some(s) = props.margin_bottom.as_deref().and_then(parse_length) { - out.space_after = Some(Spacing::Exact(s)); - } - - // ── Indentation ──────────────────────────────────────────────────────── - if let Some(pts) = props.margin_left.as_deref().and_then(parse_length) { - out.indent_start = Some(pts); - } - if let Some(pts) = props.margin_right.as_deref().and_then(parse_length) { - out.indent_end = Some(pts); - } - if let Some(raw) = props.text_indent.as_deref() - && let Some(pts) = parse_length(raw) - { - let v = pts.value(); - if v < 0.0 { - // Negative text-indent = hanging indent (stored as positive) - out.indent_hanging = Some(loki_primitives::units::Points::new(-v)); - } else { - out.indent_first_line = Some(pts); - } - } - - // ── Line height ──────────────────────────────────────────────────────── - if let Some(raw) = props.line_height.as_deref() { - if let Some(pct_str) = raw.strip_suffix('%') { - if let Ok(pct) = pct_str.trim().parse::() { - out.line_height = Some(LineHeight::Multiple(pct / 100.0)); - } - } else if let Some(pts) = parse_length(raw) { - out.line_height = Some(LineHeight::Exact(pts)); - } - } - if let Some(pts) = props.line_height_at_least.as_deref().and_then(parse_length) { - // Only set if line_height wasn't already set from fo:line-height - if out.line_height.is_none() { - out.line_height = Some(LineHeight::AtLeast(pts)); - } - } - - // ── Alignment ────────────────────────────────────────────────────────── - if let Some(align) = props.text_align.as_deref().map(map_text_align) { - out.alignment = Some(align); - } - - // ── Flow control ─────────────────────────────────────────────────────── - if props.keep_together.as_deref() == Some("always") { - out.keep_together = Some(true); - } - if props.keep_with_next.as_deref() == Some("always") { - out.keep_with_next = Some(true); - } - - // ── Widow / orphan control ───────────────────────────────────────────── - out.widow_control = props.widows; - out.orphan_control = props.orphans; - - // ── Page breaks ──────────────────────────────────────────────────────── - if props.break_before.as_deref() == Some("page") { - out.page_break_before = Some(true); - } - if props.break_after.as_deref() == Some("page") { - out.page_break_after = Some(true); - } - - // ── Borders ──────────────────────────────────────────────────────────── - // ODF fo:border is a CSS shorthand "width style color"; per-side values - // override the shorthand on a per-side basis. - let border_fallback = props.border.as_deref().and_then(parse_odf_border); - out.border_top = props - .border_top - .as_deref() - .and_then(parse_odf_border) - .or_else(|| border_fallback.clone()); - out.border_bottom = props - .border_bottom - .as_deref() - .and_then(parse_odf_border) - .or_else(|| border_fallback.clone()); - out.border_left = props - .border_left - .as_deref() - .and_then(parse_odf_border) - .or_else(|| border_fallback.clone()); - out.border_right = props - .border_right - .as_deref() - .and_then(parse_odf_border) - .or(border_fallback); - - // ── Padding ──────────────────────────────────────────────────────────── - // ODF only has fo:padding shorthand; apply it to all four sides. - if let Some(pts) = props.padding.as_deref().and_then(parse_length) { - out.padding_top = Some(pts); - out.padding_bottom = Some(pts); - out.padding_left = Some(pts); - out.padding_right = Some(pts); - } - - // ── Background color ─────────────────────────────────────────────────── - if let Some(hex) = props.background_color.as_deref() - && hex != "transparent" - && let Ok(dc) = DocumentColor::from_hex(hex) - { - out.background_color = Some(dc); - } - - // ── Bidirectional direction ──────────────────────────────────────────── - // ODF `style:writing-mode` values that indicate RTL text direction. - // "rl-tb" is right-to-left, top-to-bottom (Arabic/Hebrew). - // "rl" is shorthand for rl-tb. - if matches!( - props.writing_mode.as_deref(), - Some("rl-tb" | "rl" | "tb-rl") - ) { - out.bidi = Some(true); - } - - // ── Tab stops ────────────────────────────────────────────────────────── - if !props.tab_stops.is_empty() { - let stops: Vec = props.tab_stops.iter().filter_map(map_tab_stop).collect(); - if !stops.is_empty() { - out.tab_stops = Some(stops); - } - } - - out -} - -/// Parse an ODF CSS-like border shorthand `"width style color"` into a -/// [`Border`]. -/// -/// ODF `fo:border` uses the XSL-FO shorthand syntax, e.g. `"1pt solid #000000"`. -/// Width tokens are parsed via [`parse_length`]; style is mapped to -/// [`BorderStyle`]; colour is parsed as a `#RRGGBB` hex string. -/// Returns `None` when the string is `"none"` or cannot be parsed. -fn parse_odf_border(s: &str) -> Option { - let s = s.trim(); - if s == "none" || s.is_empty() { - return None; - } - let tokens: Vec<&str> = s.split_whitespace().collect(); - let mut width: Option = None; - let mut style = BorderStyle::Solid; - let mut color: Option = None; - - for tok in &tokens { - if width.is_none() - && let Some(pts) = parse_length(tok) - { - width = Some(pts); - continue; - } - match *tok { - "none" => return None, - "solid" => style = BorderStyle::Solid, - "dashed" => style = BorderStyle::Dashed, - "dotted" => style = BorderStyle::Dotted, - "double" => style = BorderStyle::Double, - "groove" => style = BorderStyle::Groove, - "ridge" => style = BorderStyle::Ridge, - "inset" => style = BorderStyle::Inset, - "outset" => style = BorderStyle::Outset, - "wave" => style = BorderStyle::Wave, - hex if hex.starts_with('#') => { - if let Ok(dc) = DocumentColor::from_hex(hex) { - color = Some(dc); - } - } - _ => {} - } - } - - let width = width.unwrap_or_else(|| Points::new(1.0)); - Some(Border { - style, - width, - color, - spacing: None, - }) -} - -// ── Cell property mapping ────────────────────────────────────────────────────── - -/// Convert [`OdfCellProps`] to the format-neutral [`CellProps`]. -/// -/// All length strings are parsed via [`parse_length`]. Unparseable or absent -/// values silently map to `None`. ODF 1.3 §17.18. -/// -/// NOTE: ODF cell properties are mapped to the same [`CellProps`] type as -/// OOXML. The layout engine applies them identically. -pub(crate) fn map_cell_props(cell_props: &OdfCellProps) -> CellProps { - CellProps { - padding_top: cell_props.padding_top.as_deref().and_then(parse_length), - padding_bottom: cell_props.padding_bottom.as_deref().and_then(parse_length), - padding_left: cell_props.padding_left.as_deref().and_then(parse_length), - padding_right: cell_props.padding_right.as_deref().and_then(parse_length), - vertical_align: cell_props - .vertical_align - .as_deref() - .and_then(map_odf_vertical_align), - text_direction: cell_props - .writing_mode - .as_deref() - .and_then(map_odf_writing_mode), - background_color: cell_props.background_color.as_deref().and_then(|c| { - if c == "transparent" { - None - } else { - DocumentColor::from_hex(c).ok() - } - }), - border_top: cell_props.border_top.as_deref().and_then(parse_odf_border), - border_bottom: cell_props - .border_bottom - .as_deref() - .and_then(parse_odf_border), - border_left: cell_props.border_left.as_deref().and_then(parse_odf_border), - border_right: cell_props - .border_right - .as_deref() - .and_then(parse_odf_border), - } -} - -/// Map an ODF `style:vertical-align` string to [`CellVerticalAlign`]. -/// -/// `"automatic"` falls through to the default `Top`. -pub(crate) fn map_odf_vertical_align(val: &str) -> Option { - match val { - "top" | "automatic" => Some(CellVerticalAlign::Top), - "middle" => Some(CellVerticalAlign::Middle), - "bottom" => Some(CellVerticalAlign::Bottom), - _ => None, - } -} - -/// Map an ODF `style:writing-mode` string to [`CellTextDirection`]. -pub(crate) fn map_odf_writing_mode(val: &str) -> Option { - match val { - "lr-tb" | "lr" => Some(CellTextDirection::LrTb), - "tb-rl" | "tb" => Some(CellTextDirection::TbRl), - "tb-lr" => Some(CellTextDirection::TbLr), - "bt-lr" => Some(CellTextDirection::BtLr), - _ => None, - } -} - -/// Map an [`OdfTabStop`] to a doc-model [`TabStop`]. -/// -/// ODF tab alignment values: `"left"` → [`TabAlignment::Left`], -/// `"right"` → [`TabAlignment::Right`], `"center"` → [`TabAlignment::Center`], -/// `"char"` → [`TabAlignment::Decimal`]. -/// `style:leader-style` is now mapped to [`TabLeader`]. -fn map_tab_stop(ts: &OdfTabStop) -> Option { - let position = parse_length(&ts.position)?; - let alignment = match ts.tab_type.as_deref() { - Some("right") => TabAlignment::Right, - Some("center") => TabAlignment::Center, - Some("char") => TabAlignment::Decimal, - _ => TabAlignment::Left, - }; - Some(TabStop { - position, - alignment, - leader: map_leader_style(ts.leader_style.as_deref()), - }) -} - -fn map_leader_style(s: Option<&str>) -> TabLeader { - match s { - Some("dotted") => TabLeader::Dot, - Some("dash" | "long-dash" | "dot-dash" | "dot-dot-dash") => TabLeader::Dash, - Some("solid" | "wave" | "small-wave" | "double-wave") => TabLeader::Underscore, - Some( - "bold" | "bold-dash" | "bold-long-dash" | "bold-dot-dash" | "bold-dot-dot-dash" - | "bold-wave", - ) => TabLeader::Heavy, - _ => TabLeader::None, - } -} - -/// Map an ODF `fo:text-align` string to [`ParagraphAlignment`]. -fn map_text_align(s: &str) -> ParagraphAlignment { - match s { - "right" | "end" => ParagraphAlignment::Right, - "center" => ParagraphAlignment::Center, - "justify" | "both" => ParagraphAlignment::Justify, - _ => ParagraphAlignment::Left, - } -} - -// ── Character properties ────────────────────────────────────────────────────── - -/// Convert [`OdfTextProps`] to the format-neutral [`CharProps`]. -/// -/// ODF 1.3 §20.2 (`style:text-properties`). -pub(crate) fn map_text_props(props: &OdfTextProps) -> CharProps { - // ── Font ─────────────────────────────────────────────────────────────── - // Prefer style:font-name (the font face alias, typically matching the actual - // family name); fall back to fo:font-family when only that is present. - let mut out = CharProps { - font_name: props - .font_name - .clone() - .or_else(|| props.font_family.clone()), - font_name_complex: props.font_name_complex.clone(), - font_name_east_asian: props.font_name_asian.clone(), - outline: props.text_outline, - ..Default::default() - }; - - if let Some(pts) = props.font_size.as_deref().and_then(parse_length) { - out.font_size = Some(pts); - } - if let Some(pts) = props.font_size_complex.as_deref().and_then(parse_length) { - out.font_size_complex = Some(pts); - } - - // ── Style flags ──────────────────────────────────────────────────────── - out.bold = match props.font_weight.as_deref() { - Some("bold") => Some(true), - Some("normal") => Some(false), - _ => None, - }; - out.italic = match props.font_style.as_deref() { - Some("italic" | "oblique") => Some(true), - Some("normal") => Some(false), - _ => None, - }; - out.underline = props - .text_underline_style - .as_deref() - .and_then(map_underline_style); - out.strikethrough = props - .text_line_through_style - .as_deref() - .and_then(map_strikethrough_style); - - // ── Case / variant ───────────────────────────────────────────────────── - if props.font_variant.as_deref() == Some("small-caps") { - out.small_caps = Some(true); - } - if props.text_transform.as_deref() == Some("uppercase") { - out.all_caps = Some(true); - } - - // ── Vertical alignment (super/subscript) ─────────────────────────────── - if let Some(pos) = props.text_position.as_deref() { - out.vertical_align = map_text_position(pos); - } - - // ── Color ────────────────────────────────────────────────────────────── - if let Some(hex) = props.color.as_deref() - && let Ok(dc) = DocumentColor::from_hex(hex) - { - out.color = Some(dc); - } - if let Some(hex) = props.background_color.as_deref() - && hex != "transparent" - && let Ok(dc) = DocumentColor::from_hex(hex) - { - out.background_color = Some(dc); - } - - // ── Shadow ───────────────────────────────────────────────────────────── - // ODF fo:text-shadow is a CSS shadow string; any non-empty, non-"none" - // value means shadow is enabled. - if let Some(shadow) = props.text_shadow.as_deref() { - out.shadow = Some(!shadow.is_empty() && shadow != "none"); - } - - // ── Spacing ──────────────────────────────────────────────────────────── - if let Some(pts) = props.letter_spacing.as_deref().and_then(parse_length) { - out.letter_spacing = Some(pts); - } - if let Some(pts) = props.word_spacing.as_deref().and_then(parse_length) { - out.word_spacing = Some(pts); - } - if let Some(v) = props.letter_kerning { - out.kerning = Some(v); - } - // style:text-scale is a percentage string like "150%" → 150.0 (same unit as OOXML w:w) - if let Some(pct) = props.text_scale.as_deref() - && let Some(v) = pct.strip_suffix('%').and_then(|s| s.parse::().ok()) - { - out.scale = Some(v); - } - - // ── Language ─────────────────────────────────────────────────────────── - if let Some(lang) = props.language.as_deref() { - let tag = if let Some(country) = props.country.as_deref() { - LanguageTag::new(format!("{lang}-{country}")) - } else { - LanguageTag::new(lang) - }; - out.language = Some(tag); - } - if let Some(lang) = props.language_complex.as_deref() { - let tag = if let Some(country) = props.country_complex.as_deref() { - LanguageTag::new(format!("{lang}-{country}")) - } else { - LanguageTag::new(lang) - }; - out.language_complex = Some(tag); - } - if let Some(lang) = props.language_asian.as_deref() { - let tag = if let Some(country) = props.country_asian.as_deref() { - LanguageTag::new(format!("{lang}-{country}")) - } else { - LanguageTag::new(lang) - }; - out.language_east_asian = Some(tag); - } - - out -} - -/// Map ODF `style:text-underline-style` to [`UnderlineStyle`]. -/// -/// `"none"` → `None` (explicit removal). All other recognised values map to -/// a concrete style; unrecognised values map to [`UnderlineStyle::Single`]. -fn map_underline_style(s: &str) -> Option { - match s { - "none" => None, - "double" => Some(UnderlineStyle::Double), - "dotted" => Some(UnderlineStyle::Dotted), - "dash" | "long-dash" | "dot-dash" | "dot-dot-dash" => Some(UnderlineStyle::Dash), - "wave" => Some(UnderlineStyle::Wave), - "bold" => Some(UnderlineStyle::Thick), - _ => Some(UnderlineStyle::Single), - } -} - -/// Map ODF `style:text-line-through-style` to [`StrikethroughStyle`]. -/// -/// `"none"` → `None`. `"double"` → `Double`. All other values → `Single`. -fn map_strikethrough_style(s: &str) -> Option { - match s { - "none" => None, - "double" => Some(StrikethroughStyle::Double), - _ => Some(StrikethroughStyle::Single), - } -} - -/// Map ODF `style:text-position` to [`VerticalAlign`]. -/// -/// Recognised forms: `"super"`, `"sub"`, percentage strings (positive = -/// superscript, negative = subscript), or a percentage followed by a font -/// size (the second token is ignored). ODF 1.3 §19.879. -fn map_text_position(s: &str) -> Option { - let first = s.split_whitespace().next().unwrap_or(s); - match first { - "super" => Some(VerticalAlign::Superscript), - "sub" => Some(VerticalAlign::Subscript), - "0%" | "0" => Some(VerticalAlign::Baseline), - other => { - // Percentage string: positive → super, negative → sub - if let Some(pct_str) = other.strip_suffix('%') - && let Ok(pct) = pct_str.parse::() - { - return if pct > 0.0 { - Some(VerticalAlign::Superscript) - } else if pct < 0.0 { - Some(VerticalAlign::Subscript) - } else { - Some(VerticalAlign::Baseline) - }; - } - None - } - } -} - -// ── Tests ────────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use crate::odt::model::styles::{OdfParaProps, OdfTextProps}; - use loki_doc_model::style::props::char_props::VerticalAlign; - use loki_doc_model::style::props::para_props::{LineHeight, ParagraphAlignment, Spacing}; - - // ── map_para_props ───────────────────────────────────────────────────── - - #[test] - fn para_margins_to_spacing() { - let props = OdfParaProps { - margin_top: Some("6pt".into()), - margin_bottom: Some("12pt".into()), - margin_left: Some("1cm".into()), - margin_right: Some("0.5cm".into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert!( - matches!(out.space_before, Some(Spacing::Exact(p)) if (p.value() - 6.0).abs() < 1e-6) - ); - assert!( - matches!(out.space_after, Some(Spacing::Exact(p)) if (p.value() - 12.0).abs() < 1e-6) - ); - assert!(out.indent_start.is_some()); - assert!(out.indent_end.is_some()); - } - - #[test] - fn text_indent_positive_is_first_line() { - let props = OdfParaProps { - text_indent: Some("0.5cm".into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert!(out.indent_first_line.is_some()); - assert!(out.indent_hanging.is_none()); - } - - #[test] - fn text_indent_negative_is_hanging() { - let props = OdfParaProps { - text_indent: Some("-0.5cm".into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert!(out.indent_hanging.is_some()); - assert!(out.indent_first_line.is_none()); - // hanging indent is stored as positive value - let hanging = out.indent_hanging.unwrap().value(); - assert!( - (hanging - crate::xml_util::parse_length("0.5cm").unwrap().value()).abs() < 1e-6, - "expected 0.5cm ≈ {:.3}pt, got {:.3}pt", - crate::xml_util::parse_length("0.5cm").unwrap().value(), - hanging - ); - } - - #[test] - fn line_height_percent() { - let props = OdfParaProps { - line_height: Some("150%".into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert!( - matches!(out.line_height, Some(LineHeight::Multiple(m)) if (m - 1.5).abs() < 1e-5), - "expected Multiple(1.5), got {:?}", - out.line_height - ); - } - - #[test] - fn line_height_exact_points() { - let props = OdfParaProps { - line_height: Some("14pt".into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert!( - matches!(out.line_height, Some(LineHeight::Exact(p)) if (p.value() - 14.0).abs() < 1e-6), - "expected Exact(14pt), got {:?}", - out.line_height - ); - } - - #[test] - fn line_height_at_least() { - let props = OdfParaProps { - line_height_at_least: Some("10pt".into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert!( - matches!(out.line_height, Some(LineHeight::AtLeast(p)) if (p.value() - 10.0).abs() < 1e-6) - ); - } - - #[test] - fn text_align_mappings() { - let cases = [ - ("left", ParagraphAlignment::Left), - ("start", ParagraphAlignment::Left), - ("right", ParagraphAlignment::Right), - ("end", ParagraphAlignment::Right), - ("center", ParagraphAlignment::Center), - ("justify", ParagraphAlignment::Justify), - ("both", ParagraphAlignment::Justify), - ]; - for (input, expected) in cases { - let props = OdfParaProps { - text_align: Some(input.into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert_eq!(out.alignment, Some(expected), "for input {:?}", input); - } - } - - #[test] - fn keep_together_and_keep_with_next() { - let props = OdfParaProps { - keep_together: Some("always".into()), - keep_with_next: Some("always".into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert_eq!(out.keep_together, Some(true)); - assert_eq!(out.keep_with_next, Some(true)); - } - - #[test] - fn widows_orphans_break() { - let props = OdfParaProps { - widows: Some(2), - orphans: Some(2), - break_before: Some("page".into()), - ..Default::default() - }; - let out = map_para_props(&props); - assert_eq!(out.widow_control, Some(2)); - assert_eq!(out.orphan_control, Some(2)); - assert_eq!(out.page_break_before, Some(true)); - } - - // ── map_text_props ───────────────────────────────────────────────────── - - #[test] - fn bold_true_false_none() { - let bold = OdfTextProps { - font_weight: Some("bold".into()), - ..Default::default() - }; - assert_eq!(map_text_props(&bold).bold, Some(true)); - - let normal = OdfTextProps { - font_weight: Some("normal".into()), - ..Default::default() - }; - assert_eq!(map_text_props(&normal).bold, Some(false)); - - let absent = OdfTextProps::default(); - assert_eq!(map_text_props(&absent).bold, None); - } - - #[test] - fn italic_mapping() { - let italic = OdfTextProps { - font_style: Some("italic".into()), - ..Default::default() - }; - assert_eq!(map_text_props(&italic).italic, Some(true)); - - let normal = OdfTextProps { - font_style: Some("normal".into()), - ..Default::default() - }; - assert_eq!(map_text_props(&normal).italic, Some(false)); - } - - #[test] - fn font_size_parsed() { - let props = OdfTextProps { - font_size: Some("12pt".into()), - ..Default::default() - }; - let out = map_text_props(&props); - assert!(matches!(out.font_size, Some(p) if (p.value() - 12.0).abs() < 1e-6)); - } - - #[test] - fn underline_none_clears() { - let props = OdfTextProps { - text_underline_style: Some("none".into()), - ..Default::default() - }; - assert!(map_text_props(&props).underline.is_none()); - } - - #[test] - fn underline_solid_maps_to_single() { - let props = OdfTextProps { - text_underline_style: Some("solid".into()), - ..Default::default() - }; - assert_eq!( - map_text_props(&props).underline, - Some(UnderlineStyle::Single) - ); - } - - #[test] - fn text_position_super_and_sub() { - let sup = OdfTextProps { - text_position: Some("super".into()), - ..Default::default() - }; - assert_eq!( - map_text_props(&sup).vertical_align, - Some(VerticalAlign::Superscript) - ); - - let sub = OdfTextProps { - text_position: Some("sub".into()), - ..Default::default() - }; - assert_eq!( - map_text_props(&sub).vertical_align, - Some(VerticalAlign::Subscript) - ); - } - - #[test] - fn text_position_positive_pct_is_super() { - let props = OdfTextProps { - text_position: Some("33%".into()), - ..Default::default() - }; - assert_eq!( - map_text_props(&props).vertical_align, - Some(VerticalAlign::Superscript) - ); - } - - #[test] - fn text_position_negative_pct_is_sub() { - let props = OdfTextProps { - text_position: Some("-33%".into()), - ..Default::default() - }; - assert_eq!( - map_text_props(&props).vertical_align, - Some(VerticalAlign::Subscript) - ); - } - - #[test] - fn small_caps_and_all_caps() { - let props = OdfTextProps { - font_variant: Some("small-caps".into()), - text_transform: Some("uppercase".into()), - ..Default::default() - }; - let out = map_text_props(&props); - assert_eq!(out.small_caps, Some(true)); - assert_eq!(out.all_caps, Some(true)); - } - - #[test] - fn language_with_country() { - let props = OdfTextProps { - language: Some("en".into()), - country: Some("US".into()), - ..Default::default() - }; - let out = map_text_props(&props); - assert_eq!(out.language.as_ref().map(|t| t.as_str()), Some("en-US")); - } - - #[test] - fn language_without_country() { - let props = OdfTextProps { - language: Some("de".into()), - ..Default::default() - }; - let out = map_text_props(&props); - assert_eq!(out.language.as_ref().map(|t| t.as_str()), Some("de")); - } - - #[test] - fn color_hex_parsed() { - let props = OdfTextProps { - color: Some("#FF0000".into()), - ..Default::default() - }; - let out = map_text_props(&props); - assert!(out.color.is_some()); - } - - #[test] - fn letter_spacing_parsed() { - let props = OdfTextProps { - letter_spacing: Some("0.5pt".into()), - ..Default::default() - }; - let out = map_text_props(&props); - assert!(matches!(out.letter_spacing, Some(p) if (p.value() - 0.5).abs() < 1e-6)); - } - - // ── cell property helpers ────────────────────────────────────────────── - - #[test] - fn vertical_align_middle_maps_to_middle() { - assert_eq!( - map_odf_vertical_align("middle"), - Some(CellVerticalAlign::Middle) - ); - } - - #[test] - fn vertical_align_top_maps_to_top() { - assert_eq!(map_odf_vertical_align("top"), Some(CellVerticalAlign::Top)); - } - - #[test] - fn vertical_align_automatic_maps_to_top() { - assert_eq!( - map_odf_vertical_align("automatic"), - Some(CellVerticalAlign::Top) - ); - } - - #[test] - fn vertical_align_bottom_maps_to_bottom() { - assert_eq!( - map_odf_vertical_align("bottom"), - Some(CellVerticalAlign::Bottom) - ); - } - - #[test] - fn vertical_align_unknown_returns_none() { - assert_eq!(map_odf_vertical_align("baseline"), None); - } - - #[test] - fn writing_mode_tb_rl_maps_to_tbrl() { - assert_eq!(map_odf_writing_mode("tb-rl"), Some(CellTextDirection::TbRl)); - } - - #[test] - fn writing_mode_lr_tb_maps_to_lrtb() { - assert_eq!(map_odf_writing_mode("lr-tb"), Some(CellTextDirection::LrTb)); - } - - #[test] - fn writing_mode_lr_shorthand_maps_to_lrtb() { - assert_eq!(map_odf_writing_mode("lr"), Some(CellTextDirection::LrTb)); - } - - #[test] - fn parse_odf_border_solid_black() { - let b = parse_odf_border("0.06pt solid #000000").expect("should parse"); - // Width rounds to 0.06pt - assert!( - (b.width.value() - 0.06).abs() < 0.01, - "width should be ~0.06pt, got {}", - b.width.value() - ); - use loki_doc_model::style::props::border::BorderStyle; - assert_eq!(b.style, BorderStyle::Solid); - assert!(b.color.is_some(), "color should be parsed"); - } - - #[test] - fn parse_odf_border_none_returns_none() { - assert!(parse_odf_border("none").is_none()); - } - - #[test] - fn fo_padding_shorthand_applies_to_all_edges() { - use crate::odt::model::styles::OdfCellProps; - - let cell_props = OdfCellProps { - padding_top: Some("0.2cm".into()), - padding_bottom: Some("0.2cm".into()), - padding_left: Some("0.2cm".into()), - padding_right: Some("0.2cm".into()), - ..Default::default() - }; - let props = map_cell_props(&cell_props); - // 0.2cm ≈ 5.669pt - for (label, val) in [ - ("top", props.padding_top), - ("bottom", props.padding_bottom), - ("left", props.padding_left), - ("right", props.padding_right), - ] { - let pts = val - .expect(&format!("padding_{label} should be Some")) - .value(); - assert!( - (pts - 5.669).abs() < 0.1, - "padding_{label} should be ~5.67pt, got {pts:.3}" - ); - } - } -} diff --git a/loki-odf/src/odt/mapper/props/cell.rs b/loki-odf/src/odt/mapper/props/cell.rs new file mode 100644 index 00000000..29ce67a1 --- /dev/null +++ b/loki-odf/src/odt/mapper/props/cell.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Table-cell property mapping and tab-stop / alignment helpers. + +use loki_doc_model::content::table::row::{CellProps, CellTextDirection, CellVerticalAlign}; +use loki_doc_model::style::props::para_props::ParagraphAlignment; +use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; +use loki_primitives::color::DocumentColor; + +use crate::odt::model::styles::{OdfCellProps, OdfTabStop}; +use crate::xml_util::parse_length; + +use super::paragraph::parse_odf_border; + +// ── Cell property mapping ────────────────────────────────────────────────────── + +/// Convert [`OdfCellProps`] to the format-neutral [`CellProps`]. +/// +/// All length strings are parsed via [`parse_length`]. Unparseable or absent +/// values silently map to `None`. ODF 1.3 §17.18. +/// +/// NOTE: ODF cell properties are mapped to the same [`CellProps`] type as +/// OOXML. The layout engine applies them identically. +pub(crate) fn map_cell_props(cell_props: &OdfCellProps) -> CellProps { + CellProps { + padding_top: cell_props.padding_top.as_deref().and_then(parse_length), + padding_bottom: cell_props.padding_bottom.as_deref().and_then(parse_length), + padding_left: cell_props.padding_left.as_deref().and_then(parse_length), + padding_right: cell_props.padding_right.as_deref().and_then(parse_length), + vertical_align: cell_props + .vertical_align + .as_deref() + .and_then(map_odf_vertical_align), + text_direction: cell_props + .writing_mode + .as_deref() + .and_then(map_odf_writing_mode), + background_color: cell_props.background_color.as_deref().and_then(|c| { + if c == "transparent" { + None + } else { + DocumentColor::from_hex(c).ok() + } + }), + border_top: cell_props.border_top.as_deref().and_then(parse_odf_border), + border_bottom: cell_props + .border_bottom + .as_deref() + .and_then(parse_odf_border), + border_left: cell_props.border_left.as_deref().and_then(parse_odf_border), + border_right: cell_props + .border_right + .as_deref() + .and_then(parse_odf_border), + } +} + +/// Map an ODF `style:vertical-align` string to [`CellVerticalAlign`]. +/// +/// `"automatic"` falls through to the default `Top`. +pub(crate) fn map_odf_vertical_align(val: &str) -> Option { + match val { + "top" | "automatic" => Some(CellVerticalAlign::Top), + "middle" => Some(CellVerticalAlign::Middle), + "bottom" => Some(CellVerticalAlign::Bottom), + _ => None, + } +} + +/// Map an ODF `style:writing-mode` string to [`CellTextDirection`]. +pub(crate) fn map_odf_writing_mode(val: &str) -> Option { + match val { + "lr-tb" | "lr" => Some(CellTextDirection::LrTb), + "tb-rl" | "tb" => Some(CellTextDirection::TbRl), + "tb-lr" => Some(CellTextDirection::TbLr), + "bt-lr" => Some(CellTextDirection::BtLr), + _ => None, + } +} + +/// Map an [`OdfTabStop`] to a doc-model [`TabStop`]. +/// +/// ODF tab alignment values: `"left"` → [`TabAlignment::Left`], +/// `"right"` → [`TabAlignment::Right`], `"center"` → [`TabAlignment::Center`], +/// `"char"` → [`TabAlignment::Decimal`]. +/// `style:leader-style` is now mapped to [`TabLeader`]. +pub(super) fn map_tab_stop(ts: &OdfTabStop) -> Option { + let position = parse_length(&ts.position)?; + let alignment = match ts.tab_type.as_deref() { + Some("right") => TabAlignment::Right, + Some("center") => TabAlignment::Center, + Some("char") => TabAlignment::Decimal, + _ => TabAlignment::Left, + }; + Some(TabStop { + position, + alignment, + leader: map_leader_style(ts.leader_style.as_deref()), + }) +} + +fn map_leader_style(s: Option<&str>) -> TabLeader { + match s { + Some("dotted") => TabLeader::Dot, + Some("dash" | "long-dash" | "dot-dash" | "dot-dot-dash") => TabLeader::Dash, + Some("solid" | "wave" | "small-wave" | "double-wave") => TabLeader::Underscore, + Some( + "bold" | "bold-dash" | "bold-long-dash" | "bold-dot-dash" | "bold-dot-dot-dash" + | "bold-wave", + ) => TabLeader::Heavy, + _ => TabLeader::None, + } +} + +/// Map an ODF `fo:text-align` string to [`ParagraphAlignment`]. +pub(super) fn map_text_align(s: &str) -> ParagraphAlignment { + match s { + "right" | "end" => ParagraphAlignment::Right, + "center" => ParagraphAlignment::Center, + "justify" | "both" => ParagraphAlignment::Justify, + _ => ParagraphAlignment::Left, + } +} diff --git a/loki-odf/src/odt/mapper/props/character.rs b/loki-odf/src/odt/mapper/props/character.rs new file mode 100644 index 00000000..145cca3c --- /dev/null +++ b/loki-odf/src/odt/mapper/props/character.rs @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Character property mapping (`OdfTextProps` → `CharProps`). + +use loki_doc_model::meta::LanguageTag; +use loki_doc_model::style::props::char_props::{ + CharProps, StrikethroughStyle, UnderlineStyle, VerticalAlign, +}; +use loki_primitives::color::DocumentColor; + +use crate::odt::model::styles::OdfTextProps; +use crate::xml_util::parse_length; + +// ── Character properties ────────────────────────────────────────────────────── + +/// Convert [`OdfTextProps`] to the format-neutral [`CharProps`]. +/// +/// ODF 1.3 §20.2 (`style:text-properties`). +pub(crate) fn map_text_props(props: &OdfTextProps) -> CharProps { + // ── Font ─────────────────────────────────────────────────────────────── + // Prefer style:font-name (the font face alias, typically matching the actual + // family name); fall back to fo:font-family when only that is present. + let mut out = CharProps { + font_name: props + .font_name + .clone() + .or_else(|| props.font_family.clone()), + font_name_complex: props.font_name_complex.clone(), + font_name_east_asian: props.font_name_asian.clone(), + outline: props.text_outline, + ..Default::default() + }; + + if let Some(pts) = props.font_size.as_deref().and_then(parse_length) { + out.font_size = Some(pts); + } + if let Some(pts) = props.font_size_complex.as_deref().and_then(parse_length) { + out.font_size_complex = Some(pts); + } + + // ── Style flags ──────────────────────────────────────────────────────── + out.bold = match props.font_weight.as_deref() { + Some("bold") => Some(true), + Some("normal") => Some(false), + _ => None, + }; + out.italic = match props.font_style.as_deref() { + Some("italic" | "oblique") => Some(true), + Some("normal") => Some(false), + _ => None, + }; + out.underline = props + .text_underline_style + .as_deref() + .and_then(map_underline_style); + out.strikethrough = props + .text_line_through_style + .as_deref() + .and_then(map_strikethrough_style); + + // ── Case / variant ───────────────────────────────────────────────────── + if props.font_variant.as_deref() == Some("small-caps") { + out.small_caps = Some(true); + } + if props.text_transform.as_deref() == Some("uppercase") { + out.all_caps = Some(true); + } + + // ── Vertical alignment (super/subscript) ─────────────────────────────── + if let Some(pos) = props.text_position.as_deref() { + out.vertical_align = map_text_position(pos); + } + + // ── Color ────────────────────────────────────────────────────────────── + if let Some(hex) = props.color.as_deref() + && let Ok(dc) = DocumentColor::from_hex(hex) + { + out.color = Some(dc); + } + if let Some(hex) = props.background_color.as_deref() + && hex != "transparent" + && let Ok(dc) = DocumentColor::from_hex(hex) + { + out.background_color = Some(dc); + } + + // ── Shadow ───────────────────────────────────────────────────────────── + // ODF fo:text-shadow is a CSS shadow string; any non-empty, non-"none" + // value means shadow is enabled. + if let Some(shadow) = props.text_shadow.as_deref() { + out.shadow = Some(!shadow.is_empty() && shadow != "none"); + } + + // ── Spacing ──────────────────────────────────────────────────────────── + if let Some(pts) = props.letter_spacing.as_deref().and_then(parse_length) { + out.letter_spacing = Some(pts); + } + if let Some(pts) = props.word_spacing.as_deref().and_then(parse_length) { + out.word_spacing = Some(pts); + } + if let Some(v) = props.letter_kerning { + out.kerning = Some(v); + } + // style:text-scale is a percentage string like "150%" → 150.0 (same unit as OOXML w:w) + if let Some(pct) = props.text_scale.as_deref() + && let Some(v) = pct.strip_suffix('%').and_then(|s| s.parse::().ok()) + { + out.scale = Some(v); + } + + // ── Language ─────────────────────────────────────────────────────────── + if let Some(lang) = props.language.as_deref() { + let tag = if let Some(country) = props.country.as_deref() { + LanguageTag::new(format!("{lang}-{country}")) + } else { + LanguageTag::new(lang) + }; + out.language = Some(tag); + } + if let Some(lang) = props.language_complex.as_deref() { + let tag = if let Some(country) = props.country_complex.as_deref() { + LanguageTag::new(format!("{lang}-{country}")) + } else { + LanguageTag::new(lang) + }; + out.language_complex = Some(tag); + } + if let Some(lang) = props.language_asian.as_deref() { + let tag = if let Some(country) = props.country_asian.as_deref() { + LanguageTag::new(format!("{lang}-{country}")) + } else { + LanguageTag::new(lang) + }; + out.language_east_asian = Some(tag); + } + + out +} + +/// Map ODF `style:text-underline-style` to [`UnderlineStyle`]. +/// +/// `"none"` → `None` (explicit removal). All other recognised values map to +/// a concrete style; unrecognised values map to [`UnderlineStyle::Single`]. +fn map_underline_style(s: &str) -> Option { + match s { + "none" => None, + "double" => Some(UnderlineStyle::Double), + "dotted" => Some(UnderlineStyle::Dotted), + "dash" | "long-dash" | "dot-dash" | "dot-dot-dash" => Some(UnderlineStyle::Dash), + "wave" => Some(UnderlineStyle::Wave), + "bold" => Some(UnderlineStyle::Thick), + _ => Some(UnderlineStyle::Single), + } +} + +/// Map ODF `style:text-line-through-style` to [`StrikethroughStyle`]. +/// +/// `"none"` → `None`. `"double"` → `Double`. All other values → `Single`. +fn map_strikethrough_style(s: &str) -> Option { + match s { + "none" => None, + "double" => Some(StrikethroughStyle::Double), + _ => Some(StrikethroughStyle::Single), + } +} + +/// Map ODF `style:text-position` to [`VerticalAlign`]. +/// +/// Recognised forms: `"super"`, `"sub"`, percentage strings (positive = +/// superscript, negative = subscript), or a percentage followed by a font +/// size (the second token is ignored). ODF 1.3 §19.879. +fn map_text_position(s: &str) -> Option { + let first = s.split_whitespace().next().unwrap_or(s); + match first { + "super" => Some(VerticalAlign::Superscript), + "sub" => Some(VerticalAlign::Subscript), + "0%" | "0" => Some(VerticalAlign::Baseline), + other => { + // Percentage string: positive → super, negative → sub + if let Some(pct_str) = other.strip_suffix('%') + && let Ok(pct) = pct_str.parse::() + { + return if pct > 0.0 { + Some(VerticalAlign::Superscript) + } else if pct < 0.0 { + Some(VerticalAlign::Subscript) + } else { + Some(VerticalAlign::Baseline) + }; + } + None + } + } +} diff --git a/loki-odf/src/odt/mapper/props/mod.rs b/loki-odf/src/odt/mapper/props/mod.rs new file mode 100644 index 00000000..66db0a02 --- /dev/null +++ b/loki-odf/src/odt/mapper/props/mod.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Paragraph and character property mappers. +//! +//! Converts [`OdfParaProps`] → [`ParaProps`] and +//! [`OdfTextProps`] → [`CharProps`]. +//! All ODF measurement values are length strings (e.g. `"2.5cm"`, `"12pt"`); +//! conversion uses [`crate::xml_util::parse_length`]. +mod cell; +mod character; +mod paragraph; + +pub(crate) use cell::map_cell_props; +pub(crate) use character::map_text_props; +pub(crate) use paragraph::map_para_props; + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/loki-odf/src/odt/mapper/props/paragraph.rs b/loki-odf/src/odt/mapper/props/paragraph.rs new file mode 100644 index 00000000..8e3af01a --- /dev/null +++ b/loki-odf/src/odt/mapper/props/paragraph.rs @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Paragraph property mapping (`OdfParaProps` → `ParaProps`) and its border helper. + +use loki_doc_model::style::props::border::{Border, BorderStyle}; +use loki_doc_model::style::props::para_props::{LineHeight, ParaProps, Spacing}; +use loki_doc_model::style::props::tab_stop::TabStop; +use loki_primitives::color::DocumentColor; +use loki_primitives::units::Points; + +use crate::odt::model::styles::OdfParaProps; +use crate::xml_util::parse_length; + +use super::cell::{map_tab_stop, map_text_align}; + +// ── Paragraph properties ─────────────────────────────────────────────────────── + +/// Convert [`OdfParaProps`] to the format-neutral [`ParaProps`]. +/// +/// All length values (margins, text-indent, line-height) are parsed from ODF +/// attribute strings via [`parse_length`]. Unmapped or unparseable values are +/// silently dropped (the corresponding field remains `None`). ODF 1.3 §17.6. +pub(crate) fn map_para_props(props: &OdfParaProps) -> ParaProps { + let mut out = ParaProps::default(); + + // ── Spacing ──────────────────────────────────────────────────────────── + if let Some(s) = props.margin_top.as_deref().and_then(parse_length) { + out.space_before = Some(Spacing::Exact(s)); + } + if let Some(s) = props.margin_bottom.as_deref().and_then(parse_length) { + out.space_after = Some(Spacing::Exact(s)); + } + + // ── Indentation ──────────────────────────────────────────────────────── + if let Some(pts) = props.margin_left.as_deref().and_then(parse_length) { + out.indent_start = Some(pts); + } + if let Some(pts) = props.margin_right.as_deref().and_then(parse_length) { + out.indent_end = Some(pts); + } + if let Some(raw) = props.text_indent.as_deref() + && let Some(pts) = parse_length(raw) + { + let v = pts.value(); + if v < 0.0 { + // Negative text-indent = hanging indent (stored as positive) + out.indent_hanging = Some(loki_primitives::units::Points::new(-v)); + } else { + out.indent_first_line = Some(pts); + } + } + + // ── Line height ──────────────────────────────────────────────────────── + if let Some(raw) = props.line_height.as_deref() { + if let Some(pct_str) = raw.strip_suffix('%') { + if let Ok(pct) = pct_str.trim().parse::() { + out.line_height = Some(LineHeight::Multiple(pct / 100.0)); + } + } else if let Some(pts) = parse_length(raw) { + out.line_height = Some(LineHeight::Exact(pts)); + } + } + if let Some(pts) = props.line_height_at_least.as_deref().and_then(parse_length) { + // Only set if line_height wasn't already set from fo:line-height + if out.line_height.is_none() { + out.line_height = Some(LineHeight::AtLeast(pts)); + } + } + + // ── Alignment ────────────────────────────────────────────────────────── + if let Some(align) = props.text_align.as_deref().map(map_text_align) { + out.alignment = Some(align); + } + + // ── Flow control ─────────────────────────────────────────────────────── + if props.keep_together.as_deref() == Some("always") { + out.keep_together = Some(true); + } + if props.keep_with_next.as_deref() == Some("always") { + out.keep_with_next = Some(true); + } + + // ── Widow / orphan control ───────────────────────────────────────────── + out.widow_control = props.widows; + out.orphan_control = props.orphans; + + // ── Page breaks ──────────────────────────────────────────────────────── + if props.break_before.as_deref() == Some("page") { + out.page_break_before = Some(true); + } + if props.break_after.as_deref() == Some("page") { + out.page_break_after = Some(true); + } + + // ── Borders ──────────────────────────────────────────────────────────── + // ODF fo:border is a CSS shorthand "width style color"; per-side values + // override the shorthand on a per-side basis. + let border_fallback = props.border.as_deref().and_then(parse_odf_border); + out.border_top = props + .border_top + .as_deref() + .and_then(parse_odf_border) + .or_else(|| border_fallback.clone()); + out.border_bottom = props + .border_bottom + .as_deref() + .and_then(parse_odf_border) + .or_else(|| border_fallback.clone()); + out.border_left = props + .border_left + .as_deref() + .and_then(parse_odf_border) + .or_else(|| border_fallback.clone()); + out.border_right = props + .border_right + .as_deref() + .and_then(parse_odf_border) + .or(border_fallback); + + // ── Padding ──────────────────────────────────────────────────────────── + // ODF only has fo:padding shorthand; apply it to all four sides. + if let Some(pts) = props.padding.as_deref().and_then(parse_length) { + out.padding_top = Some(pts); + out.padding_bottom = Some(pts); + out.padding_left = Some(pts); + out.padding_right = Some(pts); + } + + // ── Background color ─────────────────────────────────────────────────── + if let Some(hex) = props.background_color.as_deref() + && hex != "transparent" + && let Ok(dc) = DocumentColor::from_hex(hex) + { + out.background_color = Some(dc); + } + + // ── Bidirectional direction ──────────────────────────────────────────── + // ODF `style:writing-mode` values that indicate RTL text direction. + // "rl-tb" is right-to-left, top-to-bottom (Arabic/Hebrew). + // "rl" is shorthand for rl-tb. + if matches!( + props.writing_mode.as_deref(), + Some("rl-tb" | "rl" | "tb-rl") + ) { + out.bidi = Some(true); + } + + // ── Tab stops ────────────────────────────────────────────────────────── + if !props.tab_stops.is_empty() { + let stops: Vec = props.tab_stops.iter().filter_map(map_tab_stop).collect(); + if !stops.is_empty() { + out.tab_stops = Some(stops); + } + } + + out +} + +/// Parse an ODF CSS-like border shorthand `"width style color"` into a +/// [`Border`]. +/// +/// ODF `fo:border` uses the XSL-FO shorthand syntax, e.g. `"1pt solid #000000"`. +/// Width tokens are parsed via [`parse_length`]; style is mapped to +/// [`BorderStyle`]; colour is parsed as a `#RRGGBB` hex string. +/// Returns `None` when the string is `"none"` or cannot be parsed. +pub(super) fn parse_odf_border(s: &str) -> Option { + let s = s.trim(); + if s == "none" || s.is_empty() { + return None; + } + let tokens: Vec<&str> = s.split_whitespace().collect(); + let mut width: Option = None; + let mut style = BorderStyle::Solid; + let mut color: Option = None; + + for tok in &tokens { + if width.is_none() + && let Some(pts) = parse_length(tok) + { + width = Some(pts); + continue; + } + match *tok { + "none" => return None, + "solid" => style = BorderStyle::Solid, + "dashed" => style = BorderStyle::Dashed, + "dotted" => style = BorderStyle::Dotted, + "double" => style = BorderStyle::Double, + "groove" => style = BorderStyle::Groove, + "ridge" => style = BorderStyle::Ridge, + "inset" => style = BorderStyle::Inset, + "outset" => style = BorderStyle::Outset, + "wave" => style = BorderStyle::Wave, + hex if hex.starts_with('#') => { + if let Ok(dc) = DocumentColor::from_hex(hex) { + color = Some(dc); + } + } + _ => {} + } + } + + let width = width.unwrap_or_else(|| Points::new(1.0)); + Some(Border { + style, + width, + color, + spacing: None, + }) +} diff --git a/loki-odf/src/odt/mapper/props/tests.rs b/loki-odf/src/odt/mapper/props/tests.rs new file mode 100644 index 00000000..bc24950d --- /dev/null +++ b/loki-odf/src/odt/mapper/props/tests.rs @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for the property mappers. + +use super::cell::{map_odf_vertical_align, map_odf_writing_mode}; +use super::paragraph::parse_odf_border; +use super::*; +use crate::odt::model::styles::{OdfParaProps, OdfTextProps}; +use loki_doc_model::content::table::row::{CellTextDirection, CellVerticalAlign}; +use loki_doc_model::style::props::char_props::{UnderlineStyle, VerticalAlign}; +use loki_doc_model::style::props::para_props::{LineHeight, ParagraphAlignment, Spacing}; + +// ── map_para_props ───────────────────────────────────────────────────── + +#[test] +fn para_margins_to_spacing() { + let props = OdfParaProps { + margin_top: Some("6pt".into()), + margin_bottom: Some("12pt".into()), + margin_left: Some("1cm".into()), + margin_right: Some("0.5cm".into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert!(matches!(out.space_before, Some(Spacing::Exact(p)) if (p.value() - 6.0).abs() < 1e-6)); + assert!(matches!(out.space_after, Some(Spacing::Exact(p)) if (p.value() - 12.0).abs() < 1e-6)); + assert!(out.indent_start.is_some()); + assert!(out.indent_end.is_some()); +} + +#[test] +fn text_indent_positive_is_first_line() { + let props = OdfParaProps { + text_indent: Some("0.5cm".into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert!(out.indent_first_line.is_some()); + assert!(out.indent_hanging.is_none()); +} + +#[test] +fn text_indent_negative_is_hanging() { + let props = OdfParaProps { + text_indent: Some("-0.5cm".into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert!(out.indent_hanging.is_some()); + assert!(out.indent_first_line.is_none()); + // hanging indent is stored as positive value + let hanging = out.indent_hanging.unwrap().value(); + assert!( + (hanging - crate::xml_util::parse_length("0.5cm").unwrap().value()).abs() < 1e-6, + "expected 0.5cm ≈ {:.3}pt, got {:.3}pt", + crate::xml_util::parse_length("0.5cm").unwrap().value(), + hanging + ); +} + +#[test] +fn line_height_percent() { + let props = OdfParaProps { + line_height: Some("150%".into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert!( + matches!(out.line_height, Some(LineHeight::Multiple(m)) if (m - 1.5).abs() < 1e-5), + "expected Multiple(1.5), got {:?}", + out.line_height + ); +} + +#[test] +fn line_height_exact_points() { + let props = OdfParaProps { + line_height: Some("14pt".into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert!( + matches!(out.line_height, Some(LineHeight::Exact(p)) if (p.value() - 14.0).abs() < 1e-6), + "expected Exact(14pt), got {:?}", + out.line_height + ); +} + +#[test] +fn line_height_at_least() { + let props = OdfParaProps { + line_height_at_least: Some("10pt".into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert!( + matches!(out.line_height, Some(LineHeight::AtLeast(p)) if (p.value() - 10.0).abs() < 1e-6) + ); +} + +#[test] +fn text_align_mappings() { + let cases = [ + ("left", ParagraphAlignment::Left), + ("start", ParagraphAlignment::Left), + ("right", ParagraphAlignment::Right), + ("end", ParagraphAlignment::Right), + ("center", ParagraphAlignment::Center), + ("justify", ParagraphAlignment::Justify), + ("both", ParagraphAlignment::Justify), + ]; + for (input, expected) in cases { + let props = OdfParaProps { + text_align: Some(input.into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert_eq!(out.alignment, Some(expected), "for input {:?}", input); + } +} + +#[test] +fn keep_together_and_keep_with_next() { + let props = OdfParaProps { + keep_together: Some("always".into()), + keep_with_next: Some("always".into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert_eq!(out.keep_together, Some(true)); + assert_eq!(out.keep_with_next, Some(true)); +} + +#[test] +fn widows_orphans_break() { + let props = OdfParaProps { + widows: Some(2), + orphans: Some(2), + break_before: Some("page".into()), + ..Default::default() + }; + let out = map_para_props(&props); + assert_eq!(out.widow_control, Some(2)); + assert_eq!(out.orphan_control, Some(2)); + assert_eq!(out.page_break_before, Some(true)); +} + +// ── map_text_props ───────────────────────────────────────────────────── + +#[test] +fn bold_true_false_none() { + let bold = OdfTextProps { + font_weight: Some("bold".into()), + ..Default::default() + }; + assert_eq!(map_text_props(&bold).bold, Some(true)); + + let normal = OdfTextProps { + font_weight: Some("normal".into()), + ..Default::default() + }; + assert_eq!(map_text_props(&normal).bold, Some(false)); + + let absent = OdfTextProps::default(); + assert_eq!(map_text_props(&absent).bold, None); +} + +#[test] +fn italic_mapping() { + let italic = OdfTextProps { + font_style: Some("italic".into()), + ..Default::default() + }; + assert_eq!(map_text_props(&italic).italic, Some(true)); + + let normal = OdfTextProps { + font_style: Some("normal".into()), + ..Default::default() + }; + assert_eq!(map_text_props(&normal).italic, Some(false)); +} + +#[test] +fn font_size_parsed() { + let props = OdfTextProps { + font_size: Some("12pt".into()), + ..Default::default() + }; + let out = map_text_props(&props); + assert!(matches!(out.font_size, Some(p) if (p.value() - 12.0).abs() < 1e-6)); +} + +#[test] +fn underline_none_clears() { + let props = OdfTextProps { + text_underline_style: Some("none".into()), + ..Default::default() + }; + assert!(map_text_props(&props).underline.is_none()); +} + +#[test] +fn underline_solid_maps_to_single() { + let props = OdfTextProps { + text_underline_style: Some("solid".into()), + ..Default::default() + }; + assert_eq!( + map_text_props(&props).underline, + Some(UnderlineStyle::Single) + ); +} + +#[test] +fn text_position_super_and_sub() { + let sup = OdfTextProps { + text_position: Some("super".into()), + ..Default::default() + }; + assert_eq!( + map_text_props(&sup).vertical_align, + Some(VerticalAlign::Superscript) + ); + + let sub = OdfTextProps { + text_position: Some("sub".into()), + ..Default::default() + }; + assert_eq!( + map_text_props(&sub).vertical_align, + Some(VerticalAlign::Subscript) + ); +} + +#[test] +fn text_position_positive_pct_is_super() { + let props = OdfTextProps { + text_position: Some("33%".into()), + ..Default::default() + }; + assert_eq!( + map_text_props(&props).vertical_align, + Some(VerticalAlign::Superscript) + ); +} + +#[test] +fn text_position_negative_pct_is_sub() { + let props = OdfTextProps { + text_position: Some("-33%".into()), + ..Default::default() + }; + assert_eq!( + map_text_props(&props).vertical_align, + Some(VerticalAlign::Subscript) + ); +} + +#[test] +fn small_caps_and_all_caps() { + let props = OdfTextProps { + font_variant: Some("small-caps".into()), + text_transform: Some("uppercase".into()), + ..Default::default() + }; + let out = map_text_props(&props); + assert_eq!(out.small_caps, Some(true)); + assert_eq!(out.all_caps, Some(true)); +} + +#[test] +fn language_with_country() { + let props = OdfTextProps { + language: Some("en".into()), + country: Some("US".into()), + ..Default::default() + }; + let out = map_text_props(&props); + assert_eq!(out.language.as_ref().map(|t| t.as_str()), Some("en-US")); +} + +#[test] +fn language_without_country() { + let props = OdfTextProps { + language: Some("de".into()), + ..Default::default() + }; + let out = map_text_props(&props); + assert_eq!(out.language.as_ref().map(|t| t.as_str()), Some("de")); +} + +#[test] +fn color_hex_parsed() { + let props = OdfTextProps { + color: Some("#FF0000".into()), + ..Default::default() + }; + let out = map_text_props(&props); + assert!(out.color.is_some()); +} + +#[test] +fn letter_spacing_parsed() { + let props = OdfTextProps { + letter_spacing: Some("0.5pt".into()), + ..Default::default() + }; + let out = map_text_props(&props); + assert!(matches!(out.letter_spacing, Some(p) if (p.value() - 0.5).abs() < 1e-6)); +} + +// ── cell property helpers ────────────────────────────────────────────── + +#[test] +fn vertical_align_middle_maps_to_middle() { + assert_eq!( + map_odf_vertical_align("middle"), + Some(CellVerticalAlign::Middle) + ); +} + +#[test] +fn vertical_align_top_maps_to_top() { + assert_eq!(map_odf_vertical_align("top"), Some(CellVerticalAlign::Top)); +} + +#[test] +fn vertical_align_automatic_maps_to_top() { + assert_eq!( + map_odf_vertical_align("automatic"), + Some(CellVerticalAlign::Top) + ); +} + +#[test] +fn vertical_align_bottom_maps_to_bottom() { + assert_eq!( + map_odf_vertical_align("bottom"), + Some(CellVerticalAlign::Bottom) + ); +} + +#[test] +fn vertical_align_unknown_returns_none() { + assert_eq!(map_odf_vertical_align("baseline"), None); +} + +#[test] +fn writing_mode_tb_rl_maps_to_tbrl() { + assert_eq!(map_odf_writing_mode("tb-rl"), Some(CellTextDirection::TbRl)); +} + +#[test] +fn writing_mode_lr_tb_maps_to_lrtb() { + assert_eq!(map_odf_writing_mode("lr-tb"), Some(CellTextDirection::LrTb)); +} + +#[test] +fn writing_mode_lr_shorthand_maps_to_lrtb() { + assert_eq!(map_odf_writing_mode("lr"), Some(CellTextDirection::LrTb)); +} + +#[test] +fn parse_odf_border_solid_black() { + let b = parse_odf_border("0.06pt solid #000000").expect("should parse"); + // Width rounds to 0.06pt + assert!( + (b.width.value() - 0.06).abs() < 0.01, + "width should be ~0.06pt, got {}", + b.width.value() + ); + use loki_doc_model::style::props::border::BorderStyle; + assert_eq!(b.style, BorderStyle::Solid); + assert!(b.color.is_some(), "color should be parsed"); +} + +#[test] +fn parse_odf_border_none_returns_none() { + assert!(parse_odf_border("none").is_none()); +} + +#[test] +fn fo_padding_shorthand_applies_to_all_edges() { + use crate::odt::model::styles::OdfCellProps; + + let cell_props = OdfCellProps { + padding_top: Some("0.2cm".into()), + padding_bottom: Some("0.2cm".into()), + padding_left: Some("0.2cm".into()), + padding_right: Some("0.2cm".into()), + ..Default::default() + }; + let props = map_cell_props(&cell_props); + // 0.2cm ≈ 5.669pt + for (label, val) in [ + ("top", props.padding_top), + ("bottom", props.padding_bottom), + ("left", props.padding_left), + ("right", props.padding_right), + ] { + let pts = val + .expect(&format!("padding_{label} should be Some")) + .value(); + assert!( + (pts - 5.669).abs() < 0.1, + "padding_{label} should be ~5.67pt, got {pts:.3}" + ); + } +} diff --git a/loki-odf/src/odt/math.rs b/loki-odf/src/odt/math.rs new file mode 100644 index 00000000..515fc910 --- /dev/null +++ b/loki-odf/src/odt/math.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Embedded math (formula) objects for ODT. +//! +//! ODF stores mathematical content as an embedded formula sub-document: a +//! `draw:frame`/`draw:object` in `content.xml` references an `Object N/` +//! directory whose `content.xml` holds a `MathML` `` document (ODF 1.3 +//! §3.16, §12). The format-neutral model stores that same `MathML` string in +//! [`loki_doc_model::content::inline::Inline::Math`]. +//! +//! This module renders the object's `content.xml` on export and extracts + +//! canonicalises the `MathML` on import, so a formula round-trips byte-for-byte +//! through the model. + +use quick_xml::Reader; +use quick_xml::events::Event; + +/// The `MathML` namespace URI placed on the root `` element. +const MATHML_NS: &str = "http://www.w3.org/1998/Math/MathML"; + +/// Builds the `content.xml` body of a formula object from a `MathML` string. +#[must_use] +pub(crate) fn object_content_xml(mathml: &str) -> String { + format!("\n{mathml}") +} + +/// Extracts the `MathML` `` document from a formula object's `content.xml` +/// bytes, re-serialising it into the model's canonical form (unprefixed tags, +/// the `MathML` namespace on the root, no insignificant whitespace). +/// +/// Returns `None` if no `math` root element is found. +#[must_use] +pub(crate) fn extract_mathml(object_xml: &[u8]) -> Option { + let root = read_math_root(object_xml)?; + Some(serialize(&root, true)) +} + +/// A minimal XML element tree used to canonicalise the parsed `MathML`. +#[derive(Default)] +struct Node { + tag: String, + text: String, + children: Vec, +} + +/// Parses `bytes`, returning the first `math` element as a tree. +fn read_math_root(bytes: &[u8]) -> Option { + let mut reader = Reader::from_reader(bytes); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let tag = local_tag(e.name().as_ref()); + if tag == "math" { + return Some(read_node(&mut reader, &tag)); + } + } + Ok(Event::Eof) | Err(_) => return None, + _ => {} + } + buf.clear(); + } +} + +/// Reads the children of an element named `tag` (its `Start` already consumed) +/// until the matching `End`. +fn read_node(reader: &mut Reader<&[u8]>, tag: &str) -> Node { + let mut node = Node { + tag: tag.to_string(), + ..Default::default() + }; + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let ctag = local_tag(e.name().as_ref()); + node.children.push(read_node(reader, &ctag)); + } + Ok(Event::Empty(ref e)) => node.children.push(Node { + tag: local_tag(e.name().as_ref()), + ..Default::default() + }), + Ok(Event::Text(ref t)) => { + if let Ok(s) = t.unescape() { + node.text.push_str(&s); + } + } + Ok(Event::End(ref e)) => { + if local_tag(e.name().as_ref()) == tag { + break; + } + } + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + buf.clear(); + } + node +} + +/// Canonically serialises a node; the root carries the `MathML` namespace. +fn serialize(node: &Node, is_root: bool) -> String { + let attrs = if is_root { + format!(" xmlns=\"{MATHML_NS}\"") + } else { + String::new() + }; + if node.children.is_empty() { + if node.text.is_empty() { + format!("<{0}{1}/>", node.tag, attrs) + } else { + format!("<{0}{1}>{2}", node.tag, attrs, escape(&node.text)) + } + } else { + let inner: String = node.children.iter().map(|c| serialize(c, false)).collect(); + format!("<{0}{1}>{2}", node.tag, attrs, inner) + } +} + +/// Local name (prefix stripped) of an element name as an owned string. +fn local_tag(name: &[u8]) -> String { + let local = match name.iter().rposition(|&b| b == b':') { + Some(pos) => &name[pos + 1..], + None => name, + }; + String::from_utf8_lossy(local).into_owned() +} + +/// Escapes the five XML predefined entities, matching the OOXML converter so +/// `MathML` produced by either format normalises identically. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +#[cfg(test)] +#[path = "math_tests.rs"] +mod tests; diff --git a/loki-odf/src/odt/math_tests.rs b/loki-odf/src/odt/math_tests.rs new file mode 100644 index 00000000..5f7ca420 --- /dev/null +++ b/loki-odf/src/odt/math_tests.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for the embedded-formula MathML (de)serialiser. + +use super::{extract_mathml, object_content_xml}; + +const NS: &str = "http://www.w3.org/1998/Math/MathML"; + +#[test] +fn round_trips_canonical_mathml() { + let mathml = format!("12"); + let xml = object_content_xml(&mathml); + let back = extract_mathml(xml.as_bytes()).expect("math present"); + assert_eq!(back, mathml); +} + +#[test] +fn canonicalises_prefixed_and_whitespaced_input() { + // A foreign formula with a namespace prefix and pretty-print whitespace + // normalises to the same canonical string. + let foreign = "\n\ + \n \ + x\n\ + "; + let back = extract_mathml(foreign.as_bytes()).expect("math present"); + assert_eq!( + back, + format!("x") + ); +} + +#[test] +fn no_math_returns_none() { + assert!(extract_mathml(b"").is_none()); +} diff --git a/loki-odf/src/odt/mod.rs b/loki-odf/src/odt/mod.rs index d06081dc..da25db9b 100644 --- a/loki-odf/src/odt/mod.rs +++ b/loki-odf/src/odt/mod.rs @@ -13,8 +13,9 @@ //! # Export //! //! Use [`export::OdtExport`] (via the [`loki_doc_model::io::DocumentExport`] -//! trait). Export is not yet implemented; all calls return -//! [`crate::error::OdfError::NotImplemented`]. +//! trait). It writes the `content.xml`, `styles.xml`, and `meta.xml` parts of +//! an ODT package — paragraphs, headings, styled paragraphs, lists, tables, +//! inline formatting, the named style catalog, page geometry, and metadata. //! //! # Version round-trip //! @@ -25,5 +26,7 @@ pub mod export; pub mod import; pub(crate) mod mapper; +pub(crate) mod math; pub(crate) mod model; pub(crate) mod reader; +pub(crate) mod write; diff --git a/loki-odf/src/odt/model/document.rs b/loki-odf/src/odt/model/document.rs index ceea3b05..0611bd64 100644 --- a/loki-odf/src/odt/model/document.rs +++ b/loki-odf/src/odt/model/document.rs @@ -155,12 +155,26 @@ pub(crate) struct OdfPageLayout { pub margin_right: Option, /// `style:print-orientation` — `"portrait"` or `"landscape"`. pub print_orientation: Option, + /// Multi-column layout from a `style:columns` child, if present. + pub columns: Option, /// Header area properties, if a `style:header-style` child is present. pub header_props: Option, /// Footer area properties, if a `style:footer-style` child is present. pub footer_props: Option, } +/// Multi-column layout from `style:columns` inside `style:page-layout-properties`. +/// ODF 1.3 §16.27.10. +#[derive(Debug, Clone)] +pub(crate) struct OdfColumns { + /// `fo:column-count` — the number of columns. + pub count: u32, + /// `fo:column-gap` — the gap between columns (an ODF length), if specified. + pub gap: Option, + /// Whether a `style:column-sep` separator child is present. + pub separator: bool, +} + /// Properties for the header or footer area of a page layout. /// /// ODF 1.3 §16.5 `style:header-style` / `style:footer-style`. @@ -225,4 +239,8 @@ pub(crate) struct OdfMeta { pub editing_cycles: Option, /// `meta:keyword` — keywords describing the document. pub keywords: Vec, + /// `meta:user-defined` entries as `(meta:name, value)` pairs. Carries the + /// extended Dublin Core fields (under reserved `dcmi:` names) plus any other + /// user-defined metadata. + pub user_defined: Vec<(String, String)>, } diff --git a/loki-odf/src/odt/model/frames.rs b/loki-odf/src/odt/model/frames.rs index ea103d83..9f4036b2 100644 --- a/loki-odf/src/odt/model/frames.rs +++ b/loki-odf/src/odt/model/frames.rs @@ -61,6 +61,13 @@ pub(crate) enum OdfFrameKind { paragraphs: Vec, }, + /// An embedded object (`draw:object`), e.g. a formula. ODF 1.3 §10.4.5. + Object { + /// `xlink:href` — path to the object sub-document directory within the + /// package (e.g. `"./Object 1"`). + href: String, + }, + /// Any other frame content not specifically modelled above. Other, } diff --git a/loki-odf/src/odt/model/paragraph.rs b/loki-odf/src/odt/model/paragraph.rs index 29b17864..26852872 100644 --- a/loki-odf/src/odt/model/paragraph.rs +++ b/loki-odf/src/odt/model/paragraph.rs @@ -104,6 +104,25 @@ pub(crate) enum OdfParagraphChild { /// A hard line break (`text:line-break`). ODF 1.3 §6.5. LineBreak, + /// A comment start anchor (`office:annotation`). ODF 1.3 §14.1. Carries the + /// comment body inline (creator, date, and plain-text body). + Annotation { + /// `office:name` — links the start to its `annotation-end`. + name: Option, + /// `dc:creator` — the comment author. + creator: Option, + /// `dc:date` — the comment timestamp (ISO-8601). + date: Option, + /// Plain text of each body paragraph (`text:p`), in order. + body: Vec, + }, + + /// A comment end anchor (`office:annotation-end`). ODF 1.3 §14.1. + AnnotationEnd { + /// `office:name` — matches the corresponding `Annotation`. + name: Option, + }, + /// Any inline element not specifically modelled above. Other, } diff --git a/loki-odf/src/odt/reader/annotations.rs b/loki-odf/src/odt/reader/annotations.rs new file mode 100644 index 00000000..082cfe05 --- /dev/null +++ b/loki-odf/src/odt/reader/annotations.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Reader for `office:annotation` (comment) bodies. ODF 1.3 §14.1. + +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; + +use crate::error::{OdfError, OdfResult}; +use crate::odt::model::paragraph::OdfParagraphChild; +use crate::xml_util::local_attr_val; + +/// Parses an `office:annotation` element (the `Start` event already consumed) +/// into an [`OdfParagraphChild::Annotation`]. Collects `dc:creator`, `dc:date`, +/// and the plain text of the body paragraphs (joined by `\n`). +pub(crate) fn read_annotation( + reader: &mut Reader<&[u8]>, + e: &BytesStart<'_>, +) -> OdfResult { + let name = local_attr_val(e, b"name"); + let mut creator = None; + let mut date = None; + let mut body: Vec = Vec::new(); + + let mut buf = Vec::new(); + // Which metadata element's text we are collecting (`creator` / `date`), or + // `None` while inside a body paragraph. + let mut collecting: Option<&'static str> = None; + let mut meta_text = String::new(); + let mut para_text = String::new(); + let mut in_paragraph = false; + + loop { + buf.clear(); + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref c)) => match c.local_name().into_inner() { + b"creator" => { + collecting = Some("creator"); + meta_text.clear(); + } + b"date" => { + collecting = Some("date"); + meta_text.clear(); + } + b"p" => { + in_paragraph = true; + para_text.clear(); + } + _ => {} + }, + Ok(Event::Text(ref t)) => { + let s = t.unescape().map_err(xml_err)?; + if collecting.is_some() { + meta_text.push_str(&s); + } else if in_paragraph { + para_text.push_str(&s); + } + } + Ok(Event::End(ref c)) => match c.local_name().into_inner() { + b"creator" => { + creator = Some(std::mem::take(&mut meta_text)); + collecting = None; + } + b"date" => { + date = Some(std::mem::take(&mut meta_text)); + collecting = None; + } + b"p" => { + in_paragraph = false; + body.push(std::mem::take(&mut para_text)); + } + b"annotation" => break, + _ => {} + }, + Ok(Event::Eof) => break, + Err(e) => return Err(xml_err(e)), + _ => {} + } + } + + Ok(OdfParagraphChild::Annotation { + name, + creator, + date, + body, + }) +} + +fn xml_err(source: quick_xml::Error) -> OdfError { + OdfError::Xml { + part: "content.xml".to_string(), + source, + } +} diff --git a/loki-odf/src/odt/reader/columns.rs b/loki-odf/src/odt/reader/columns.rs new file mode 100644 index 00000000..db85b738 --- /dev/null +++ b/loki-odf/src/odt/reader/columns.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Reader for the `style:columns` child of `style:page-layout-properties` +//! (ODF 1.3 §16.27.10) — multi-column section layout. + +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; + +use crate::error::{OdfError, OdfResult}; +use crate::odt::model::document::OdfColumns; +use crate::odt::reader::styles::skip_element; +use crate::xml_util::local_attr_val; + +/// Scans the children of `style:page-layout-properties` for a `style:columns` +/// element, returning the parsed [`OdfColumns`] if present. Consumes up to and +/// including the closing ``. +pub(crate) fn parse_plp_columns(reader: &mut Reader<&[u8]>) -> OdfResult> { + let mut buf = Vec::new(); + let mut columns = None; + loop { + buf.clear(); + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let local = e.local_name().into_inner().to_vec(); + if local == b"columns" { + columns = Some(parse_columns(reader, e, false)?); + } else { + skip_element(reader, &local)?; + } + } + Ok(Event::Empty(ref e)) if e.local_name().into_inner() == b"columns" => { + columns = Some(parse_columns(reader, e, true)?); + } + Ok(Event::End(ref e)) if e.local_name().into_inner() == b"page-layout-properties" => { + break; + } + Ok(Event::Eof) => break, + Err(e) => { + return Err(OdfError::Xml { + part: "styles.xml".to_string(), + source: e, + }); + } + _ => {} + } + } + Ok(columns) +} + +/// Parses a `style:columns` element. `count`/`gap` come from its attributes; +/// `separator` is set when a `style:column-sep` child is present. When `empty` +/// the element is self-closing (no separator, nothing more to read). +fn parse_columns( + reader: &mut Reader<&[u8]>, + e: &BytesStart<'_>, + empty: bool, +) -> OdfResult { + let count = local_attr_val(e, b"column-count") + .and_then(|v| v.parse().ok()) + .unwrap_or(1); + let gap = local_attr_val(e, b"column-gap"); + let mut separator = false; + if !empty { + let mut buf = Vec::new(); + loop { + buf.clear(); + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref c) | Event::Empty(ref c)) + if c.local_name().into_inner() == b"column-sep" => + { + separator = true; + } + Ok(Event::End(ref c)) if c.local_name().into_inner() == b"columns" => break, + Ok(Event::Eof) => break, + Err(err) => { + return Err(OdfError::Xml { + part: "styles.xml".to_string(), + source: err, + }); + } + _ => {} + } + } + } + Ok(OdfColumns { + count, + gap, + separator, + }) +} diff --git a/loki-odf/src/odt/reader/document.rs b/loki-odf/src/odt/reader/document.rs index cf418f76..77659dae 100644 --- a/loki-odf/src/odt/reader/document.rs +++ b/loki-odf/src/odt/reader/document.rs @@ -254,14 +254,20 @@ pub(super) fn read_frame_kind(reader: &mut Reader<&[u8]>) -> OdfResult { + let href = local_attr_val(e, b"href").unwrap_or_default(); + drop(e); + skip_element(reader)?; + kind = OdfFrameKind::Object { href }; + } _ => { drop(e); skip_element(reader)?; } } } - Ok(Event::Empty(ref e)) => { - if e.local_name().into_inner() == b"image" { + Ok(Event::Empty(ref e)) => match e.local_name().into_inner() { + b"image" => { let href = local_attr_val(e, b"href").unwrap_or_default(); let media_type = local_attr_val(e, b"type"); kind = OdfFrameKind::Image { @@ -271,7 +277,12 @@ pub(super) fn read_frame_kind(reader: &mut Reader<&[u8]>) -> OdfResult { + let href = local_attr_val(e, b"href").unwrap_or_default(); + kind = OdfFrameKind::Object { href }; + } + _ => {} + }, Ok(Event::End(_) | Event::Eof) => break, Err(e) => { return Err(OdfError::Xml { diff --git a/loki-odf/src/odt/reader/inlines.rs b/loki-odf/src/odt/reader/inlines.rs index 502d1560..5da4dd9b 100644 --- a/loki-odf/src/odt/reader/inlines.rs +++ b/loki-odf/src/odt/reader/inlines.rs @@ -75,6 +75,10 @@ pub(crate) fn read_inline_children( children: link_children, })); } + // Comment start (`office:annotation`) — ODF 1.3 §14.1 + b"annotation" => { + children.push(super::annotations::read_annotation(reader, e)?); + } // All non-recursive inline elements are handled out of // line so this recursive frame stays small. _ => read_inline_start_other(reader, e, &local, &mut children)?, @@ -197,6 +201,16 @@ fn inline_from_empty(e: &BytesStart<'_>) -> OdfParagraphChild { let name = local_attr_val(e, b"name").unwrap_or_default(); OdfParagraphChild::BookmarkEnd { name } } + b"annotation-end" => OdfParagraphChild::AnnotationEnd { + name: local_attr_val(e, b"name"), + }, + b"annotation" => OdfParagraphChild::Annotation { + // Self-closing annotation: a point comment with no body. + name: local_attr_val(e, b"name"), + creator: None, + date: None, + body: Vec::new(), + }, _ => field_from_element(e, local), } } diff --git a/loki-odf/src/odt/reader/meta.rs b/loki-odf/src/odt/reader/meta.rs index d2925266..455ad2e0 100644 --- a/loki-odf/src/odt/reader/meta.rs +++ b/loki-odf/src/odt/reader/meta.rs @@ -27,6 +27,8 @@ pub(crate) fn read_meta(xml: &[u8]) -> OdfResult { // Local name of the element currently being collected, if any. let mut collecting: Option> = None; let mut collect_text = String::new(); + // The `meta:name` of the `meta:user-defined` element currently open. + let mut user_name: Option = None; loop { buf.clear(); @@ -39,6 +41,11 @@ pub(crate) fn read_meta(xml: &[u8]) -> OdfResult { collecting = Some(local); collect_text.clear(); } + b"user-defined" => { + user_name = crate::xml_util::local_attr_val(e, b"name"); + collecting = Some(local); + collect_text.clear(); + } _ => { // Descending into an unrecognised element; stop // collecting so we don't mix text from nested content. @@ -64,6 +71,11 @@ pub(crate) fn read_meta(xml: &[u8]) -> OdfResult { b"initial-creator" => meta.initial_creator = Some(text), b"subject" => meta.subject = Some(text), b"keyword" => meta.keywords.push(text), + b"user-defined" => { + if let Some(name) = user_name.take() { + meta.user_defined.push((name, text)); + } + } _ => {} } collecting = None; diff --git a/loki-odf/src/odt/reader/mod.rs b/loki-odf/src/odt/reader/mod.rs index 99191502..1dfc8b56 100644 --- a/loki-odf/src/odt/reader/mod.rs +++ b/loki-odf/src/odt/reader/mod.rs @@ -8,6 +8,8 @@ //! significant whitespace inside `text:span` and similar elements is //! preserved verbatim. +pub(crate) mod annotations; +pub(crate) mod columns; pub(crate) mod document; pub(crate) mod inlines; pub(crate) mod meta; diff --git a/loki-odf/src/odt/reader/styles.rs b/loki-odf/src/odt/reader/styles.rs index 49285004..6b058ae1 100644 --- a/loki-odf/src/odt/reader/styles.rs +++ b/loki-odf/src/odt/reader/styles.rs @@ -21,6 +21,7 @@ use crate::odt::model::styles::{ OdfCellProps, OdfDefaultStyle, OdfParaProps, OdfStyle, OdfStyleFamily, OdfStylesheet, OdfTabStop, OdfTextProps, }; +use crate::odt::reader::columns::parse_plp_columns; use crate::odt::reader::document::read_paragraph; use crate::xml_util::local_attr_val; @@ -869,6 +870,7 @@ fn parse_page_layout(reader: &mut Reader<&[u8]>, name: String) -> OdfResult, name: String) -> OdfResult { drop(e); diff --git a/loki-odf/src/odt/write/auto.rs b/loki-odf/src/odt/write/auto.rs new file mode 100644 index 00000000..b0aaa5b5 --- /dev/null +++ b/loki-odf/src/odt/write/auto.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Collector for ODF *automatic styles* — the per-use styles emitted into +//! `content.xml`'s `` for direct (inline / paragraph) +//! formatting that is not a named catalog style. + +use std::collections::HashMap; + +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::para_props::ParaProps; + +use super::para_props::emit_paragraph_properties; +use super::props::emit_text_properties; + +/// Deduplicates automatic text (`family="text"`) and paragraph +/// (`family="paragraph"`) styles, assigning stable `T{n}` / `P{n}` names. +#[derive(Default)] +pub(super) struct AutoStyles { + /// text-properties element → style name (`T{n}`). + text: HashMap, + /// (parent, paragraph-properties, text-properties) → style name (`P{n}`). + para: HashMap<(String, String, String), String>, + /// Rendered `` elements, in creation order. + rendered: Vec, +} + +impl AutoStyles { + pub(super) fn new() -> Self { + Self::default() + } + + /// Returns the automatic text-style name for `cp`, or `None` when `cp` + /// carries no formatting. + pub(super) fn text_style(&mut self, cp: &CharProps) -> Option { + let props = emit_text_properties(cp); + if props.is_empty() { + return None; + } + if let Some(name) = self.text.get(&props) { + return Some(name.clone()); + } + let name = format!("T{}", self.rendered.len() + 1); + self.rendered.push(format!( + "{props}" + )); + self.text.insert(props, name.clone()); + Some(name) + } + + /// Returns the automatic paragraph-style name for direct paragraph/char + /// formatting with optional `parent`, or `None` when nothing is set. + pub(super) fn para_style( + &mut self, + parent: Option<&str>, + pp: &ParaProps, + cp: &CharProps, + ) -> Option { + let p_props = emit_paragraph_properties(pp); + let t_props = emit_text_properties(cp); + if p_props.is_empty() && t_props.is_empty() && parent.is_none() { + return None; + } + Some(self.para_style_inner(parent, &p_props, &t_props, None)) + } + + /// Like [`Self::para_style`] but forces an automatic style carrying + /// `style:master-page-name` — used on the first paragraph of each section + /// to trigger the master-page (page-geometry) transition on re-import. + /// + /// Always returns a name, since the master-page reference must be emitted + /// even when the paragraph has no other direct formatting. + pub(super) fn para_style_master( + &mut self, + parent: Option<&str>, + pp: &ParaProps, + cp: &CharProps, + master_page: &str, + ) -> String { + let p_props = emit_paragraph_properties(pp); + let t_props = emit_text_properties(cp); + self.para_style_inner(parent, &p_props, &t_props, Some(master_page)) + } + + /// Builds (and deduplicates) a `family="paragraph"` automatic style from + /// pre-rendered property strings, with an optional master-page reference. + fn para_style_inner( + &mut self, + parent: Option<&str>, + p_props: &str, + t_props: &str, + master_page: Option<&str>, + ) -> String { + let key = ( + format!("{}\u{1}{}", parent.unwrap_or(""), master_page.unwrap_or("")), + p_props.to_string(), + t_props.to_string(), + ); + if let Some(name) = self.para.get(&key) { + return name.clone(); + } + let name = format!("P{}", self.rendered.len() + 1); + let mut el = format!("'); + el.push_str(p_props); + el.push_str(t_props); + el.push_str(""); + self.rendered.push(el); + self.para.insert(key, name.clone()); + name + } + + /// Renders all collected automatic styles as concatenated XML. + pub(super) fn render(&self) -> String { + self.rendered.concat() + } +} diff --git a/loki-odf/src/odt/write/content.rs b/loki-odf/src/odt/write/content.rs new file mode 100644 index 00000000..b869ecea --- /dev/null +++ b/loki-odf/src/odt/write/content.rs @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `content.xml` writer: the document body, its automatic styles, and the +//! embedded images it references. Inline-run serialisation lives in +//! [`super::inlines`]. + +use loki_doc_model::content::block::{Block, StyledParagraph}; +use loki_doc_model::document::Document; +use loki_doc_model::style::catalog::StyleId; +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::para_props::ParaProps; + +use super::auto::AutoStyles; +use super::inlines::write_inlines; +use super::media::{MathPart, Media, Rendered}; +use super::tables::table; +use super::xml::{attr, escape, master_page_name}; + +const HEADER: &str = concat!( + "\n", + "", +); + +/// Shared writer state threaded through the body: the automatic-style collector, +/// the embedded-image collector, and a comment lookup (id → body) for emitting +/// `office:annotation` content at the comment's start anchor. +pub(super) struct Cx { + pub(super) auto: AutoStyles, + pub(super) media: Media, + pub(super) comments: + std::collections::HashMap, + /// Embedded formula objects collected from `Inline::Math` runs. + pub(super) objects: Vec, +} + +/// Renders the whole `content.xml` for `doc`, collecting any embedded images. +#[must_use] +pub(crate) fn content_xml(doc: &Document) -> Rendered { + let mut cx = Cx { + auto: AutoStyles::new(), + media: Media::new(), + comments: doc + .comments + .iter() + .map(|c| (c.id.clone(), c.clone())) + .collect(), + objects: Vec::new(), + }; + let mut body = String::new(); + for (idx, section) in doc.sections.iter().enumerate() { + // Sections after the first trigger a page-geometry change by attaching + // `style:master-page-name` to their first paragraph (ODF has no explicit + // section element). The first section uses the initial master page. + let master = (idx > 0).then(|| master_page_name(idx)); + match (master.as_deref(), section.blocks.first()) { + (Some(mp), Some(first)) => { + write_block_with_master(&mut body, first, mp, &mut cx); + for block in §ion.blocks[1..] { + write_block(&mut body, block, &mut cx); + } + } + (Some(mp), None) => { + // Empty section: emit a carrier paragraph so the break survives. + let style = cx.auto.para_style_master( + None, + &ParaProps::default(), + &CharProps::default(), + mp, + ); + body.push_str(&format!("")); + } + _ => { + for block in §ion.blocks { + write_block(&mut body, block, &mut cx); + } + } + } + } + let mut out = String::with_capacity(body.len() + 1024); + out.push_str(HEADER); + out.push_str(""); + out.push_str(&cx.auto.render()); + out.push_str(""); + out.push_str(""); + out.push_str(&body); + out.push_str(""); + Rendered { + xml: out, + media: cx.media.into_parts(), + objects: cx.objects, + } +} + +/// Writes a single block element. +pub(super) fn write_block(out: &mut String, block: &Block, cx: &mut Cx) { + match block { + Block::Para(inl) | Block::Plain(inl) => paragraph(out, None, inl, cx), + Block::Heading(level, _, inl) => { + let lvl = (*level).clamp(1, 6); + out.push_str(&format!( + "" + )); + write_inlines(out, inl, cx); + out.push_str(""); + } + Block::StyledPara(sp) => styled_paragraph(out, sp, cx), + Block::BlockQuote(blocks) | Block::Div(_, blocks) | Block::Figure(_, _, blocks) => { + for b in blocks { + write_block(out, b, cx); + } + } + Block::BulletList(items) | Block::OrderedList(_, items) => list(out, items, cx), + Block::DefinitionList(items) => { + for (term, defs) in items { + paragraph(out, None, term, cx); + for blocks in defs { + for b in blocks { + write_block(out, b, cx); + } + } + } + } + Block::CodeBlock(_, text) => { + for line in text.split('\n') { + out.push_str(""); + out.push_str(&escape(line)); + out.push_str(""); + } + } + Block::LineBlock(lines) => { + out.push_str(""); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + out.push_str(""); + } + write_inlines(out, line, cx); + } + out.push_str(""); + } + Block::Table(t) => table(out, t, cx), + // Preserve the rendered text of generated blocks (TOC / index) rather + // than dropping it. + Block::TableOfContents(toc) => { + for b in &toc.body { + write_block(out, b, cx); + } + } + Block::Index(idx) => { + for b in &idx.body { + write_block(out, b, cx); + } + } + // Raw / notes blocks have no faithful ODF body representation. + _ => {} + } +} + +/// Writes the first block of a section, attaching `style:master-page-name` so +/// the page-geometry change round-trips. Paragraph-like blocks carry the +/// reference directly; for any other block (table, list, …) a minimal carrier +/// paragraph is injected before it, since only paragraphs hold the attribute. +fn write_block_with_master(out: &mut String, block: &Block, master: &str, cx: &mut Cx) { + match block { + Block::Para(inl) | Block::Plain(inl) => { + let style = cx.auto.para_style_master( + None, + &ParaProps::default(), + &CharProps::default(), + master, + ); + paragraph(out, Some(&style), inl, cx); + } + Block::StyledPara(sp) => { + let base = sp.style_id.as_ref().map(StyleId::as_str); + let pp = sp.direct_para_props.as_deref().cloned().unwrap_or_default(); + let cp = sp.direct_char_props.as_deref().cloned().unwrap_or_default(); + let style = cx.auto.para_style_master(base, &pp, &cp, master); + paragraph(out, Some(&style), &sp.inlines, cx); + } + Block::Heading(level, _, inl) => { + let lvl = (*level).clamp(1, 6); + let parent = format!("Heading{lvl}"); + let style = cx.auto.para_style_master( + Some(&parent), + &ParaProps::default(), + &CharProps::default(), + master, + ); + out.push_str(&format!( + "" + )); + write_inlines(out, inl, cx); + out.push_str(""); + } + other => { + let style = cx.auto.para_style_master( + None, + &ParaProps::default(), + &CharProps::default(), + master, + ); + out.push_str(&format!("")); + write_block(out, other, cx); + } + } +} + +/// Writes a `` with optional automatic style and inline content. +fn paragraph( + out: &mut String, + style: Option<&str>, + inl: &[loki_doc_model::content::inline::Inline], + cx: &mut Cx, +) { + out.push_str("'); + write_inlines(out, inl, cx); + out.push_str(""); +} + +/// Writes a `StyledParagraph`: references its named style, layering an automatic +/// style on top when it carries direct paragraph / character overrides. +fn styled_paragraph(out: &mut String, sp: &StyledParagraph, cx: &mut Cx) { + let base = sp.style_id.as_ref().map(StyleId::as_str); + let pp = sp.direct_para_props.as_deref().cloned().unwrap_or_default(); + let cp = sp.direct_char_props.as_deref().cloned().unwrap_or_default(); + let name = if sp.direct_para_props.is_some() || sp.direct_char_props.is_some() { + cx.auto.para_style(base, &pp, &cp) + } else { + base.map(str::to_string) + }; + paragraph(out, name.as_deref(), &sp.inlines, cx); +} + +/// Writes a `` from a list of items (each a block sequence). +fn list(out: &mut String, items: &[Vec], cx: &mut Cx) { + out.push_str(""); + for item in items { + out.push_str(""); + if item.is_empty() { + out.push_str(""); + } else { + for b in item { + write_block(out, b, cx); + } + } + out.push_str(""); + } + out.push_str(""); +} diff --git a/loki-odf/src/odt/write/inlines.rs b/loki-odf/src/odt/write/inlines.rs new file mode 100644 index 00000000..42c1ff8a --- /dev/null +++ b/loki-odf/src/odt/write/inlines.rs @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Inline-run serialisation for `content.xml` (text spans, links, footnotes, +//! bookmarks, fields, and embedded images). + +use loki_doc_model::content::annotation::{CommentRef, CommentRefKind}; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::field::types::{CrossRefFormat, Field, FieldKind}; +use loki_doc_model::content::inline::{BookmarkKind, Inline, NoteKind}; +use loki_doc_model::style::props::char_props::{ + CharProps, StrikethroughStyle, UnderlineStyle, VerticalAlign, +}; + +use super::content::{Cx, write_block}; +use super::xml::{attr, escape}; + +/// Writes a sequence of inline runs. +pub(super) fn write_inlines(out: &mut String, inlines: &[Inline], cx: &mut Cx) { + for inl in inlines { + write_inline(out, inl, cx); + } +} + +/// Writes one inline run. +fn write_inline(out: &mut String, inl: &Inline, cx: &mut Cx) { + match inl { + Inline::Str(s) | Inline::Code(_, s) => out.push_str(&escape(s)), + Inline::Space | Inline::SoftBreak => out.push(' '), + Inline::LineBreak => out.push_str(""), + Inline::Strong(c) => span(out, c, cx, set_bold), + Inline::Emph(c) => span(out, c, cx, set_italic), + Inline::Underline(c) => span(out, c, cx, set_underline), + Inline::Strikeout(c) => span(out, c, cx, set_strikethrough), + Inline::Superscript(c) => span(out, c, cx, set_superscript), + Inline::Subscript(c) => span(out, c, cx, set_subscript), + Inline::SmallCaps(c) => span(out, c, cx, set_small_caps), + Inline::Span(_, c) | Inline::Quoted(_, c) | Inline::Cite(_, c) => write_inlines(out, c, cx), + Inline::StyledRun(sr) => { + let name = match sr.direct_props.as_deref() { + Some(dp) => cx.auto.text_style(dp), + None => sr.style_id.as_ref().map(|s| s.as_str().to_string()), + }; + wrap_span(out, name.as_deref(), &sr.content, cx); + } + Inline::Link(_, c, target) => { + out.push_str("'); + write_inlines(out, c, cx); + out.push_str(""); + } + Inline::Bookmark(kind, name) => { + let tag = if matches!(kind, BookmarkKind::End) { + "bookmark-end" + } else { + "bookmark-start" + }; + out.push_str(&format!(""); + } + Inline::Field(field) => write_field(out, field), + Inline::Image(_, alt, target) => write_image(out, alt, &target.url, cx), + Inline::Note(kind, blocks) => note(out, *kind, blocks, cx), + Inline::Comment(c) => write_comment(out, c, cx), + Inline::Math(_, mathml) => write_math(out, mathml, cx), + // RawInline: no faithful ODF inline representation. + _ => {} + } +} + +/// Writes a `` referencing an embedded +/// formula object, registering its `MathML` `content.xml` with the collector. +/// ODF stores math as a sub-document; see [`crate::odt::math`]. +fn write_math(out: &mut String, mathml: &str, cx: &mut Cx) { + let dir = format!("Object {}", cx.objects.len() + 1); + out.push_str(""); + cx.objects.push(super::media::MathPart { + dir, + content_xml: crate::odt::math::object_content_xml(mathml), + }); +} + +/// Writes a comment anchor: `office:annotation` (with body) at the start/point, +/// `office:annotation-end` at the end. ODF 1.3 §14.1. +fn write_comment(out: &mut String, c: &CommentRef, cx: &Cx) { + if matches!(c.kind, CommentRefKind::End) { + out.push_str(""); + return; + } + out.push_str("'); + if let Some(comment) = cx.comments.get(&c.id) { + if let Some(author) = &comment.author { + out.push_str(""); + out.push_str(&escape(author)); + out.push_str(""); + } + if let Some(date) = &comment.date { + out.push_str(""); + out.push_str(&escape( + &date.to_rfc3339_opts(chrono::SecondsFormat::Secs, false), + )); + out.push_str(""); + } + for block in &comment.body { + out.push_str(""); + out.push_str(&escape(&block_plain_text(block))); + out.push_str(""); + } + } + out.push_str(""); +} + +/// Concatenates the plain text of a paragraph-like [`Block`]. +fn block_plain_text(block: &Block) -> String { + let inlines = match block { + Block::Para(i) | Block::Plain(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + inlines + .iter() + .map(|i| match i { + Inline::Str(s) => s.as_str(), + Inline::Space => " ", + _ => "", + }) + .collect() +} + +/// Writes an ODF field element for `field`. +fn write_field(out: &mut String, field: &Field) { + match &field.kind { + FieldKind::PageNumber => { + out.push_str("1"); + } + FieldKind::PageCount => out.push_str("1"), + FieldKind::Date { .. } => out.push_str("0000-00-00"), + FieldKind::Time { .. } => out.push_str("00:00:00"), + FieldKind::Title => out.push_str(""), + FieldKind::Subject => out.push_str(""), + FieldKind::Author => out.push_str(""), + FieldKind::FileName => out.push_str(""), + FieldKind::WordCount => out.push_str("0"), + FieldKind::CrossReference { target, format } => { + let fmt = match format { + CrossRefFormat::Number => "number", + CrossRefFormat::Page => "page", + CrossRefFormat::Caption => "caption", + _ => "text", + }; + out.push_str(""); + } + FieldKind::Raw { .. } | _ => {} + } +} + +/// Writes a `` for an embedded or linked +/// image, registering the bytes with the media collector. +fn write_image(out: &mut String, alt: &[Inline], url: &str, cx: &mut Cx) { + let Some(href) = cx.media.add_image(url) else { + return; + }; + out.push_str("'); + let alt_text = plain_text(alt); + if !alt_text.is_empty() { + out.push_str(&format!("{}", escape(&alt_text))); + } + out.push_str(""); +} + +/// Flattens inline runs to their plain text (for image alt text). +fn plain_text(inlines: &[Inline]) -> String { + let mut s = String::new(); + for inl in inlines { + match inl { + Inline::Str(t) | Inline::Code(_, t) => s.push_str(t), + Inline::Space | Inline::SoftBreak | Inline::LineBreak => s.push(' '), + Inline::Strong(c) | Inline::Emph(c) | Inline::Underline(c) | Inline::Span(_, c) => { + s.push_str(&plain_text(c)); + } + _ => {} + } + } + s +} + +fn set_bold(p: &mut CharProps) { + p.bold = Some(true); +} +fn set_italic(p: &mut CharProps) { + p.italic = Some(true); +} +fn set_underline(p: &mut CharProps) { + p.underline = Some(UnderlineStyle::Single); +} +fn set_strikethrough(p: &mut CharProps) { + p.strikethrough = Some(StrikethroughStyle::Single); +} +fn set_superscript(p: &mut CharProps) { + p.vertical_align = Some(VerticalAlign::Superscript); +} +fn set_subscript(p: &mut CharProps) { + p.vertical_align = Some(VerticalAlign::Subscript); +} +fn set_small_caps(p: &mut CharProps) { + p.small_caps = Some(true); +} + +/// Wraps `children` in a `` whose automatic text style is built by +/// applying `f` to a default [`CharProps`]. +fn span(out: &mut String, children: &[Inline], cx: &mut Cx, f: impl FnOnce(&mut CharProps)) { + let mut cp = CharProps::default(); + f(&mut cp); + let name = cx.auto.text_style(&cp); + wrap_span(out, name.as_deref(), children, cx); +} + +/// Wraps `children` in a ``; emits them bare when +/// `name` is `None`. +fn wrap_span(out: &mut String, name: Option<&str>, children: &[Inline], cx: &mut Cx) { + if let Some(name) = name { + out.push_str("'); + write_inlines(out, children, cx); + out.push_str(""); + } else { + write_inlines(out, children, cx); + } +} + +/// Writes a footnote / endnote. +fn note( + out: &mut String, + kind: NoteKind, + blocks: &[loki_doc_model::content::block::Block], + cx: &mut Cx, +) { + let class = match kind { + NoteKind::Endnote => "endnote", + _ => "footnote", + }; + out.push_str(&format!( + "\ + " + )); + for b in blocks { + write_block(out, b, cx); + } + out.push_str(""); +} diff --git a/loki-odf/src/odt/write/media.rs b/loki-odf/src/odt/write/media.rs new file mode 100644 index 00000000..0ad6d038 --- /dev/null +++ b/loki-odf/src/odt/write/media.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Collects embedded image bytes for the ODT package's `Pictures/` subtree. +//! +//! Inline images carry their data as a `data:;base64,…` URI (that is +//! how the importer round-trips them). On export each embedded image is decoded +//! and written as a `Pictures/imageN.` part, and the `draw:image` element +//! references that path — so image data is preserved across a save. + +use base64::Engine as _; + +/// One embedded media part destined for the package. +pub(crate) struct MediaPart { + /// ZIP entry path, e.g. `"Pictures/image1.png"`. + pub(crate) path: String, + /// MIME media type, e.g. `"image/png"`. + pub(crate) media_type: String, + /// Raw image bytes. + pub(crate) bytes: Vec, +} + +/// One embedded object sub-document (e.g. a formula) destined for the package. +pub(crate) struct MathPart { + /// Object directory path, e.g. `"Object 1"`. + pub(crate) dir: String, + /// The object's `content.xml` body (a `MathML` document). + pub(crate) content_xml: String, +} + +/// A rendered ODF part (XML) together with the image parts and embedded object +/// sub-documents it references. +pub(crate) struct Rendered { + pub(crate) xml: String, + pub(crate) media: Vec, + pub(crate) objects: Vec, +} + +/// Accumulates the embedded images referenced by a document part. +/// +/// The `prefix` distinguishes images from different parts (the body vs. the +/// master-page header/footer), so their `Pictures/` filenames never collide. +pub(super) struct Media { + prefix: &'static str, + parts: Vec, +} + +impl Media { + /// Collector for the document body (`Pictures/image…`). + pub(super) fn new() -> Self { + Self::with_prefix("image") + } + + /// Collector whose parts are named `Pictures/…`. + pub(super) fn with_prefix(prefix: &'static str) -> Self { + Self { + prefix, + parts: Vec::new(), + } + } + + /// Resolves an image `url` to the `xlink:href` to use in the XML. + /// + /// A `data:` URI is decoded and stored as a new `Pictures/` part (returning + /// that path); any other non-empty URL is treated as an external link and + /// referenced as-is. Returns `None` for an empty or undecodable URL. + pub(super) fn add_image(&mut self, url: &str) -> Option { + if let Some(rest) = url.strip_prefix("data:") { + let (meta, data) = rest.split_once(',')?; + if !meta.contains("base64") { + return None; + } + let media_type = meta.split(';').next().unwrap_or("image/png").to_string(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.trim()) + .ok()?; + let path = format!( + "Pictures/{}{}.{}", + self.prefix, + self.parts.len() + 1, + ext_for(&media_type) + ); + self.parts.push(MediaPart { + path: path.clone(), + media_type, + bytes, + }); + Some(path) + } else if url.is_empty() { + None + } else { + Some(url.to_string()) + } + } + + /// Consumes the collector, returning the gathered image parts. + pub(super) fn into_parts(self) -> Vec { + self.parts + } +} + +/// File extension for a raster/vector image MIME type. +fn ext_for(media_type: &str) -> &'static str { + match media_type { + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/svg+xml" => "svg", + "image/bmp" => "bmp", + "image/tiff" => "tif", + "image/webp" => "webp", + _ => "png", + } +} diff --git a/loki-odf/src/odt/write/mod.rs b/loki-odf/src/odt/write/mod.rs new file mode 100644 index 00000000..8afd665b --- /dev/null +++ b/loki-odf/src/odt/write/mod.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! ODT export writers: a [`loki_doc_model::document::Document`] is serialised to +//! the `content.xml`, `styles.xml`, and `meta.xml` parts of an ODT package, +//! along with any embedded image parts. + +mod auto; +mod content; +mod inlines; +mod media; +mod para_props; +mod props; +mod styles; +mod tables; +mod xml; + +pub(crate) use content::content_xml; +pub(crate) use media::{MathPart, MediaPart}; +pub(crate) use styles::{meta_xml, styles_xml}; diff --git a/loki-odf/src/odt/write/para_props.rs b/loki-odf/src/odt/write/para_props.rs new file mode 100644 index 00000000..9cac5443 --- /dev/null +++ b/loki-odf/src/odt/write/para_props.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Serialises [`ParaProps`] to a `style:paragraph-properties` element, mirroring +//! the import mapper (`odt::mapper::props::paragraph`) so every property it reads +//! back round-trips (alignment, indents, spacing, line height, flow control, +//! borders, padding, tab stops, bidi, background). + +use loki_doc_model::style::props::border::{Border, BorderStyle}; +use loki_doc_model::style::props::para_props::{ + LineHeight, ParaProps, ParagraphAlignment, Spacing, +}; +use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; +use loki_primitives::color::DocumentColor; +use loki_primitives::units::Points; + +use super::xml::{attr, pt}; + +/// Emits a complete `` element (with any tab-stop +/// children) from `pp`, or an empty string when `pp` carries no formatting. +#[must_use] +pub(super) fn emit_paragraph_properties(pp: &ParaProps) -> String { + let a = paragraph_properties_attrs(pp); + let children = tab_stops_xml(pp.tab_stops.as_deref().unwrap_or(&[])); + if a.is_empty() && children.is_empty() { + return String::new(); + } + if children.is_empty() { + format!("") + } else { + format!("{children}") + } +} + +fn paragraph_properties_attrs(pp: &ParaProps) -> String { + let mut s = String::new(); + if let Some(a) = pp.alignment { + let v = match a { + ParagraphAlignment::Right => "end", + ParagraphAlignment::Center => "center", + ParagraphAlignment::Justify | ParagraphAlignment::Distribute => "justify", + _ => "start", + }; + attr(&mut s, "fo:text-align", v); + } + if let Some(Spacing::Exact(p)) = pp.space_before { + attr(&mut s, "fo:margin-top", &pt(p)); + } + if let Some(Spacing::Exact(p)) = pp.space_after { + attr(&mut s, "fo:margin-bottom", &pt(p)); + } + if let Some(p) = pp.indent_start { + attr(&mut s, "fo:margin-left", &pt(p)); + } + if let Some(p) = pp.indent_end { + attr(&mut s, "fo:margin-right", &pt(p)); + } + // ODF expresses a hanging indent as a negative fo:text-indent; first-line + // indent as a positive one (mutually exclusive — hanging wins). + if let Some(p) = pp.indent_hanging { + attr(&mut s, "fo:text-indent", &format!("-{}", pt(p))); + } else if let Some(p) = pp.indent_first_line { + attr(&mut s, "fo:text-indent", &pt(p)); + } + match pp.line_height { + Some(LineHeight::Multiple(m)) => { + attr(&mut s, "fo:line-height", &format!("{:.0}%", m * 100.0)); + } + Some(LineHeight::Exact(p)) => attr(&mut s, "fo:line-height", &pt(p)), + Some(LineHeight::AtLeast(p)) => attr(&mut s, "style:line-height-at-least", &pt(p)), + _ => {} + } + if pp.keep_together == Some(true) { + attr(&mut s, "fo:keep-together", "always"); + } + if pp.keep_with_next == Some(true) { + attr(&mut s, "fo:keep-with-next", "always"); + } + if let Some(n) = pp.widow_control { + attr(&mut s, "fo:widows", &n.to_string()); + } + if let Some(n) = pp.orphan_control { + attr(&mut s, "fo:orphans", &n.to_string()); + } + if pp.page_break_before == Some(true) { + attr(&mut s, "fo:break-before", "page"); + } + if pp.page_break_after == Some(true) { + attr(&mut s, "fo:break-after", "page"); + } + border_attr(&mut s, "fo:border-top", pp.border_top.as_ref()); + border_attr(&mut s, "fo:border-bottom", pp.border_bottom.as_ref()); + border_attr(&mut s, "fo:border-left", pp.border_left.as_ref()); + border_attr(&mut s, "fo:border-right", pp.border_right.as_ref()); + emit_padding(&mut s, pp); + if pp.bidi == Some(true) { + attr(&mut s, "style:writing-mode", "rl-tb"); + } + if let Some(hex) = pp.background_color.as_ref().and_then(DocumentColor::to_hex) { + attr(&mut s, "fo:background-color", &hex); + } + s +} + +fn padding_attr(s: &mut String, name: &str, p: Option) { + if let Some(p) = p { + attr(s, name, &pt(p)); + } +} + +/// Emits paragraph padding. Uses the `fo:padding` shorthand when all four sides +/// are equal (the common case, which the importer reads back); otherwise emits +/// each side (valid ODF, preserved for other apps). +fn emit_padding(s: &mut String, pp: &ParaProps) { + let top = pp.padding_top; + let (bottom, left, right) = (pp.padding_bottom, pp.padding_left, pp.padding_right); + if let (Some(p), true) = (top, top == bottom && bottom == left && left == right) { + attr(s, "fo:padding", &pt(p)); + } else { + padding_attr(s, "fo:padding-top", top); + padding_attr(s, "fo:padding-bottom", bottom); + padding_attr(s, "fo:padding-left", left); + padding_attr(s, "fo:padding-right", right); + } +} + +/// Appends a border attribute as the ODF `"width style color"` shorthand. +fn border_attr(s: &mut String, name: &str, border: Option<&Border>) { + if let Some(b) = border { + let style = match b.style { + BorderStyle::None => { + attr(s, name, "none"); + return; + } + BorderStyle::Dashed => "dashed", + BorderStyle::Dotted => "dotted", + BorderStyle::Double => "double", + BorderStyle::Groove => "groove", + BorderStyle::Ridge => "ridge", + BorderStyle::Inset => "inset", + BorderStyle::Outset => "outset", + BorderStyle::Wave => "wave", + _ => "solid", + }; + let mut val = format!("{} {style}", pt(b.width)); + if let Some(hex) = b.color.as_ref().and_then(DocumentColor::to_hex) { + val.push(' '); + val.push_str(&hex); + } + attr(s, name, &val); + } +} + +/// Renders `` from a paragraph's tab stops (empty when none). +fn tab_stops_xml(stops: &[TabStop]) -> String { + if stops.is_empty() { + return String::new(); + } + let mut s = String::from(""); + for ts in stops { + s.push_str(" Some("right"), + TabAlignment::Center => Some("center"), + TabAlignment::Decimal => Some("char"), + _ => None, + }; + if let Some(kind) = kind { + attr(&mut s, "style:type", kind); + } + let leader = match ts.leader { + TabLeader::Dot | TabLeader::MiddleDot => Some("dotted"), + TabLeader::Dash => Some("dash"), + TabLeader::Underscore => Some("solid"), + TabLeader::Heavy => Some("bold"), + _ => None, + }; + if let Some(leader) = leader { + attr(&mut s, "style:leader-style", leader); + } + s.push_str("/>"); + } + s.push_str(""); + s +} diff --git a/loki-odf/src/odt/write/props.rs b/loki-odf/src/odt/write/props.rs new file mode 100644 index 00000000..46cc1909 --- /dev/null +++ b/loki-odf/src/odt/write/props.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Serialises [`CharProps`] to a `style:text-properties` element, mirroring the +//! import mapper (`odt::mapper::props::character`) so every property it reads +//! back round-trips. Paragraph properties live in [`super::para_props`]. + +use loki_doc_model::style::props::char_props::{ + CharProps, StrikethroughStyle, UnderlineStyle, VerticalAlign, +}; +use loki_primitives::color::DocumentColor; + +use super::xml::{attr, pt}; + +/// Emits a complete `` element from `cp`, or an +/// empty string when `cp` carries no formatting. +#[must_use] +pub(super) fn emit_text_properties(cp: &CharProps) -> String { + let a = text_properties_attrs(cp); + if a.is_empty() { + String::new() + } else { + format!("") + } +} + +fn text_properties_attrs(cp: &CharProps) -> String { + let mut s = String::new(); + if let Some(f) = &cp.font_name { + attr(&mut s, "style:font-name", f); + attr(&mut s, "fo:font-family", f); + } + if let Some(f) = &cp.font_name_complex { + attr(&mut s, "style:font-name-complex", f); + } + if let Some(f) = &cp.font_name_east_asian { + attr(&mut s, "style:font-name-asian", f); + } + if let Some(sz) = cp.font_size { + attr(&mut s, "fo:font-size", &pt(sz)); + } + if let Some(sz) = cp.font_size_complex { + attr(&mut s, "style:font-size-complex", &pt(sz)); + } + match cp.bold { + Some(true) => attr(&mut s, "fo:font-weight", "bold"), + Some(false) => attr(&mut s, "fo:font-weight", "normal"), + None => {} + } + match cp.italic { + Some(true) => attr(&mut s, "fo:font-style", "italic"), + Some(false) => attr(&mut s, "fo:font-style", "normal"), + None => {} + } + if let Some(u) = cp.underline { + attr(&mut s, "style:text-underline-style", underline_style(u)); + attr(&mut s, "style:text-underline-width", "auto"); + attr(&mut s, "style:text-underline-color", "font-color"); + } + if let Some(st) = cp.strikethrough { + let v = match st { + StrikethroughStyle::Double => "double", + _ => "solid", + }; + attr(&mut s, "style:text-line-through-style", v); + } + if cp.small_caps == Some(true) { + attr(&mut s, "fo:font-variant", "small-caps"); + } + if cp.all_caps == Some(true) { + attr(&mut s, "fo:text-transform", "uppercase"); + } + if cp.outline == Some(true) { + attr(&mut s, "style:text-outline", "true"); + } + if cp.shadow == Some(true) { + attr(&mut s, "fo:text-shadow", "1pt 1pt"); + } + if let Some(va) = cp.vertical_align { + let v = match va { + VerticalAlign::Superscript => "super 58%", + VerticalAlign::Subscript => "sub 58%", + _ => "0%", + }; + attr(&mut s, "style:text-position", v); + } + if let Some(hex) = cp.color.as_ref().and_then(DocumentColor::to_hex) { + attr(&mut s, "fo:color", &hex); + } + if let Some(hex) = cp.background_color.as_ref().and_then(DocumentColor::to_hex) { + attr(&mut s, "fo:background-color", &hex); + } + if let Some(ls) = cp.letter_spacing { + attr(&mut s, "fo:letter-spacing", &pt(ls)); + } + if let Some(ws) = cp.word_spacing { + attr(&mut s, "fo:word-spacing", &pt(ws)); + } + if let Some(k) = cp.kerning { + attr( + &mut s, + "style:letter-kerning", + if k { "true" } else { "false" }, + ); + } + if let Some(scale) = cp.scale { + attr(&mut s, "style:text-scale", &format!("{scale:.0}%")); + } + lang_attrs(&mut s, cp.language.as_ref(), "fo:language", "fo:country"); + lang_attrs( + &mut s, + cp.language_complex.as_ref(), + "style:language-complex", + "style:country-complex", + ); + lang_attrs( + &mut s, + cp.language_east_asian.as_ref(), + "style:language-asian", + "style:country-asian", + ); + s +} + +/// Appends `lang_attr` / `country_attr` for an optional BCP 47 tag (`xx-YY`). +fn lang_attrs( + s: &mut String, + tag: Option<&loki_doc_model::meta::LanguageTag>, + lang_attr: &str, + country_attr: &str, +) { + if let Some(tag) = tag { + match tag.as_str().split_once('-') { + Some((l, c)) => { + attr(s, lang_attr, l); + attr(s, country_attr, c); + } + None => attr(s, lang_attr, tag.as_str()), + } + } +} + +fn underline_style(u: UnderlineStyle) -> &'static str { + match u { + UnderlineStyle::Double => "double", + UnderlineStyle::Dotted => "dotted", + UnderlineStyle::Dash => "dash", + UnderlineStyle::Wave => "wave", + UnderlineStyle::Thick => "bold", + _ => "solid", + } +} diff --git a/loki-odf/src/odt/write/styles.rs b/loki-odf/src/odt/write/styles.rs new file mode 100644 index 00000000..edb0c3f2 --- /dev/null +++ b/loki-odf/src/odt/write/styles.rs @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `styles.xml` writer: the named style catalog, the page layout, and the +//! master page — which carries the section's page geometry and the +//! header/footer content. + +use loki_doc_model::document::Document; +use loki_doc_model::layout::header_footer::HeaderFooter; +use loki_doc_model::layout::page::{PageLayout, PageOrientation, SectionColumns}; +use loki_doc_model::style::para_style::ParagraphStyle; + +use super::auto::AutoStyles; +use super::content::{Cx, write_block}; +use super::media::{Media, Rendered}; +use super::para_props::emit_paragraph_properties; +use super::props::emit_text_properties; +use super::xml::{attr, escape, master_page_name, page_layout_name, pt}; + +const HEADER: &str = concat!( + "\n", + "", +); + +/// Renders the whole `styles.xml` for `doc`, collecting any images embedded in +/// the master-page header/footer. +#[must_use] +pub(crate) fn styles_xml(doc: &Document) -> Rendered { + // One page-layout + master-page per section so each section's geometry and + // header/footer round-trip. A document with no sections falls back to a + // single default master page. + let layouts: Vec = if doc.sections.is_empty() { + vec![PageLayout::default()] + } else { + doc.sections.iter().map(|s| s.layout.clone()).collect() + }; + + // Render the master-page header/footer content first so its automatic + // styles (and images) are collected before the automatic-styles section is + // written. Header/footer styles MUST live in styles.xml, not content.xml. + let mut cx = Cx { + auto: AutoStyles::new(), + media: Media::with_prefix("himg"), + // Comments inside headers/footers are not modelled; use an empty lookup. + comments: std::collections::HashMap::new(), + objects: Vec::new(), + }; + let mut masters = String::new(); + let mut page_layouts = String::new(); + for (idx, layout) in layouts.iter().enumerate() { + let mp_name = master_page_name(idx); + let pl_name = page_layout_name(idx); + masters.push_str(&render_master_page(&mp_name, &pl_name, layout, &mut cx)); + write_page_layout(&mut page_layouts, &pl_name, layout); + } + + let mut out = String::new(); + out.push_str(HEADER); + + // ── Named styles (the catalog) ───────────────────────────────────────── + out.push_str(""); + for (id, style) in &doc.styles.paragraph_styles { + write_paragraph_style(&mut out, id.as_str(), style); + } + for (id, style) in &doc.styles.character_styles { + out.push_str("'); + out.push_str(&emit_text_properties(&style.char_props)); + out.push_str(""); + } + out.push_str(""); + + // ── Automatic styles: page layouts + header/footer styles ────────────── + out.push_str(""); + out.push_str(&page_layouts); + out.push_str(&cx.auto.render()); + out.push_str(""); + + out.push_str(""); + out.push_str(&masters); + out.push_str(""); + out.push_str(""); + + Rendered { + xml: out, + media: cx.media.into_parts(), + // Master-page headers/footers do not carry embedded formula objects. + objects: Vec::new(), + } +} + +/// Builds one `` element (named `mp_name`, referencing +/// page-layout `pl_name`), rendering each present header/footer variant into +/// ODF `` / ``. +fn render_master_page(mp_name: &str, pl_name: &str, layout: &PageLayout, cx: &mut Cx) -> String { + let mut m = String::from("'); + write_hf(&mut m, "style:header", layout.header.as_ref(), cx); + write_hf(&mut m, "style:footer", layout.footer.as_ref(), cx); + write_hf( + &mut m, + "style:header-first", + layout.header_first.as_ref(), + cx, + ); + write_hf( + &mut m, + "style:footer-first", + layout.footer_first.as_ref(), + cx, + ); + write_hf(&mut m, "style:header-left", layout.header_even.as_ref(), cx); + write_hf(&mut m, "style:footer-left", layout.footer_even.as_ref(), cx); + m.push_str(""); + m +} + +/// Writes one `` / `` element with its paragraphs. +fn write_hf(out: &mut String, tag: &str, hf: Option<&HeaderFooter>, cx: &mut Cx) { + let Some(hf) = hf else { + return; + }; + out.push_str(&format!("<{tag}>")); + if hf.blocks.is_empty() { + out.push_str(""); + } else { + for b in &hf.blocks { + write_block(out, b, cx); + } + } + out.push_str(&format!("")); +} + +/// Writes a `` for a catalog style. +fn write_paragraph_style(out: &mut String, id: &str, style: &ParagraphStyle) { + out.push_str("'); + out.push_str(&emit_paragraph_properties(&style.para_props)); + out.push_str(&emit_text_properties(&style.char_props)); + out.push_str(""); +} + +/// Writes the `` element from `layout`. +fn write_page_layout(out: &mut String, pl_name: &str, layout: &PageLayout) { + out.push_str(" "landscape", + PageOrientation::Portrait => "portrait", + }; + attr(out, "style:print-orientation", orient); + // `style:columns` is a child of page-layout-properties, so the element can + // only self-close when there is no multi-column layout. + if let Some(cols) = &layout.columns { + out.push('>'); + write_columns(out, cols); + out.push_str(""); + } else { + out.push_str("/>"); + } + // Declaring header/footer styles makes apps reserve the space and render the + // master-page content; omit them when the document has none. + if layout.header.is_some() || layout.header_first.is_some() || layout.header_even.is_some() { + out.push_str(""); + } + if layout.footer.is_some() || layout.footer_first.is_some() || layout.footer_even.is_some() { + out.push_str(""); + } + out.push_str(""); +} + +/// Writes a `` element (ODF 1.3 §16.27.10): `fo:column-count` +/// equal columns with a uniform `fo:column-gap`, and an optional separator line. +fn write_columns(out: &mut String, cols: &SectionColumns) { + out.push_str("'); + out.push_str(""); + out.push_str(""); + } else { + out.push_str("/>"); + } +} + +/// Renders `meta.xml` for `doc` (Dublin Core core properties). +#[must_use] +pub(crate) fn meta_xml(doc: &Document) -> String { + let mut out = String::from(concat!( + "\n", + "", + )); + let m = &doc.meta; + { + let mut el = |tag: &str, val: &Option| { + if let Some(v) = val { + out.push_str(&format!("<{tag}>{}", escape(v))); + } + }; + el("dc:title", &m.title); + el("dc:creator", &m.creator); + el("meta:initial-creator", &m.creator); + el("dc:subject", &m.subject); + el("dc:description", &m.description); + el("meta:keyword", &m.keywords); + } + // Extended Dublin Core has no native office:meta element; carry each field + // as a meta:user-defined entry under its reserved dcmi: name so it + // round-trips. + for (name, value) in m.dublin_core.to_named_pairs() { + out.push_str(&format!( + "{}", + escape(&name), + escape(&value), + )); + } + out.push_str(""); + out +} diff --git a/loki-odf/src/odt/write/tables.rs b/loki-odf/src/odt/write/tables.rs new file mode 100644 index 00000000..4ec082c1 --- /dev/null +++ b/loki-odf/src/odt/write/tables.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `content.xml` table serialisation: `` and its rows/cells. + +use loki_doc_model::content::table::{Row, Table}; + +use super::content::{Cx, write_block}; +use super::xml::attr; + +/// Writes a `` (header rows, then bodies, then footer). +pub(super) fn table(out: &mut String, t: &Table, cx: &mut Cx) { + out.push_str(""); + let cols = t.col_specs.len().max(1); + out.push_str(&format!( + "" + )); + for row in &t.head.rows { + table_row(out, row, cx); + } + for body in &t.bodies { + for row in body.head_rows.iter().chain(body.body_rows.iter()) { + table_row(out, row, cx); + } + } + for row in &t.foot.rows { + table_row(out, row, cx); + } + out.push_str(""); +} + +fn table_row(out: &mut String, row: &Row, cx: &mut Cx) { + out.push_str(""); + for cell in &row.cells { + out.push_str(" 1 { + attr( + out, + "table:number-columns-spanned", + &cell.col_span.to_string(), + ); + } + if cell.row_span > 1 { + attr(out, "table:number-rows-spanned", &cell.row_span.to_string()); + } + out.push('>'); + if cell.blocks.is_empty() { + out.push_str(""); + } else { + for b in &cell.blocks { + write_block(out, b, cx); + } + } + out.push_str(""); + for _ in 1..cell.col_span { + out.push_str(""); + } + } + out.push_str(""); +} diff --git a/loki-odf/src/odt/write/xml.rs b/loki-odf/src/odt/write/xml.rs new file mode 100644 index 00000000..b21ed46a --- /dev/null +++ b/loki-odf/src/odt/write/xml.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Small XML helpers shared by the ODT writers. + +use loki_primitives::units::Points; + +/// Escapes XML text content / attribute values. +#[must_use] +pub(super) fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +/// Formats a [`Points`] value as an ODF length string (e.g. `"36pt"`, +/// `"12.5pt"`), dropping a redundant `.00`. +#[must_use] +pub(super) fn pt(p: Points) -> String { + let v = p.value(); + if (v - v.round()).abs() < 1e-6 { + format!("{v:.0}pt") + } else { + format!("{v:.2}pt") + } +} + +/// The `style:master-page` name for the section at `idx`. +/// +/// Section 0 uses the conventional `"Standard"` master (the importer's initial +/// master); later sections use `"MP{idx}"`. `content.xml` references these names +/// from the first paragraph of each section via `style:master-page-name`, and +/// `styles.xml` defines them — the two must agree, so both call this. +#[must_use] +pub(super) fn master_page_name(idx: usize) -> String { + if idx == 0 { + "Standard".to_string() + } else { + format!("MP{idx}") + } +} + +/// The `style:page-layout` name for the section at `idx` (`PL1`, `PL2`, …). +#[must_use] +pub(super) fn page_layout_name(idx: usize) -> String { + format!("PL{}", idx + 1) +} + +/// Appends ` name="value"` to `out`, escaping the value. +pub(super) fn attr(out: &mut String, name: &str, value: &str) { + out.push(' '); + out.push_str(name); + out.push_str("=\""); + out.push_str(&escape(value)); + out.push('"'); +} diff --git a/loki-odf/src/package.rs b/loki-odf/src/package.rs index 2fb1cd46..9ec2f0e4 100644 --- a/loki-odf/src/package.rs +++ b/loki-odf/src/package.rs @@ -20,7 +20,7 @@ use zip::ZipArchive; use crate::constants::{ ENTRY_CONTENT, ENTRY_MANIFEST, ENTRY_META, ENTRY_MIMETYPE, ENTRY_SETTINGS, ENTRY_STYLES, - MIME_ODS, MIME_ODT, + MIME_ODS, MIME_ODT, MIME_OTS, MIME_OTT, }; use crate::error::{OdfError, OdfResult}; use crate::limits::read_entry_capped; @@ -59,6 +59,12 @@ pub struct OdfPackage { /// The media type is inferred from the file extension. pub images: HashMap)>, + /// Embedded object sub-documents (e.g. formula objects): object directory + /// → raw `content.xml` bytes. The key is the directory path without a + /// trailing slash (e.g. `"Object 1"`), matching a `draw:object`'s + /// `xlink:href` once `"./"` is stripped. ODF 1.3 §3.16. + pub objects: HashMap>, + /// `true` if the `office:version` attribute was absent in `content.xml`. /// /// An absent attribute is valid for ODF 1.1 documents; in that case the @@ -120,6 +126,9 @@ impl OdfPackage { // ── 6. Collect images from Pictures/ ───────────────────────────── let images = collect_images(&mut archive, &mut total_decompressed)?; + // ── 6b. Collect embedded object sub-documents (e.g. formulas) ───── + let objects = collect_objects(&mut archive, &mut total_decompressed)?; + // ── 7. Detect version from content.xml ──────────────────────────── let (version, version_was_absent) = Self::detect_version(&content)?; @@ -131,6 +140,7 @@ impl OdfPackage { meta, settings, images, + objects, version_was_absent, }) } @@ -246,13 +256,19 @@ fn validate_mimetype( reason: "mimetype entry contains invalid UTF-8".into(), })?; - if mimetype_str != MIME_ODT && mimetype_str != MIME_ODS { + // Accept document packages (ODT/ODS) and their template variants + // (OTT/OTS). A template is structurally identical to its document form; the + // editor opens it as a new untitled document. + if !matches!( + mimetype_str.as_str(), + MIME_ODT | MIME_ODS | MIME_OTT | MIME_OTS + ) { return Err(OdfError::MalformedElement { element: ENTRY_MIMETYPE.into(), part: ENTRY_MIMETYPE.into(), reason: format!( - "mimetype must contain either {MIME_ODT:?} or {MIME_ODS:?} with no trailing newline, \ - found {mimetype_str:?}" + "mimetype must contain one of {MIME_ODT:?}, {MIME_ODS:?}, {MIME_OTT:?}, or \ + {MIME_OTS:?} with no trailing newline, found {mimetype_str:?}" ), }); } @@ -332,6 +348,36 @@ fn collect_images( Ok(images) } +/// Walk all ZIP entries, collecting embedded object sub-documents — any +/// `/content.xml` other than the package root `content.xml`. The key is +/// the directory path (no trailing slash). ODF 1.3 §3.16. +fn collect_objects( + archive: &mut ZipArchive, + total_decompressed: &mut u64, +) -> OdfResult>> { + let mut objects = HashMap::new(); + + let names: Vec = (0..archive.len()) + .filter_map(|i| archive.by_index(i).ok().map(|e| e.name().to_owned())) + .filter(|n| n.ends_with("/content.xml")) + .collect(); + + for name in names { + let Some(dir) = name.strip_suffix("/content.xml") else { + continue; + }; + if dir.is_empty() { + continue; + } + if let Ok(mut entry) = archive.by_name(&name) { + let bytes = read_entry_capped(&mut entry, &name, total_decompressed)?; + objects.insert(dir.to_string(), bytes); + } + } + + Ok(objects) +} + /// Infer a media type from a file extension (case-insensitive). /// /// ODF 1.3 §3.16 (embedded objects / images). diff --git a/loki-odf/tests/math_round_trip.rs b/loki-odf/tests/math_round_trip.rs new file mode 100644 index 00000000..004e3aef --- /dev/null +++ b/loki-odf/tests/math_round_trip.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! ODT math round-trip: an `Inline::Math` (MathML) is exported as an embedded +//! formula object (`draw:object` → `Object N/content.xml`) and re-imported back +//! to the same MathML. + +use std::io::Cursor; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, MathType}; +use loki_doc_model::document::Document; +use loki_doc_model::io::{DocumentExport, DocumentImport}; +use loki_odf::odt::export::OdtExport; +use loki_odf::odt::import::{OdtImport, OdtImportOptions}; + +const NS: &str = "http://www.w3.org/1998/Math/MathML"; + +fn doc_with_math(mathml: &str) -> Document { + let para = Block::Para(vec![ + Inline::Str("x = ".to_string()), + Inline::Math(MathType::InlineMath, mathml.to_string()), + ]); + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para]; + doc +} + +fn first_math(doc: &Document) -> Option { + for section in &doc.sections { + for block in §ion.blocks { + let inlines = match block { + Block::Para(i) | Block::Plain(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + for i in inlines { + if let Inline::Math(_, s) = i { + return Some(s.clone()); + } + } + } + } + None +} + +fn round_trip(doc: &Document) -> Document { + let mut buf = Cursor::new(Vec::new()); + OdtExport::export(doc, &mut buf, Default::default()).expect("export should succeed"); + OdtImport::import(Cursor::new(buf.into_inner()), OdtImportOptions::default()) + .expect("re-import should succeed") +} + +#[test] +fn embedded_fraction_round_trips() { + let mathml = format!("12"); + let re = round_trip(&doc_with_math(&mathml)); + assert_eq!(first_math(&re).as_deref(), Some(mathml.as_str())); +} + +#[test] +fn embedded_root_round_trips() { + let mathml = format!("x3"); + let re = round_trip(&doc_with_math(&mathml)); + assert_eq!(first_math(&re).as_deref(), Some(mathml.as_str())); +} diff --git a/loki-odf/tests/odt_export_round_trip.rs b/loki-odf/tests/odt_export_round_trip.rs new file mode 100644 index 00000000..3719fb15 --- /dev/null +++ b/loki-odf/tests/odt_export_round_trip.rs @@ -0,0 +1,627 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! ODT export → import round-trip: a document's styles, page geometry, and +//! metadata must survive being written to an ODT package and read back. + +use std::io::Cursor; + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::{Block, StyledParagraph}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::{DocumentExport, DocumentImport}; +use loki_doc_model::layout::page::{PageLayout, PageMargins, PageSize}; +use loki_doc_model::layout::section::Section; +use loki_doc_model::meta::DocumentMeta; +use loki_doc_model::style::ParagraphStyle; +use loki_doc_model::style::catalog::StyleId; +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::para_props::{ParaProps, ParagraphAlignment}; +use loki_odf::odt::export::{OdtExport, OdtExportOptions}; +use loki_odf::odt::import::{OdtImport, OdtImportOptions}; +use loki_primitives::units::Points; + +fn para_style(id: &str, name: &str, ch: CharProps, pa: ParaProps) -> ParagraphStyle { + ParagraphStyle { + id: StyleId::new(id), + display_name: Some(name.to_string()), + parent: (id != "Normal").then(|| StyleId::new("Normal")), + linked_char_style: None, + next_style_id: None, + para_props: pa, + char_props: ch, + is_default: id == "Normal", + is_custom: false, + extensions: Default::default(), + } +} + +fn sample_doc() -> Document { + let mut doc = Document::new(); + + doc.meta = DocumentMeta { + title: Some("Round Trip ODT".into()), + creator: Some("Ada".into()), + ..Default::default() + }; + + doc.styles.paragraph_styles.insert( + StyleId::new("Normal"), + para_style( + "Normal", + "Normal", + CharProps { + font_name: Some("Times New Roman".into()), + font_size: Some(Points::new(12.0)), + ..Default::default() + }, + ParaProps::default(), + ), + ); + doc.styles.paragraph_styles.insert( + StyleId::new("Quote"), + para_style( + "Quote", + "Quote", + CharProps { + italic: Some(true), + ..Default::default() + }, + ParaProps { + indent_start: Some(Points::new(36.0)), + alignment: Some(ParagraphAlignment::Center), + ..Default::default() + }, + ), + ); + + let layout = PageLayout { + page_size: PageSize::letter(), + margins: PageMargins { + top: Points::new(72.0), + bottom: Points::new(72.0), + left: Points::new(90.0), + right: Points::new(72.0), + ..Default::default() + }, + ..Default::default() + }; + + let blocks = vec![ + Block::Heading( + 1, + NodeAttr::default(), + vec![Inline::Str("The Title".into())], + ), + Block::Para(vec![ + Inline::Str("Plain and ".into()), + Inline::Strong(vec![Inline::Str("bold".into())]), + Inline::Str(" text.".into()), + ]), + Block::StyledPara(StyledParagraph { + style_id: Some(StyleId::new("Quote")), + direct_para_props: None, + direct_char_props: None, + inlines: vec![Inline::Str("A quoted line.".into())], + attr: NodeAttr::default(), + }), + ]; + doc.sections = vec![Section::with_layout_and_blocks(layout, blocks)]; + doc +} + +fn round_trip(doc: &Document) -> Document { + let mut buf = Cursor::new(Vec::::new()); + OdtExport::export(doc, &mut buf, OdtExportOptions::default()).expect("ODT export"); + OdtImport::import(Cursor::new(buf.into_inner()), OdtImportOptions::default()) + .expect("ODT re-import") +} + +#[test] +fn styles_round_trip() { + let doc = round_trip(&sample_doc()); + + let quote = doc + .styles + .paragraph_styles + .get(&StyleId::new("Quote")) + .expect("Quote style must survive"); + assert_eq!(quote.char_props.italic, Some(true)); + assert_eq!( + quote.para_props.indent_start.map(|p| p.value().round()), + Some(36.0) + ); + assert_eq!(quote.para_props.alignment, Some(ParagraphAlignment::Center)); + + let normal = doc + .styles + .paragraph_styles + .get(&StyleId::new("Normal")) + .expect("Normal style must survive"); + assert_eq!( + normal.char_props.font_name.as_deref(), + Some("Times New Roman") + ); + assert_eq!( + normal.char_props.font_size.map(|p| p.value().round()), + Some(12.0) + ); +} + +#[test] +fn page_geometry_round_trips() { + let doc = round_trip(&sample_doc()); + let layout = &doc.sections[0].layout; + assert_eq!(layout.page_size.width.value().round(), 612.0); // US Letter + assert_eq!(layout.page_size.height.value().round(), 792.0); + assert_eq!(layout.margins.left.value().round(), 90.0); + assert_eq!(layout.margins.top.value().round(), 72.0); +} + +#[test] +fn full_character_and_paragraph_props_round_trip() { + use loki_doc_model::meta::LanguageTag; + use loki_doc_model::style::props::border::{Border, BorderStyle}; + use loki_doc_model::style::props::char_props::{StrikethroughStyle, VerticalAlign}; + use loki_doc_model::style::props::para_props::Spacing; + use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; + use loki_primitives::color::DocumentColor; + + let red = DocumentColor::from_hex("#FF0000").unwrap(); + let char_props = CharProps { + font_name: Some("Arial".into()), + font_name_complex: Some("Arial Complex".into()), + font_name_east_asian: Some("MS Mincho".into()), + font_size: Some(Points::new(14.0)), + font_size_complex: Some(Points::new(15.0)), + bold: Some(true), + italic: Some(true), + strikethrough: Some(StrikethroughStyle::Single), + small_caps: Some(true), + all_caps: Some(true), + outline: Some(true), + shadow: Some(true), + vertical_align: Some(VerticalAlign::Superscript), + color: Some(red.clone()), + letter_spacing: Some(Points::new(1.0)), + word_spacing: Some(Points::new(2.0)), + kerning: Some(true), + scale: Some(90.0), + language: Some(LanguageTag::new("en-GB")), + language_complex: Some(LanguageTag::new("ar-SA")), + language_east_asian: Some(LanguageTag::new("ja-JP")), + ..Default::default() + }; + let para_props = ParaProps { + border_top: Some(Border { + style: BorderStyle::Solid, + width: Points::new(1.0), + color: Some(red), + spacing: None, + }), + padding_top: Some(Points::new(6.0)), + padding_bottom: Some(Points::new(6.0)), + padding_left: Some(Points::new(6.0)), + padding_right: Some(Points::new(6.0)), + tab_stops: Some(vec![TabStop { + position: Points::new(72.0), + alignment: TabAlignment::Right, + leader: TabLeader::Dot, + }]), + widow_control: Some(3), + orphan_control: Some(2), + bidi: Some(true), + keep_together: Some(true), + keep_with_next: Some(true), + page_break_before: Some(true), + space_before: Some(Spacing::Exact(Points::new(8.0))), + ..Default::default() + }; + + let mut doc = sample_doc(); + doc.styles.paragraph_styles.insert( + StyleId::new("Fancy"), + para_style("Fancy", "Fancy", char_props, para_props), + ); + + let out = round_trip(&doc); + let f = out + .styles + .paragraph_styles + .get(&StyleId::new("Fancy")) + .expect("Fancy style must survive"); + + let c = &f.char_props; + assert_eq!(c.font_name_complex.as_deref(), Some("Arial Complex")); + assert_eq!(c.font_name_east_asian.as_deref(), Some("MS Mincho")); + assert_eq!(c.font_size_complex.map(|p| p.value().round()), Some(15.0)); + assert_eq!(c.strikethrough, Some(StrikethroughStyle::Single)); + assert_eq!(c.small_caps, Some(true)); + assert_eq!(c.all_caps, Some(true)); + assert_eq!(c.outline, Some(true)); + assert_eq!(c.shadow, Some(true)); + assert_eq!(c.vertical_align, Some(VerticalAlign::Superscript)); + assert!(c.color.is_some()); + assert_eq!(c.letter_spacing.map(|p| p.value().round()), Some(1.0)); + assert_eq!(c.word_spacing.map(|p| p.value().round()), Some(2.0)); + assert_eq!(c.kerning, Some(true)); + assert_eq!(c.scale, Some(90.0)); + assert_eq!(c.language.as_ref().map(|l| l.as_str()), Some("en-GB")); + assert_eq!( + c.language_complex.as_ref().map(|l| l.as_str()), + Some("ar-SA") + ); + assert_eq!( + c.language_east_asian.as_ref().map(|l| l.as_str()), + Some("ja-JP") + ); + + let p = &f.para_props; + let border = p.border_top.as_ref().expect("top border survives"); + assert_eq!(border.style, BorderStyle::Solid); + assert_eq!(border.width.value().round(), 1.0); + assert_eq!(p.padding_top.map(|v| v.value().round()), Some(6.0)); + let tabs = p.tab_stops.as_ref().expect("tab stops survive"); + assert_eq!(tabs.len(), 1); + assert_eq!(tabs[0].position.value().round(), 72.0); + assert_eq!(tabs[0].alignment, TabAlignment::Right); + assert_eq!(tabs[0].leader, TabLeader::Dot); + assert_eq!(p.widow_control, Some(3)); + assert_eq!(p.orphan_control, Some(2)); + assert_eq!(p.bidi, Some(true)); + assert_eq!(p.keep_together, Some(true)); + assert_eq!(p.keep_with_next, Some(true)); + assert_eq!(p.page_break_before, Some(true)); +} + +#[test] +fn inline_bookmark_field_and_image_round_trip() { + use loki_doc_model::content::field::types::{Field, FieldKind}; + use loki_doc_model::content::inline::{BookmarkKind, LinkTarget}; + + // A 1x1 transparent PNG, embedded as a data URI (how images live in the model). + let png_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + let data_uri = format!("data:image/png;base64,{png_b64}"); + + let mut doc = sample_doc(); + doc.sections[0].blocks.push(Block::Para(vec![ + Inline::Bookmark(BookmarkKind::Start, "anchor".into()), + Inline::Str("see ".into()), + Inline::Field(Field::new(FieldKind::PageNumber)), + Inline::Str(" of ".into()), + Inline::Field(Field::new(FieldKind::PageCount)), + Inline::Image( + NodeAttr::default(), + vec![Inline::Str("a dot".into())], + LinkTarget::new(data_uri), + ), + Inline::Bookmark(BookmarkKind::End, "anchor".into()), + ])); + + let out = round_trip(&doc); + let inlines: Vec<&Inline> = out + .sections + .iter() + .flat_map(|s| &s.blocks) + .flat_map(|b| match b { + Block::Para(i) | Block::Plain(i) => i.iter().collect(), + Block::StyledPara(sp) => sp.inlines.iter().collect(), + _ => Vec::new(), + }) + .collect(); + + assert!( + inlines + .iter() + .any(|i| matches!(i, Inline::Bookmark(BookmarkKind::Start, n) if n == "anchor")), + "bookmark start must survive" + ); + assert!( + inlines + .iter() + .any(|i| matches!(i, Inline::Field(f) if matches!(f.kind, FieldKind::PageNumber))), + "page-number field must survive" + ); + assert!( + inlines + .iter() + .any(|i| matches!(i, Inline::Field(f) if matches!(f.kind, FieldKind::PageCount))), + "page-count field must survive" + ); + let img = inlines + .iter() + .find_map(|i| match i { + Inline::Image(_, _, t) => Some(t), + _ => None, + }) + .expect("image must survive the round-trip"); + assert!( + img.url.starts_with("data:image/png;base64,"), + "image bytes must round-trip as an embedded data URI, got {}", + &img.url[..img.url.len().min(32)] + ); +} + +#[test] +fn headers_and_footers_round_trip() { + use loki_doc_model::layout::header_footer::{HeaderFooter, HeaderFooterKind}; + + let hf = |kind, text: &str| HeaderFooter { + kind, + blocks: vec![Block::Para(vec![Inline::Str(text.into())])], + }; + + let mut doc = sample_doc(); + let layout = &mut doc.sections[0].layout; + layout.header = Some(hf(HeaderFooterKind::Default, "Page header")); + layout.footer = Some(hf(HeaderFooterKind::Default, "Page footer")); + layout.header_first = Some(hf(HeaderFooterKind::First, "First-page header")); + layout.footer_even = Some(hf(HeaderFooterKind::Even, "Even-page footer")); + + let out = round_trip(&doc); + let layout = &out.sections[0].layout; + + let text_of = |hf: &Option| -> String { + let Some(h) = hf else { + return String::new(); + }; + let Some(block) = h.blocks.first() else { + return String::new(); + }; + let inl: &[Inline] = match block { + Block::Para(i) | Block::Plain(i) => i, + Block::StyledPara(sp) => &sp.inlines, + _ => return String::new(), + }; + inl.iter() + .filter_map(|i| match i { + Inline::Str(s) => Some(s.as_str()), + _ => None, + }) + .collect() + }; + + assert_eq!(text_of(&layout.header), "Page header"); + assert_eq!(text_of(&layout.footer), "Page footer"); + assert_eq!(text_of(&layout.header_first), "First-page header"); + assert_eq!(text_of(&layout.footer_even), "Even-page footer"); +} + +#[test] +fn metadata_and_heading_round_trip() { + let doc = round_trip(&sample_doc()); + assert_eq!(doc.meta.title.as_deref(), Some("Round Trip ODT")); + + let has_heading = doc + .sections + .iter() + .flat_map(|s| &s.blocks) + .any(|b| matches!(b, Block::Heading(1, _, _))); + assert!( + has_heading, + "the level-1 heading must survive the round-trip" + ); +} + +#[test] +fn multi_section_page_geometry_round_trips() { + use loki_doc_model::layout::page::PageOrientation; + + let body = |t: &str| vec![Block::Para(vec![Inline::Str(t.to_string())])]; + + let portrait = Section::with_layout_and_blocks( + PageLayout { + page_size: PageSize::a4(), + orientation: PageOrientation::Portrait, + ..PageLayout::default() + }, + body("First section, portrait A4."), + ); + let landscape = Section::with_layout_and_blocks( + PageLayout { + page_size: PageSize::letter(), + orientation: PageOrientation::Landscape, + ..PageLayout::default() + }, + body("Second section, landscape Letter."), + ); + + let mut doc = Document::new(); + doc.sections = vec![portrait, landscape]; + + let re = round_trip(&doc); + + assert_eq!( + re.sections.len(), + 2, + "both sections must survive the round-trip" + ); + + // Each section keeps its own page-layout. ODF records orientation as a + // flag without swapping the stored width/height, so dimensions round-trip + // verbatim alongside the orientation. + let s0 = &re.sections[0].layout; + assert_eq!(s0.orientation, PageOrientation::Portrait); + assert_eq!(s0.page_size.width.value().round(), 595.0); // A4 + assert_eq!(s0.page_size.height.value().round(), 842.0); + + let s1 = &re.sections[1].layout; + assert_eq!(s1.orientation, PageOrientation::Landscape); + assert_eq!(s1.page_size.width.value().round(), 612.0); // US Letter + assert_eq!(s1.page_size.height.value().round(), 792.0); + + // The body text of both sections must be preserved. + let all_text: String = re + .sections + .iter() + .flat_map(|s| &s.blocks) + .map(text_of_block) + .collect::>() + .join(" "); + assert!(all_text.contains("First section"), "got: {all_text}"); + assert!(all_text.contains("Second section"), "got: {all_text}"); +} + +#[test] +fn extended_dublin_core_round_trips() { + use loki_doc_model::meta::dublin_core::DublinCoreMeta; + + let dc = DublinCoreMeta { + contributors: vec!["Editor One".into(), "Translator Two".into()], + publisher: Some("AppThere Press".into()), + rights: Some("© 2026 AppThere".into()), + license: Some("https://creativecommons.org/licenses/by/4.0/".into()), + identifier: Some("urn:uuid:1234".into()), + identifier_scheme: Some("UUID".into()), + dc_type: Some("Text".into()), + format: Some("application/vnd.oasis.opendocument.text".into()), + source: Some("Original".into()), + relation: Some("Companion".into()), + coverage: Some("2026".into()), + issued: Some("2026-06-22".into()), + bibliographic_citation: Some("AppThere (2026)".into()), + }; + let mut doc = Document::new(); + doc.meta.title = Some("DC Doc".into()); + doc.meta.dublin_core = dc.clone(); + + let re = round_trip(&doc); + assert_eq!( + re.meta.dublin_core, dc, + "all extended Dublin Core fields must survive the ODT round-trip" + ); +} + +#[test] +fn comments_round_trip() { + use loki_doc_model::content::annotation::{Comment, CommentRef, CommentRefKind}; + + let para = Block::Para(vec![ + Inline::Str("Hello ".into()), + Inline::Comment(CommentRef::new("c1", CommentRefKind::Start)), + Inline::Str("world".into()), + Inline::Comment(CommentRef::new("c1", CommentRefKind::End)), + ]); + let mut comment = Comment::new("c1").with_plain_body("Please rephrase.\nAnd shorten it."); + comment.author = Some("Reviewer".into()); + + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para]; + doc.comments = vec![comment]; + + let re = round_trip(&doc); + + // Anchors survive in the content flow. + let kinds: Vec = re + .sections + .iter() + .flat_map(|s| &s.blocks) + .flat_map(|b| match b { + Block::Para(i) | Block::Plain(i) => i.clone(), + Block::StyledPara(sp) => sp.inlines.clone(), + _ => Vec::new(), + }) + .filter_map(|i| { + if let Inline::Comment(c) = i { + Some(c.kind) + } else { + None + } + }) + .collect(); + assert!( + kinds.contains(&CommentRefKind::Start), + "start anchor: {kinds:?}" + ); + assert!( + kinds.contains(&CommentRefKind::End), + "end anchor: {kinds:?}" + ); + + // The comment body + author survive. + assert_eq!(re.comments.len(), 1, "one comment expected"); + let c = &re.comments[0]; + assert_eq!(c.id, "c1"); + assert_eq!(c.author.as_deref(), Some("Reviewer")); + let texts: Vec = c + .body + .iter() + .map(|b| match b { + Block::Para(i) | Block::Plain(i) => i + .iter() + .filter_map(|x| { + if let Inline::Str(s) = x { + Some(s.as_str()) + } else { + None + } + }) + .collect(), + _ => String::new(), + }) + .collect(); + assert_eq!(texts, vec!["Please rephrase.", "And shorten it."]); +} + +#[test] +fn multi_column_section_round_trips() { + use loki_doc_model::layout::page::SectionColumns; + + let section = Section::with_layout_and_blocks( + PageLayout { + page_size: PageSize::a4(), + columns: Some(SectionColumns { + count: 3, + gap: Points::new(18.0), + separator: true, + }), + ..PageLayout::default() + }, + vec![Block::Para(vec![Inline::Str("Three-column body.".into())])], + ); + let mut doc = Document::new(); + doc.sections = vec![section]; + + let re = round_trip(&doc); + let cols = re.sections[0] + .layout + .columns + .clone() + .expect("style:columns must survive the round-trip"); + + assert_eq!(cols.count, 3, "column count"); + assert_eq!(cols.gap.value().round(), 18.0, "column gap (pt)"); + assert!(cols.separator, "the column separator must survive"); +} + +#[test] +fn no_columns_means_no_column_layout() { + // The default sample document is single-column; it must not gain a + // spurious multi-column layout on round-trip. + let doc = round_trip(&sample_doc()); + assert!( + doc.sections[0].layout.columns.is_none(), + "single-column document must not produce a style:columns layout" + ); +} + +/// Extracts the concatenated plain text of a paragraph-like block. +fn text_of_block(block: &Block) -> String { + let inlines = match block { + Block::Para(i) | Block::Plain(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + inlines + .iter() + .filter_map(|i| { + if let Inline::Str(s) = i { + Some(s.clone()) + } else { + None + } + }) + .collect::>() + .join("") +} diff --git a/loki-ooxml/Cargo.toml b/loki-ooxml/Cargo.toml index 20b1e93a..c6babdb1 100644 --- a/loki-ooxml/Cargo.toml +++ b/loki-ooxml/Cargo.toml @@ -15,7 +15,9 @@ xlsx = [] pptx = ["dep:loki-graphics", "dep:loki-presentation-model"] [dependencies] -loki-opc = { path = "../loki-opc" } +# serde: enables the real core-properties (docProps/core.xml) reader/writer +# in loki-opc (otherwise both are no-op stubs). Required for metadata import/export. +loki-opc = { path = "../loki-opc", features = ["serde"] } loki-doc-model = { path = "../loki-doc-model" } loki-sheet-model = { path = "../loki-sheet-model" } loki-primitives = { path = "../loki-primitives" } @@ -38,9 +40,13 @@ features = ["std"] loki-opc = { path = "../loki-opc" } loki-layout = { path = "../loki-layout" } zip = { version = "2", default-features = false, features = ["deflate"] } -parley = "0.8" -read-fonts = "0.37.0" +parley = "0.10" +read-fonts = "0.40" quick-xml = { version = "0.36" } +# The PPTX round-trip integration test (gated on the `pptx` feature) builds a +# presentation from these crates directly. +loki-presentation-model = { path = "../loki-presentation-model" } +loki-graphics = { path = "../loki-graphics", features = ["serde"] } [package.metadata.docs.rs] all-features = true diff --git a/loki-ooxml/src/constants.rs b/loki-ooxml/src/constants.rs index 35bb12a3..a800a855 100644 --- a/loki-ooxml/src/constants.rs +++ b/loki-ooxml/src/constants.rs @@ -51,6 +51,10 @@ pub const REL_ENDNOTES: &str = pub const REL_SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings"; +/// OPC relationship type for the comments part (ECMA-376 §17.13.4). +pub const REL_COMMENTS: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; + /// OPC relationship type for image parts (ECMA-376 §17.3.3.9). pub const REL_IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; diff --git a/loki-ooxml/src/docx/export.rs b/loki-ooxml/src/docx/export.rs index 8d8d66ca..8253d5c9 100644 --- a/loki-ooxml/src/docx/export.rs +++ b/loki-ooxml/src/docx/export.rs @@ -11,7 +11,7 @@ use std::io::{Seek, Write}; use loki_doc_model::document::Document; use loki_doc_model::io::DocumentExport; -use crate::docx::write::assembly::assemble_docx; +use crate::docx::write::assembly::{DocxKind, assemble_docx, assemble_docx_kind}; use crate::error::OoxmlError; /// Unit struct that implements [`DocumentExport`] for DOCX. @@ -30,11 +30,54 @@ impl DocumentExport for DocxExport { } } +/// Unit struct that implements [`DocumentExport`] for a Word **template** +/// (`.dotx`). Identical to [`DocxExport`] except the main part carries the +/// template content type, so Office opens it as a template (creating a new +/// document based on it) rather than editing the file in place. +pub struct DocxTemplateExport; + +impl DocumentExport for DocxTemplateExport { + type Error = OoxmlError; + type Options = (); + + fn export( + doc: &Document, + writer: impl Write + Seek, + _options: Self::Options, + ) -> Result<(), Self::Error> { + assemble_docx_kind(doc, writer, DocxKind::Template) + } +} + #[cfg(test)] mod tests { use super::*; use std::io::Cursor; + #[test] + fn template_export_uses_template_content_type() { + let doc = Document::new(); + let mut buf = Cursor::new(Vec::::new()); + DocxTemplateExport::export(&doc, &mut buf, ()).expect("template export failed"); + let mut zip = zip::ZipArchive::new(Cursor::new(buf.into_inner())).expect("valid zip"); + let mut ct = String::new(); + { + use std::io::Read; + zip.by_name("[Content_Types].xml") + .expect("content types part") + .read_to_string(&mut ct) + .unwrap(); + } + assert!( + ct.contains("wordprocessingml.template.main+xml"), + "main part must use the template content type, got: {ct}" + ); + assert!( + !ct.contains("wordprocessingml.document.main+xml"), + "template must not also declare the document content type" + ); + } + #[test] fn export_empty_document_produces_zip() { let doc = Document::new(); diff --git a/loki-ooxml/src/docx/import.rs b/loki-ooxml/src/docx/import.rs index 3dcaffe9..e4128525 100644 --- a/loki-ooxml/src/docx/import.rs +++ b/loki-ooxml/src/docx/import.rs @@ -26,8 +26,8 @@ use loki_doc_model::io::DocumentImport; use loki_opc::{Package, PartData, PartName}; use crate::constants::{ - REL_ENDNOTES, REL_FOOTER, REL_FOOTNOTES, REL_HEADER, REL_HYPERLINK, REL_IMAGE, REL_NUMBERING, - REL_OFFICE_DOCUMENT, REL_SETTINGS, REL_STYLES, + REL_COMMENTS, REL_ENDNOTES, REL_FOOTER, REL_FOOTNOTES, REL_HEADER, REL_HYPERLINK, REL_IMAGE, + REL_NUMBERING, REL_OFFICE_DOCUMENT, REL_SETTINGS, REL_STYLES, }; use crate::docx::mapper::document::map_document; use crate::docx::model::paragraph::DocxParagraph; @@ -218,6 +218,15 @@ pub(crate) fn parse_and_map_package( |bytes, _part| parse_settings(bytes), )?; + let comments = resolve_optional_part( + package, + doc_rels, + REL_COMMENTS, + doc_part_name.as_str(), + |bytes, _part| crate::docx::reader::comments::parse_comments(bytes), + )? + .unwrap_or_default(); + // ── Build hyperlinks and images maps ────────────────────────────── let mut hyperlinks: HashMap = HashMap::new(); let mut images: HashMap = HashMap::new(); @@ -259,7 +268,7 @@ pub(crate) fn parse_and_map_package( } // ── Map everything to the abstract model ────────────────────────── - let result = map_document( + let (mut document, warnings) = map_document( &raw_doc, &raw_styles, raw_numbering.as_ref(), @@ -274,7 +283,15 @@ pub(crate) fn parse_and_map_package( options, ); - Ok(result) + // Extended Dublin Core from docProps/custom.xml (core.xml only covers the + // core subset + dc:identifier). + crate::docx::reader::custom_props::apply_extended_dc(package, &mut document.meta.dublin_core); + + // Comment bodies parsed from word/comments.xml (anchors are already in the + // content flow as Inline::Comment). + document.comments = comments; + + Ok((document, warnings)) } // ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/loki-ooxml/src/docx/mapper/document.rs b/loki-ooxml/src/docx/mapper/document.rs index 068a78ac..1f537f29 100644 --- a/loki-ooxml/src/docx/mapper/document.rs +++ b/loki-ooxml/src/docx/mapper/document.rs @@ -10,7 +10,9 @@ use loki_doc_model::content::attr::ExtensionBag; use loki_doc_model::content::block::Block; use loki_doc_model::document::Document; use loki_doc_model::layout::header_footer::{HeaderFooter, HeaderFooterKind}; -use loki_doc_model::layout::page::{PageLayout, PageMargins, PageOrientation, PageSize}; +use loki_doc_model::layout::page::{ + PageLayout, PageMargins, PageOrientation, PageSize, SectionColumns, +}; use loki_doc_model::layout::section::Section; use loki_doc_model::meta::core::DocumentMeta; use loki_doc_model::settings::DocumentSettings; @@ -103,6 +105,17 @@ fn map_page_layout(sect_pr: Option<&DocxSectPr>) -> PageLayout { }; } + // Multi-column layout: only meaningful for two or more columns. + if let Some(ref cols) = sp.cols + && cols.num >= 2 + { + layout.columns = Some(SectionColumns { + count: u8::try_from(cols.num.clamp(2, i32::from(u8::MAX))).unwrap_or(2), + gap: Points::new(f64::from(cols.space) / 20.0), + separator: cols.sep, + }); + } + layout } @@ -210,6 +223,10 @@ fn map_meta(core_props: Option<&loki_opc::CoreProperties>) -> DocumentMeta { let Some(cp) = core_props else { return DocumentMeta::default(); }; + let dublin_core = loki_doc_model::meta::dublin_core::DublinCoreMeta { + identifier: cp.identifier.clone(), + ..Default::default() + }; DocumentMeta { title: cp.title.clone(), creator: cp.creator.clone(), @@ -219,6 +236,12 @@ fn map_meta(core_props: Option<&loki_opc::CoreProperties>) -> DocumentMeta { last_modified_by: cp.last_modified_by.clone(), created: cp.created, modified: cp.modified, + language: cp + .language + .as_ref() + .map(|l| loki_doc_model::meta::language::LanguageTag::new(l.clone())), + revision: cp.revision.as_ref().and_then(|r| r.parse().ok()), + dublin_core, ..Default::default() } } @@ -371,6 +394,8 @@ pub(crate) fn map_document( styles: catalog, sections, settings: doc_settings, + // Comment bodies are merged from word/comments.xml by the importer. + comments: Vec::new(), source: None, }; @@ -415,6 +440,7 @@ mod tests { header_refs: vec![], footer_refs: vec![], title_page: false, + cols: None, } } diff --git a/loki-ooxml/src/docx/mapper/fields.rs b/loki-ooxml/src/docx/mapper/fields.rs new file mode 100644 index 00000000..2f5ffcb6 --- /dev/null +++ b/loki-ooxml/src/docx/mapper/fields.rs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Field-instruction parsing shared by the complex-field state machine and the +//! `w:fldSimple` mapper. ECMA-376 §17.16. + +use loki_doc_model::content::field::types::{CrossRefFormat, FieldKind}; + +use crate::docx::model::paragraph::{DocxRun, DocxRunChild}; + +/// Parses an OOXML field instruction string into a [`FieldKind`]. +/// +/// The first word of the instruction (case-insensitive) identifies the field +/// type. Unknown types are stored as [`FieldKind::Raw`] for round-trip +/// fidelity. ADR-0005. +pub(super) fn parse_field_instruction(instruction: &str) -> FieldKind { + let trimmed = instruction.trim(); + let first_word = trimmed.split_whitespace().next().unwrap_or(""); + + match first_word.to_ascii_uppercase().as_str() { + "PAGE" => FieldKind::PageNumber, + "NUMPAGES" => FieldKind::PageCount, + "DATE" => FieldKind::Date { + format: extract_switch(trimmed, "@"), + }, + "TIME" => FieldKind::Time { + format: extract_switch(trimmed, "@"), + }, + "TITLE" => FieldKind::Title, + "AUTHOR" => FieldKind::Author, + "SUBJECT" => FieldKind::Subject, + "FILENAME" => FieldKind::FileName, + "NUMWORDS" => FieldKind::WordCount, + "REF" => { + let target = trimmed.split_whitespace().nth(1).unwrap_or("").to_string(); + FieldKind::CrossReference { + target, + format: CrossRefFormat::Number, + } + } + "PAGEREF" => { + let target = trimmed.split_whitespace().nth(1).unwrap_or("").to_string(); + FieldKind::CrossReference { + target, + format: CrossRefFormat::Page, + } + } + _ => FieldKind::Raw { + instruction: trimmed.to_string(), + }, + } +} + +/// Extracts the value following a backslash-switch (e.g. `\@`) from a field +/// instruction string. +/// +/// Returns the content of the first quoted string after `\{sw}`, or `None` +/// if the switch is not present. +fn extract_switch(instruction: &str, sw: &str) -> Option { + let needle = format!("\\{sw}"); + let pos = instruction.find(&needle)?; + let rest = instruction[pos + needle.len()..].trim_start(); + if let Some(inner) = rest.strip_prefix('"') { + let end = inner.find('"')?; + Some(inner[..end].to_string()) + } else { + None + } +} + +/// Concatenates the visible text of a `w:fldSimple`'s cached-result runs. +pub(super) fn fld_simple_text(runs: &[DocxRun]) -> String { + let mut out = String::new(); + for run in runs { + for child in &run.children { + if let DocxRunChild::Text { text, .. } = child { + out.push_str(text); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_date_field_with_format_switch() { + let kind = parse_field_instruction(r#" DATE \@ "MMMM d, yyyy" "#); + assert!(matches!(kind, FieldKind::Date { format: Some(ref s) } if s == "MMMM d, yyyy")); + } + + #[test] + fn parse_ref_field() { + let kind = parse_field_instruction(" REF _MyBookmark "); + assert!( + matches!(kind, FieldKind::CrossReference { target, format: CrossRefFormat::Number } if target == "_MyBookmark") + ); + } + + #[test] + fn parse_unknown_field_is_raw() { + let kind = parse_field_instruction(" HYPERLINK \"https://example.com\" "); + assert!( + matches!(kind, FieldKind::Raw { instruction } if instruction.contains("HYPERLINK")) + ); + } +} diff --git a/loki-ooxml/src/docx/mapper/inline.rs b/loki-ooxml/src/docx/mapper/inline.rs index fe838436..ddaba727 100644 --- a/loki-ooxml/src/docx/mapper/inline.rs +++ b/loki-ooxml/src/docx/mapper/inline.rs @@ -7,10 +7,13 @@ //! (`w:fldChar` / `w:instrText`) to assemble [`Inline::Field`] values //! and maps runs, hyperlinks, bookmarks, and drawings. +use loki_doc_model::content::annotation::{CommentRef, CommentRefKind}; use loki_doc_model::content::attr::NodeAttr; use loki_doc_model::content::block::Block; -use loki_doc_model::content::field::types::{CrossRefFormat, Field, FieldKind}; -use loki_doc_model::content::inline::{BookmarkKind, Inline, LinkTarget, NoteKind, StyledRun}; +use loki_doc_model::content::field::types::Field; +use loki_doc_model::content::inline::{ + BookmarkKind, Inline, LinkTarget, MathType, NoteKind, StyledRun, +}; use loki_doc_model::style::catalog::StyleId; use loki_doc_model::style::props::char_props::CharProps; @@ -18,6 +21,7 @@ use crate::docx::model::paragraph::{DocxParaChild, DocxRun, DocxRunChild}; use crate::error::{NoteKind as WarnNoteKind, OoxmlWarning}; use super::document::MappingContext; +use super::fields::{fld_simple_text, parse_field_instruction}; use super::images::map_drawing; use super::props::map_rpr; @@ -116,6 +120,40 @@ pub(crate) fn map_inlines(children: &[DocxParaChild], ctx: &mut MappingContext<' result.extend(process_run(run, &mut state, ctx)); } } + DocxParaChild::CommentRangeStart { id } => { + result.push(Inline::Comment(CommentRef::new( + id.clone(), + CommentRefKind::Start, + ))); + } + DocxParaChild::CommentRangeEnd { id } => { + result.push(Inline::Comment(CommentRef::new( + id.clone(), + CommentRefKind::End, + ))); + } + DocxParaChild::Math { mathml, display } => { + let kind = if *display { + MathType::DisplayMath + } else { + MathType::InlineMath + }; + result.push(Inline::Math(kind, mathml.clone())); + } + DocxParaChild::SimpleField { instr, runs } => { + // `w:fldSimple` is a self-contained field: the `@w:instr` + // instruction plus the cached result as child runs. Map it the + // same way a complex field is, so both forms produce an + // identical `Inline::Field`. + let kind = parse_field_instruction(instr); + let snapshot = fld_simple_text(runs); + let mut field = Field::new(kind); + let trimmed = snapshot.trim(); + if !trimmed.is_empty() { + field.current_value = Some(trimmed.to_string()); + } + result.push(Inline::Field(field)); + } } } @@ -335,68 +373,6 @@ fn lookup_note( }) } -// ── Field instruction parser ─────────────────────────────────────────────────── - -/// Parses an OOXML field instruction string into a [`FieldKind`]. -/// -/// The first word of the instruction (case-insensitive) identifies the field -/// type. Unknown types are stored as [`FieldKind::Raw`] for round-trip -/// fidelity. ADR-0005. -fn parse_field_instruction(instruction: &str) -> FieldKind { - let trimmed = instruction.trim(); - let first_word = trimmed.split_whitespace().next().unwrap_or(""); - - match first_word.to_ascii_uppercase().as_str() { - "PAGE" => FieldKind::PageNumber, - "NUMPAGES" => FieldKind::PageCount, - "DATE" => FieldKind::Date { - format: extract_switch(trimmed, "@"), - }, - "TIME" => FieldKind::Time { - format: extract_switch(trimmed, "@"), - }, - "TITLE" => FieldKind::Title, - "AUTHOR" => FieldKind::Author, - "SUBJECT" => FieldKind::Subject, - "FILENAME" => FieldKind::FileName, - "NUMWORDS" => FieldKind::WordCount, - "REF" => { - let target = trimmed.split_whitespace().nth(1).unwrap_or("").to_string(); - FieldKind::CrossReference { - target, - format: CrossRefFormat::Number, - } - } - "PAGEREF" => { - let target = trimmed.split_whitespace().nth(1).unwrap_or("").to_string(); - FieldKind::CrossReference { - target, - format: CrossRefFormat::Page, - } - } - _ => FieldKind::Raw { - instruction: trimmed.to_string(), - }, - } -} - -/// Extracts the value following a backslash-switch (e.g. `\@`) from a field -/// instruction string. -/// -/// Returns the content of the first quoted string after `\{sw}`, or `None` -/// if the switch is not present. -fn extract_switch(instruction: &str, sw: &str) -> Option { - let needle = format!("\\{sw}"); - let pos = instruction.find(&needle)?; - let rest = instruction[pos + needle.len()..].trim_start(); - if let Some(inner) = rest.strip_prefix('"') { - let end = inner.find('"')?; - Some(inner[..end].to_string()) - } else { - None - } -} - // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -405,6 +381,7 @@ mod tests { use crate::docx::import::DocxImportOptions; use crate::docx::model::paragraph::{DocxHyperlink, DocxRPr}; use loki_doc_model::content::block::Block; + use loki_doc_model::content::field::types::FieldKind; use loki_doc_model::style::catalog::StyleCatalog; use std::collections::HashMap; @@ -566,6 +543,48 @@ mod tests { } } + #[test] + fn simple_field_maps_to_field_inline() { + let (styles, fn_m, en_m, hl_m, img_m, opts) = default_ctx(); + let mut ctx = make_ctx(&fn_m, &en_m, &hl_m, &img_m, &styles, &opts); + let children = vec![DocxParaChild::SimpleField { + instr: " PAGE ".into(), + runs: vec![DocxRun { + rpr: None, + children: vec![DocxRunChild::Text { + text: "7".into(), + preserve: false, + }], + }], + }]; + let inlines = map_inlines(&children, &mut ctx); + assert_eq!(inlines.len(), 1); + if let Inline::Field(f) = &inlines[0] { + assert_eq!(f.kind, FieldKind::PageNumber); + assert_eq!(f.current_value.as_deref(), Some("7")); + } else { + panic!("expected Field, got {:?}", inlines[0]); + } + } + + #[test] + fn empty_simple_field_has_no_current_value() { + let (styles, fn_m, en_m, hl_m, img_m, opts) = default_ctx(); + let mut ctx = make_ctx(&fn_m, &en_m, &hl_m, &img_m, &styles, &opts); + let children = vec![DocxParaChild::SimpleField { + instr: " TITLE ".into(), + runs: vec![], + }]; + let inlines = map_inlines(&children, &mut ctx); + assert_eq!(inlines.len(), 1); + if let Inline::Field(f) = &inlines[0] { + assert_eq!(f.kind, FieldKind::Title); + assert!(f.current_value.is_none()); + } else { + panic!("expected Field"); + } + } + #[test] fn field_without_separate_has_no_current_value() { let (styles, fn_m, en_m, hl_m, img_m, opts) = default_ctx(); @@ -708,26 +727,4 @@ mod tests { matches!(&inlines[2], Inline::Bookmark(BookmarkKind::End, name) if name == "myBookmark") ); } - - #[test] - fn parse_date_field_with_format_switch() { - let kind = parse_field_instruction(r#" DATE \@ "MMMM d, yyyy" "#); - assert!(matches!(kind, FieldKind::Date { format: Some(ref s) } if s == "MMMM d, yyyy")); - } - - #[test] - fn parse_ref_field() { - let kind = parse_field_instruction(" REF _MyBookmark "); - assert!( - matches!(kind, FieldKind::CrossReference { target, format: CrossRefFormat::Number } if target == "_MyBookmark") - ); - } - - #[test] - fn parse_unknown_field_is_raw() { - let kind = parse_field_instruction(" HYPERLINK \"https://example.com\" "); - assert!( - matches!(kind, FieldKind::Raw { instruction } if instruction.contains("HYPERLINK")) - ); - } } diff --git a/loki-ooxml/src/docx/mapper/mod.rs b/loki-ooxml/src/docx/mapper/mod.rs index 651abc96..f801ca28 100644 --- a/loki-ooxml/src/docx/mapper/mod.rs +++ b/loki-ooxml/src/docx/mapper/mod.rs @@ -198,6 +198,7 @@ pub mod error; pub use error::MapperError; pub(crate) mod document; +pub(crate) mod fields; pub(crate) mod images; pub(crate) mod inline; pub(crate) mod numbering; @@ -267,138 +268,5 @@ pub fn map_document( // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use loki_opc::relationships::{Relationship, TargetMode}; - use loki_opc::{PartData, PartName}; - - const REL_OFFICE_DOCUMENT: &str = - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"; - const MEDIA_TYPE_DOCUMENT: &str = - "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; - - /// Builds a minimal in-memory DOCX OPC package programmatically. - /// - /// Contains only the parts needed by `map_document`: the package-level - /// `officeDocument` relationship and a valid `word/document.xml`. - fn make_package(doc_xml: &[u8]) -> Package { - let mut pkg = Package::new(); - let part_name = PartName::new("/word/document.xml").unwrap(); - pkg.set_part( - part_name, - PartData::new(doc_xml.to_vec(), MEDIA_TYPE_DOCUMENT), - ); - pkg.relationships_mut() - .add(Relationship { - id: "rId1".into(), - rel_type: REL_OFFICE_DOCUMENT.into(), - target: "/word/document.xml".into(), - target_mode: TargetMode::Internal, - }) - .unwrap(); - pkg - } - - // ── Round-trip test ─────────────────────────────────────────────────────── - - #[test] - fn round_trip_minimal_document() { - let package = make_package( - br#" - - - Hello, world! - - -"#, - ); - let doc = map_document(&package, &DocxImportOptions::default()) - .expect("map_document must succeed for a minimal package"); - - assert!(!doc.sections.is_empty(), "at least one section expected"); - let blocks = &doc.sections[0].blocks; - assert!(!blocks.is_empty(), "paragraph should be present"); - - use loki_doc_model::content::block::Block; - assert!( - matches!(blocks[0], Block::StyledPara(_)), - "first block should be StyledPara, got {:?}", - blocks[0] - ); - } - - // ── Optional absent: no styles part → empty catalog, no error ──────────── - - #[test] - fn missing_styles_part_uses_defaults() { - let package = make_package( - br#" - - -"#, - ); - let doc = map_document(&package, &DocxImportOptions::default()) - .expect("missing styles part should not error"); - // No styles were loaded; catalog is empty. - assert!(doc.styles.paragraph_styles.is_empty()); - } - - // ── MapperError variants display correctly ──────────────────────────────── - - #[test] - fn missing_required_element_message() { - let e = MapperError::MissingRequiredElement { element: "w:body" }; - assert!(e.to_string().contains("w:body")); - } - - #[test] - fn invalid_value_message() { - let e = MapperError::InvalidValue { - element: "w:pgSz", - detail: "width must be positive".into(), - }; - let s = e.to_string(); - assert!(s.contains("w:pgSz")); - assert!(s.contains("width must be positive")); - } - - // ── A4 defaults when no sectPr present ──────────────────────────────────── - - #[test] - fn no_sect_pr_yields_a4_layout() { - let package = make_package( - br#" - - -"#, - ); - let doc = map_document(&package, &DocxImportOptions::default()).unwrap(); - assert_eq!(doc.sections.len(), 1); - let sz = &doc.sections[0].layout.page_size; - // A4: 595.28 × 841.89 pt — allow ±0.1 pt tolerance. - assert!( - (sz.width.value() - 595.28).abs() < 0.1, - "A4 width expected, got {}", - sz.width.value() - ); - assert!( - (sz.height.value() - 841.89).abs() < 0.1, - "A4 height expected, got {}", - sz.height.value() - ); - } - - // ── Pipeline error for missing officeDocument relationship ──────────────── - - #[test] - fn missing_office_document_rel_yields_pipeline_error() { - // An empty package has no officeDocument relationship. - let pkg = Package::new(); - let result = map_document(&pkg, &DocxImportOptions::default()); - assert!(result.is_err()); - assert!( - matches!(result.unwrap_err(), MapperError::Pipeline(_)), - "expected Pipeline error variant" - ); - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/mapper/mod_tests.rs b/loki-ooxml/src/docx/mapper/mod_tests.rs new file mode 100644 index 00000000..60913212 --- /dev/null +++ b/loki-ooxml/src/docx/mapper/mod_tests.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `mod`. + +use super::*; +use loki_opc::relationships::{Relationship, TargetMode}; +use loki_opc::{PartData, PartName}; + +const REL_OFFICE_DOCUMENT: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"; +const MEDIA_TYPE_DOCUMENT: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; + +/// Builds a minimal in-memory DOCX OPC package programmatically. +/// +/// Contains only the parts needed by `map_document`: the package-level +/// `officeDocument` relationship and a valid `word/document.xml`. +fn make_package(doc_xml: &[u8]) -> Package { + let mut pkg = Package::new(); + let part_name = PartName::new("/word/document.xml").unwrap(); + pkg.set_part( + part_name, + PartData::new(doc_xml.to_vec(), MEDIA_TYPE_DOCUMENT), + ); + pkg.relationships_mut() + .add(Relationship { + id: "rId1".into(), + rel_type: REL_OFFICE_DOCUMENT.into(), + target: "/word/document.xml".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + pkg +} + +// ── Round-trip test ─────────────────────────────────────────────────────── + +#[test] +fn round_trip_minimal_document() { + let package = make_package( + br#" + + + Hello, world! + + +"#, + ); + let doc = map_document(&package, &DocxImportOptions::default()) + .expect("map_document must succeed for a minimal package"); + + assert!(!doc.sections.is_empty(), "at least one section expected"); + let blocks = &doc.sections[0].blocks; + assert!(!blocks.is_empty(), "paragraph should be present"); + + use loki_doc_model::content::block::Block; + assert!( + matches!(blocks[0], Block::StyledPara(_)), + "first block should be StyledPara, got {:?}", + blocks[0] + ); +} + +// ── Optional absent: no styles part → empty catalog, no error ──────────── + +#[test] +fn missing_styles_part_uses_defaults() { + let package = make_package( + br#" + + +"#, + ); + let doc = map_document(&package, &DocxImportOptions::default()) + .expect("missing styles part should not error"); + // No styles were loaded; catalog is empty. + assert!(doc.styles.paragraph_styles.is_empty()); +} + +// ── MapperError variants display correctly ──────────────────────────────── + +#[test] +fn missing_required_element_message() { + let e = MapperError::MissingRequiredElement { element: "w:body" }; + assert!(e.to_string().contains("w:body")); +} + +#[test] +fn invalid_value_message() { + let e = MapperError::InvalidValue { + element: "w:pgSz", + detail: "width must be positive".into(), + }; + let s = e.to_string(); + assert!(s.contains("w:pgSz")); + assert!(s.contains("width must be positive")); +} + +// ── A4 defaults when no sectPr present ──────────────────────────────────── + +#[test] +fn no_sect_pr_yields_a4_layout() { + let package = make_package( + br#" + + +"#, + ); + let doc = map_document(&package, &DocxImportOptions::default()).unwrap(); + assert_eq!(doc.sections.len(), 1); + let sz = &doc.sections[0].layout.page_size; + // A4: 595.28 × 841.89 pt — allow ±0.1 pt tolerance. + assert!( + (sz.width.value() - 595.28).abs() < 0.1, + "A4 width expected, got {}", + sz.width.value() + ); + assert!( + (sz.height.value() - 841.89).abs() < 0.1, + "A4 height expected, got {}", + sz.height.value() + ); +} + +// ── Pipeline error for missing officeDocument relationship ──────────────── + +#[test] +fn missing_office_document_rel_yields_pipeline_error() { + // An empty package has no officeDocument relationship. + let pkg = Package::new(); + let result = map_document(&pkg, &DocxImportOptions::default()); + assert!(result.is_err()); + assert!( + matches!(result.unwrap_err(), MapperError::Pipeline(_)), + "expected Pipeline error variant" + ); +} diff --git a/loki-ooxml/src/docx/mapper/numbering.rs b/loki-ooxml/src/docx/mapper/numbering.rs index f67fb681..c7f5b994 100644 --- a/loki-ooxml/src/docx/mapper/numbering.rs +++ b/loki-ooxml/src/docx/mapper/numbering.rs @@ -222,180 +222,5 @@ pub(crate) fn map_numbering( // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use crate::docx::model::numbering::{DocxAbstractNum, DocxLevel, DocxLvlOverride, DocxNum}; - - fn make_numbering( - abstract_num_id: u32, - num_id: u32, - levels: Vec, - overrides: Vec, - ) -> DocxNumbering { - DocxNumbering { - abstract_nums: vec![DocxAbstractNum { - abstract_num_id, - levels, - }], - nums: vec![DocxNum { - num_id, - abstract_num_id, - level_overrides: overrides, - }], - } - } - - fn bullet_level(ilvl: u8, text: &str) -> DocxLevel { - DocxLevel { - ilvl, - start: Some(1), - num_fmt: Some("bullet".into()), - lvl_text: Some(text.into()), - lvl_jc: None, - ppr: None, - rpr: None, - } - } - - fn decimal_level(ilvl: u8, text: &str) -> DocxLevel { - DocxLevel { - ilvl, - start: Some(1), - num_fmt: Some("decimal".into()), - lvl_text: Some(text.into()), - lvl_jc: None, - ppr: None, - rpr: None, - } - } - - #[test] - fn bullet_level_maps_correctly() { - let numbering = make_numbering(0, 1, vec![bullet_level(0, "•")], vec![]); - let mut catalog = StyleCatalog::new(); - let warnings = map_numbering(&numbering, &mut catalog); - assert!(warnings.is_empty()); - let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); - assert!(matches!( - ls.levels[0].kind, - ListLevelKind::Bullet { - char: BulletChar::Char('•'), - .. - } - )); - } - - #[test] - fn decimal_level_maps_correctly() { - let numbering = make_numbering(0, 1, vec![decimal_level(0, "%1.")], vec![]); - let mut catalog = StyleCatalog::new(); - map_numbering(&numbering, &mut catalog); - let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); - if let ListLevelKind::Numbered { scheme, format, .. } = &ls.levels[0].kind { - assert_eq!(*scheme, NumberingScheme::Decimal); - assert_eq!(format, "%1."); - } else { - panic!("expected Numbered"); - } - } - - #[test] - fn start_override_applied() { - let numbering = make_numbering( - 0, - 1, - vec![decimal_level(0, "%1.")], - vec![DocxLvlOverride { - ilvl: 0, - start_override: Some(5), - level: None, - }], - ); - let mut catalog = StyleCatalog::new(); - map_numbering(&numbering, &mut catalog); - let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); - if let ListLevelKind::Numbered { start_value, .. } = &ls.levels[0].kind { - assert_eq!(*start_value, 5); - } else { - panic!("expected Numbered"); - } - } - - #[test] - fn unresolvable_abstract_num_produces_warning() { - let numbering = DocxNumbering { - abstract_nums: vec![], - nums: vec![DocxNum { - num_id: 99, - abstract_num_id: 42, - level_overrides: vec![], - }], - }; - let mut catalog = StyleCatalog::new(); - let warnings = map_numbering(&numbering, &mut catalog); - assert!(!warnings.is_empty()); - assert!(catalog.list_styles.is_empty()); - } - - #[test] - fn display_levels_counted_correctly() { - assert_eq!(count_display_levels("%1.%2."), 2); - assert_eq!(count_display_levels("%1."), 1); - assert_eq!(count_display_levels("•"), 0); - } - - #[test] - fn pua_wingdings_bullet_normalized_to_unicode() { - // U+F0B7 is the Wingdings bullet (PUA); must be remapped to U+2022 •. - let numbering = make_numbering(0, 1, vec![bullet_level(0, "\u{F0B7}")], vec![]); - let mut catalog = StyleCatalog::new(); - map_numbering(&numbering, &mut catalog); - let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); - assert!( - matches!( - ls.levels[0].kind, - ListLevelKind::Bullet { - char: BulletChar::Char('•'), - .. - } - ), - "U+F0B7 Wingdings bullet should normalize to U+2022 BULLET" - ); - } - - #[test] - fn pua_wingdings_square_normalized_to_unicode() { - // U+F0FC is the Wingdings filled square; must remap to ■. - let numbering = make_numbering(0, 1, vec![bullet_level(0, "\u{F0FC}")], vec![]); - let mut catalog = StyleCatalog::new(); - map_numbering(&numbering, &mut catalog); - let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); - assert!(matches!( - ls.levels[0].kind, - ListLevelKind::Bullet { - char: BulletChar::Char('■'), - .. - } - )); - } - - #[test] - fn standard_unicode_bullet_unchanged() { - // Non-PUA Unicode bullets must not be remapped. - for (ch, _desc) in [ - ('•', "bullet"), - ('–', "en-dash"), - ('○', "circle"), - ('▪', "square"), - ] { - let numbering = make_numbering(0, 1, vec![bullet_level(0, &ch.to_string())], vec![]); - let mut catalog = StyleCatalog::new(); - map_numbering(&numbering, &mut catalog); - let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); - assert!( - matches!(&ls.levels[0].kind, ListLevelKind::Bullet { char: BulletChar::Char(c), .. } if *c == ch), - "Standard bullet char '{ch}' should not be remapped" - ); - } - } -} +#[path = "numbering_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/mapper/numbering_tests.rs b/loki-ooxml/src/docx/mapper/numbering_tests.rs new file mode 100644 index 00000000..8bed5386 --- /dev/null +++ b/loki-ooxml/src/docx/mapper/numbering_tests.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `numbering`. + +use super::*; +use crate::docx::model::numbering::{DocxAbstractNum, DocxLevel, DocxLvlOverride, DocxNum}; + +fn make_numbering( + abstract_num_id: u32, + num_id: u32, + levels: Vec, + overrides: Vec, +) -> DocxNumbering { + DocxNumbering { + abstract_nums: vec![DocxAbstractNum { + abstract_num_id, + levels, + }], + nums: vec![DocxNum { + num_id, + abstract_num_id, + level_overrides: overrides, + }], + } +} + +fn bullet_level(ilvl: u8, text: &str) -> DocxLevel { + DocxLevel { + ilvl, + start: Some(1), + num_fmt: Some("bullet".into()), + lvl_text: Some(text.into()), + lvl_jc: None, + ppr: None, + rpr: None, + } +} + +fn decimal_level(ilvl: u8, text: &str) -> DocxLevel { + DocxLevel { + ilvl, + start: Some(1), + num_fmt: Some("decimal".into()), + lvl_text: Some(text.into()), + lvl_jc: None, + ppr: None, + rpr: None, + } +} + +#[test] +fn bullet_level_maps_correctly() { + let numbering = make_numbering(0, 1, vec![bullet_level(0, "•")], vec![]); + let mut catalog = StyleCatalog::new(); + let warnings = map_numbering(&numbering, &mut catalog); + assert!(warnings.is_empty()); + let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); + assert!(matches!( + ls.levels[0].kind, + ListLevelKind::Bullet { + char: BulletChar::Char('•'), + .. + } + )); +} + +#[test] +fn decimal_level_maps_correctly() { + let numbering = make_numbering(0, 1, vec![decimal_level(0, "%1.")], vec![]); + let mut catalog = StyleCatalog::new(); + map_numbering(&numbering, &mut catalog); + let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); + if let ListLevelKind::Numbered { scheme, format, .. } = &ls.levels[0].kind { + assert_eq!(*scheme, NumberingScheme::Decimal); + assert_eq!(format, "%1."); + } else { + panic!("expected Numbered"); + } +} + +#[test] +fn start_override_applied() { + let numbering = make_numbering( + 0, + 1, + vec![decimal_level(0, "%1.")], + vec![DocxLvlOverride { + ilvl: 0, + start_override: Some(5), + level: None, + }], + ); + let mut catalog = StyleCatalog::new(); + map_numbering(&numbering, &mut catalog); + let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); + if let ListLevelKind::Numbered { start_value, .. } = &ls.levels[0].kind { + assert_eq!(*start_value, 5); + } else { + panic!("expected Numbered"); + } +} + +#[test] +fn unresolvable_abstract_num_produces_warning() { + let numbering = DocxNumbering { + abstract_nums: vec![], + nums: vec![DocxNum { + num_id: 99, + abstract_num_id: 42, + level_overrides: vec![], + }], + }; + let mut catalog = StyleCatalog::new(); + let warnings = map_numbering(&numbering, &mut catalog); + assert!(!warnings.is_empty()); + assert!(catalog.list_styles.is_empty()); +} + +#[test] +fn display_levels_counted_correctly() { + assert_eq!(count_display_levels("%1.%2."), 2); + assert_eq!(count_display_levels("%1."), 1); + assert_eq!(count_display_levels("•"), 0); +} + +#[test] +fn pua_wingdings_bullet_normalized_to_unicode() { + // U+F0B7 is the Wingdings bullet (PUA); must be remapped to U+2022 •. + let numbering = make_numbering(0, 1, vec![bullet_level(0, "\u{F0B7}")], vec![]); + let mut catalog = StyleCatalog::new(); + map_numbering(&numbering, &mut catalog); + let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); + assert!( + matches!( + ls.levels[0].kind, + ListLevelKind::Bullet { + char: BulletChar::Char('•'), + .. + } + ), + "U+F0B7 Wingdings bullet should normalize to U+2022 BULLET" + ); +} + +#[test] +fn pua_wingdings_square_normalized_to_unicode() { + // U+F0FC is the Wingdings filled square; must remap to ■. + let numbering = make_numbering(0, 1, vec![bullet_level(0, "\u{F0FC}")], vec![]); + let mut catalog = StyleCatalog::new(); + map_numbering(&numbering, &mut catalog); + let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); + assert!(matches!( + ls.levels[0].kind, + ListLevelKind::Bullet { + char: BulletChar::Char('■'), + .. + } + )); +} + +#[test] +fn standard_unicode_bullet_unchanged() { + // Non-PUA Unicode bullets must not be remapped. + for (ch, _desc) in [ + ('•', "bullet"), + ('–', "en-dash"), + ('○', "circle"), + ('▪', "square"), + ] { + let numbering = make_numbering(0, 1, vec![bullet_level(0, &ch.to_string())], vec![]); + let mut catalog = StyleCatalog::new(); + map_numbering(&numbering, &mut catalog); + let ls = catalog.list_styles.get(&ListId::new("1")).unwrap(); + assert!( + matches!(&ls.levels[0].kind, ListLevelKind::Bullet { char: BulletChar::Char(c), .. } if *c == ch), + "Standard bullet char '{ch}' should not be remapped" + ); + } +} diff --git a/loki-ooxml/src/docx/mapper/paragraph.rs b/loki-ooxml/src/docx/mapper/paragraph.rs index 3e05c990..16fd0eda 100644 --- a/loki-ooxml/src/docx/mapper/paragraph.rs +++ b/loki-ooxml/src/docx/mapper/paragraph.rs @@ -110,213 +110,5 @@ pub(crate) fn map_paragraph(p: &DocxParagraph, ctx: &mut MappingContext<'_>) -> // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use crate::docx::import::DocxImportOptions; - use crate::docx::model::paragraph::{DocxPPr, DocxParaChild, DocxRun, DocxRunChild}; - use loki_doc_model::content::attr::ExtensionBag; - use loki_doc_model::content::inline::Inline; - use loki_doc_model::style::catalog::{StyleCatalog, StyleId}; - use loki_doc_model::style::para_style::ParagraphStyle; - use loki_doc_model::style::props::char_props::CharProps; - use loki_doc_model::style::props::para_props::ParaProps; - use loki_opc::PartData; - use std::collections::HashMap; - - fn make_ctx<'a>( - styles: &'a StyleCatalog, - footnotes: &'a HashMap>, - endnotes: &'a HashMap>, - hyperlinks: &'a HashMap, - images: &'a HashMap, - options: &'a DocxImportOptions, - ) -> MappingContext<'a> { - MappingContext { - styles, - footnotes, - endnotes, - hyperlinks, - images, - options, - warnings: Vec::new(), - open_bookmarks: Vec::new(), - } - } - - fn text_child(s: &str) -> DocxParaChild { - DocxParaChild::Run(DocxRun { - rpr: None, - children: vec![DocxRunChild::Text { - text: s.to_string(), - preserve: false, - }], - }) - } - - fn default_opts() -> DocxImportOptions { - DocxImportOptions::default() - } - - fn empty_maps() -> ( - HashMap>, - HashMap>, - HashMap, - HashMap, - ) { - ( - HashMap::new(), - HashMap::new(), - HashMap::new(), - HashMap::new(), - ) - } - - #[test] - fn plain_paragraph_produces_styled_para() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = empty_maps(); - let opts = default_opts(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let p = DocxParagraph { - ppr: None, - children: vec![text_child("hello world")], - }; - let blocks = map_paragraph(&p, &mut ctx); - assert_eq!(blocks.len(), 1); - if let Block::StyledPara(sp) = &blocks[0] { - assert_eq!(sp.style_id, None); - assert_eq!(sp.inlines, vec![Inline::Str("hello world".into())]); - } else { - panic!("expected StyledPara"); - } - } - - #[test] - fn paragraph_with_style_id() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = empty_maps(); - let opts = default_opts(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let p = DocxParagraph { - ppr: Some(DocxPPr { - style_id: Some("BodyText".into()), - ..Default::default() - }), - children: vec![text_child("text")], - }; - let blocks = map_paragraph(&p, &mut ctx); - if let Block::StyledPara(sp) = &blocks[0] { - assert_eq!(sp.style_id, Some(StyleId::new("BodyText"))); - } else { - panic!("expected StyledPara"); - } - } - - #[test] - fn heading_via_direct_outline_level_emits_heading_block() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = empty_maps(); - let opts = DocxImportOptions { - emit_heading_blocks: true, - ..Default::default() - }; - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let p = DocxParagraph { - ppr: Some(DocxPPr { - outline_lvl: Some(0), - ..Default::default() - }), // 0 = Heading1 in OOXML (0-indexed) - children: vec![text_child("Title")], - }; - let blocks = map_paragraph(&p, &mut ctx); - // Should produce [Heading(1, ...)] - assert_eq!(blocks.len(), 1); - assert!(matches!(&blocks[0], Block::Heading(1, _, _))); - } - - #[test] - fn heading_via_style_outline_level() { - let mut styles = StyleCatalog::default(); - let heading_style = ParagraphStyle { - id: StyleId::new("Heading1"), - display_name: Some("Heading 1".into()), - parent: None, - linked_char_style: None, - next_style_id: None, - para_props: ParaProps { - outline_level: Some(1), - ..Default::default() - }, - char_props: CharProps::default(), - is_default: false, - is_custom: false, - extensions: ExtensionBag::default(), - }; - styles - .paragraph_styles - .insert(StyleId::new("Heading1"), heading_style); - - let (fn_m, en_m, hl_m, img_m) = empty_maps(); - let opts = DocxImportOptions { - emit_heading_blocks: true, - ..Default::default() - }; - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let p = DocxParagraph { - ppr: Some(DocxPPr { - style_id: Some("Heading1".into()), - ..Default::default() - }), - children: vec![text_child("Chapter 1")], - }; - let blocks = map_paragraph(&p, &mut ctx); - assert_eq!(blocks.len(), 1); - assert!(matches!(&blocks[0], Block::Heading(1, _, _))); - } - - #[test] - fn heading_suppressed_when_option_disabled() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = empty_maps(); - let opts = DocxImportOptions { - emit_heading_blocks: false, - ..Default::default() - }; - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let p = DocxParagraph { - ppr: Some(DocxPPr { - outline_lvl: Some(0), - ..Default::default() - }), - children: vec![text_child("No heading block")], - }; - let blocks = map_paragraph(&p, &mut ctx); - assert_eq!(blocks.len(), 1); - assert!(matches!(&blocks[0], Block::StyledPara(_))); - } - - #[test] - fn empty_paragraph_produces_empty_inlines() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = empty_maps(); - let opts = default_opts(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let p = DocxParagraph { - ppr: None, - children: vec![], - }; - let blocks = map_paragraph(&p, &mut ctx); - assert_eq!(blocks.len(), 1); - if let Block::StyledPara(sp) = &blocks[0] { - assert!(sp.inlines.is_empty()); - } else { - panic!("expected StyledPara"); - } - } -} +#[path = "paragraph_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/mapper/paragraph_tests.rs b/loki-ooxml/src/docx/mapper/paragraph_tests.rs new file mode 100644 index 00000000..134e909e --- /dev/null +++ b/loki-ooxml/src/docx/mapper/paragraph_tests.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `paragraph`. + +use super::*; +use crate::docx::import::DocxImportOptions; +use crate::docx::model::paragraph::{DocxPPr, DocxParaChild, DocxRun, DocxRunChild}; +use loki_doc_model::content::attr::ExtensionBag; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::style::catalog::{StyleCatalog, StyleId}; +use loki_doc_model::style::para_style::ParagraphStyle; +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::para_props::ParaProps; +use loki_opc::PartData; +use std::collections::HashMap; + +fn make_ctx<'a>( + styles: &'a StyleCatalog, + footnotes: &'a HashMap>, + endnotes: &'a HashMap>, + hyperlinks: &'a HashMap, + images: &'a HashMap, + options: &'a DocxImportOptions, +) -> MappingContext<'a> { + MappingContext { + styles, + footnotes, + endnotes, + hyperlinks, + images, + options, + warnings: Vec::new(), + open_bookmarks: Vec::new(), + } +} + +fn text_child(s: &str) -> DocxParaChild { + DocxParaChild::Run(DocxRun { + rpr: None, + children: vec![DocxRunChild::Text { + text: s.to_string(), + preserve: false, + }], + }) +} + +fn default_opts() -> DocxImportOptions { + DocxImportOptions::default() +} + +fn empty_maps() -> ( + HashMap>, + HashMap>, + HashMap, + HashMap, +) { + ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ) +} + +#[test] +fn plain_paragraph_produces_styled_para() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = empty_maps(); + let opts = default_opts(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let p = DocxParagraph { + ppr: None, + children: vec![text_child("hello world")], + }; + let blocks = map_paragraph(&p, &mut ctx); + assert_eq!(blocks.len(), 1); + if let Block::StyledPara(sp) = &blocks[0] { + assert_eq!(sp.style_id, None); + assert_eq!(sp.inlines, vec![Inline::Str("hello world".into())]); + } else { + panic!("expected StyledPara"); + } +} + +#[test] +fn paragraph_with_style_id() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = empty_maps(); + let opts = default_opts(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let p = DocxParagraph { + ppr: Some(DocxPPr { + style_id: Some("BodyText".into()), + ..Default::default() + }), + children: vec![text_child("text")], + }; + let blocks = map_paragraph(&p, &mut ctx); + if let Block::StyledPara(sp) = &blocks[0] { + assert_eq!(sp.style_id, Some(StyleId::new("BodyText"))); + } else { + panic!("expected StyledPara"); + } +} + +#[test] +fn heading_via_direct_outline_level_emits_heading_block() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = empty_maps(); + let opts = DocxImportOptions { + emit_heading_blocks: true, + ..Default::default() + }; + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let p = DocxParagraph { + ppr: Some(DocxPPr { + outline_lvl: Some(0), + ..Default::default() + }), // 0 = Heading1 in OOXML (0-indexed) + children: vec![text_child("Title")], + }; + let blocks = map_paragraph(&p, &mut ctx); + // Should produce [Heading(1, ...)] + assert_eq!(blocks.len(), 1); + assert!(matches!(&blocks[0], Block::Heading(1, _, _))); +} + +#[test] +fn heading_via_style_outline_level() { + let mut styles = StyleCatalog::default(); + let heading_style = ParagraphStyle { + id: StyleId::new("Heading1"), + display_name: Some("Heading 1".into()), + parent: None, + linked_char_style: None, + next_style_id: None, + para_props: ParaProps { + outline_level: Some(1), + ..Default::default() + }, + char_props: CharProps::default(), + is_default: false, + is_custom: false, + extensions: ExtensionBag::default(), + }; + styles + .paragraph_styles + .insert(StyleId::new("Heading1"), heading_style); + + let (fn_m, en_m, hl_m, img_m) = empty_maps(); + let opts = DocxImportOptions { + emit_heading_blocks: true, + ..Default::default() + }; + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let p = DocxParagraph { + ppr: Some(DocxPPr { + style_id: Some("Heading1".into()), + ..Default::default() + }), + children: vec![text_child("Chapter 1")], + }; + let blocks = map_paragraph(&p, &mut ctx); + assert_eq!(blocks.len(), 1); + assert!(matches!(&blocks[0], Block::Heading(1, _, _))); +} + +#[test] +fn heading_suppressed_when_option_disabled() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = empty_maps(); + let opts = DocxImportOptions { + emit_heading_blocks: false, + ..Default::default() + }; + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let p = DocxParagraph { + ppr: Some(DocxPPr { + outline_lvl: Some(0), + ..Default::default() + }), + children: vec![text_child("No heading block")], + }; + let blocks = map_paragraph(&p, &mut ctx); + assert_eq!(blocks.len(), 1); + assert!(matches!(&blocks[0], Block::StyledPara(_))); +} + +#[test] +fn empty_paragraph_produces_empty_inlines() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = empty_maps(); + let opts = default_opts(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let p = DocxParagraph { + ppr: None, + children: vec![], + }; + let blocks = map_paragraph(&p, &mut ctx); + assert_eq!(blocks.len(), 1); + if let Block::StyledPara(sp) = &blocks[0] { + assert!(sp.inlines.is_empty()); + } else { + panic!("expected StyledPara"); + } +} diff --git a/loki-ooxml/src/docx/mapper/styles.rs b/loki-ooxml/src/docx/mapper/styles.rs index 851248da..736e6449 100644 --- a/loki-ooxml/src/docx/mapper/styles.rs +++ b/loki-ooxml/src/docx/mapper/styles.rs @@ -58,7 +58,7 @@ pub(crate) fn map_styles(styles: &DocxStyles) -> StyleCatalog { para_props: style.ppr.as_ref().map(map_ppr).unwrap_or_default(), char_props: style.rpr.as_ref().map(map_rpr).unwrap_or_default(), is_default: style.is_default, - is_custom: false, + is_custom: style.is_custom, extensions: ExtensionBag::default(), }; // COMPAT(microsoft): duplicate styleId — last definition wins, diff --git a/loki-ooxml/src/docx/mapper/table.rs b/loki-ooxml/src/docx/mapper/table.rs index 99ea11f2..2bca6c11 100644 --- a/loki-ooxml/src/docx/mapper/table.rs +++ b/loki-ooxml/src/docx/mapper/table.rs @@ -281,405 +281,5 @@ fn compute_v_merge_spans( // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use crate::docx::import::DocxImportOptions; - use crate::docx::model::paragraph::DocxParagraph; - use crate::docx::model::styles::{DocxTableCell, DocxTableRow, DocxTcPr, DocxTrPr}; - use loki_doc_model::content::block::Block; - use loki_doc_model::style::catalog::StyleCatalog; - use loki_opc::PartData; - use std::collections::HashMap; - - fn make_ctx<'a>( - styles: &'a StyleCatalog, - footnotes: &'a HashMap>, - endnotes: &'a HashMap>, - hyperlinks: &'a HashMap, - images: &'a HashMap, - options: &'a DocxImportOptions, - ) -> MappingContext<'a> { - MappingContext { - styles, - footnotes, - endnotes, - hyperlinks, - images, - options, - warnings: Vec::new(), - open_bookmarks: Vec::new(), - } - } - - fn simple_cell(paragraphs: Vec) -> DocxTableCell { - DocxTableCell { - tc_pr: None, - paragraphs, - } - } - - fn simple_row(cells: Vec) -> DocxTableRow { - DocxTableRow { tr_pr: None, cells } - } - - #[test] - fn empty_table_produces_table_block() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = ( - HashMap::new(), - HashMap::new(), - HashMap::new(), - HashMap::new(), - ); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let t = DocxTableModel { - tbl_pr: None, - col_widths: vec![], - rows: vec![], - }; - let block = map_table(&t, &mut ctx); - assert!(matches!(block, Block::Table(_))); - if let Block::Table(tbl) = block { - assert_eq!(tbl.col_specs.len(), 0); - assert!(tbl.bodies[0].body_rows.is_empty()); - } - } - - #[test] - fn two_by_two_table() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = ( - HashMap::new(), - HashMap::new(), - HashMap::new(), - HashMap::new(), - ); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let t = DocxTableModel { - tbl_pr: None, - col_widths: vec![1440, 1440], // 72pt each - rows: vec![ - simple_row(vec![ - simple_cell(vec![DocxParagraph::default()]), - simple_cell(vec![DocxParagraph::default()]), - ]), - simple_row(vec![ - simple_cell(vec![DocxParagraph::default()]), - simple_cell(vec![DocxParagraph::default()]), - ]), - ], - }; - let block = map_table(&t, &mut ctx); - if let Block::Table(tbl) = block { - assert_eq!(tbl.col_specs.len(), 2); - assert_eq!(tbl.bodies[0].body_rows.len(), 2); - assert_eq!(tbl.bodies[0].body_rows[0].cells.len(), 2); - // 1440 twips = 72 pt - assert!( - matches!(tbl.col_specs[0].width, ColWidth::Fixed(p) if (p.value() - 72.0).abs() < 0.01) - ); - } else { - panic!("expected Table"); - } - } - - #[test] - fn header_row_goes_to_head() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = ( - HashMap::new(), - HashMap::new(), - HashMap::new(), - HashMap::new(), - ); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let header_row = DocxTableRow { - tr_pr: Some(DocxTrPr { is_header: true }), - cells: vec![simple_cell(vec![])], - }; - let body_row = simple_row(vec![simple_cell(vec![])]); - let t = DocxTableModel { - tbl_pr: None, - col_widths: vec![], - rows: vec![header_row, body_row], - }; - let block = map_table(&t, &mut ctx); - if let Block::Table(tbl) = block { - assert_eq!(tbl.head.rows.len(), 1); - assert_eq!(tbl.bodies[0].body_rows.len(), 1); - } else { - panic!("expected Table"); - } - } - - #[test] - fn cell_col_span_preserved() { - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = ( - HashMap::new(), - HashMap::new(), - HashMap::new(), - HashMap::new(), - ); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let cell_with_span = DocxTableCell { - tc_pr: Some(DocxTcPr { - grid_span: Some(3), - v_merge: None, - ..Default::default() - }), - paragraphs: vec![], - }; - let t = DocxTableModel { - tbl_pr: None, - col_widths: vec![], - rows: vec![simple_row(vec![cell_with_span])], - }; - let block = map_table(&t, &mut ctx); - if let Block::Table(tbl) = block { - assert_eq!(tbl.bodies[0].body_rows[0].cells[0].col_span, 3); - } else { - panic!("expected Table"); - } - } - - // ── compute_v_merge_spans unit tests ───────────────────────────────────── - - fn merge_cell(v_merge: DocxVMerge) -> DocxTableCell { - DocxTableCell { - tc_pr: Some(DocxTcPr { - v_merge: Some(v_merge), - ..Default::default() - }), - paragraphs: vec![], - } - } - - fn merge_cell_with_col_span(v_merge: DocxVMerge, col_span: u32) -> DocxTableCell { - DocxTableCell { - tc_pr: Some(DocxTcPr { - v_merge: Some(v_merge), - grid_span: Some(col_span), - ..Default::default() - }), - paragraphs: vec![], - } - } - - /// Simple 2-row merge: 2×2 table, col 0 merged across rows 0-1. - #[test] - fn vmerge_simple_2_row_merge() { - let rows = vec![ - simple_row(vec![merge_cell(DocxVMerge::Restart), simple_cell(vec![])]), - simple_row(vec![merge_cell(DocxVMerge::Continue), simple_cell(vec![])]), - ]; - let (span_map, skip_set) = compute_v_merge_spans(&rows); - - assert_eq!(span_map[&(0, 0)], 2, "restart cell should have row_span=2"); - assert!( - skip_set.contains(&(1, 0)), - "continuation cell (1,0) should be skipped" - ); - assert!( - !skip_set.contains(&(0, 0)), - "restart cell must not be skipped" - ); - assert!( - !skip_set.contains(&(0, 1)), - "col-1 cells must not be skipped" - ); - assert!( - !skip_set.contains(&(1, 1)), - "col-1 cells must not be skipped" - ); - } - - /// 3-row merge: col 0 merged across 3 rows. - #[test] - fn vmerge_3_row_merge() { - let rows = vec![ - simple_row(vec![merge_cell(DocxVMerge::Restart), simple_cell(vec![])]), - simple_row(vec![merge_cell(DocxVMerge::Continue), simple_cell(vec![])]), - simple_row(vec![merge_cell(DocxVMerge::Continue), simple_cell(vec![])]), - ]; - let (span_map, skip_set) = compute_v_merge_spans(&rows); - - assert_eq!(span_map[&(0, 0)], 3, "restart cell should have row_span=3"); - assert!( - skip_set.contains(&(1, 0)), - "row 1 continuation must be skipped" - ); - assert!( - skip_set.contains(&(2, 0)), - "row 2 continuation must be skipped" - ); - } - - /// No merge: table with no vMerge → all cells row_span=1, none removed. - #[test] - fn vmerge_no_merge() { - let rows = vec![ - simple_row(vec![simple_cell(vec![]), simple_cell(vec![])]), - simple_row(vec![simple_cell(vec![]), simple_cell(vec![])]), - ]; - let (span_map, skip_set) = compute_v_merge_spans(&rows); - - assert!(span_map.is_empty(), "no spans expected"); - assert!(skip_set.is_empty(), "no cells to skip"); - } - - /// Multiple independent merges in different columns. - #[test] - fn vmerge_multiple_independent_merges() { - // 3×2 table: col 0 merged rows 0-1, col 1 merged rows 1-2. - let rows = vec![ - simple_row(vec![merge_cell(DocxVMerge::Restart), simple_cell(vec![])]), - simple_row(vec![ - merge_cell(DocxVMerge::Continue), - merge_cell(DocxVMerge::Restart), - ]), - simple_row(vec![simple_cell(vec![]), merge_cell(DocxVMerge::Continue)]), - ]; - let (span_map, skip_set) = compute_v_merge_spans(&rows); - - assert_eq!(span_map[&(0, 0)], 2, "col-0 restart at row 0 → span 2"); - assert_eq!(span_map[&(1, 1)], 2, "col-1 restart at row 1 → span 2"); - assert!( - skip_set.contains(&(1, 0)), - "col-0 continuation (row 1) skipped" - ); - assert!( - skip_set.contains(&(2, 1)), - "col-1 continuation (row 2) skipped" - ); - assert!(!skip_set.contains(&(0, 1)), "col-1 row 0 is a plain cell"); - assert!(!skip_set.contains(&(2, 0)), "col-0 row 2 is a plain cell"); - } - - fn cell_with_props(tc_pr: DocxTcPr) -> DocxTableCell { - DocxTableCell { - tc_pr: Some(tc_pr), - paragraphs: vec![], - } - } - - #[test] - fn cell_padding_maps_to_points() { - use crate::docx::model::styles::DocxCellMargins; - use loki_primitives::units::Points; - - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = ( - HashMap::new(), - HashMap::new(), - HashMap::new(), - HashMap::new(), - ); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - let tc = cell_with_props(DocxTcPr { - tc_margins: Some(DocxCellMargins { - top: Some(100), // 5pt - bottom: Some(200), // 10pt - left: Some(300), // 15pt - right: Some(400), // 20pt - }), - ..Default::default() - }); - let cell = map_cell(&tc, &mut ctx); - assert_eq!(cell.props.padding_top, Some(Points::new(5.0))); - assert_eq!(cell.props.padding_bottom, Some(Points::new(10.0))); - assert_eq!(cell.props.padding_left, Some(Points::new(15.0))); - assert_eq!(cell.props.padding_right, Some(Points::new(20.0))); - } - - #[test] - fn cell_valign_maps_correctly() { - use crate::docx::model::styles::DocxVAlign; - use loki_doc_model::content::table::row::CellVerticalAlign; - - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = ( - HashMap::new(), - HashMap::new(), - HashMap::new(), - HashMap::new(), - ); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - for (docx_val, expected) in [ - (DocxVAlign::Top, CellVerticalAlign::Top), - (DocxVAlign::Center, CellVerticalAlign::Middle), - (DocxVAlign::Bottom, CellVerticalAlign::Bottom), - ] { - let tc = cell_with_props(DocxTcPr { - v_align: Some(docx_val), - ..Default::default() - }); - let cell = map_cell(&tc, &mut ctx); - assert_eq!(cell.props.vertical_align, Some(expected)); - } - } - - #[test] - fn cell_text_direction_maps_correctly() { - use crate::docx::model::styles::DocxTextDirection; - use loki_doc_model::content::table::row::CellTextDirection; - - let styles = StyleCatalog::default(); - let (fn_m, en_m, hl_m, img_m) = ( - HashMap::new(), - HashMap::new(), - HashMap::new(), - HashMap::new(), - ); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); - - for (docx_val, expected) in [ - (DocxTextDirection::LrTb, CellTextDirection::LrTb), - (DocxTextDirection::TbRl, CellTextDirection::TbRl), - (DocxTextDirection::TbLr, CellTextDirection::TbLr), - (DocxTextDirection::BtLr, CellTextDirection::BtLr), - ] { - let tc = cell_with_props(DocxTcPr { - text_direction: Some(docx_val), - ..Default::default() - }); - let cell = map_cell(&tc, &mut ctx); - assert_eq!(cell.props.text_direction, Some(expected)); - } - } - - /// col_span + vMerge: a restart cell with col_span=2 spans two grid columns. - #[test] - fn vmerge_with_col_span() { - // 2×1 logical table: row 0 has a 2-wide restart, row 1 has a 2-wide continuation. - let rows = vec![ - simple_row(vec![merge_cell_with_col_span(DocxVMerge::Restart, 2)]), - simple_row(vec![merge_cell_with_col_span(DocxVMerge::Continue, 2)]), - ]; - let (span_map, skip_set) = compute_v_merge_spans(&rows); - - // Grid col 0 (first of the two expanded columns) holds the span. - assert_eq!( - span_map[&(0, 0)], - 2, - "wide restart cell should have row_span=2" - ); - assert!( - skip_set.contains(&(1, 0)), - "wide continuation cell must be skipped" - ); - } -} +#[path = "table_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/mapper/table_tests.rs b/loki-ooxml/src/docx/mapper/table_tests.rs new file mode 100644 index 00000000..03e437ef --- /dev/null +++ b/loki-ooxml/src/docx/mapper/table_tests.rs @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `table`. + +use super::*; +use crate::docx::import::DocxImportOptions; +use crate::docx::model::paragraph::DocxParagraph; +use crate::docx::model::styles::{DocxTableCell, DocxTableRow, DocxTcPr, DocxTrPr}; +use loki_doc_model::content::block::Block; +use loki_doc_model::style::catalog::StyleCatalog; +use loki_opc::PartData; +use std::collections::HashMap; + +fn make_ctx<'a>( + styles: &'a StyleCatalog, + footnotes: &'a HashMap>, + endnotes: &'a HashMap>, + hyperlinks: &'a HashMap, + images: &'a HashMap, + options: &'a DocxImportOptions, +) -> MappingContext<'a> { + MappingContext { + styles, + footnotes, + endnotes, + hyperlinks, + images, + options, + warnings: Vec::new(), + open_bookmarks: Vec::new(), + } +} + +fn simple_cell(paragraphs: Vec) -> DocxTableCell { + DocxTableCell { + tc_pr: None, + paragraphs, + } +} + +fn simple_row(cells: Vec) -> DocxTableRow { + DocxTableRow { tr_pr: None, cells } +} + +#[test] +fn empty_table_produces_table_block() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let t = DocxTableModel { + tbl_pr: None, + col_widths: vec![], + rows: vec![], + }; + let block = map_table(&t, &mut ctx); + assert!(matches!(block, Block::Table(_))); + if let Block::Table(tbl) = block { + assert_eq!(tbl.col_specs.len(), 0); + assert!(tbl.bodies[0].body_rows.is_empty()); + } +} + +#[test] +fn two_by_two_table() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let t = DocxTableModel { + tbl_pr: None, + col_widths: vec![1440, 1440], // 72pt each + rows: vec![ + simple_row(vec![ + simple_cell(vec![DocxParagraph::default()]), + simple_cell(vec![DocxParagraph::default()]), + ]), + simple_row(vec![ + simple_cell(vec![DocxParagraph::default()]), + simple_cell(vec![DocxParagraph::default()]), + ]), + ], + }; + let block = map_table(&t, &mut ctx); + if let Block::Table(tbl) = block { + assert_eq!(tbl.col_specs.len(), 2); + assert_eq!(tbl.bodies[0].body_rows.len(), 2); + assert_eq!(tbl.bodies[0].body_rows[0].cells.len(), 2); + // 1440 twips = 72 pt + assert!( + matches!(tbl.col_specs[0].width, ColWidth::Fixed(p) if (p.value() - 72.0).abs() < 0.01) + ); + } else { + panic!("expected Table"); + } +} + +#[test] +fn header_row_goes_to_head() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let header_row = DocxTableRow { + tr_pr: Some(DocxTrPr { is_header: true }), + cells: vec![simple_cell(vec![])], + }; + let body_row = simple_row(vec![simple_cell(vec![])]); + let t = DocxTableModel { + tbl_pr: None, + col_widths: vec![], + rows: vec![header_row, body_row], + }; + let block = map_table(&t, &mut ctx); + if let Block::Table(tbl) = block { + assert_eq!(tbl.head.rows.len(), 1); + assert_eq!(tbl.bodies[0].body_rows.len(), 1); + } else { + panic!("expected Table"); + } +} + +#[test] +fn cell_col_span_preserved() { + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let cell_with_span = DocxTableCell { + tc_pr: Some(DocxTcPr { + grid_span: Some(3), + v_merge: None, + ..Default::default() + }), + paragraphs: vec![], + }; + let t = DocxTableModel { + tbl_pr: None, + col_widths: vec![], + rows: vec![simple_row(vec![cell_with_span])], + }; + let block = map_table(&t, &mut ctx); + if let Block::Table(tbl) = block { + assert_eq!(tbl.bodies[0].body_rows[0].cells[0].col_span, 3); + } else { + panic!("expected Table"); + } +} + +// ── compute_v_merge_spans unit tests ───────────────────────────────────── + +fn merge_cell(v_merge: DocxVMerge) -> DocxTableCell { + DocxTableCell { + tc_pr: Some(DocxTcPr { + v_merge: Some(v_merge), + ..Default::default() + }), + paragraphs: vec![], + } +} + +fn merge_cell_with_col_span(v_merge: DocxVMerge, col_span: u32) -> DocxTableCell { + DocxTableCell { + tc_pr: Some(DocxTcPr { + v_merge: Some(v_merge), + grid_span: Some(col_span), + ..Default::default() + }), + paragraphs: vec![], + } +} + +/// Simple 2-row merge: 2×2 table, col 0 merged across rows 0-1. +#[test] +fn vmerge_simple_2_row_merge() { + let rows = vec![ + simple_row(vec![merge_cell(DocxVMerge::Restart), simple_cell(vec![])]), + simple_row(vec![merge_cell(DocxVMerge::Continue), simple_cell(vec![])]), + ]; + let (span_map, skip_set) = compute_v_merge_spans(&rows); + + assert_eq!(span_map[&(0, 0)], 2, "restart cell should have row_span=2"); + assert!( + skip_set.contains(&(1, 0)), + "continuation cell (1,0) should be skipped" + ); + assert!( + !skip_set.contains(&(0, 0)), + "restart cell must not be skipped" + ); + assert!( + !skip_set.contains(&(0, 1)), + "col-1 cells must not be skipped" + ); + assert!( + !skip_set.contains(&(1, 1)), + "col-1 cells must not be skipped" + ); +} + +/// 3-row merge: col 0 merged across 3 rows. +#[test] +fn vmerge_3_row_merge() { + let rows = vec![ + simple_row(vec![merge_cell(DocxVMerge::Restart), simple_cell(vec![])]), + simple_row(vec![merge_cell(DocxVMerge::Continue), simple_cell(vec![])]), + simple_row(vec![merge_cell(DocxVMerge::Continue), simple_cell(vec![])]), + ]; + let (span_map, skip_set) = compute_v_merge_spans(&rows); + + assert_eq!(span_map[&(0, 0)], 3, "restart cell should have row_span=3"); + assert!( + skip_set.contains(&(1, 0)), + "row 1 continuation must be skipped" + ); + assert!( + skip_set.contains(&(2, 0)), + "row 2 continuation must be skipped" + ); +} + +/// No merge: table with no vMerge → all cells row_span=1, none removed. +#[test] +fn vmerge_no_merge() { + let rows = vec![ + simple_row(vec![simple_cell(vec![]), simple_cell(vec![])]), + simple_row(vec![simple_cell(vec![]), simple_cell(vec![])]), + ]; + let (span_map, skip_set) = compute_v_merge_spans(&rows); + + assert!(span_map.is_empty(), "no spans expected"); + assert!(skip_set.is_empty(), "no cells to skip"); +} + +/// Multiple independent merges in different columns. +#[test] +fn vmerge_multiple_independent_merges() { + // 3×2 table: col 0 merged rows 0-1, col 1 merged rows 1-2. + let rows = vec![ + simple_row(vec![merge_cell(DocxVMerge::Restart), simple_cell(vec![])]), + simple_row(vec![ + merge_cell(DocxVMerge::Continue), + merge_cell(DocxVMerge::Restart), + ]), + simple_row(vec![simple_cell(vec![]), merge_cell(DocxVMerge::Continue)]), + ]; + let (span_map, skip_set) = compute_v_merge_spans(&rows); + + assert_eq!(span_map[&(0, 0)], 2, "col-0 restart at row 0 → span 2"); + assert_eq!(span_map[&(1, 1)], 2, "col-1 restart at row 1 → span 2"); + assert!( + skip_set.contains(&(1, 0)), + "col-0 continuation (row 1) skipped" + ); + assert!( + skip_set.contains(&(2, 1)), + "col-1 continuation (row 2) skipped" + ); + assert!(!skip_set.contains(&(0, 1)), "col-1 row 0 is a plain cell"); + assert!(!skip_set.contains(&(2, 0)), "col-0 row 2 is a plain cell"); +} + +fn cell_with_props(tc_pr: DocxTcPr) -> DocxTableCell { + DocxTableCell { + tc_pr: Some(tc_pr), + paragraphs: vec![], + } +} + +#[test] +fn cell_padding_maps_to_points() { + use crate::docx::model::styles::DocxCellMargins; + use loki_primitives::units::Points; + + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let tc = cell_with_props(DocxTcPr { + tc_margins: Some(DocxCellMargins { + top: Some(100), // 5pt + bottom: Some(200), // 10pt + left: Some(300), // 15pt + right: Some(400), // 20pt + }), + ..Default::default() + }); + let cell = map_cell(&tc, &mut ctx); + assert_eq!(cell.props.padding_top, Some(Points::new(5.0))); + assert_eq!(cell.props.padding_bottom, Some(Points::new(10.0))); + assert_eq!(cell.props.padding_left, Some(Points::new(15.0))); + assert_eq!(cell.props.padding_right, Some(Points::new(20.0))); +} + +#[test] +fn cell_valign_maps_correctly() { + use crate::docx::model::styles::DocxVAlign; + use loki_doc_model::content::table::row::CellVerticalAlign; + + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + for (docx_val, expected) in [ + (DocxVAlign::Top, CellVerticalAlign::Top), + (DocxVAlign::Center, CellVerticalAlign::Middle), + (DocxVAlign::Bottom, CellVerticalAlign::Bottom), + ] { + let tc = cell_with_props(DocxTcPr { + v_align: Some(docx_val), + ..Default::default() + }); + let cell = map_cell(&tc, &mut ctx); + assert_eq!(cell.props.vertical_align, Some(expected)); + } +} + +#[test] +fn cell_text_direction_maps_correctly() { + use crate::docx::model::styles::DocxTextDirection; + use loki_doc_model::content::table::row::CellTextDirection; + + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + for (docx_val, expected) in [ + (DocxTextDirection::LrTb, CellTextDirection::LrTb), + (DocxTextDirection::TbRl, CellTextDirection::TbRl), + (DocxTextDirection::TbLr, CellTextDirection::TbLr), + (DocxTextDirection::BtLr, CellTextDirection::BtLr), + ] { + let tc = cell_with_props(DocxTcPr { + text_direction: Some(docx_val), + ..Default::default() + }); + let cell = map_cell(&tc, &mut ctx); + assert_eq!(cell.props.text_direction, Some(expected)); + } +} + +/// col_span + vMerge: a restart cell with col_span=2 spans two grid columns. +#[test] +fn vmerge_with_col_span() { + // 2×1 logical table: row 0 has a 2-wide restart, row 1 has a 2-wide continuation. + let rows = vec![ + simple_row(vec![merge_cell_with_col_span(DocxVMerge::Restart, 2)]), + simple_row(vec![merge_cell_with_col_span(DocxVMerge::Continue, 2)]), + ]; + let (span_map, skip_set) = compute_v_merge_spans(&rows); + + // Grid col 0 (first of the two expanded columns) holds the span. + assert_eq!( + span_map[&(0, 0)], + 2, + "wide restart cell should have row_span=2" + ); + assert!( + skip_set.contains(&(1, 0)), + "wide continuation cell must be skipped" + ); +} diff --git a/loki-ooxml/src/docx/mod.rs b/loki-ooxml/src/docx/mod.rs index 0480b3b7..4f8db514 100644 --- a/loki-ooxml/src/docx/mod.rs +++ b/loki-ooxml/src/docx/mod.rs @@ -21,5 +21,6 @@ pub mod export; pub mod import; pub mod mapper; pub(crate) mod model; +pub(crate) mod omml; pub(crate) mod reader; pub(crate) mod write; diff --git a/loki-ooxml/src/docx/model/paragraph.rs b/loki-ooxml/src/docx/model/paragraph.rs index deb6414a..1e3bdac9 100644 --- a/loki-ooxml/src/docx/model/paragraph.rs +++ b/loki-ooxml/src/docx/model/paragraph.rs @@ -5,7 +5,7 @@ //! //! Mirrors ECMA-376 §17.3.1 (paragraphs) and §17.3.2 (runs). -pub use super::section::{DocxHdrFtrRef, DocxPgMar, DocxPgSz, DocxSectPr}; +pub use super::section::{DocxCols, DocxHdrFtrRef, DocxPgMar, DocxPgSz, DocxSectPr}; /// Intermediate model for `w:p` (ECMA-376 §17.3.1.22). #[derive(Debug, Clone, Default)] @@ -33,6 +33,32 @@ pub enum DocxParaChild { TrackDel(Vec), /// A `w:ins` tracked insertion (ECMA-376 §17.13.5.16). TrackIns(Vec), + /// A `w:fldSimple` simple field (ECMA-376 §17.16.19): the `@w:instr` + /// instruction with the cached result carried as child runs. + SimpleField { + /// The field instruction string from `@w:instr`. + instr: String, + /// The cached result content (child runs). + runs: Vec, + }, + /// A `w:commentRangeStart` element (ECMA-376 §17.13.4.4). + CommentRangeStart { + /// The `@w:id` identifying the comment. + id: String, + }, + /// A `w:commentRangeEnd` element (ECMA-376 §17.13.4.3). + CommentRangeEnd { + /// The `@w:id` identifying the comment. + id: String, + }, + /// An OMML math zone (`m:oMath` inline or `m:oMathPara` display), already + /// converted to a `MathML` string. ECMA-376 §22.1. + Math { + /// The math content as a `MathML` `` string. + mathml: String, + /// `true` when the source was `m:oMathPara` (display/block math). + display: bool, + }, } /// Intermediate model for `w:pPr` (ECMA-376 §17.3.1.26). diff --git a/loki-ooxml/src/docx/model/section.rs b/loki-ooxml/src/docx/model/section.rs index b62f2681..8e3f87cc 100644 --- a/loki-ooxml/src/docx/model/section.rs +++ b/loki-ooxml/src/docx/model/section.rs @@ -19,6 +19,19 @@ pub struct DocxSectPr { pub footer_refs: Vec, /// `` — distinct first-page header/footer active (ECMA-376 §17.6.17). pub title_page: bool, + /// Multi-column layout from `w:cols` (ECMA-376 §17.6.4). + pub cols: Option, +} + +/// `w:cols` multi-column section layout (ECMA-376 §17.6.4). +#[derive(Debug, Clone)] +pub struct DocxCols { + /// `@w:num` — the number of equal-width columns. + pub num: i32, + /// `@w:space` — the spacing between columns, in twips. + pub space: i32, + /// `@w:sep` — whether a separator line is drawn between columns. + pub sep: bool, } /// `w:pgSz` page size (ECMA-376 §17.6.13). diff --git a/loki-ooxml/src/docx/omml/mod.rs b/loki-ooxml/src/docx/omml/mod.rs new file mode 100644 index 00000000..6ec0e494 --- /dev/null +++ b/loki-ooxml/src/docx/omml/mod.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! OMML (Office Math Markup Language) ⇄ `MathML` conversion. +//! +//! DOCX stores mathematical content as OMML (`m:oMath` / `m:oMathPara`, +//! ECMA-376 §22.1). The format-neutral document model stores math as a single +//! `MathML` string in [`loki_doc_model::content::inline::Inline::Math`] (`MathML` is +//! the W3C interchange standard and ODF's native math representation). This +//! module converts between the two on import and export. +//! +//! # Covered constructs +//! +//! The converter is bidirectional and 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` ⇄ ``/``). +//! Unrecognised elements pass their content through (best effort). Delimiters, +//! matrices, n-ary operators, and accents are not yet mapped. +//! TODO(omml): extend the construct set (delimiters, n-ary, matrices). + +mod read; +mod write; + +pub(crate) use read::read_math; +pub(crate) use write::write_omath; + +/// The `MathML` namespace URI placed on the root `` element. +pub(crate) const MATHML_NS: &str = "http://www.w3.org/1998/Math/MathML"; + +/// The OMML namespace URI declared on `w:document` so `m:`-prefixed math +/// elements resolve. ECMA-376 §22.1. +pub(crate) const OMML_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/math"; + +/// A minimal generic XML element tree used as the intermediate representation +/// for both conversion directions. +#[derive(Debug, Clone, Default)] +pub(crate) struct XmlNode { + /// Local element name (namespace prefix stripped). + pub tag: String, + /// Direct character data of this element (concatenated text events). + pub text: String, + /// Child elements, in document order. + pub children: Vec, +} + +impl XmlNode { + /// Returns the first direct child with the given local `tag`, if any. + pub fn child(&self, tag: &str) -> Option<&XmlNode> { + self.children.iter().find(|c| c.tag == tag) + } +} + +/// Escapes the five XML predefined entities for use in element text. +pub(crate) fn escape_xml(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +#[cfg(test)] +#[path = "omml_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/omml/omml_tests.rs b/loki-ooxml/src/docx/omml/omml_tests.rs new file mode 100644 index 00000000..8f630d49 --- /dev/null +++ b/loki-ooxml/src/docx/omml/omml_tests.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Round-trip tests for the `OMML` ⇄ `MathML` converter. + +use quick_xml::Reader; +use quick_xml::Writer; +use quick_xml::events::Event; + +use super::{read_math, write_omath}; + +/// Drives the OMML reader over `xml`, returning `(mathml, is_display)`. +fn to_mathml(xml: &str) -> (String, bool) { + let mut reader = Reader::from_reader(xml.as_bytes()); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let is_math = { + let ln = e.local_name(); + let n = ln.as_ref(); + n == b"oMath" || n == b"oMathPara" + }; + if is_math { + let owned = e.to_owned(); + return read_math(&mut reader, &owned).expect("read_math"); + } + buf.clear(); + } + Ok(Event::Eof) => panic!("no oMath element in fixture"), + _ => buf.clear(), + } + } +} + +/// Emits `OMML` for `mathml` and returns the serialized string. +fn to_omml(mathml: &str, display: bool) -> String { + let mut out = Vec::new(); + { + let mut w = Writer::new(&mut out); + write_omath(&mut w, mathml, display); + } + String::from_utf8(out).expect("utf8") +} + +/// Asserts that `mathml` survives a MathML → OMML → MathML cycle unchanged. +fn assert_stable(mathml: &str, display: bool) { + let omml = to_omml(mathml, display); + let (back, back_display) = to_mathml(&omml); + assert_eq!(back, mathml, "round-trip changed MathML (omml: {omml})"); + assert_eq!(back_display, display); +} + +const NS: &str = "http://www.w3.org/1998/Math/MathML"; + +#[test] +fn fraction_one_half() { + let omml = "1\ + 2"; + let (mathml, display) = to_mathml(omml); + assert!(!display); + assert_eq!( + mathml, + format!("12") + ); + assert_stable(&mathml, display); +} + +#[test] +fn superscript_x_squared() { + let omml = "x\ + 2"; + let (mathml, _) = to_mathml(omml); + assert_eq!( + mathml, + format!("x2") + ); + assert_stable(&mathml, false); +} + +#[test] +fn subscript_a_i() { + let omml = "a\ + i"; + let (mathml, _) = to_mathml(omml); + assert_eq!( + mathml, + format!("ai") + ); + assert_stable(&mathml, false); +} + +#[test] +fn square_root() { + let omml = "\ + x"; + let (mathml, _) = to_mathml(omml); + assert_eq!( + mathml, + format!("x") + ); + assert_stable(&mathml, false); +} + +#[test] +fn nth_root() { + let omml = "3\ + x"; + let (mathml, _) = to_mathml(omml); + assert_eq!( + mathml, + format!("x3") + ); + assert_stable(&mathml, false); +} + +#[test] +fn display_math_wrapper() { + let omml = "E"; + let (mathml, display) = to_mathml(omml); + assert!(display); + assert_eq!(mathml, format!("E")); + assert_stable(&mathml, true); +} + +#[test] +fn compound_sum_expression() { + // a + b/c with an operator run; exercises multi-element mrow wrapping. + let omml = "a+\ + b\ + c"; + let (mathml, _) = to_mathml(omml); + assert_eq!( + mathml, + format!( + "a+\ + bc" + ) + ); + assert_stable(&mathml, false); +} diff --git a/loki-ooxml/src/docx/omml/read.rs b/loki-ooxml/src/docx/omml/read.rs new file mode 100644 index 00000000..409ae35c --- /dev/null +++ b/loki-ooxml/src/docx/omml/read.rs @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! OMML → `MathML`: reads an `m:oMath` / `m:oMathPara` subtree from the live +//! document reader and converts it to a `MathML` string. ECMA-376 §22.1. + +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; + +use super::{MATHML_NS, XmlNode, escape_xml}; +use crate::docx::reader::util::local_name; +use crate::error::{OoxmlError, OoxmlResult}; + +/// Reads the math element whose `Start` event (`start`) has just been consumed +/// by the paragraph reader, returning `(mathml, is_display)`. +/// +/// `m:oMathPara` denotes display math (its child `m:oMath` carries the body); +/// a bare `m:oMath` is inline math. +pub(crate) fn read_math( + reader: &mut Reader<&[u8]>, + start: &BytesStart, +) -> OoxmlResult<(String, bool)> { + let root_tag = local_tag(start.name().as_ref()); + let node = read_node(reader, &root_tag)?; + let display = root_tag == "oMathPara"; + let omath = if display { + node.child("oMath").cloned().unwrap_or(node) + } else { + node + }; + let body: String = omath.children.iter().filter_map(convert).collect(); + Ok(( + format!("{body}"), + display, + )) +} + +/// Returns the prefix-stripped local name of an element name as an owned string. +fn local_tag(name: &[u8]) -> String { + String::from_utf8_lossy(local_name(name)).into_owned() +} + +/// Reads the children of an element named `tag` (whose `Start` was already +/// consumed) until the matching `End`, returning the assembled node. +/// +/// Shared by the OMML reader and the MathML-string parser used on export. +pub(super) fn read_node(reader: &mut Reader<&[u8]>, tag: &str) -> OoxmlResult { + let mut node = XmlNode { + tag: tag.to_string(), + ..Default::default() + }; + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let ctag = local_tag(e.name().as_ref()); + node.children.push(read_node(reader, &ctag)?); + } + Ok(Event::Empty(ref e)) => { + node.children.push(XmlNode { + tag: local_tag(e.name().as_ref()), + ..Default::default() + }); + } + Ok(Event::Text(ref t)) => { + if let Ok(s) = t.unescape() { + node.text.push_str(&s); + } + } + Ok(Event::End(ref e)) => { + if local_tag(e.name().as_ref()) == tag { + break; + } + } + Ok(Event::Eof) => break, + Err(e) => { + return Err(OoxmlError::Xml { + part: "word/document.xml".into(), + source: e, + }); + } + _ => {} + } + buf.clear(); + } + Ok(node) +} + +// ── OMML node → MathML fragment ───────────────────────────────────────────── + +/// Converts one OMML content node to a `MathML` fragment, or `None` for property +/// elements and empty content. +fn convert(node: &XmlNode) -> Option { + match node.tag.as_str() { + "r" => { + let text: String = node + .children + .iter() + .filter(|c| c.tag == "t") + .map(|c| c.text.as_str()) + .collect(); + token(&text) + } + "f" => Some(format!( + "{}{}", + wrapper(node, "num"), + wrapper(node, "den") + )), + "sSup" => Some(format!( + "{}{}", + wrapper(node, "e"), + wrapper(node, "sup") + )), + "sSub" => Some(format!( + "{}{}", + wrapper(node, "e"), + wrapper(node, "sub") + )), + "sSubSup" => Some(format!( + "{}{}{}", + wrapper(node, "e"), + wrapper(node, "sub"), + wrapper(node, "sup") + )), + "rad" => { + let base = wrapper(node, "e"); + let deg_empty = node + .child("deg") + .is_none_or(|d| seq(&d.children).is_empty()); + if deg_empty { + Some(format!("{base}")) + } else { + Some(format!("{base}{}", wrapper(node, "deg"))) + } + } + // Property and text-leaf elements carry no standalone math content. + "rPr" | "fPr" | "sSupPr" | "sSubPr" | "sSubSupPr" | "radPr" | "ctrlPr" | "t" => None, + // Unknown container: emit its content (best effort). + _ => { + let inner = seq(&node.children).concat(); + (!inner.is_empty()).then_some(inner) + } + } +} + +/// Converts a sequence of nodes, dropping the ones with no math content. +fn seq(nodes: &[XmlNode]) -> Vec { + nodes.iter().filter_map(convert).collect() +} + +/// Converts the content of `parent`'s child named `tag` into a single `MathML` +/// argument, wrapping multiple elements in an ``. +fn wrapper(parent: &XmlNode, tag: &str) -> String { + let parts = parent + .child(tag) + .map(|c| seq(&c.children)) + .unwrap_or_default(); + mrow(parts) +} + +/// Groups `parts` into a single `MathML` element: a lone element is returned as +/// is; zero or several are wrapped in ``. +fn mrow(parts: Vec) -> String { + match parts.len() { + 0 => "".to_string(), + 1 => parts.into_iter().next().unwrap_or_default(), + _ => format!("{}", parts.concat()), + } +} + +/// Classifies a math run's text into the appropriate `MathML` token element: +/// `` for numerals, `` for identifiers, `` otherwise. +fn token(s: &str) -> Option { + if s.is_empty() { + return None; + } + let esc = escape_xml(s); + if s.chars().all(|c| c.is_ascii_digit() || c == '.') { + Some(format!("{esc}")) + } else if s.chars().all(char::is_alphabetic) { + Some(format!("{esc}")) + } else { + Some(format!("{esc}")) + } +} diff --git a/loki-ooxml/src/docx/omml/write.rs b/loki-ooxml/src/docx/omml/write.rs new file mode 100644 index 00000000..f2db502a --- /dev/null +++ b/loki-ooxml/src/docx/omml/write.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `MathML` → OMML: parses the model's `MathML` string and emits the corresponding +//! `m:oMath` / `m:oMathPara` OOXML. ECMA-376 §22.1. + +use std::io::Write; + +use quick_xml::Reader; +use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; +use quick_xml::{Writer, name::QName}; + +use super::XmlNode; +use super::read::read_node; + +/// Writes `mathml` as OMML into the document writer. `display` selects the +/// `m:oMathPara` (block) wrapper; otherwise a bare inline `m:oMath` is emitted. +pub(crate) fn write_omath(w: &mut Writer, mathml: &str, display: bool) { + let Some(root) = parse_mathml(mathml) else { + return; + }; + if display { + start(w, "m:oMathPara", &[]); + start(w, "m:oMath", &[]); + write_seq(w, &root.children); + end(w, "m:oMath"); + end(w, "m:oMathPara"); + } else { + start(w, "m:oMath", &[]); + write_seq(w, &root.children); + end(w, "m:oMath"); + } +} + +/// Parses a `MathML` string into the generic node tree, returning the root +/// `` element. +fn parse_mathml(s: &str) -> Option { + let mut reader = Reader::from_reader(s.as_bytes()); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let tag = local_tag(e.name()); + return read_node(&mut reader, &tag).ok(); + } + Ok(Event::Eof) | Err(_) => return None, + _ => buf.clear(), + } + } +} + +/// Local name of a qualified name as an owned string. +fn local_tag(name: QName) -> String { + String::from_utf8_lossy(name.local_name().as_ref()).into_owned() +} + +// ── MathML node → OMML ────────────────────────────────────────────────────── + +fn write_seq(w: &mut Writer, nodes: &[XmlNode]) { + for n in nodes { + write_node(w, n); + } +} + +fn write_node(w: &mut Writer, node: &XmlNode) { + match node.tag.as_str() { + "mi" | "mn" | "mo" | "mtext" => write_run(w, &node.text), + "mfrac" => { + start(w, "m:f", &[]); + write_arg(w, "m:num", node.children.first()); + write_arg(w, "m:den", node.children.get(1)); + end(w, "m:f"); + } + "msup" => write_scripts(w, "m:sSup", &[("m:e", 0), ("m:sup", 1)], node), + "msub" => write_scripts(w, "m:sSub", &[("m:e", 0), ("m:sub", 1)], node), + "msubsup" => { + write_scripts( + w, + "m:sSubSup", + &[("m:e", 0), ("m:sub", 1), ("m:sup", 2)], + node, + ); + } + "msqrt" => { + start(w, "m:rad", &[]); + start(w, "m:radPr", &[]); + empty(w, "m:degHide", &[("m:val", "1")]); + end(w, "m:radPr"); + empty(w, "m:deg", &[]); + start(w, "m:e", &[]); + write_seq(w, &node.children); + end(w, "m:e"); + end(w, "m:rad"); + } + "mroot" => { + start(w, "m:rad", &[]); + write_arg(w, "m:deg", node.children.get(1)); + write_arg(w, "m:e", node.children.first()); + end(w, "m:rad"); + } + // `mrow` and unknown wrappers (math, mstyle, semantics, …): OMML has no + // explicit grouping element, so pass the content through directly. + _ => write_seq(w, &node.children), + } +} + +/// Emits an OMML script element with each `(omml_tag, child_index)` argument. +fn write_scripts(w: &mut Writer, elem: &str, args: &[(&str, usize)], node: &XmlNode) { + start(w, elem, &[]); + for (tag, idx) in args { + write_arg(w, tag, node.children.get(*idx)); + } + end(w, elem); +} + +/// Writes `` around the OMML for `child` (flattening an +/// ``). +fn write_arg(w: &mut Writer, tag: &str, child: Option<&XmlNode>) { + start(w, tag, &[]); + if let Some(c) = child { + if c.tag == "mrow" { + write_seq(w, &c.children); + } else { + write_node(w, c); + } + } + end(w, tag); +} + +/// Writes a math run `text`. +fn write_run(w: &mut Writer, text: &str) { + start(w, "m:r", &[]); + start(w, "m:t", &[("xml:space", "preserve")]); + let _ = w.write_event(Event::Text(BytesText::new(text))); + end(w, "m:t"); + end(w, "m:r"); +} + +// ── quick-xml convenience wrappers ────────────────────────────────────────── + +fn start(w: &mut Writer, tag: &str, attrs: &[(&str, &str)]) { + let mut e = BytesStart::new(tag); + for (k, v) in attrs { + e.push_attribute((*k, *v)); + } + let _ = w.write_event(Event::Start(e)); +} + +fn end(w: &mut Writer, tag: &str) { + let _ = w.write_event(Event::End(BytesEnd::new(tag))); +} + +fn empty(w: &mut Writer, tag: &str, attrs: &[(&str, &str)]) { + let mut e = BytesStart::new(tag); + for (k, v) in attrs { + e.push_attribute((*k, *v)); + } + let _ = w.write_event(Event::Empty(e)); +} diff --git a/loki-ooxml/src/docx/reader/comments.rs b/loki-ooxml/src/docx/reader/comments.rs new file mode 100644 index 00000000..fb834e4e --- /dev/null +++ b/loki-ooxml/src/docx/reader/comments.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Reader for `word/comments.xml` (ECMA-376 §17.13.4.2): parses each +//! `w:comment` into a [`Comment`], with its author, date, and block body +//! (one [`Block::Para`] per `w:p`, preserving multiple paragraphs). + +use chrono::DateTime; +use quick_xml::Reader; +use quick_xml::events::Event; + +use loki_doc_model::content::annotation::Comment; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; + +use crate::docx::reader::util::{attr_val, local_name}; +use crate::error::{OoxmlError, OoxmlResult}; + +/// Parses `word/comments.xml` into the document's comments. +/// +/// Each `w:p` in a comment becomes a [`Block::Para`]; run text is concatenated +/// as plain text (inline formatting inside comments is not preserved). +pub(crate) fn parse_comments(xml: &[u8]) -> OoxmlResult> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + let mut comments = Vec::new(); + + // State for the comment currently open. + let mut current: Option = None; + let mut para_text = String::new(); + let mut in_text = false; + let mut in_paragraph = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => match local_name(e.local_name().as_ref()) { + b"comment" => { + let mut c = Comment::new(attr_val(e, b"id").unwrap_or_default()); + c.author = attr_val(e, b"author"); + c.date = attr_val(e, b"date") + .and_then(|d| DateTime::parse_from_rfc3339(&d).ok()) + .map(|d| d.with_timezone(&chrono::Utc)); + current = Some(c); + } + b"p" if current.is_some() => { + in_paragraph = true; + para_text.clear(); + } + b"t" if current.is_some() => in_text = true, + _ => {} + }, + Ok(Event::Text(ref t)) if in_text => { + if let Ok(s) = t.unescape() { + para_text.push_str(&s); + } + } + Ok(Event::End(ref e)) => match local_name(e.local_name().as_ref()) { + b"t" => in_text = false, + b"p" if in_paragraph => { + in_paragraph = false; + if let Some(c) = current.as_mut() { + c.body.push(Block::Para(vec![Inline::Str(std::mem::take( + &mut para_text, + ))])); + } + } + b"comment" => { + if let Some(c) = current.take() { + comments.push(c); + } + } + _ => {} + }, + Ok(Event::Eof) => break, + Err(e) => { + return Err(OoxmlError::Xml { + part: "word/comments.xml".into(), + source: e, + }); + } + _ => {} + } + buf.clear(); + } + Ok(comments) +} diff --git a/loki-ooxml/src/docx/reader/custom_props.rs b/loki-ooxml/src/docx/reader/custom_props.rs new file mode 100644 index 00000000..4a2a6c80 --- /dev/null +++ b/loki-ooxml/src/docx/reader/custom_props.rs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Reader for `docProps/custom.xml` (ECMA-376 §15.2.12.2): extracts the +//! `(name, value)` custom-property pairs so the extended Dublin Core fields can +//! be reconstructed via [`DublinCoreMeta::from_named_pairs`]. + +use loki_doc_model::meta::dublin_core::DublinCoreMeta; +use loki_opc::{Package, PartName}; +use quick_xml::Reader; +use quick_xml::events::Event; + +use crate::docx::reader::util::{attr_val, local_name}; +use crate::error::{OoxmlError, OoxmlResult}; + +/// Reads `docProps/custom.xml` (resolved via the package custom-properties +/// relationship) and merges its reserved `dcmi:` fields into `dc`, preserving +/// any `identifier` already mapped from core.xml. A no-op when the part is +/// absent or unparsable. +pub(crate) fn apply_extended_dc(package: &Package, dc: &mut DublinCoreMeta) { + // Match both transitional and strict relationship namespaces by suffix. + let Some(rel) = package + .relationships() + .iter() + .find(|r| r.rel_type.ends_with("custom-properties")) + else { + return; + }; + let target = if rel.target.starts_with('/') { + rel.target.clone() + } else { + format!("/{}", rel.target) + }; + let Ok(part_name) = PartName::new(&target) else { + return; + }; + let Some(part) = package.part(&part_name) else { + return; + }; + let Ok(pairs) = parse_custom_props(&part.bytes) else { + return; + }; + let identifier = dc.identifier.take(); + *dc = DublinCoreMeta::from_named_pairs(&pairs); + dc.identifier = identifier; +} + +/// Parses `docProps/custom.xml` into `(name, value)` pairs. Only the string +/// (`vt:lpwstr`) value type is read; other typed values are skipped. +pub(crate) fn parse_custom_props(xml: &[u8]) -> OoxmlResult> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + let mut pairs = Vec::new(); + + // The `name` of the `` currently open, and the text accumulated + // from its `` child. + let mut current_name: Option = None; + let mut in_value = false; + let mut value = String::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => match local_name(e.local_name().as_ref()) { + b"property" => { + current_name = attr_val(e, b"name"); + value.clear(); + } + b"lpwstr" => in_value = current_name.is_some(), + _ => {} + }, + Ok(Event::Text(ref t)) if in_value => { + if let Ok(s) = t.unescape() { + value.push_str(&s); + } + } + Ok(Event::End(ref e)) => match local_name(e.local_name().as_ref()) { + b"lpwstr" => in_value = false, + b"property" => { + if let Some(name) = current_name.take() { + pairs.push((name, std::mem::take(&mut value))); + } + } + _ => {} + }, + Ok(Event::Eof) => break, + Err(e) => { + return Err(OoxmlError::Xml { + part: "docProps/custom.xml".into(), + source: e, + }); + } + _ => {} + } + buf.clear(); + } + Ok(pairs) +} diff --git a/loki-ooxml/src/docx/reader/document.rs b/loki-ooxml/src/docx/reader/document.rs index 66a16dee..7f47bc60 100644 --- a/loki-ooxml/src/docx/reader/document.rs +++ b/loki-ooxml/src/docx/reader/document.rs @@ -11,14 +11,15 @@ use quick_xml::events::Event; use crate::docx::model::document::{DocxBodyChild, DocxDocument}; use crate::docx::model::paragraph::{ - DocxBorderEdge, DocxDrawing, DocxHdrFtrRef, DocxHyperlink, DocxInd, DocxNumPr, DocxPBdr, - DocxPPr, DocxParaChild, DocxParagraph, DocxPgMar, DocxPgSz, DocxRFonts, DocxRPr, DocxRun, - DocxRunChild, DocxSectPr, DocxSpacing, DocxTab, + DocxBorderEdge, DocxCols, DocxDrawing, DocxHdrFtrRef, DocxHyperlink, DocxInd, DocxNumPr, + DocxPBdr, DocxPPr, DocxParaChild, DocxParagraph, DocxPgMar, DocxPgSz, DocxRFonts, DocxRPr, + DocxRun, DocxRunChild, DocxSectPr, DocxSpacing, DocxTab, }; use crate::docx::model::styles::{ DocxCellMargins, DocxTableCell, DocxTableModel, DocxTableRow, DocxTblPr, DocxTblWidth, DocxTcBorders, DocxTcPr, DocxTextDirection, DocxTrPr, DocxVAlign, }; +use crate::docx::reader::runs::{parse_fld_simple_runs, parse_hyperlink_runs, parse_tracked_runs}; use crate::docx::reader::util::{attr_val, local_name, parse_emu, toggle_prop}; use crate::error::{OoxmlError, OoxmlResult}; @@ -113,6 +114,14 @@ pub(crate) fn parse_paragraph(reader: &mut Reader<&[u8]>) -> OoxmlResult { + let id = attr_val(e, b"id").unwrap_or_default(); + para.children.push(DocxParaChild::CommentRangeStart { id }); + } + b"commentRangeEnd" => { + let id = attr_val(e, b"id").unwrap_or_default(); + para.children.push(DocxParaChild::CommentRangeEnd { id }); + } b"del" => { depth -= 1; let runs = parse_tracked_runs(reader, b"del")?; @@ -125,6 +134,20 @@ pub(crate) fn parse_paragraph(reader: &mut Reader<&[u8]>) -> OoxmlResult { + depth -= 1; + let instr = attr_val(e, b"instr").unwrap_or_default(); + let runs = parse_fld_simple_runs(reader)?; + para.children + .push(DocxParaChild::SimpleField { instr, runs }); + continue; + } + b"oMath" | b"oMathPara" => { + depth -= 1; // read_math consumes the element's end tag + let (mathml, display) = crate::docx::omml::read_math(reader, e)?; + para.children.push(DocxParaChild::Math { mathml, display }); + continue; + } _ => {} } } @@ -139,6 +162,22 @@ pub(crate) fn parse_paragraph(reader: &mut Reader<&[u8]>) -> OoxmlResult { + let id = attr_val(e, b"id").unwrap_or_default(); + para.children.push(DocxParaChild::CommentRangeStart { id }); + } + b"commentRangeEnd" => { + let id = attr_val(e, b"id").unwrap_or_default(); + para.children.push(DocxParaChild::CommentRangeEnd { id }); + } + b"fldSimple" => { + // Self-closing simple field: instruction only, no cached result. + let instr = attr_val(e, b"instr").unwrap_or_default(); + para.children.push(DocxParaChild::SimpleField { + instr, + runs: Vec::new(), + }); + } _ => {} }, Ok(Event::End(ref e)) => { @@ -372,7 +411,7 @@ pub(crate) fn parse_rpr_element(reader: &mut Reader<&[u8]>) -> OoxmlResult) -> OoxmlResult { +pub(crate) fn parse_run(reader: &mut Reader<&[u8]>) -> OoxmlResult { let mut run = DocxRun::default(); let mut buf = Vec::new(); @@ -478,58 +517,6 @@ fn parse_run(reader: &mut Reader<&[u8]>) -> OoxmlResult { Ok(run) } -/// Parses the runs inside a `w:hyperlink` element. -fn parse_hyperlink_runs(reader: &mut Reader<&[u8]>) -> OoxmlResult> { - let mut runs = Vec::new(); - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) if local_name(e.local_name().as_ref()) == b"r" => { - runs.push(parse_run(reader)?); - } - Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"hyperlink" => { - break; - } - Ok(Event::Eof) => break, - Err(e) => { - return Err(OoxmlError::Xml { - part: "word/document.xml".into(), - source: e, - }); - } - _ => {} - } - buf.clear(); - } - Ok(runs) -} - -/// Consumes runs inside a `w:del` or `w:ins` element. -fn parse_tracked_runs(reader: &mut Reader<&[u8]>, end_tag: &[u8]) -> OoxmlResult> { - let mut runs = Vec::new(); - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) if local_name(e.local_name().as_ref()) == b"r" => { - runs.push(parse_run(reader)?); - } - Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == end_tag => { - break; - } - Ok(Event::Eof) => break, - Err(e) => { - return Err(OoxmlError::Xml { - part: "word/document.xml".into(), - source: e, - }); - } - _ => {} - } - buf.clear(); - } - Ok(runs) -} - /// Parses a `w:pBdr` element. Called after Start("pBdr") is consumed. fn parse_pbdr(reader: &mut Reader<&[u8]>) -> OoxmlResult { let mut pbdr = DocxPBdr::default(); @@ -668,6 +655,18 @@ pub(crate) fn parse_sect_pr(reader: &mut Reader<&[u8]>) -> OoxmlResult { + sect.cols = Some(DocxCols { + num: attr_val(e, b"num") + .and_then(|v| v.parse().ok()) + .unwrap_or(1), + space: attr_val(e, b"space") + .and_then(|v| v.parse().ok()) + .unwrap_or(720), + sep: attr_val(e, b"sep") + .is_some_and(|v| !matches!(v.as_str(), "0" | "false" | "off")), + }); + } _ => {} } } diff --git a/loki-ooxml/src/docx/reader/mod.rs b/loki-ooxml/src/docx/reader/mod.rs index 8f11cd2a..91dec644 100644 --- a/loki-ooxml/src/docx/reader/mod.rs +++ b/loki-ooxml/src/docx/reader/mod.rs @@ -6,10 +6,13 @@ //! All readers use `quick-xml` in event reader mode with `trim_text(false)` //! to preserve whitespace. See ADR-0002. +pub mod comments; +pub mod custom_props; pub mod document; pub mod footnotes; pub mod header_footer; pub mod numbering; +pub mod runs; pub mod settings; pub mod styles; pub mod util; diff --git a/loki-ooxml/src/docx/reader/runs.rs b/loki-ooxml/src/docx/reader/runs.rs new file mode 100644 index 00000000..50c0fee4 --- /dev/null +++ b/loki-ooxml/src/docx/reader/runs.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Helpers that collect a sequence of `w:r` runs nested inside a wrapper +//! element (`w:hyperlink`, `w:del`/`w:ins`, `w:fldSimple`). + +use quick_xml::Reader; +use quick_xml::events::Event; + +use crate::docx::model::paragraph::DocxRun; +use crate::docx::reader::document::parse_run; +use crate::docx::reader::util::local_name; +use crate::error::{OoxmlError, OoxmlResult}; + +/// Consumes runs inside a `w:hyperlink` element. +pub(crate) fn parse_hyperlink_runs(reader: &mut Reader<&[u8]>) -> OoxmlResult> { + collect_runs(reader, b"hyperlink") +} + +/// Consumes runs inside a `w:del` or `w:ins` element. +pub(crate) fn parse_tracked_runs( + reader: &mut Reader<&[u8]>, + end_tag: &[u8], +) -> OoxmlResult> { + collect_runs(reader, end_tag) +} + +/// Consumes the cached-result runs inside a `w:fldSimple` element. +/// +/// A nested `w:fldSimple` (a field inside another field's result) has its own +/// result runs flattened into the outer field's result, which is sufficient for +/// recovering the displayed snapshot text. +pub(crate) fn parse_fld_simple_runs(reader: &mut Reader<&[u8]>) -> OoxmlResult> { + let mut runs = Vec::new(); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => match local_name(e.local_name().as_ref()) { + b"r" => runs.push(parse_run(reader)?), + b"fldSimple" => runs.extend(parse_fld_simple_runs(reader)?), + _ => {} + }, + Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"fldSimple" => { + break; + } + Ok(Event::Eof) => break, + Err(e) => return Err(xml_err(e)), + _ => {} + } + buf.clear(); + } + Ok(runs) +} + +/// Collects `w:r` runs until the matching `end_tag` end element is seen. +fn collect_runs(reader: &mut Reader<&[u8]>, end_tag: &[u8]) -> OoxmlResult> { + let mut runs = Vec::new(); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) if local_name(e.local_name().as_ref()) == b"r" => { + runs.push(parse_run(reader)?); + } + Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == end_tag => break, + Ok(Event::Eof) => break, + Err(e) => return Err(xml_err(e)), + _ => {} + } + buf.clear(); + } + Ok(runs) +} + +fn xml_err(source: quick_xml::Error) -> OoxmlError { + OoxmlError::Xml { + part: "word/document.xml".into(), + source, + } +} diff --git a/loki-ooxml/src/docx/write/assembly.rs b/loki-ooxml/src/docx/write/assembly.rs index 185769c4..a9147a2c 100644 --- a/loki-ooxml/src/docx/write/assembly.rs +++ b/loki-ooxml/src/docx/write/assembly.rs @@ -20,10 +20,8 @@ use crate::docx::write::collector::ExportCollector; use crate::docx::write::document::{write_document_xml, write_header_footer_xml}; use crate::docx::write::footnotes::{write_endnotes_xml, write_footnotes_xml}; use crate::docx::write::numbering::write_numbering_xml; +use crate::docx::write::rels::{AuxParts, add_document_relationships}; use crate::docx::write::styles::write_styles_xml; -use crate::docx::write::xml::{ - REL_ENDNOTES, REL_FOOTER, REL_FOOTNOTES, REL_HEADER, REL_IMAGE, REL_NUMBERING, REL_STYLES, -}; use crate::error::OoxmlError; // ── OPC relationship type URIs ─────────────────────────────────────────────── @@ -35,6 +33,29 @@ const REL_OFFICE_DOCUMENT: &str = const MT_DOCUMENT: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; +/// Content type for the main part of a Word **template** (`.dotx`). Structurally +/// identical to a `.docx`; only this override differs. +const MT_TEMPLATE: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"; + +/// Whether to assemble a regular document (`.docx`) or a template (`.dotx`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DocxKind { + /// A normal document part (`document.main+xml`). + Document, + /// A template part (`template.main+xml`). + Template, +} + +impl DocxKind { + /// The main-part content type for this kind. + fn main_content_type(self) -> &'static str { + match self { + DocxKind::Document => MT_DOCUMENT, + DocxKind::Template => MT_TEMPLATE, + } + } +} const MT_STYLES: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"; const MT_NUMBERING: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"; @@ -44,10 +65,21 @@ const MT_ENDNOTES: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml"; const MT_HEADER: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"; const MT_FOOTER: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"; +const MT_COMMENTS: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"; /// Assembles a complete `.docx` package from `doc` and writes it to `writer`. -#[allow(clippy::too_many_lines)] // Pre-existing pattern — structural refactor deferred pub(crate) fn assemble_docx(doc: &Document, writer: impl Write + Seek) -> Result<(), OoxmlError> { + assemble_docx_kind(doc, writer, DocxKind::Document) +} + +/// Assembles a `.docx` or `.dotx` package, depending on `kind`, and writes it. +#[allow(clippy::too_many_lines)] // Pre-existing pattern — structural refactor deferred +pub(crate) fn assemble_docx_kind( + doc: &Document, + writer: impl Write + Seek, + kind: DocxKind, +) -> Result<(), OoxmlError> { // ── Step 1: Build styles.xml ───────────────────────────────────────── let styles_bytes = write_styles_xml(&doc.styles); @@ -78,6 +110,18 @@ pub(crate) fn assemble_docx(doc: &Document, writer: impl Write + Seek) -> Result None }; + // Even-page headers/footers only round-trip when the document declares + // `w:evenAndOddHeaders` in settings.xml. Write that part when any section + // carries an even-page variant. + let needs_even_odd = doc + .sections + .iter() + .any(|s| s.layout.header_even.is_some() || s.layout.footer_even.is_some()); + + let has_comments = !doc.comments.is_empty(); + let comments_bytes = + has_comments.then(|| crate::docx::write::comments::write_comments_xml(&doc.comments)); + // ── Step 4: Assemble OPC package ───────────────────────────────────── let mut pkg = Package::new(); @@ -87,6 +131,7 @@ pub(crate) fn assemble_docx(doc: &Document, writer: impl Write + Seek) -> Result let numbering_part = PartName::new("/word/numbering.xml").map_err(OoxmlError::Opc)?; let footnotes_part = PartName::new("/word/footnotes.xml").map_err(OoxmlError::Opc)?; let endnotes_part = PartName::new("/word/endnotes.xml").map_err(OoxmlError::Opc)?; + let comments_part = PartName::new("/word/comments.xml").map_err(OoxmlError::Opc)?; // Insert parts. pkg.set_part(doc_part.clone(), PartData::new(document_bytes, MT_DOCUMENT)); @@ -100,6 +145,9 @@ pub(crate) fn assemble_docx(doc: &Document, writer: impl Write + Seek) -> Result if let Some(eb) = endnotes_bytes { pkg.set_part(endnotes_part.clone(), PartData::new(eb, MT_ENDNOTES)); } + if let Some(cb) = comments_bytes { + pkg.set_part(comments_part.clone(), PartData::new(cb, MT_COMMENTS)); + } // Insert headers and footers. let headers_footers = collector.take_headers_footers(); @@ -134,91 +182,25 @@ pub(crate) fn assemble_docx(doc: &Document, writer: impl Write + Seek) -> Result .map_err(OoxmlError::Opc)?; // ── Document-level relationships: word/_rels/document.xml.rels ─────── - pkg.part_relationships_mut(&doc_part) - .add(Relationship { - id: "rId1".to_string(), - rel_type: REL_STYLES.to_string(), - target: "styles.xml".to_string(), - target_mode: TargetMode::Internal, - }) - .map_err(OoxmlError::Opc)?; - - // numbering - if has_numbering { - pkg.part_relationships_mut(&doc_part) - .add(Relationship { - id: "rId2".to_string(), - rel_type: REL_NUMBERING.to_string(), - target: "numbering.xml".to_string(), - target_mode: TargetMode::Internal, - }) - .map_err(OoxmlError::Opc)?; - } - - // footnotes - if has_footnotes { - pkg.part_relationships_mut(&doc_part) - .add(Relationship { - id: "rId3".to_string(), // TODO: Should we use collector to manage these IDs too? - rel_type: REL_FOOTNOTES.to_string(), - target: "footnotes.xml".to_string(), - target_mode: TargetMode::Internal, - }) - .map_err(OoxmlError::Opc)?; - } - - // endnotes - if has_endnotes { - pkg.part_relationships_mut(&doc_part) - .add(Relationship { - id: "rId4".to_string(), - rel_type: REL_ENDNOTES.to_string(), - target: "endnotes.xml".to_string(), - target_mode: TargetMode::Internal, - }) - .map_err(OoxmlError::Opc)?; - } - - // hyperlinks - for (r_id, url) in &collector.hyperlinks { - pkg.part_relationships_mut(&doc_part) - .add(Relationship { - id: r_id.clone(), - rel_type: crate::docx::write::xml::REL_HYPERLINK.to_string(), - target: url.clone(), - target_mode: TargetMode::External, - }) - .map_err(OoxmlError::Opc)?; - } - - // media - for m in &collector.media { - pkg.part_relationships_mut(&doc_part) - .add(Relationship { - id: m.r_id.clone(), - rel_type: REL_IMAGE.to_string(), - target: m.path.strip_prefix("word/").unwrap_or(&m.path).to_string(), - target_mode: TargetMode::Internal, - }) - .map_err(OoxmlError::Opc)?; - } - - // headers/footers - for hf in &headers_footers { - let rel_type = if hf.is_header { REL_HEADER } else { REL_FOOTER }; - pkg.part_relationships_mut(&doc_part) - .add(Relationship { - id: hf.r_id.clone(), - rel_type: rel_type.to_string(), - target: hf - .path - .strip_prefix("word/") - .unwrap_or(&hf.path) - .to_string(), - target_mode: TargetMode::Internal, - }) - .map_err(OoxmlError::Opc)?; - } + add_document_relationships( + &mut pkg, + &doc_part, + &mut collector, + &headers_footers, + AuxParts { + numbering: has_numbering, + footnotes: has_footnotes, + endnotes: has_endnotes, + even_odd: needs_even_odd, + comments: has_comments, + }, + )?; + + // ── Document metadata ───────────────────────────────────────────────── + // Core properties (docProps/core.xml) are serialized by the OPC layer; + // the extended Dublin Core fields go to docProps/custom.xml. + crate::docx::write::metadata::populate_core_properties(&mut pkg, &doc.meta); + crate::docx::write::custom_props::add_custom_properties(&mut pkg, &doc.meta.dublin_core)?; // ── Content types ───────────────────────────────────────────────────── let ct = pkg.content_type_map_mut(); @@ -227,7 +209,7 @@ pub(crate) fn assemble_docx(doc: &Document, writer: impl Write + Seek) -> Result "application/vnd.openxmlformats-package.relationships+xml", ); ct.add_default("xml", "application/xml"); - ct.add_override(&doc_part, MT_DOCUMENT); + ct.add_override(&doc_part, kind.main_content_type()); ct.add_override(&styles_part, MT_STYLES); if has_numbering { ct.add_override(&numbering_part, MT_NUMBERING); @@ -238,6 +220,9 @@ pub(crate) fn assemble_docx(doc: &Document, writer: impl Write + Seek) -> Result if has_endnotes { ct.add_override(&endnotes_part, MT_ENDNOTES); } + if has_comments { + ct.add_override(&comments_part, MT_COMMENTS); + } // Header/footer content types. for hf in &headers_footers { diff --git a/loki-ooxml/src/docx/write/collector.rs b/loki-ooxml/src/docx/write/collector.rs index 04c02991..a330c690 100644 --- a/loki-ooxml/src/docx/write/collector.rs +++ b/loki-ooxml/src/docx/write/collector.rs @@ -115,6 +115,18 @@ impl ExportCollector { id } + /// Reserves the next free relationship ID without attaching it to a part. + /// + /// Used for document-level relationships (e.g. `settings.xml`) that are + /// wired up during package assembly rather than while serializing + /// `word/document.xml`. Reserving from the same counter guarantees the ID + /// does not collide with header/footer, media, or hyperlink relationships. + pub fn reserve_r_id(&mut self) -> String { + let r_id = format!("rId{}", self.next_r_id); + self.next_r_id += 1; + r_id + } + /// Registers a header or footer and returns its assigned rId. pub fn add_header_footer(&mut self, blocks: Vec, is_header: bool) -> String { let n = self.headers_footers.len() + 1; diff --git a/loki-ooxml/src/docx/write/comments.rs b/loki-ooxml/src/docx/write/comments.rs new file mode 100644 index 00000000..08c3ccac --- /dev/null +++ b/loki-ooxml/src/docx/write/comments.rs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Comment serialization: the in-flow range anchors (`w:commentRangeStart` / +//! `w:commentRangeEnd` / `w:commentReference`) and the `word/comments.xml` part. +//! ECMA-376 §17.13.4. + +use quick_xml::Writer; + +use loki_doc_model::content::annotation::{Comment, CommentRef, CommentRefKind}; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; + +use super::xml::{NS_W, write_empty, write_end, write_start, wval}; + +/// Writes the in-flow anchor for a [`CommentRef`]. +/// +/// `Start`/`End` emit the matching range markers; `End` and `Point` also emit a +/// `w:commentReference` run (styled `CommentReference`) so Word renders the +/// comment marker. +pub(super) fn write_comment_ref(w: &mut Writer, c: &CommentRef) { + match c.kind { + CommentRefKind::Start => { + let _ = write_empty(w, "w:commentRangeStart", &[("w:id", &c.id)]); + } + CommentRefKind::End => { + let _ = write_empty(w, "w:commentRangeEnd", &[("w:id", &c.id)]); + write_reference_run(w, &c.id); + } + // `Point` and any future kind fall back to a reference run. + _ => write_reference_run(w, &c.id), + } +} + +/// Writes ``. +fn write_reference_run(w: &mut Writer, id: &str) { + let _ = write_start(w, "w:r", &[]); + let _ = write_start(w, "w:rPr", &[]); + let _ = write_empty(w, "w:rStyle", &wval("CommentReference")); + let _ = write_end(w, "w:rPr"); + let _ = write_empty(w, "w:commentReference", &[("w:id", id)]); + let _ = write_end(w, "w:r"); +} + +/// Serializes `word/comments.xml` from the document's comments. +/// +/// Each body [`Block`] is written as a `w:p`; its plain text is concatenated +/// into a single run (inline formatting inside comments is not preserved). +#[must_use] +pub(super) fn write_comments_xml(comments: &[Comment]) -> Vec { + let mut out = String::from(concat!( + "\n", + ""); + for c in comments { + out.push_str("'); + // Always emit at least one paragraph (a comment with no body is invalid). + if c.body.is_empty() { + out.push_str(""); + } + for block in &c.body { + out.push_str(""); + out.push_str(&escape(&block_text(block))); + out.push_str(""); + } + out.push_str(""); + } + out.push_str(""); + out.into_bytes() +} + +/// Concatenates the plain text of a paragraph-like [`Block`]. +fn block_text(block: &Block) -> String { + let inlines = match block { + Block::Para(i) | Block::Plain(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + inlines + .iter() + .map(|i| match i { + Inline::Str(s) => s.as_str(), + Inline::Space => " ", + _ => "", + }) + .collect() +} + +/// Appends ` name="value"` (value escaped) to `out`. +fn attr(out: &mut String, name: &str, value: &str) { + out.push(' '); + out.push_str(name); + out.push_str("=\""); + out.push_str(&escape(value)); + out.push('"'); +} + +/// Escapes XML text / attribute content. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} diff --git a/loki-ooxml/src/docx/write/custom_props.rs b/loki-ooxml/src/docx/write/custom_props.rs new file mode 100644 index 00000000..d8cbc84a --- /dev/null +++ b/loki-ooxml/src/docx/write/custom_props.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Writer for `docProps/custom.xml` (ECMA-376 §15.2.12.2) — carries the +//! extended Dublin Core fields that core.xml cannot represent, under reserved +//! `dcmi:` names (via [`DublinCoreMeta::to_named_pairs`]). + +use loki_doc_model::meta::dublin_core::DublinCoreMeta; +use loki_opc::Package; +use loki_opc::part::{PartData, PartName}; +use loki_opc::relationships::{Relationship, TargetMode}; + +use crate::error::OoxmlError; + +/// Content type for the custom-properties part. +const MT_CUSTOM: &str = "application/vnd.openxmlformats-officedocument.custom-properties+xml"; +/// OPC relationship type for the custom-properties part. +const REL_CUSTOM: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; +/// The fixed FMTID required on every `` (ECMA-376 §22.7.2). +const FMTID: &str = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; + +/// Adds `docProps/custom.xml` (plus its package relationship and content-type +/// override) to `pkg` when `dc` carries any custom-property field. A no-op for +/// empty extended metadata. +pub(super) fn add_custom_properties( + pkg: &mut Package, + dc: &DublinCoreMeta, +) -> Result<(), OoxmlError> { + // `dc:identifier` is written to core.xml as the native element, so drop it + // from the custom properties to avoid duplicating it. + let pairs: Vec<(String, String)> = dc + .to_named_pairs() + .into_iter() + .filter(|(name, _)| name != "dcmi:identifier") + .collect(); + if pairs.is_empty() { + return Ok(()); + } + + let mut xml = String::from(concat!( + "\n", + "", + )); + // `pid` is a per-property id that must start at 2 and be unique (§22.7.2). + for (pid, (name, value)) in pairs.iter().enumerate() { + xml.push_str(&format!( + "{}", + escape(name), + escape(value), + pid = pid + 2, + )); + } + xml.push_str(""); + + let part = PartName::new("/docProps/custom.xml").map_err(OoxmlError::Opc)?; + pkg.set_part(part.clone(), PartData::new(xml.into_bytes(), MT_CUSTOM)); + pkg.relationships_mut() + .add(Relationship { + id: "rIdCustomProps".to_string(), + rel_type: REL_CUSTOM.to_string(), + target: "docProps/custom.xml".to_string(), + target_mode: TargetMode::Internal, + }) + .map_err(OoxmlError::Opc)?; + pkg.content_type_map_mut().add_override(&part, MT_CUSTOM); + Ok(()) +} + +/// Escapes XML text / attribute values. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} diff --git a/loki-ooxml/src/docx/write/document.rs b/loki-ooxml/src/docx/write/document.rs index 2bf64fa0..85fedd4d 100644 --- a/loki-ooxml/src/docx/write/document.rs +++ b/loki-ooxml/src/docx/write/document.rs @@ -4,8 +4,8 @@ //! `word/document.xml` serializer. //! //! Converts a sequence of [`Section`]s into OOXML body content. All -//! Tier-3 block and inline variants are handled; Tier-4+ content (images, -//! footnotes, complex fields) is silently omitted. +//! Tier-3 block and inline variants are handled; images, footnotes, and +//! complex fields are serialized via their sibling `write/` modules. //! //! ECMA-376 §17.2 (document structure) and §17.3 (block-level content). @@ -21,7 +21,8 @@ use loki_doc_model::style::catalog::StyleCatalog; use loki_doc_model::style::props::char_props::CharProps; use crate::docx::write::collector::ExportCollector; -use crate::docx::write::styles::emit_char_props; +use crate::docx::write::section::write_sect_pr; +use crate::docx::write::style_props::emit_char_props; use crate::docx::write::xml::{ NS_A, NS_PIC, NS_R, NS_W, NS_WP, color_to_hex, pts_to_twips, write_decl, write_empty, write_end, write_start, wval, @@ -47,6 +48,7 @@ pub(super) fn write_document_xml( ("xmlns:wp", NS_WP), ("xmlns:a", NS_A), ("xmlns:pic", NS_PIC), + ("xmlns:m", crate::docx::omml::OMML_NS), ], ); let _ = write_start(&mut w, "w:body", &[]); @@ -82,109 +84,6 @@ pub(super) fn write_document_xml( out } -// ── Section properties ─────────────────────────────────────────────────────── - -fn write_sect_pr( - w: &mut Writer, - layout: &PageLayout, - collector: &mut ExportCollector, -) { - let _ = write_start(w, "w:sectPr", &[]); - - if let Some(hf) = &layout.header { - let r_id = collector.add_header_footer(hf.blocks.clone(), true); - let _ = write_empty( - w, - "w:headerReference", - &[("w:type", "default"), ("r:id", &r_id)], - ); - } - if let Some(hf) = &layout.header_first { - let r_id = collector.add_header_footer(hf.blocks.clone(), true); - let _ = write_empty( - w, - "w:headerReference", - &[("w:type", "first"), ("r:id", &r_id)], - ); - } - if let Some(hf) = &layout.header_even { - let r_id = collector.add_header_footer(hf.blocks.clone(), true); - let _ = write_empty( - w, - "w:headerReference", - &[("w:type", "even"), ("r:id", &r_id)], - ); - } - - if let Some(hf) = &layout.footer { - let r_id = collector.add_header_footer(hf.blocks.clone(), false); - let _ = write_empty( - w, - "w:footerReference", - &[("w:type", "default"), ("r:id", &r_id)], - ); - } - if let Some(hf) = &layout.footer_first { - let r_id = collector.add_header_footer(hf.blocks.clone(), false); - let _ = write_empty( - w, - "w:footerReference", - &[("w:type", "first"), ("r:id", &r_id)], - ); - } - if let Some(hf) = &layout.footer_even { - let r_id = collector.add_header_footer(hf.blocks.clone(), false); - let _ = write_empty( - w, - "w:footerReference", - &[("w:type", "even"), ("r:id", &r_id)], - ); - } - - if layout.header_first.is_some() - || layout.footer_first.is_some() - || layout.header_even.is_some() - || layout.footer_even.is_some() - { - let _ = write_empty(w, "w:titlePg", &[]); - } - - let pw = pts_to_twips(layout.page_size.width.value()).to_string(); - let ph = pts_to_twips(layout.page_size.height.value()).to_string(); - let orient = match layout.orientation { - loki_doc_model::layout::page::PageOrientation::Landscape => "landscape", - loki_doc_model::layout::page::PageOrientation::Portrait => "portrait", - }; - let _ = write_empty( - w, - "w:pgSz", - &[("w:w", &pw), ("w:h", &ph), ("w:orient", orient)], - ); - - let mt = pts_to_twips(layout.margins.top.value()).to_string(); - let mb = pts_to_twips(layout.margins.bottom.value()).to_string(); - let ml = pts_to_twips(layout.margins.left.value()).to_string(); - let mr = pts_to_twips(layout.margins.right.value()).to_string(); - let mh = pts_to_twips(layout.margins.header.value()).to_string(); - let mf = pts_to_twips(layout.margins.footer.value()).to_string(); - let mg = pts_to_twips(layout.margins.gutter.value()).to_string(); - let _ = write_empty( - w, - "w:pgMar", - &[ - ("w:top", &mt), - ("w:right", &mr), - ("w:bottom", &mb), - ("w:left", &ml), - ("w:header", &mh), - ("w:footer", &mf), - ("w:gutter", &mg), - ], - ); - - let _ = write_end(w, "w:sectPr"); -} - /// Serializes header/footer blocks to `word/headerN.xml` or `word/footerN.xml`. pub(super) fn write_header_footer_xml( blocks: &[Block], @@ -205,6 +104,7 @@ pub(super) fn write_header_footer_xml( ("xmlns:wp", NS_WP), ("xmlns:a", NS_A), ("xmlns:pic", NS_PIC), + ("xmlns:m", crate::docx::omml::OMML_NS), ], ); @@ -785,7 +685,7 @@ fn write_table_cell( /// Accumulated run formatting inherited from inline wrappers. #[allow(clippy::struct_excessive_bools)] // Pre-existing pattern — structural refactor deferred #[derive(Default, Clone)] -struct RunProps { +pub(super) struct RunProps { bold: bool, italic: bool, underline: bool, @@ -905,9 +805,12 @@ fn write_inline( Inline::Bookmark(kind, name) => { write_bookmark(w, *kind, name, collector); } - Inline::Math(_, s) => { - write_text_run(w, s, props); + Inline::Math(kind, mathml) => { + use loki_doc_model::content::inline::MathType; + crate::docx::omml::write_omath(w, mathml, *kind == MathType::DisplayMath); } + Inline::Field(field) => super::fields::write_field(w, field, props), + Inline::Comment(c) => super::comments::write_comment_ref(w, c), Inline::Image(_, inlines, target) => { if let Some(r_id) = collector.add_image(&target.url) { // Default: 1 inch = 914400 EMU. @@ -968,7 +871,7 @@ fn write_styled_run( } /// Writes a single `` element with text content. -fn write_text_run(w: &mut Writer, text: &str, props: &RunProps) { +pub(super) fn write_text_run(w: &mut Writer, text: &str, props: &RunProps) { if text.is_empty() { return; } @@ -1146,7 +1049,8 @@ fn inlines_to_string(inlines: &[Inline]) -> String { let mut s = String::new(); for inline in inlines { match inline { - Inline::Str(t) | Inline::Code(_, t) | Inline::Math(_, t) => s.push_str(t), + // Math carries MathML markup, not display text — excluded here. + Inline::Str(t) | Inline::Code(_, t) => s.push_str(t), Inline::Space | Inline::SoftBreak => s.push(' '), Inline::LineBreak => s.push('\n'), Inline::Strong(i) diff --git a/loki-ooxml/src/docx/write/fields.rs b/loki-ooxml/src/docx/write/fields.rs new file mode 100644 index 00000000..87419ecb --- /dev/null +++ b/loki-ooxml/src/docx/write/fields.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Writer for OOXML complex document fields (`w:fldChar` / `w:instrText`). +//! +//! A [`Field`] is serialized as the standard complex-field run sequence +//! (ECMA-376 §17.16.18): +//! `begin → instrText → [separate → result] → end`. We emit the complex +//! form (rather than `w:fldSimple`) because the importer's field state +//! machine only reads complex fields, so this is what round-trips. + +use quick_xml::Writer; +use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; +use std::io::Write; + +use loki_doc_model::content::field::types::{CrossRefFormat, Field, FieldKind}; + +use super::document::{RunProps, write_text_run}; +use super::xml::{write_empty, write_end, write_start}; + +/// Serializes a [`Field`] as a complex field, applying `props` to the +/// displayed result run so inline formatting (bold, style, …) is preserved. +pub(super) fn write_field(w: &mut Writer, field: &Field, props: &RunProps) { + let instruction = field_instruction(&field.kind); + // A future `FieldKind` we cannot map yields an empty instruction; emit + // nothing rather than a malformed field. All current variants map. + if instruction.is_empty() { + return; + } + + write_fld_char(w, "begin"); + write_instr_text(w, &format!(" {instruction} ")); + + // The result snapshot is optional: emit `separate` + the cached value only + // when one is present, so a field imported without a snapshot round-trips + // back to `current_value == None`. + if let Some(value) = &field.current_value { + write_fld_char(w, "separate"); + write_text_run(w, value, props); + } + + write_fld_char(w, "end"); +} + +/// Writes ``. +fn write_fld_char(w: &mut Writer, kind: &str) { + let _ = write_start(w, "w:r", &[]); + let _ = write_empty(w, "w:fldChar", &[("w:fldCharType", kind)]); + let _ = write_end(w, "w:r"); +} + +/// Writes `TEXT`. +fn write_instr_text(w: &mut Writer, text: &str) { + let _ = write_start(w, "w:r", &[]); + let mut start = BytesStart::new("w:instrText"); + start.push_attribute(("xml:space", "preserve")); + let _ = w.write_event(Event::Start(start)); + let _ = w.write_event(Event::Text(BytesText::new(text))); + let _ = w.write_event(Event::End(BytesEnd::new("w:instrText"))); + let _ = write_end(w, "w:r"); +} + +/// Builds the OOXML field instruction string for a [`FieldKind`]. +/// +/// This is the inverse of the importer's `parse_field_instruction`, so a +/// known field round-trips to the same [`FieldKind`]. +fn field_instruction(kind: &FieldKind) -> String { + match kind { + FieldKind::PageNumber => "PAGE".to_string(), + FieldKind::PageCount => "NUMPAGES".to_string(), + FieldKind::Date { format } => date_like("DATE", format.as_deref()), + FieldKind::Time { format } => date_like("TIME", format.as_deref()), + FieldKind::Title => "TITLE".to_string(), + FieldKind::Author => "AUTHOR".to_string(), + FieldKind::Subject => "SUBJECT".to_string(), + FieldKind::FileName => "FILENAME".to_string(), + FieldKind::WordCount => "NUMWORDS".to_string(), + FieldKind::CrossReference { target, format } => match format { + // PAGEREF re-imports as `CrossRefFormat::Page`; everything else + // maps to a plain `REF` (which re-imports as `Number`). + CrossRefFormat::Page => format!("PAGEREF {target}"), + _ => format!("REF {target}"), + }, + FieldKind::Raw { instruction } => instruction.clone(), + // `FieldKind` is `#[non_exhaustive]`; an unknown future variant has no + // known instruction string and is skipped by the caller. + _ => String::new(), + } +} + +/// Formats a `DATE`/`TIME` field, appending a `\@ "format"` switch when set. +fn date_like(name: &str, format: Option<&str>) -> String { + match format { + Some(f) => format!("{name} \\@ \"{f}\""), + None => name.to_string(), + } +} + +#[cfg(test)] +#[path = "fields_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/write/fields_tests.rs b/loki-ooxml/src/docx/write/fields_tests.rs new file mode 100644 index 00000000..9c08c234 --- /dev/null +++ b/loki-ooxml/src/docx/write/fields_tests.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Unit tests for the complex-field writer. + +use super::*; + +/// Renders a field to a UTF-8 string for assertion. +fn render(field: &Field) -> String { + let mut out = Vec::new(); + let mut w = Writer::new(&mut out); + write_field(&mut w, field, &RunProps::default()); + String::from_utf8(out).expect("valid UTF-8") +} + +#[test] +fn page_field_emits_begin_instr_end() { + let xml = render(&Field::new(FieldKind::PageNumber)); + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#" PAGE "#)); + assert!(xml.contains(r#""#)); + // No snapshot ⇒ no `separate` and no result run. + assert!(!xml.contains(r#"w:fldCharType="separate""#)); +} + +#[test] +fn field_with_snapshot_emits_separate_and_result() { + let field = Field::new(FieldKind::PageCount).with_current_value("7"); + let xml = render(&field); + assert!(xml.contains(r#" NUMPAGES "#)); + assert!(xml.contains(r#""#)); + assert!(xml.contains("7")); +} + +#[test] +fn instruction_strings_are_inverse_of_parser() { + assert_eq!(field_instruction(&FieldKind::PageNumber), "PAGE"); + assert_eq!(field_instruction(&FieldKind::PageCount), "NUMPAGES"); + assert_eq!(field_instruction(&FieldKind::Title), "TITLE"); + assert_eq!(field_instruction(&FieldKind::Author), "AUTHOR"); + assert_eq!(field_instruction(&FieldKind::Subject), "SUBJECT"); + assert_eq!(field_instruction(&FieldKind::FileName), "FILENAME"); + assert_eq!(field_instruction(&FieldKind::WordCount), "NUMWORDS"); +} + +#[test] +fn date_field_includes_format_switch() { + let kind = FieldKind::Date { + format: Some("MMMM d, yyyy".to_string()), + }; + assert_eq!(field_instruction(&kind), r#"DATE \@ "MMMM d, yyyy""#); + + let no_fmt = FieldKind::Date { format: None }; + assert_eq!(field_instruction(&no_fmt), "DATE"); +} + +#[test] +fn cross_reference_uses_ref_or_pageref() { + let number = FieldKind::CrossReference { + target: "_Bm".to_string(), + format: CrossRefFormat::Number, + }; + assert_eq!(field_instruction(&number), "REF _Bm"); + + let page = FieldKind::CrossReference { + target: "_Bm".to_string(), + format: CrossRefFormat::Page, + }; + assert_eq!(field_instruction(&page), "PAGEREF _Bm"); +} + +#[test] +fn raw_field_is_verbatim() { + let kind = FieldKind::Raw { + instruction: r#"HYPERLINK "https://example.com""#.to_string(), + }; + assert_eq!( + field_instruction(&kind), + r#"HYPERLINK "https://example.com""# + ); +} diff --git a/loki-ooxml/src/docx/write/metadata.rs b/loki-ooxml/src/docx/write/metadata.rs new file mode 100644 index 00000000..6acd2b94 --- /dev/null +++ b/loki-ooxml/src/docx/write/metadata.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Document metadata export: populates the OPC core properties +//! (`docProps/core.xml`) from the format-neutral [`DocumentMeta`]. +//! +//! The OPC layer (`loki-opc`) serialises the populated `CoreProperties`, +//! registers the part, its relationship, and content-type automatically during +//! `Package::write`. ECMA-376 §15.2.12.3 / ISO/IEC 29500-2 §11. + +use loki_doc_model::meta::core::DocumentMeta; +use loki_opc::Package; + +/// Copies the core document metadata from `meta` into `pkg`'s core properties +/// so they are written to `docProps/core.xml`. +/// +/// The extended Dublin Core fields (publisher, contributors, …) have no home in +/// core.xml; only [`DocumentMeta::dublin_core`]'s `identifier` maps here (the +/// native `dc:identifier`). The remaining extended fields go to +/// `docProps/custom.xml` (see the `custom_props` module). +pub(super) fn populate_core_properties(pkg: &mut Package, meta: &DocumentMeta) { + let cp = pkg.core_properties_mut(); + cp.title.clone_from(&meta.title); + cp.subject.clone_from(&meta.subject); + cp.keywords.clone_from(&meta.keywords); + cp.description.clone_from(&meta.description); + cp.creator.clone_from(&meta.creator); + cp.last_modified_by.clone_from(&meta.last_modified_by); + cp.created = meta.created; + cp.modified = meta.modified; + cp.language = meta.language.as_ref().map(|l| l.as_str().to_string()); + cp.revision = meta.revision.map(|r| r.to_string()); + cp.identifier.clone_from(&meta.dublin_core.identifier); +} diff --git a/loki-ooxml/src/docx/write/mod.rs b/loki-ooxml/src/docx/write/mod.rs index 0d1c2978..8e62a874 100644 --- a/loki-ooxml/src/docx/write/mod.rs +++ b/loki-ooxml/src/docx/write/mod.rs @@ -12,9 +12,17 @@ pub(super) mod assembly; pub(super) mod collector; +mod comments; +mod custom_props; mod document; +mod fields; pub(super) mod footnotes; pub(super) mod media; +mod metadata; mod numbering; +mod rels; +mod section; +mod settings; +mod style_props; mod styles; mod xml; diff --git a/loki-ooxml/src/docx/write/rels.rs b/loki-ooxml/src/docx/write/rels.rs new file mode 100644 index 00000000..679a09de --- /dev/null +++ b/loki-ooxml/src/docx/write/rels.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Builds `word/_rels/document.xml.rels` — the relationships from the main +//! document part to its styles, numbering, notes, hyperlinks, media, +//! headers/footers, and settings parts. + +use loki_opc::Package; +use loki_opc::part::PartName; +use loki_opc::relationships::{Relationship, TargetMode}; + +use crate::docx::write::collector::{ExportCollector, HeaderFooterEntry}; +use crate::docx::write::settings::wire_settings; +use crate::docx::write::xml::{ + REL_ENDNOTES, REL_FOOTER, REL_FOOTNOTES, REL_HEADER, REL_HYPERLINK, REL_IMAGE, REL_NUMBERING, + REL_STYLES, +}; +use crate::error::OoxmlError; + +/// Adds one internal/external relationship to `word/_rels/document.xml.rels`. +fn add( + pkg: &mut Package, + doc_part: &PartName, + id: &str, + rel_type: &str, + target: &str, + mode: TargetMode, +) -> Result<(), OoxmlError> { + pkg.part_relationships_mut(doc_part) + .add(Relationship { + id: id.to_string(), + rel_type: rel_type.to_string(), + target: target.to_string(), + target_mode: mode, + }) + .map_err(OoxmlError::Opc) +} + +/// Wires every document-level relationship. Fixed-rId parts (styles rId1; +/// numbering/footnotes/endnotes rId2-4) come first; collector-assigned rIds +/// (hyperlinks, media, headers/footers, settings) follow. +pub(super) fn add_document_relationships( + pkg: &mut Package, + doc_part: &PartName, + collector: &mut ExportCollector, + headers_footers: &[HeaderFooterEntry], + has: AuxParts, +) -> Result<(), OoxmlError> { + use TargetMode::{External, Internal}; + + add(pkg, doc_part, "rId1", REL_STYLES, "styles.xml", Internal)?; + if has.numbering { + add( + pkg, + doc_part, + "rId2", + REL_NUMBERING, + "numbering.xml", + Internal, + )?; + } + if has.footnotes { + add( + pkg, + doc_part, + "rId3", + REL_FOOTNOTES, + "footnotes.xml", + Internal, + )?; + } + if has.endnotes { + add( + pkg, + doc_part, + "rId4", + REL_ENDNOTES, + "endnotes.xml", + Internal, + )?; + } + + for (r_id, url) in &collector.hyperlinks { + add(pkg, doc_part, r_id, REL_HYPERLINK, url, External)?; + } + for m in &collector.media { + let target = m.path.strip_prefix("word/").unwrap_or(&m.path); + add(pkg, doc_part, &m.r_id, REL_IMAGE, target, Internal)?; + } + for hf in headers_footers { + let rel_type = if hf.is_header { REL_HEADER } else { REL_FOOTER }; + let target = hf.path.strip_prefix("word/").unwrap_or(&hf.path); + add(pkg, doc_part, &hf.r_id, rel_type, target, Internal)?; + } + + // Settings (reserve an rId now that all H/F + media relationships exist). + if has.even_odd { + let r_id = collector.reserve_r_id(); + wire_settings(pkg, doc_part, r_id)?; + } + if has.comments { + let r_id = collector.reserve_r_id(); + add( + pkg, + doc_part, + &r_id, + crate::constants::REL_COMMENTS, + "comments.xml", + Internal, + )?; + } + Ok(()) +} + +/// Flags for which optional auxiliary parts/relationships are present. +/// +// A plain presence-flag carrier, not a state machine — the lint's +// "two-variant enum" suggestion does not apply. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Copy)] +pub(super) struct AuxParts { + pub numbering: bool, + pub footnotes: bool, + pub endnotes: bool, + pub even_odd: bool, + pub comments: bool, +} diff --git a/loki-ooxml/src/docx/write/section.rs b/loki-ooxml/src/docx/write/section.rs new file mode 100644 index 00000000..fce8380a --- /dev/null +++ b/loki-ooxml/src/docx/write/section.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `w:sectPr` serializer: page geometry, header/footer references, and +//! multi-column layout for a section. +//! +//! ECMA-376 §17.6 (sections), §17.6.13 (page size), §17.6.11 (margins), +//! §17.6.4 (columns). + +use quick_xml::Writer; + +use loki_doc_model::layout::page::{PageLayout, PageOrientation}; + +use crate::docx::write::collector::ExportCollector; +use crate::docx::write::xml::{pts_to_twips, write_empty, write_end, write_start}; + +/// Writes a `` element for `layout`, registering any header/footer +/// content with `collector`. +pub(super) fn write_sect_pr( + w: &mut Writer, + layout: &PageLayout, + collector: &mut ExportCollector, +) { + let _ = write_start(w, "w:sectPr", &[]); + + write_hf_refs(w, layout, collector); + + // `w:titlePg` enables the first-page header/footer for this section. It is + // NOT what enables even-page H/F — that is the document-level + // `w:evenAndOddHeaders` setting (written to settings.xml), so emit titlePg + // only when a first-page variant is present. + if layout.header_first.is_some() || layout.footer_first.is_some() { + let _ = write_empty(w, "w:titlePg", &[]); + } + + let pw = pts_to_twips(layout.page_size.width.value()).to_string(); + let ph = pts_to_twips(layout.page_size.height.value()).to_string(); + let orient = match layout.orientation { + PageOrientation::Landscape => "landscape", + PageOrientation::Portrait => "portrait", + }; + let _ = write_empty( + w, + "w:pgSz", + &[("w:w", &pw), ("w:h", &ph), ("w:orient", orient)], + ); + + let mt = pts_to_twips(layout.margins.top.value()).to_string(); + let mb = pts_to_twips(layout.margins.bottom.value()).to_string(); + let ml = pts_to_twips(layout.margins.left.value()).to_string(); + let mr = pts_to_twips(layout.margins.right.value()).to_string(); + let mh = pts_to_twips(layout.margins.header.value()).to_string(); + let mf = pts_to_twips(layout.margins.footer.value()).to_string(); + let mg = pts_to_twips(layout.margins.gutter.value()).to_string(); + let _ = write_empty( + w, + "w:pgMar", + &[ + ("w:top", &mt), + ("w:right", &mr), + ("w:bottom", &mb), + ("w:left", &ml), + ("w:header", &mh), + ("w:footer", &mf), + ("w:gutter", &mg), + ], + ); + + write_cols(w, layout); + + let _ = write_end(w, "w:sectPr"); +} + +/// Writes the `w:headerReference` / `w:footerReference` entries for every +/// present variant, registering each header/footer body with `collector`. +fn write_hf_refs( + w: &mut Writer, + layout: &PageLayout, + collector: &mut ExportCollector, +) { + let headers = [ + ("default", &layout.header), + ("first", &layout.header_first), + ("even", &layout.header_even), + ]; + for (kind, hf) in headers { + if let Some(hf) = hf { + let r_id = collector.add_header_footer(hf.blocks.clone(), true); + let _ = write_empty(w, "w:headerReference", &[("w:type", kind), ("r:id", &r_id)]); + } + } + + let footers = [ + ("default", &layout.footer), + ("first", &layout.footer_first), + ("even", &layout.footer_even), + ]; + for (kind, hf) in footers { + if let Some(hf) = hf { + let r_id = collector.add_header_footer(hf.blocks.clone(), false); + let _ = write_empty(w, "w:footerReference", &[("w:type", kind), ("r:id", &r_id)]); + } + } +} + +/// Writes `` for a multi-column section (ECMA-376 §17.6.4): equal-width +/// columns with a uniform gap. `w:sep` is emitted only when a separator line is +/// requested. +fn write_cols(w: &mut Writer, layout: &PageLayout) { + let Some(cols) = &layout.columns else { + return; + }; + let num = i32::from(cols.count).to_string(); + let space = pts_to_twips(cols.gap.value()).to_string(); + let mut attrs = vec![ + ("w:num", num.as_str()), + ("w:space", space.as_str()), + ("w:equalWidth", "1"), + ]; + if cols.separator { + attrs.push(("w:sep", "1")); + } + let _ = write_empty(w, "w:cols", &attrs); +} diff --git a/loki-ooxml/src/docx/write/settings.rs b/loki-ooxml/src/docx/write/settings.rs new file mode 100644 index 00000000..c756f119 --- /dev/null +++ b/loki-ooxml/src/docx/write/settings.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Writer for `word/settings.xml`. +//! +//! Only the settings needed for round-trip fidelity are emitted. Currently +//! that is `w:evenAndOddHeaders` (ECMA-376 §17.10.1), which is what enables +//! even-page headers/footers — without it, `even`-typed header/footer +//! references are ignored by Word and by our own importer. + +use loki_opc::Package; +use loki_opc::part::{PartData, PartName}; +use loki_opc::relationships::{Relationship, TargetMode}; + +use crate::constants::REL_SETTINGS; +use crate::docx::write::xml::NS_W; +use crate::error::OoxmlError; + +/// OOXML content type for the document settings part (ECMA-376 §17.15). +const MT_SETTINGS: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"; + +/// Builds a minimal `word/settings.xml` carrying ``. +fn write_settings_xml() -> Vec { + format!( + concat!( + "\n", + "" + ), + ns = NS_W, + ) + .into_bytes() +} + +/// Adds `word/settings.xml` to `pkg`: the part, its content-type override, and +/// a document relationship (using the caller-supplied `r_id`). +/// +/// Call only when the document needs `w:evenAndOddHeaders` (i.e. it has an +/// even-page header or footer); without this part those references are ignored. +pub(super) fn wire_settings( + pkg: &mut Package, + doc_part: &PartName, + r_id: String, +) -> Result<(), OoxmlError> { + let settings_part = PartName::new("/word/settings.xml").map_err(OoxmlError::Opc)?; + pkg.set_part( + settings_part.clone(), + PartData::new(write_settings_xml(), MT_SETTINGS), + ); + pkg.part_relationships_mut(doc_part) + .add(Relationship { + id: r_id, + rel_type: REL_SETTINGS.to_string(), + target: "settings.xml".to_string(), + target_mode: TargetMode::Internal, + }) + .map_err(OoxmlError::Opc)?; + pkg.content_type_map_mut() + .add_override(&settings_part, MT_SETTINGS); + Ok(()) +} diff --git a/loki-ooxml/src/docx/write/style_props.rs b/loki-ooxml/src/docx/write/style_props.rs new file mode 100644 index 00000000..7ec4cc76 --- /dev/null +++ b/loki-ooxml/src/docx/write/style_props.rs @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `` / `` property serializers shared by the styles writer and +//! the document-body writer. +//! +//! ECMA-376 §17.3 (Paragraph and run properties). + +use quick_xml::Writer; + +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::para_props::{LineHeight, ParaProps, Spacing}; + +use crate::docx::write::xml::{ + hex_color_val, pts_to_half_pts, pts_to_twips, write_empty, write_end, write_start, wval, +}; + +/// Writes a `` element from [`ParaProps`] (nothing if no field is set). +pub(super) fn write_para_props_elem(w: &mut Writer, pp: &ParaProps) { + let has_ind = pp.indent_start.is_some() + || pp.indent_end.is_some() + || pp.indent_hanging.is_some() + || pp.indent_first_line.is_some(); + let has_spacing = + pp.space_before.is_some() || pp.space_after.is_some() || pp.line_height.is_some(); + let has_content = + pp.alignment.is_some() || has_ind || has_spacing || pp.outline_level.is_some(); + if !has_content { + return; + } + let _ = write_start(w, "w:pPr", &[]); + + if let Some(align) = pp.alignment { + use loki_doc_model::style::props::para_props::ParagraphAlignment; + let jc = match align { + ParagraphAlignment::Right => "right", + ParagraphAlignment::Center => "center", + ParagraphAlignment::Justify => "both", + _ => "left", + }; + let _ = write_empty(w, "w:jc", &wval(jc)); + } + + if has_ind { + write_indent_elem(w, pp); + } + if has_spacing { + write_spacing_elem(w, pp); + } + + if let Some(lvl) = pp.outline_level { + // Model `outline_level` is 1-indexed (1 = Heading 1); OOXML + // `w:outlineLvl` is 0-indexed. Mirror `map_ppr`'s `+1` on import. + let lvl_s = lvl.saturating_sub(1).to_string(); + let _ = write_empty(w, "w:outlineLvl", &wval(&lvl_s)); + } + + let _ = write_end(w, "w:pPr"); +} + +/// Emits `` from the indentation fields (left / right / hanging / firstLine). +fn write_indent_elem(w: &mut Writer, pp: &ParaProps) { + let left = pp.indent_start.map_or(0, |v| pts_to_twips(v.value())); + let right = pp.indent_end.map_or(0, |v| pts_to_twips(v.value())); + let hanging = pp.indent_hanging.map_or(0, |v| pts_to_twips(v.value())); + let first_line = pp.indent_first_line.map_or(0, |v| pts_to_twips(v.value())); + let (left_s, right_s) = (left.to_string(), right.to_string()); + let (hanging_s, first_s) = (hanging.to_string(), first_line.to_string()); + let mut attrs: Vec<(&str, &str)> = Vec::new(); + if left != 0 { + attrs.push(("w:left", &left_s)); + } + if right != 0 { + attrs.push(("w:right", &right_s)); + } + // `w:hanging` and `w:firstLine` are mutually exclusive in OOXML; hanging + // wins when both are present (it is the more specific list-style indent). + if hanging != 0 { + attrs.push(("w:hanging", &hanging_s)); + } else if first_line != 0 { + attrs.push(("w:firstLine", &first_s)); + } + if !attrs.is_empty() { + let _ = write_empty(w, "w:ind", &attrs); + } +} + +/// Emits `` merging before / after points and the line rule. +// Line values are small, bounded document measurements: the f32→i32 cast after +// rounding cannot realistically truncate or change sign. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn write_spacing_elem(w: &mut Writer, pp: &ParaProps) { + let before = pp.space_before.map(spacing_twips); + let after = pp.space_after.map(spacing_twips); + // Map LineHeight back to (w:line, w:lineRule). `Multiple` is a ratio + // (1.5 = 1.5×) stored in 240ths; Exact/AtLeast are twips. Mirrors the + // reader's `map_line_height` (line/240.0 for auto). + let line_rule: Option<(i32, Option<&str>)> = pp.line_height.and_then(|lh| match lh { + LineHeight::Multiple(m) => Some(((m * 240.0).round() as i32, None)), + LineHeight::Exact(pt) => Some((pts_to_twips(pt.value()), Some("exact"))), + LineHeight::AtLeast(pt) => Some((pts_to_twips(pt.value()), Some("atLeast"))), + // `LineHeight` is #[non_exhaustive]; unknown variants emit no line rule. + _ => None, + }); + + let before_s = before.unwrap_or(0).to_string(); + let after_s = after.unwrap_or(0).to_string(); + let line_s = line_rule.map(|(l, _)| l.to_string()); + let mut attrs: Vec<(&str, &str)> = Vec::new(); + if before.is_some() { + attrs.push(("w:before", &before_s)); + } + if after.is_some() { + attrs.push(("w:after", &after_s)); + } + if let (Some((_, rule)), Some(line_s)) = (line_rule, &line_s) { + attrs.push(("w:line", line_s)); + if let Some(rule) = rule { + attrs.push(("w:lineRule", rule)); + } + } + if !attrs.is_empty() { + let _ = write_empty(w, "w:spacing", &attrs); + } +} + +/// Converts a [`Spacing`] to twips (percent spacing is not representable here). +fn spacing_twips(s: Spacing) -> i32 { + match s { + Spacing::Exact(pt) => pts_to_twips(pt.value()), + _ => 0, + } +} + +/// Writes a `` element from [`CharProps`] (nothing if no field is set). +pub(super) fn write_char_props_elem(w: &mut Writer, cp: &CharProps) { + let has_content = cp.bold.is_some() + || cp.italic.is_some() + || cp.underline.is_some() + || cp.strikethrough.is_some() + || cp.font_size.is_some() + || cp.font_name.is_some() + || cp.color.is_some() + || cp.background_color.is_some() + || cp.small_caps.is_some() + || cp.vertical_align.is_some(); + if !has_content { + return; + } + let _ = write_start(w, "w:rPr", &[]); + emit_char_props(w, cp); + let _ = write_end(w, "w:rPr"); +} + +/// Emits `` child elements for the given [`CharProps`] without the +/// wrapping `` tags (so callers can embed additional children). +pub(crate) fn emit_char_props(w: &mut Writer, cp: &CharProps) { + if let Some(ref font) = cp.font_name { + let _ = write_empty( + w, + "w:rFonts", + &[("w:ascii", font.as_str()), ("w:hAnsi", font.as_str())], + ); + } + if cp.bold == Some(true) { + let _ = write_empty(w, "w:b", &[]); + } + if cp.italic == Some(true) { + let _ = write_empty(w, "w:i", &[]); + } + if cp.small_caps == Some(true) { + let _ = write_empty(w, "w:smallCaps", &[]); + } + if let Some(ref ul) = cp.underline { + use loki_doc_model::style::props::char_props::UnderlineStyle; + let v = match ul { + UnderlineStyle::Double => "double", + UnderlineStyle::Dotted => "dotted", + UnderlineStyle::Dash => "dash", + UnderlineStyle::Wave => "wave", + UnderlineStyle::Thick => "thick", + _ => "single", + }; + let _ = write_empty(w, "w:u", &wval(v)); + } + if let Some(ref st) = cp.strikethrough { + use loki_doc_model::style::props::char_props::StrikethroughStyle; + match st { + StrikethroughStyle::Double => { + let _ = write_empty(w, "w:dstrike", &[]); + } + _ => { + let _ = write_empty(w, "w:strike", &[]); + } + } + } + if let Some(ref va) = cp.vertical_align { + use loki_doc_model::style::props::char_props::VerticalAlign; + let v = match va { + VerticalAlign::Superscript => "superscript", + VerticalAlign::Subscript => "subscript", + _ => "baseline", + }; + let _ = write_empty(w, "w:vertAlign", &wval(v)); + } + if let Some(ref color) = cp.color + && let Some(hex) = color.to_hex() + { + let hex_val = hex_color_val(&hex); + let _ = write_empty(w, "w:color", &wval(&hex_val)); + } + if let Some(pt) = cp.font_size { + let half = pts_to_half_pts(pt.value()).to_string(); + let _ = write_empty(w, "w:sz", &wval(&half)); + let _ = write_empty(w, "w:szCs", &wval(&half)); + } +} diff --git a/loki-ooxml/src/docx/write/styles.rs b/loki-ooxml/src/docx/write/styles.rs index 3166631e..94ed297d 100644 --- a/loki-ooxml/src/docx/write/styles.rs +++ b/loki-ooxml/src/docx/write/styles.rs @@ -3,24 +3,23 @@ //! `word/styles.xml` serializer. //! -//! Emits `docDefaults`, a `Normal` paragraph style, `Heading1`–`Heading6`, +//! Emits `docDefaults`, the `Normal` paragraph style, `Heading1`–`Heading6`, //! and all named paragraph/character styles from the document's -//! [`StyleCatalog`]. +//! [`StyleCatalog`]. When the catalog already carries `Normal` or a heading +//! style (e.g. after import or a style-editor edit), its full properties are +//! emitted so they round-trip; otherwise a built-in default is synthesized. //! //! ECMA-376 §17.7 (Document Styles). use quick_xml::Writer; use loki_doc_model::style::catalog::{StyleCatalog, StyleId}; -use loki_doc_model::style::props::char_props::CharProps; -use loki_doc_model::style::props::para_props::ParaProps; +use loki_doc_model::style::para_style::ParagraphStyle; -use crate::docx::write::xml::{ - NS_W, hex_color_val, pts_to_half_pts, pts_to_twips, write_decl, write_empty, write_end, - write_start, wval, -}; +use crate::docx::write::style_props::{write_char_props_elem, write_para_props_elem}; +use crate::docx::write::xml::{NS_W, write_decl, write_empty, write_end, write_start, wval}; -/// Built-in heading level definitions: (styleId, display name, font size half-pts, bold, outline level). +/// Built-in heading definitions: (styleId, display name, font size half-pts, bold, outline level). const HEADINGS: &[(&str, &str, i32, bool, u8)] = &[ ("Heading1", "heading 1", 48, true, 0), ("Heading2", "heading 2", 36, true, 1), @@ -30,6 +29,11 @@ const HEADINGS: &[(&str, &str, i32, bool, u8)] = &[ ("Heading6", "heading 6", 20, false, 5), ]; +/// Returns `true` if `sid` is `Normal` or one of the built-in heading ids. +fn is_builtin_id(sid: &str) -> bool { + sid == "Normal" || HEADINGS.iter().any(|(h, _, _, _, _)| *h == sid) +} + /// Serializes the document's style catalog to `word/styles.xml` bytes. pub(super) fn write_styles_xml(catalog: &StyleCatalog) -> Vec { let mut out = Vec::new(); @@ -46,15 +50,83 @@ pub(super) fn write_styles_xml(catalog: &StyleCatalog) -> Vec { ); write_doc_defaults(&mut w); - write_normal_style(&mut w, catalog); - write_heading_styles(&mut w); - write_catalog_styles(&mut w, catalog); + + // Normal: emit the catalog's version (with its edited props) when present, + // else a minimal default flagged as the document default. + match catalog.paragraph_styles.get(&StyleId::new("Normal")) { + Some(style) => emit_paragraph_style(&mut w, style), + None => write_default_normal(&mut w, catalog), + } + + // Headings: emit the catalog's version when present (so style-editor edits + // to a heading persist), else the built-in default. + for &(style_id, name, sz_half_pts, bold, outline_lvl) in HEADINGS { + match catalog.paragraph_styles.get(&StyleId::new(style_id)) { + Some(style) => emit_paragraph_style(&mut w, style), + None => write_default_heading(&mut w, style_id, name, sz_half_pts, bold, outline_lvl), + } + } + + // Remaining (custom / non-built-in) paragraph styles. + for (id, style) in &catalog.paragraph_styles { + if !is_builtin_id(id.as_str()) { + emit_paragraph_style(&mut w, style); + } + } + + // Character styles. + for (id, style) in &catalog.character_styles { + let sid = id.as_str(); + let _ = write_start( + &mut w, + "w:style", + &[("w:type", "character"), ("w:styleId", sid)], + ); + let name = style.display_name.as_deref().unwrap_or(sid); + let _ = write_empty(&mut w, "w:name", &wval(name)); + if let Some(ref parent) = style.parent { + let _ = write_empty(&mut w, "w:basedOn", &wval(parent.as_str())); + } + write_char_props_elem(&mut w, &style.char_props); + let _ = write_end(&mut w, "w:style"); + } let _ = write_end(&mut w, "w:styles"); drop(w); out } +/// Emits a complete `` element from a catalog style, +/// including `basedOn`, `next`, `customStyle`, `link`, and full `pPr` / `rPr`. +fn emit_paragraph_style(w: &mut Writer, style: &ParagraphStyle) { + let sid = style.id.as_str(); + let default_s = if style.is_default { "1" } else { "0" }; + let mut attrs: Vec<(&str, &str)> = vec![ + ("w:type", "paragraph"), + ("w:styleId", sid), + ("w:default", default_s), + ]; + if style.is_custom { + attrs.push(("w:customStyle", "1")); + } + let _ = write_start(w, "w:style", &attrs); + + let name = style.display_name.as_deref().unwrap_or(sid); + let _ = write_empty(w, "w:name", &wval(name)); + if let Some(ref parent) = style.parent { + let _ = write_empty(w, "w:basedOn", &wval(parent.as_str())); + } + if let Some(ref next) = style.next_style_id { + let _ = write_empty(w, "w:next", &wval(next)); + } + if let Some(ref link) = style.linked_char_style { + let _ = write_empty(w, "w:link", &wval(link.as_str())); + } + write_para_props_elem(w, &style.para_props); + write_char_props_elem(w, &style.char_props); + let _ = write_end(w, "w:style"); +} + fn write_doc_defaults(w: &mut Writer) { let _ = write_start(w, "w:docDefaults", &[]); let _ = write_start(w, "w:rPrDefault", &[]); @@ -80,14 +152,13 @@ fn write_doc_defaults(w: &mut Writer) { let _ = write_end(w, "w:docDefaults"); } -fn write_normal_style(w: &mut Writer, catalog: &StyleCatalog) { - let normal_id = StyleId::new("Normal"); +/// Minimal default `Normal` style (used when the catalog has no `Normal`). +fn write_default_normal(w: &mut Writer, catalog: &StyleCatalog) { let is_default = catalog .paragraph_styles - .get(&normal_id) + .get(&StyleId::new("Normal")) .is_some_and(|s| s.is_default) || catalog.paragraph_styles.is_empty(); - let _ = write_start( w, "w:style", @@ -101,224 +172,36 @@ fn write_normal_style(w: &mut Writer, catalog: &StyleCatal let _ = write_end(w, "w:style"); } -fn write_heading_styles(w: &mut Writer) { - for (style_id, name, sz_half_pts, bold, outline_lvl) in HEADINGS { - let _ = write_start( - w, - "w:style", - &[("w:type", "paragraph"), ("w:styleId", style_id)], - ); - let _ = write_empty(w, "w:name", &wval(name)); - let _ = write_empty(w, "w:basedOn", &wval("Normal")); - - let _ = write_start(w, "w:pPr", &[]); - let outline_s = outline_lvl.to_string(); - let _ = write_empty(w, "w:outlineLvl", &wval(&outline_s)); - let _ = write_end(w, "w:pPr"); - - let _ = write_start(w, "w:rPr", &[]); - if *bold { - let _ = write_empty(w, "w:b", &[]); - } - let sz_s = sz_half_pts.to_string(); - let _ = write_empty(w, "w:sz", &wval(&sz_s)); - let _ = write_empty(w, "w:szCs", &wval(&sz_s)); - let _ = write_end(w, "w:rPr"); - - let _ = write_end(w, "w:style"); - } -} - -fn write_catalog_styles(w: &mut Writer, catalog: &StyleCatalog) { - for (id, style) in &catalog.paragraph_styles { - let sid = id.as_str(); - // Skip Normal and Heading1-6 — already emitted above. - if sid == "Normal" || HEADINGS.iter().any(|(h, _, _, _, _)| *h == sid) { - continue; - } - let is_default_s = if style.is_default { "1" } else { "0" }; - let _ = write_start( - w, - "w:style", - &[ - ("w:type", "paragraph"), - ("w:default", is_default_s), - ("w:styleId", sid), - ], - ); - let name = style.display_name.as_deref().unwrap_or(sid); - let _ = write_empty(w, "w:name", &wval(name)); - if let Some(ref parent) = style.parent { - let _ = write_empty(w, "w:basedOn", &wval(parent.as_str())); - } - write_para_props_elem(w, &style.para_props); - write_char_props_elem(w, &style.char_props); - let _ = write_end(w, "w:style"); - } - - for (id, style) in &catalog.character_styles { - let sid = id.as_str(); - let _ = write_start(w, "w:style", &[("w:type", "character"), ("w:styleId", sid)]); - let name = style.display_name.as_deref().unwrap_or(sid); - let _ = write_empty(w, "w:name", &wval(name)); - if let Some(ref parent) = style.parent { - let _ = write_empty(w, "w:basedOn", &wval(parent.as_str())); - } - write_char_props_elem(w, &style.char_props); - let _ = write_end(w, "w:style"); - } -} +/// Built-in default heading style (used when the catalog has no such heading). +fn write_default_heading( + w: &mut Writer, + style_id: &str, + name: &str, + sz_half_pts: i32, + bold: bool, + outline_lvl: u8, +) { + let _ = write_start( + w, + "w:style", + &[("w:type", "paragraph"), ("w:styleId", style_id)], + ); + let _ = write_empty(w, "w:name", &wval(name)); + let _ = write_empty(w, "w:basedOn", &wval("Normal")); -/// Writes a `` element from [`ParaProps`] (empty if nothing set). -pub(super) fn write_para_props_elem(w: &mut Writer, pp: &ParaProps) { - let has_content = pp.alignment.is_some() - || pp.indent_start.is_some() - || pp.indent_end.is_some() - || pp.indent_hanging.is_some() - || pp.space_before.is_some() - || pp.space_after.is_some(); - if !has_content { - return; - } let _ = write_start(w, "w:pPr", &[]); - - if let Some(align) = pp.alignment { - use loki_doc_model::style::props::para_props::ParagraphAlignment; - let jc = match align { - ParagraphAlignment::Right => "right", - ParagraphAlignment::Center => "center", - ParagraphAlignment::Justify => "both", - _ => "left", - }; - let _ = write_empty(w, "w:jc", &wval(jc)); - } - - let has_ind = - pp.indent_start.is_some() || pp.indent_end.is_some() || pp.indent_hanging.is_some(); - if has_ind { - let left = pp.indent_start.map_or(0, |v| pts_to_twips(v.value())); - let right = pp.indent_end.map_or(0, |v| pts_to_twips(v.value())); - let hanging = pp.indent_hanging.map_or(0, |v| pts_to_twips(v.value())); - let left_s = left.to_string(); - let right_s = right.to_string(); - let hanging_s = hanging.to_string(); - let mut ind_attrs: Vec<(&str, &str)> = Vec::new(); - if left != 0 { - ind_attrs.push(("w:left", &left_s)); - } - if right != 0 { - ind_attrs.push(("w:right", &right_s)); - } - if hanging != 0 { - ind_attrs.push(("w:hanging", &hanging_s)); - } - if !ind_attrs.is_empty() { - let _ = write_empty(w, "w:ind", &ind_attrs); - } - } - - if pp.space_before.is_some() || pp.space_after.is_some() { - use loki_doc_model::style::props::para_props::Spacing; - let before = pp.space_before.map_or(0, |v| match v { - Spacing::Exact(pt) => pts_to_twips(pt.value()), - Spacing::Percent(_) | _ => 0, - }); - let after = pp.space_after.map_or(0, |v| match v { - Spacing::Exact(pt) => pts_to_twips(pt.value()), - Spacing::Percent(_) | _ => 0, - }); - let before_s = before.to_string(); - let after_s = after.to_string(); - let _ = write_empty( - w, - "w:spacing", - &[("w:before", &before_s), ("w:after", &after_s)], - ); - } - + let outline_s = outline_lvl.to_string(); + let _ = write_empty(w, "w:outlineLvl", &wval(&outline_s)); let _ = write_end(w, "w:pPr"); -} -/// Writes a `` element from [`CharProps`] (empty if nothing set). -pub(super) fn write_char_props_elem(w: &mut Writer, cp: &CharProps) { - let has_content = cp.bold.is_some() - || cp.italic.is_some() - || cp.underline.is_some() - || cp.strikethrough.is_some() - || cp.font_size.is_some() - || cp.font_name.is_some() - || cp.color.is_some() - || cp.background_color.is_some() - || cp.small_caps.is_some() - || cp.vertical_align.is_some(); - if !has_content { - return; - } let _ = write_start(w, "w:rPr", &[]); - emit_char_props(w, cp); - let _ = write_end(w, "w:rPr"); -} - -/// Emits `` child elements for the given [`CharProps`] without the -/// wrapping `` tags (so callers can embed additional children). -pub(super) fn emit_char_props(w: &mut Writer, cp: &CharProps) { - if let Some(ref font) = cp.font_name { - let _ = write_empty( - w, - "w:rFonts", - &[("w:ascii", font.as_str()), ("w:hAnsi", font.as_str())], - ); - } - if cp.bold == Some(true) { + if bold { let _ = write_empty(w, "w:b", &[]); } - if cp.italic == Some(true) { - let _ = write_empty(w, "w:i", &[]); - } - if cp.small_caps == Some(true) { - let _ = write_empty(w, "w:smallCaps", &[]); - } - if let Some(ref ul) = cp.underline { - use loki_doc_model::style::props::char_props::UnderlineStyle; - let v = match ul { - UnderlineStyle::Double => "double", - UnderlineStyle::Dotted => "dotted", - UnderlineStyle::Dash => "dash", - UnderlineStyle::Wave => "wave", - UnderlineStyle::Thick => "thick", - _ => "single", - }; - let _ = write_empty(w, "w:u", &wval(v)); - } - if let Some(ref st) = cp.strikethrough { - use loki_doc_model::style::props::char_props::StrikethroughStyle; - match st { - StrikethroughStyle::Double => { - let _ = write_empty(w, "w:dstrike", &[]); - } - _ => { - let _ = write_empty(w, "w:strike", &[]); - } - } - } - if let Some(ref va) = cp.vertical_align { - use loki_doc_model::style::props::char_props::VerticalAlign; - let v = match va { - VerticalAlign::Superscript => "superscript", - VerticalAlign::Subscript => "subscript", - _ => "baseline", - }; - let _ = write_empty(w, "w:vertAlign", &wval(v)); - } - if let Some(ref color) = cp.color - && let Some(hex) = color.to_hex() - { - let hex_val = hex_color_val(&hex); - let _ = write_empty(w, "w:color", &wval(&hex_val)); - } - if let Some(pt) = cp.font_size { - let half = pts_to_half_pts(pt.value()).to_string(); - let _ = write_empty(w, "w:sz", &wval(&half)); - let _ = write_empty(w, "w:szCs", &wval(&half)); - } + let sz_s = sz_half_pts.to_string(); + let _ = write_empty(w, "w:sz", &wval(&sz_s)); + let _ = write_empty(w, "w:szCs", &wval(&sz_s)); + let _ = write_end(w, "w:rPr"); + + let _ = write_end(w, "w:style"); } diff --git a/loki-ooxml/src/lib.rs b/loki-ooxml/src/lib.rs index 2c873a9c..49873554 100644 --- a/loki-ooxml/src/lib.rs +++ b/loki-ooxml/src/lib.rs @@ -60,7 +60,7 @@ pub mod pptx; pub use error::{NoteKind, OoxmlError, OoxmlResult, OoxmlWarning}; #[cfg(feature = "docx")] -pub use docx::export::DocxExport; +pub use docx::export::{DocxExport, DocxTemplateExport}; #[cfg(feature = "docx")] pub use docx::import::{DocxImport, DocxImportOptions, DocxImportResult}; #[cfg(feature = "docx")] diff --git a/loki-ooxml/src/xlsx/import.rs b/loki-ooxml/src/xlsx/import.rs index bd6e0f9c..7bddaef3 100644 --- a/loki-ooxml/src/xlsx/import.rs +++ b/loki-ooxml/src/xlsx/import.rs @@ -283,17 +283,15 @@ fn parse_styles(data: &[u8]) -> Result, OoxmlError> { cell_xfs.push(style); } } - b"alignment" => { - if in_cell_xfs { - if let Some(last_xf) = cell_xfs.last_mut() { - if let Some(horiz) = local_attr_val(e, b"horizontal") { - last_xf.align = match horiz.as_str() { - "center" => CellAlign::Center, - "right" => CellAlign::Right, - _ => CellAlign::Left, - }; - } - } + b"alignment" if in_cell_xfs => { + if let Some(last_xf) = cell_xfs.last_mut() + && let Some(horiz) = local_attr_val(e, b"horizontal") + { + last_xf.align = match horiz.as_str() { + "center" => CellAlign::Center, + "right" => CellAlign::Right, + _ => CellAlign::Left, + }; } } _ => {} diff --git a/loki-ooxml/tests/comments_round_trip.rs b/loki-ooxml/tests/comments_round_trip.rs new file mode 100644 index 00000000..1d1aa570 --- /dev/null +++ b/loki-ooxml/tests/comments_round_trip.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! DOCX comment round-trip: a commented range (`Inline::Comment` anchors) plus +//! its body (`word/comments.xml`) must survive export and re-import. + +use std::io::Cursor; + +use chrono::TimeZone; +use loki_doc_model::content::annotation::{Comment, CommentRef, CommentRefKind}; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; + +fn doc_with_comment() -> Document { + // "Hello [commented]world[/commented]!" with comment id "0". + let para = Block::Para(vec![ + Inline::Str("Hello ".to_string()), + Inline::Comment(CommentRef::new("0", CommentRefKind::Start)), + Inline::Str("world".to_string()), + Inline::Comment(CommentRef::new("0", CommentRefKind::End)), + Inline::Str("!".to_string()), + ]); + + // A two-paragraph comment body to exercise multi-paragraph round-trip. + let mut comment = Comment::new("0").with_plain_body("Please rephrase this.\nSecond paragraph."); + comment.author = Some("Reviewer".to_string()); + comment.date = Some(chrono::Utc.with_ymd_and_hms(2026, 6, 22, 9, 30, 0).unwrap()); + + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para]; + doc.comments = vec![comment]; + doc +} + +fn anchors(doc: &Document) -> Vec<(String, CommentRefKind)> { + let mut out = Vec::new(); + for section in &doc.sections { + for block in §ion.blocks { + let inlines = match block { + Block::Para(i) | Block::Plain(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + for i in inlines { + if let Inline::Comment(c) = i { + out.push((c.id.clone(), c.kind)); + } + } + } + } + out +} + +#[test] +fn comment_range_and_body_round_trip() { + let doc = doc_with_comment(); + + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(&doc, &mut buf, ()).expect("export should succeed"); + let re = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(buf.into_inner())) + .expect("re-import should succeed") + .document; + + // The start/end anchors survive in the content flow. + let got = anchors(&re); + assert!( + got.contains(&("0".to_string(), CommentRefKind::Start)), + "comment start anchor must survive; got {got:?}" + ); + assert!( + got.contains(&("0".to_string(), CommentRefKind::End)), + "comment end anchor must survive; got {got:?}" + ); + + // The comment body, author, and date survive in word/comments.xml. + assert_eq!(re.comments.len(), 1, "one comment expected"); + let c = &re.comments[0]; + assert_eq!(c.id, "0"); + assert_eq!(c.author.as_deref(), Some("Reviewer")); + assert_eq!( + para_texts(&c.body), + vec!["Please rephrase this.", "Second paragraph."], + "both comment paragraphs must survive" + ); + assert_eq!( + c.date, + Some(chrono::Utc.with_ymd_and_hms(2026, 6, 22, 9, 30, 0).unwrap()) + ); +} + +/// Plain text of each paragraph-like block. +fn para_texts(blocks: &[Block]) -> Vec { + blocks + .iter() + .map(|b| { + let inlines = match b { + Block::Para(i) | Block::Plain(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + inlines + .iter() + .filter_map(|i| { + if let Inline::Str(s) = i { + Some(s.as_str()) + } else { + None + } + }) + .collect() + }) + .collect() +} diff --git a/loki-ooxml/tests/export_columns.rs b/loki-ooxml/tests/export_columns.rs new file mode 100644 index 00000000..f334bc61 --- /dev/null +++ b/loki-ooxml/tests/export_columns.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Round-trip coverage for multi-column sections (`w:cols`): a section's +//! column count, gap, and separator flag must survive DOCX export + re-import. + +use std::io::Cursor; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::page::{PageLayout, SectionColumns}; +use loki_doc_model::layout::section::Section; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; +use loki_primitives::units::Points; + +fn doc_with_columns(cols: SectionColumns) -> Document { + let section = Section::with_layout_and_blocks( + PageLayout { + columns: Some(cols), + ..PageLayout::default() + }, + vec![Block::Para(vec![Inline::Str( + "Two-column body text".to_string(), + )])], + ); + let mut doc = Document::new(); + doc.sections = vec![section]; + doc +} + +fn round_trip(doc: &Document) -> Document { + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(doc, &mut buf, ()).expect("export should succeed"); + DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(buf.into_inner())) + .expect("re-import should succeed") + .document +} + +#[test] +fn three_columns_with_separator_round_trip() { + let doc = doc_with_columns(SectionColumns { + count: 3, + gap: Points::new(24.0), + separator: true, + }); + + let cols = round_trip(&doc).sections[0] + .layout + .columns + .clone() + .expect("columns must survive the round-trip"); + + assert_eq!(cols.count, 3, "column count"); + assert_eq!(cols.gap.value().round(), 24.0, "column gap (pt)"); + assert!(cols.separator, "separator line must survive"); +} + +#[test] +fn two_columns_no_separator_round_trip() { + let doc = doc_with_columns(SectionColumns::two_column()); + + let cols = round_trip(&doc).sections[0] + .layout + .columns + .clone() + .expect("columns must survive the round-trip"); + + assert_eq!(cols.count, 2); + assert_eq!(cols.gap.value().round(), 18.0); + assert!(!cols.separator, "no separator was requested"); +} + +#[test] +fn single_column_emits_no_cols() { + // A layout with no columns must not gain a spurious multi-column layout. + let mut doc = Document::new(); + doc.sections[0].blocks = vec![Block::Para(vec![Inline::Str("Plain".to_string())])]; + + let re = round_trip(&doc); + assert!( + re.sections[0].layout.columns.is_none(), + "single-column document must not produce a w:cols layout" + ); +} diff --git a/loki-ooxml/tests/export_fields.rs b/loki-ooxml/tests/export_fields.rs new file mode 100644 index 00000000..8b3e041a --- /dev/null +++ b/loki-ooxml/tests/export_fields.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Round-trip coverage for body-text dynamic fields: a [`Document`] carrying +//! `Inline::Field` values is exported to DOCX and re-imported, and each +//! field kind must survive (it was previously dropped on export). + +use std::io::Cursor; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::field::types::{CrossRefFormat, Field, FieldKind}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; + +/// Builds a single-paragraph document whose paragraph contains every field +/// kind we can map, interleaved with plain text. +fn doc_with_fields() -> Document { + let fields = vec![ + Field::new(FieldKind::PageNumber).with_current_value("3"), + Field::new(FieldKind::PageCount).with_current_value("9"), + Field::new(FieldKind::Title), + Field::new(FieldKind::Author), + Field::new(FieldKind::Date { + format: Some("yyyy-MM-dd".to_string()), + }), + Field::new(FieldKind::CrossReference { + target: "_Intro".to_string(), + format: CrossRefFormat::Page, + }), + Field::new(FieldKind::Raw { + instruction: "MERGEFIELD Name".to_string(), + }), + ]; + + let mut inlines = vec![Inline::Str("Page ".to_string())]; + for f in fields { + inlines.push(Inline::Field(f)); + inlines.push(Inline::Space); + } + + let mut doc = Document::new(); + doc.sections[0].blocks = vec![Block::Para(inlines)]; + doc +} + +/// Collects every `Field` found anywhere in the document body. +fn collect_fields(doc: &Document) -> Vec { + fn walk(inlines: &[Inline], out: &mut Vec) { + for i in inlines { + match i { + Inline::Field(f) => out.push(f.clone()), + Inline::StyledRun(r) => walk(&r.content, out), + _ => {} + } + } + } + let mut out = Vec::new(); + for section in &doc.sections { + for block in §ion.blocks { + let inlines = match block { + Block::Para(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + walk(inlines, &mut out); + } + } + out +} + +#[test] +fn body_fields_round_trip() { + let doc = doc_with_fields(); + + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(&doc, &mut buf, ()).expect("export should succeed"); + let bytes = buf.into_inner(); + + let re = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(bytes)) + .expect("re-import should succeed"); + + let kinds: Vec = collect_fields(&re.document) + .into_iter() + .map(|f| f.kind) + .collect(); + + assert!( + kinds.contains(&FieldKind::PageNumber), + "PAGE field must survive export; got {kinds:?}" + ); + assert!( + kinds.contains(&FieldKind::PageCount), + "NUMPAGES must survive" + ); + assert!(kinds.contains(&FieldKind::Title), "TITLE must survive"); + assert!(kinds.contains(&FieldKind::Author), "AUTHOR must survive"); + assert!( + kinds.contains(&FieldKind::Date { + format: Some("yyyy-MM-dd".to_string()), + }), + "DATE (with format switch) must survive; got {kinds:?}" + ); + assert!( + kinds.contains(&FieldKind::CrossReference { + target: "_Intro".to_string(), + format: CrossRefFormat::Page, + }), + "PAGEREF cross-reference must survive; got {kinds:?}" + ); + assert!( + kinds.iter().any(|k| matches!( + k, + FieldKind::Raw { instruction } if instruction.contains("MERGEFIELD") + )), + "Raw MERGEFIELD must survive verbatim; got {kinds:?}" + ); +} + +#[test] +fn page_number_snapshot_survives() { + let mut doc = Document::new(); + doc.sections[0].blocks = vec![Block::Para(vec![Inline::Field( + Field::new(FieldKind::PageNumber).with_current_value("42"), + )])]; + + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(&doc, &mut buf, ()).expect("export should succeed"); + + let re = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(buf.into_inner())) + .expect("re-import should succeed"); + + let fields = collect_fields(&re.document); + let page = fields + .iter() + .find(|f| f.kind == FieldKind::PageNumber) + .expect("PAGE field must be present"); + assert_eq!( + page.current_value.as_deref(), + Some("42"), + "the cached result snapshot must survive the round-trip" + ); +} diff --git a/loki-ooxml/tests/export_headers_footers.rs b/loki-ooxml/tests/export_headers_footers.rs new file mode 100644 index 00000000..8bb2f4ef --- /dev/null +++ b/loki-ooxml/tests/export_headers_footers.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Round-trip coverage for DOCX header/footer export, focusing on the +//! even-page variant which only survives when the exporter emits +//! `word/settings.xml` with `` (ECMA-376 §17.10.1). + +use std::io::Cursor; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::header_footer::{HeaderFooter, HeaderFooterKind}; +use loki_doc_model::layout::page::PageLayout; +use loki_doc_model::layout::section::Section; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; + +/// Builds a one-section document carrying a default + even header and footer, +/// each with a distinctive text string. +fn doc_with_even_hf() -> Document { + let para = |s: &str| HeaderFooter { + kind: HeaderFooterKind::Default, + blocks: vec![Block::Para(vec![Inline::Str(s.to_string())])], + }; + + let layout = PageLayout { + header: Some(para("Default Header")), + footer: Some(para("Default Footer")), + header_even: Some(HeaderFooter { + kind: HeaderFooterKind::Even, + blocks: vec![Block::Para(vec![Inline::Str("Even Header".to_string())])], + }), + footer_even: Some(HeaderFooter { + kind: HeaderFooterKind::Even, + blocks: vec![Block::Para(vec![Inline::Str("Even Footer".to_string())])], + }), + ..PageLayout::default() + }; + + let section = Section::with_layout_and_blocks( + layout, + vec![Block::Para(vec![Inline::Str("Body text".to_string())])], + ); + + let mut doc = Document::new(); + doc.sections = vec![section]; + doc +} + +/// Collects every plain string in a header/footer's blocks. +fn hf_text(hf: Option<&HeaderFooter>) -> Vec { + let Some(hf) = hf else { + return Vec::new(); + }; + hf.blocks + .iter() + .flat_map(|b| { + let inlines = match b { + Block::Para(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + inlines.iter().filter_map(|i| { + if let Inline::Str(s) = i { + Some(s.clone()) + } else { + None + } + }) + }) + .collect() +} + +#[test] +fn even_page_headers_footers_round_trip() { + let doc = doc_with_even_hf(); + + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(&doc, &mut buf, ()).expect("export should succeed"); + let bytes = buf.into_inner(); + + let re = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(bytes)) + .expect("re-import should succeed"); + + let layout = &re.document.sections.last().unwrap().layout; + + assert!( + hf_text(layout.header.as_ref()).contains(&"Default Header".to_string()), + "default header must survive round-trip" + ); + assert!( + hf_text(layout.footer.as_ref()).contains(&"Default Footer".to_string()), + "default footer must survive round-trip" + ); + assert!( + hf_text(layout.header_even.as_ref()).contains(&"Even Header".to_string()), + "even-page header must survive round-trip (requires w:evenAndOddHeaders)" + ); + assert!( + hf_text(layout.footer_even.as_ref()).contains(&"Even Footer".to_string()), + "even-page footer must survive round-trip (requires w:evenAndOddHeaders)" + ); + + // A document with no first-page variant must NOT spuriously gain one + // (w:titlePg must be first-page-only, not emitted for even-only docs). + assert!( + layout.header_first.is_none(), + "even-only document must not produce a first-page header" + ); + assert!( + layout.footer_first.is_none(), + "even-only document must not produce a first-page footer" + ); +} diff --git a/loki-ooxml/tests/export_multisection.rs b/loki-ooxml/tests/export_multisection.rs new file mode 100644 index 00000000..570b9e51 --- /dev/null +++ b/loki-ooxml/tests/export_multisection.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Multi-section page geometry: a document with two sections of differing +//! page size and orientation must round-trip through DOCX export, each section +//! keeping its own `w:sectPr` geometry. + +use std::io::Cursor; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::page::{PageLayout, PageOrientation, PageSize}; +use loki_doc_model::layout::section::Section; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; + +fn para(text: &str) -> Block { + Block::Para(vec![Inline::Str(text.to_string())]) +} + +#[test] +fn two_sections_round_trip() { + let s0 = Section::with_layout_and_blocks( + PageLayout { + page_size: PageSize::a4(), + orientation: PageOrientation::Portrait, + ..PageLayout::default() + }, + vec![para("Section one body")], + ); + let s1 = Section::with_layout_and_blocks( + PageLayout { + page_size: PageSize::letter(), + orientation: PageOrientation::Landscape, + ..PageLayout::default() + }, + vec![para("Section two body")], + ); + + let mut doc = Document::new(); + doc.sections = vec![s0, s1]; + + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(&doc, &mut buf, ()).expect("export should succeed"); + + let re = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(buf.into_inner())) + .expect("re-import should succeed"); + + let secs = &re.document.sections; + assert_eq!(secs.len(), 2, "two sections must survive round-trip"); + assert_eq!(secs[0].layout.orientation, PageOrientation::Portrait); + assert_eq!(secs[1].layout.orientation, PageOrientation::Landscape); +} diff --git a/loki-ooxml/tests/fld_simple.rs b/loki-ooxml/tests/fld_simple.rs new file mode 100644 index 00000000..e4f4aba5 --- /dev/null +++ b/loki-ooxml/tests/fld_simple.rs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `w:fldSimple` (simple field) import + round-trip. +//! +//! Word may write a field in the compact `w:fldSimple` form instead of the +//! `w:fldChar`/`w:instrText` complex form. The importer must read it, and a +//! full export→re-import must preserve the field (our exporter always emits +//! the complex form, so the second import exercises that path too). + +use std::io::{Cursor, Write}; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::field::types::FieldKind; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipWriter}; + +/// Builds a minimal DOCX whose single paragraph holds two simple fields: +/// a `PAGE` field with a cached result of "3", and a self-closing `TITLE`. +fn fld_simple_docx() -> Vec { + let mut buf = Vec::new(); + let mut zip = ZipWriter::new(Cursor::new(&mut buf)); + let d = FileOptions::<()>::default().compression_method(CompressionMethod::Deflated); + + zip.start_file("[Content_Types].xml", d).unwrap(); + zip.write_all( + br#" + + + + +"#, + ) + .unwrap(); + + zip.start_file("_rels/.rels", d).unwrap(); + zip.write_all( + br#" + + +"#, + ) + .unwrap(); + + zip.start_file("word/document.xml", d).unwrap(); + zip.write_all( + br#" + + + + Page + + 3 + + + + +"#, + ) + .unwrap(); + + zip.finish().unwrap(); + buf +} + +/// Collects every field in document order. +fn fields(doc: &Document) -> Vec<(FieldKind, Option)> { + fn walk(inlines: &[Inline], out: &mut Vec<(FieldKind, Option)>) { + for i in inlines { + match i { + Inline::Field(f) => out.push((f.kind.clone(), f.current_value.clone())), + Inline::StyledRun(r) => walk(&r.content, out), + _ => {} + } + } + } + let mut out = Vec::new(); + for section in &doc.sections { + for block in §ion.blocks { + let inlines = match block { + Block::Para(i) | Block::Plain(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + walk(inlines, &mut out); + } + } + out +} + +#[test] +fn simple_field_is_imported() { + let doc = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(fld_simple_docx())) + .expect("import should succeed") + .document; + + let got = fields(&doc); + assert_eq!( + got, + vec![ + (FieldKind::PageNumber, Some("3".to_string())), + (FieldKind::Title, None), + ], + "w:fldSimple fields must import as Inline::Field with their cached value" + ); +} + +#[test] +fn simple_field_survives_round_trip() { + let imported = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(fld_simple_docx())) + .expect("import should succeed") + .document; + + // Export (writes the complex form) and re-import. + let mut out = Cursor::new(Vec::new()); + DocxExport::export(&imported, &mut out, ()).expect("export should succeed"); + let reimported = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(out.into_inner())) + .expect("re-import should succeed") + .document; + + assert_eq!( + fields(&reimported), + vec![ + (FieldKind::PageNumber, Some("3".to_string())), + (FieldKind::Title, None), + ], + "simple-field semantics must survive a full export/re-import" + ); +} diff --git a/loki-ooxml/tests/math_round_trip.rs b/loki-ooxml/tests/math_round_trip.rs new file mode 100644 index 00000000..97f819a3 --- /dev/null +++ b/loki-ooxml/tests/math_round_trip.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! DOCX math round-trip: an `Inline::Math` (MathML) must survive export to OMML +//! and re-import back to the same MathML. + +use std::io::Cursor; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, MathType}; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; + +const NS: &str = "http://www.w3.org/1998/Math/MathML"; + +fn doc_with_math(math: Inline) -> Document { + let para = Block::Para(vec![Inline::Str("x = ".to_string()), math]); + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para]; + doc +} + +/// Returns the first `Inline::Math` found anywhere in the document. +fn first_math(doc: &Document) -> Option<(MathType, String)> { + for section in &doc.sections { + for block in §ion.blocks { + let inlines = match block { + Block::Para(i) | Block::Plain(i) => i.as_slice(), + Block::StyledPara(sp) => sp.inlines.as_slice(), + _ => &[], + }; + for i in inlines { + if let Inline::Math(t, s) = i { + return Some((*t, s.clone())); + } + } + } + } + None +} + +fn round_trip(doc: &Document) -> Document { + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(doc, &mut buf, ()).expect("export should succeed"); + DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(buf.into_inner())) + .expect("re-import should succeed") + .document +} + +#[test] +fn inline_fraction_round_trips() { + let mathml = format!("12"); + let doc = doc_with_math(Inline::Math(MathType::InlineMath, mathml.clone())); + + let re = round_trip(&doc); + let (kind, got) = first_math(&re).expect("math must survive round-trip"); + assert_eq!(kind, MathType::InlineMath); + assert_eq!(got, mathml); +} + +#[test] +fn display_superscript_round_trips() { + // x^2 as display (block) math. + let mathml = format!("x2"); + let doc = doc_with_math(Inline::Math(MathType::DisplayMath, mathml.clone())); + + let re = round_trip(&doc); + let (kind, got) = first_math(&re).expect("math must survive round-trip"); + assert_eq!(kind, MathType::DisplayMath); + assert_eq!(got, mathml); +} + +#[test] +fn square_root_round_trips() { + let mathml = format!("x"); + let doc = doc_with_math(Inline::Math(MathType::InlineMath, mathml.clone())); + + let re = round_trip(&doc); + let (_, got) = first_math(&re).expect("math must survive round-trip"); + assert_eq!(got, mathml); +} diff --git a/loki-ooxml/tests/metadata_round_trip.rs b/loki-ooxml/tests/metadata_round_trip.rs new file mode 100644 index 00000000..a5938291 --- /dev/null +++ b/loki-ooxml/tests/metadata_round_trip.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! DOCX document-metadata round-trip: core properties (`docProps/core.xml`) +//! and extended Dublin Core (`docProps/custom.xml`). + +use std::io::Cursor; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::meta::core::DocumentMeta; +use loki_doc_model::meta::dublin_core::DublinCoreMeta; +use loki_doc_model::meta::language::LanguageTag; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; + +fn doc_with_meta(meta: DocumentMeta) -> Document { + let mut doc = Document::new(); + doc.meta = meta; + doc.sections[0].blocks = vec![Block::Para(vec![Inline::Str("Body".to_string())])]; + doc +} + +fn round_trip(doc: &Document) -> Document { + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(doc, &mut buf, ()).expect("export should succeed"); + DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(buf.into_inner())) + .expect("re-import should succeed") + .document +} + +#[test] +fn core_metadata_round_trips() { + let meta = DocumentMeta { + title: Some("Quarterly Report".into()), + subject: Some("Finance".into()), + keywords: Some("q3, revenue".into()), + description: Some("An overview.".into()), + creator: Some("Ada Lovelace".into()), + last_modified_by: Some("Charles Babbage".into()), + language: Some(LanguageTag::new("en-GB")), + revision: Some(4), + ..Default::default() + }; + let re = round_trip(&doc_with_meta(meta)).meta; + + assert_eq!(re.title.as_deref(), Some("Quarterly Report")); + assert_eq!(re.subject.as_deref(), Some("Finance")); + assert_eq!(re.keywords.as_deref(), Some("q3, revenue")); + assert_eq!(re.description.as_deref(), Some("An overview.")); + assert_eq!(re.creator.as_deref(), Some("Ada Lovelace")); + assert_eq!(re.last_modified_by.as_deref(), Some("Charles Babbage")); + assert_eq!(re.language.as_ref().map(|l| l.as_str()), Some("en-GB")); + assert_eq!(re.revision, Some(4)); +} + +#[test] +fn extended_dublin_core_round_trips() { + let dc = DublinCoreMeta { + contributors: vec!["Editor One".into(), "Translator Two".into()], + publisher: Some("AppThere Press".into()), + rights: Some("© 2026 AppThere".into()), + license: Some("https://creativecommons.org/licenses/by/4.0/".into()), + identifier: Some("978-0-00-000000-0".into()), + identifier_scheme: Some("ISBN".into()), + dc_type: Some("Text".into()), + format: Some( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document".into(), + ), + source: Some("Original manuscript".into()), + relation: Some("Companion volume".into()), + coverage: Some("2020–2026".into()), + issued: Some("2026-06-22".into()), + bibliographic_citation: Some("AppThere, Report (2026)".into()), + }; + let meta = DocumentMeta { + title: Some("With Extended DC".into()), + dublin_core: dc.clone(), + ..Default::default() + }; + + let re = round_trip(&doc_with_meta(meta)).meta.dublin_core; + assert_eq!( + re, dc, + "all extended Dublin Core fields must survive round-trip" + ); +} diff --git a/loki-ooxml/tests/pptx_round_trip.rs b/loki-ooxml/tests/pptx_round_trip.rs new file mode 100644 index 00000000..3805ea5c --- /dev/null +++ b/loki-ooxml/tests/pptx_round_trip.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Integration round-trip tests for PPTX export/import (audit T-3). +//! +//! The inline `pptx/export_tests.rs` suite covers a single-slide deck; this file +//! exercises a **multi-slide** presentation through the full +//! model → export → re-import cycle and re-opens the OPC package to confirm one +//! slide part per slide plus a well-formed content-types stream. Gated on the +//! `pptx` feature (run with `cargo test -p loki-ooxml --features pptx`). +#![cfg(feature = "pptx")] + +use std::io::Cursor; + +use loki_graphics::{RectF, Shape, ShapeKind, TextAlign, TextBody}; +use loki_ooxml::pptx::export::PptxExport; +use loki_ooxml::pptx::import::{PptxImport, PptxImportOptions}; +use loki_presentation_model::{Presentation, Slide}; + +/// A three-slide deck, each slide carrying a single titled text box whose body +/// is `"Slide N"`, so ordering is checkable after re-import. +fn deck() -> Presentation { + let mut p = Presentation::new(Presentation::WIDESCREEN_16_9); + p.meta.title = Some("Three Slides".to_string()); + + for n in 1..=3 { + let mut slide = Slide::new(format!("slide{n}"), p.slide_size); + let body = TextBody::plain(format!("Slide {n}")); + let mut tb = Shape::text_box( + (n + 1).to_string(), + RectF::new(40.0, 40.0, 880.0, 120.0), + body, + ); + tb.name = Some(format!("Title {n}")); + slide.drawing.push(tb); + p.add_slide(slide); + } + p +} + +fn export_then_import(p: &Presentation) -> Presentation { + let mut buf = Cursor::new(Vec::new()); + PptxExport::export(p, &mut buf).expect("export"); + PptxImport::import(Cursor::new(buf.into_inner()), PptxImportOptions::default()) + .expect("re-import") +} + +/// Pulls the first run of text out of a slide's first shape, if any. +fn first_text(slide: &Slide) -> Option { + let shape = slide.drawing.shapes.first()?; + let ShapeKind::Geometry(g) = &shape.kind else { + return None; + }; + let body = g.text.as_ref()?; + Some(body.paragraphs.first()?.text()) +} + +#[test] +fn multi_slide_count_and_order_survive_round_trip() { + let re = export_then_import(&deck()); + assert_eq!(re.slide_count(), 3, "all three slides must survive"); + + for (i, slide) in re.slides.iter().enumerate() { + let expected = format!("Slide {}", i + 1); + assert_eq!( + first_text(slide).as_deref(), + Some(expected.as_str()), + "slide {i} text/order changed across the round trip" + ); + } +} + +#[test] +fn slide_size_and_alignment_survive_round_trip() { + let re = export_then_import(&deck()); + assert!((re.slide_size.width - 960.0).abs() < 1e-6); + assert!((re.slide_size.height - 540.0).abs() < 1e-6); + + // `TextBody::plain` defaults to left alignment; it must not mutate. + let slide = &re.slides[0]; + let ShapeKind::Geometry(g) = &slide.drawing.shapes[0].kind else { + panic!("expected geometry shape"); + }; + let para = &g.text.as_ref().unwrap().paragraphs[0]; + assert_eq!(para.align, TextAlign::Left); +} + +#[test] +fn second_round_trip_is_idempotent() { + let once = export_then_import(&deck()); + let twice = export_then_import(&once); + assert_eq!(once, twice, "a stable deck must reach a fixed point"); +} + +#[test] +fn exported_package_has_one_part_per_slide() { + use loki_opc::{Package, PartName}; + + let mut buf = Cursor::new(Vec::new()); + PptxExport::export(&deck(), &mut buf).expect("export"); + let pkg = Package::open(Cursor::new(buf.into_inner())).expect("open package"); + + for n in 1..=3 { + let path = format!("/ppt/slides/slide{n}.xml"); + assert!( + pkg.part(&PartName::new(&path).unwrap()).is_some(), + "missing {path}" + ); + } + // A fourth slide part must NOT exist (no stray parts from a 3-slide deck). + assert!( + pkg.part(&PartName::new("/ppt/slides/slide4.xml").unwrap()) + .is_none(), + "unexpected extra slide part" + ); + + // presentation.xml must list every slide in order via its relationships. + let pres = PartName::new("/ppt/presentation.xml").unwrap(); + let rels = pkg.part_relationships(&pres).expect("presentation rels"); + let slide_rels = rels + .iter() + .filter(|r| r.rel_type.ends_with("/slide")) + .count(); + assert_eq!( + slide_rels, 3, + "presentation must reference all three slides" + ); +} + +#[test] +fn empty_deck_round_trips_to_zero_slides() { + let p = Presentation::new(Presentation::STANDARD_4_3); + let re = export_then_import(&p); + assert_eq!(re.slide_count(), 0); + assert!((re.slide_size.width - 720.0).abs() < 1e-6); +} diff --git a/loki-ooxml/tests/style_round_trip.rs b/loki-ooxml/tests/style_round_trip.rs new file mode 100644 index 00000000..0b4cb9fc --- /dev/null +++ b/loki-ooxml/tests/style_round_trip.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Style-catalog persistence: a custom paragraph style (and an edited built-in +//! heading) must survive a DOCX export → import round-trip with all of the +//! style-editor-editable fields intact. + +use std::io::Cursor; + +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::style::catalog::StyleId; +use loki_doc_model::style::para_style::ParagraphStyle; +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::para_props::{ + LineHeight, ParaProps, ParagraphAlignment, Spacing, +}; +use loki_ooxml::DocxExport; +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; +use loki_primitives::units::Points; + +/// Builds a `Document` carrying a custom paragraph style plus an edited +/// `Heading1`, exports it to DOCX, re-imports, and returns the round-tripped +/// catalog styles. +fn round_trip_styles() -> loki_doc_model::style::catalog::StyleCatalog { + let mut doc = Document::new(); + + // A user-created custom style exercising every editor-editable field. + let quote = ParagraphStyle { + id: StyleId::new("MyQuote"), + display_name: Some("My Quote".to_string()), + parent: Some(StyleId::new("Normal")), + linked_char_style: None, + next_style_id: Some("Normal".to_string()), + para_props: ParaProps { + alignment: Some(ParagraphAlignment::Justify), + indent_start: Some(Points::new(36.0)), + indent_end: Some(Points::new(24.0)), + indent_first_line: Some(Points::new(18.0)), + space_before: Some(Spacing::Exact(Points::new(6.0))), + space_after: Some(Spacing::Exact(Points::new(12.0))), + line_height: Some(LineHeight::Multiple(1.5)), + ..ParaProps::default() + }, + char_props: CharProps { + font_name: Some("Arial".to_string()), + font_size: Some(Points::new(14.0)), + bold: Some(true), + ..CharProps::default() + }, + is_default: false, + is_custom: true, + extensions: Default::default(), + }; + doc.styles + .paragraph_styles + .insert(StyleId::new("MyQuote"), quote); + + // An edit to a built-in heading: bump Heading1 to 20pt, centered. This must + // also persist (export previously hard-coded the headings, dropping edits). + let heading1 = ParagraphStyle { + id: StyleId::new("Heading1"), + display_name: Some("heading 1".to_string()), + parent: Some(StyleId::new("Normal")), + linked_char_style: None, + next_style_id: None, + para_props: ParaProps { + alignment: Some(ParagraphAlignment::Center), + // Model outline level is 1-indexed (1 = Heading 1). + outline_level: Some(1), + ..ParaProps::default() + }, + char_props: CharProps { + font_size: Some(Points::new(20.0)), + bold: Some(true), + ..CharProps::default() + }, + is_default: false, + is_custom: false, + extensions: Default::default(), + }; + doc.styles + .paragraph_styles + .insert(StyleId::new("Heading1"), heading1); + + let mut buf = Cursor::new(Vec::::new()); + DocxExport::export(&doc, &mut buf, ()).expect("export should succeed"); + + let imported = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(buf.into_inner())) + .expect("re-import should succeed"); + imported.document.styles +} + +#[test] +fn custom_paragraph_style_round_trips() { + let catalog = round_trip_styles(); + let q = catalog + .paragraph_styles + .get(&StyleId::new("MyQuote")) + .expect("custom style MyQuote must survive round-trip"); + + assert_eq!(q.display_name.as_deref(), Some("My Quote")); + assert_eq!(q.parent.as_ref().map(StyleId::as_str), Some("Normal")); + assert_eq!(q.next_style_id.as_deref(), Some("Normal")); + assert!(q.is_custom, "custom flag must round-trip"); + + let pp = &q.para_props; + assert_eq!(pp.alignment, Some(ParagraphAlignment::Justify)); + assert_eq!(pp.indent_start, Some(Points::new(36.0))); + assert_eq!(pp.indent_end, Some(Points::new(24.0))); + assert_eq!(pp.indent_first_line, Some(Points::new(18.0))); + assert_eq!(pp.space_before, Some(Spacing::Exact(Points::new(6.0)))); + assert_eq!(pp.space_after, Some(Spacing::Exact(Points::new(12.0)))); + match pp.line_height { + Some(LineHeight::Multiple(m)) => assert!( + (m - 1.5).abs() < 0.001, + "line spacing ratio must round-trip (got {m})" + ), + other => panic!("expected Multiple(1.5), got {other:?}"), + } + + let cp = &q.char_props; + assert_eq!(cp.font_name.as_deref(), Some("Arial")); + assert_eq!(cp.font_size, Some(Points::new(14.0))); + assert_eq!(cp.bold, Some(true)); +} + +#[test] +fn edited_heading_round_trips() { + let catalog = round_trip_styles(); + let h1 = catalog + .paragraph_styles + .get(&StyleId::new("Heading1")) + .expect("Heading1 must be present after round-trip"); + + // The edited props (20pt, centered) must persist rather than reverting to + // the hard-coded built-in heading definition. + assert_eq!(h1.char_props.font_size, Some(Points::new(20.0))); + assert_eq!(h1.para_props.alignment, Some(ParagraphAlignment::Center)); + assert_eq!(h1.para_props.outline_level, Some(1)); +} diff --git a/loki-opc/src/compat/content_types.rs b/loki-opc/src/compat/content_types.rs index 5e3f49b7..d7ef4628 100644 --- a/loki-opc/src/compat/content_types.rs +++ b/loki-opc/src/compat/content_types.rs @@ -38,17 +38,20 @@ pub fn find_content_types( #[allow(unused_variables)] warnings: &mut Vec, ) -> Option { for i in 0..zip.len() { - if let Ok(zf) = zip.by_index(i) { - if zf.name() == "[Content_Types].xml" { - return Some(i); - } - #[cfg(not(feature = "strict"))] - if zf.name().eq_ignore_ascii_case("[content_types].xml") { - warnings.push(DeviationWarning::ContentTypesNotAtRoot { - found_at: zf.name().to_string(), - }); - return Some(i); - } + // `let … else` keeps a single level of nesting regardless of which + // feature set is active. Under `--features strict` the case-insensitive + // fallback below is compiled out, which would otherwise leave a sole + // inner `if` and trip `clippy::collapsible_if`. + let Ok(zf) = zip.by_index(i) else { continue }; + if zf.name() == "[Content_Types].xml" { + return Some(i); + } + #[cfg(not(feature = "strict"))] + if zf.name().eq_ignore_ascii_case("[content_types].xml") { + warnings.push(DeviationWarning::ContentTypesNotAtRoot { + found_at: zf.name().to_string(), + }); + return Some(i); } } None diff --git a/loki-pdf/tests/pdf_structure.rs b/loki-pdf/tests/pdf_structure.rs new file mode 100644 index 00000000..4c1acb86 --- /dev/null +++ b/loki-pdf/tests/pdf_structure.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Integration tests for PDF/X export (audit T-3): export a document and +//! validate the resulting file's low-level structure by re-parsing it — the +//! page tree's `/Count` against the actual leaf `/Page` objects, the +//! `startxref` pointer against the cross-reference table, the trailer, and the +//! PDF/X conformance markers across all three levels. +//! +//! The inline unit tests in `lib.rs` assert token presence; these tests assert +//! the file is structurally coherent (the xref offset really points at the +//! table, the page count really matches the pages emitted). + +use loki_doc_model::Document; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_pdf::{PdfXLevel, PdfXOptions, export_document}; + +/// Builds a document with `paras` single-line paragraphs — enough to span +/// several pages when `paras` is large. +fn doc_with_paras(paras: usize) -> Document { + let mut doc = Document::new_blank(); + doc.meta.title = Some("Structural Test".into()); + if let Some(sec) = doc.first_section_mut() { + sec.blocks.clear(); + for i in 0..paras { + sec.blocks.push(Block::Para(vec![Inline::Str(format!( + "Paragraph number {i} exists to fill the page with flowing text." + ))])); + } + } + doc +} + +fn export(doc: &Document, level: PdfXLevel) -> Vec { + let opts = PdfXOptions { + level, + ..Default::default() + }; + let mut out = Vec::new(); + export_document(doc, &opts, &mut out).expect("export"); + out +} + +/// Number of leaf page objects: every `/Type /Page` occurrence minus the single +/// `/Type /Pages` node (which `/Type /Page` is a textual prefix of). +fn leaf_page_count(pdf: &str) -> usize { + pdf.matches("/Type /Page").count() - pdf.matches("/Type /Pages").count() +} + +/// Parses the `/Count N` value from the page-tree node. +fn declared_page_count(pdf: &str) -> usize { + let i = pdf.find("/Count ").expect("page tree must declare /Count"); + pdf[i + "/Count ".len()..] + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .expect("/Count must be followed by an integer") +} + +#[test] +fn page_tree_count_matches_emitted_pages() { + // Many paragraphs must paginate into more than one page. + let out = export(&doc_with_paras(400), PdfXLevel::X1a); + let pdf = String::from_utf8_lossy(&out); + + let declared = declared_page_count(&pdf); + let leaves = leaf_page_count(&pdf); + assert!( + declared > 1, + "expected a multi-page document, got {declared}" + ); + assert_eq!( + declared, leaves, + "page-tree /Count ({declared}) must equal the number of leaf /Page objects ({leaves})" + ); +} + +#[test] +fn startxref_points_at_the_xref_table() { + let out = export(&doc_with_paras(50), PdfXLevel::X1a); + let pdf = String::from_utf8_lossy(&out); + + let idx = pdf.rfind("startxref").expect("missing startxref"); + let offset: usize = pdf[idx + "startxref".len()..] + .trim_start() + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .expect("startxref must be followed by an offset"); + + assert!(offset < out.len(), "startxref offset is past end of file"); + assert!( + out[offset..].starts_with(b"xref"), + "startxref must point at the cross-reference table" + ); + // The file must terminate with the EOF marker after the trailer. + assert!(pdf.trim_end().ends_with("%%EOF"), "missing trailing %%EOF"); +} + +#[test] +fn trailer_declares_root_and_id() { + let out = export(&doc_with_paras(10), PdfXLevel::X1a); + let pdf = String::from_utf8_lossy(&out); + assert!(pdf.contains("trailer"), "missing trailer"); + assert!(pdf.contains("/Root"), "trailer must reference the catalog"); + // PDF/X requires a file identifier in the trailer. + assert!(pdf.contains("/ID"), "trailer must carry a file /ID"); +} + +#[test] +fn all_pdfx_levels_are_structurally_valid() { + for (level, marker) in [ + (PdfXLevel::X1a, "PDF/X-1a"), + (PdfXLevel::X3, "PDF/X-3"), + (PdfXLevel::X4, "PDF/X-4"), + ] { + let out = export(&doc_with_paras(20), level); + let pdf = String::from_utf8_lossy(&out); + + assert!(out.starts_with(b"%PDF-1."), "{marker}: missing PDF header"); + assert!(pdf.trim_end().ends_with("%%EOF"), "{marker}: missing %%EOF"); + assert!( + pdf.contains("GTS_PDFXVersion") || pdf.contains(marker), + "{marker}: missing PDF/X conformance declaration" + ); + // Output intent is mandatory for every PDF/X level. + assert!( + pdf.contains("OutputIntent"), + "{marker}: missing OutputIntent" + ); + assert!(declared_page_count(&pdf) >= 1, "{marker}: no pages"); + } +} + +#[test] +fn single_page_document_reports_one_page() { + let out = export(&doc_with_paras(1), PdfXLevel::X1a); + let pdf = String::from_utf8_lossy(&out); + assert_eq!(declared_page_count(&pdf), 1); + assert_eq!(leaf_page_count(&pdf), 1); +} + +#[test] +fn empty_document_is_rejected_not_silently_emitted() { + // A document that produces no pages must surface NoPages rather than write a + // structurally invalid zero-page PDF. + let mut doc = Document::new_blank(); + if let Some(sec) = doc.first_section_mut() { + sec.blocks.clear(); + } + let mut out = Vec::new(); + let result = export_document(&doc, &PdfXOptions::default(), &mut out); + // Either it lays out one (empty) page, or it reports NoPages — but it must + // never emit a page tree whose /Count disagrees with its leaves. + if let Ok(()) = result { + let pdf = String::from_utf8_lossy(&out); + assert_eq!(declared_page_count(&pdf), leaf_page_count(&pdf)); + } +} diff --git a/loki-presentation-model/Cargo.toml b/loki-presentation-model/Cargo.toml index 268b2f38..68c6e559 100644 --- a/loki-presentation-model/Cargo.toml +++ b/loki-presentation-model/Cargo.toml @@ -17,3 +17,9 @@ loro = "1.11.1" thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" + +[dev-dependencies] +# Integration tests drive the LoroDoc directly to exercise concurrent merges, +# so they need `loro` and `loki-graphics` (for `Size`) in their own scope. +loro = "1.11.1" +loki-graphics = { path = "../loki-graphics", features = ["serde"] } diff --git a/loki-presentation-model/tests/loro_concurrency_tests.rs b/loki-presentation-model/tests/loro_concurrency_tests.rs new file mode 100644 index 00000000..e0223f6a --- /dev/null +++ b/loki-presentation-model/tests/loro_concurrency_tests.rs @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Concurrent-edit, merge-convergence, and pathological-state tests for the +//! `loki-presentation-model` Loro CRDT bridge (audit T-6). +//! +//! The bridge stores presentation metadata as a structural [`LoroMap`] and the +//! slide list as a [`LoroList`] of slide maps; each slide's `drawing` and +//! `placeholders` are coarse-grained JSON-snapshot strings. These tests pin +//! down the resulting merge behaviour: +//! +//! - **Structural keys merge** — concurrent edits to *different* metadata keys +//! or appends of *different* slides converge with both edits retained. +//! - **Snapshot keys are last-writer-wins registers** — concurrent edits to the +//! *same* slide snapshot string converge to a single deterministic value, not +//! a field-level merge. This is the documented MVP limitation; the test exists +//! to make a future fine-grained bridge a visible, intentional behaviour change. + +use loki_presentation_model::loro_bridge::{ + KEY_METADATA, KEY_SLIDES, loro_to_presentation, presentation_to_loro, +}; +use loki_presentation_model::presentation::Presentation; +use loro::{ExportMode, LoroDoc, LoroMap}; + +/// Exchanges all operations between two replicas so both observe the full op +/// set. After this returns the two docs must converge. +fn sync(a: &LoroDoc, b: &LoroDoc) { + a.commit(); + b.commit(); + let a_updates = a.export(ExportMode::all_updates()).expect("export a"); + let b_updates = b.export(ExportMode::all_updates()).expect("export b"); + a.import(&b_updates).expect("a imports b"); + b.import(&a_updates).expect("b imports a"); + a.commit(); + b.commit(); +} + +/// Forks `base` into an independent replica that shares the same history. +fn fork(base: &LoroDoc) -> LoroDoc { + base.commit(); + base.fork() +} + +/// Appends a minimal slide (just an id) to a replica's slide list, directly via +/// the CRDT containers — the bridge has no incremental slide-insert helper yet. +fn append_slide(doc: &LoroDoc, id: &str) { + let slides = doc.get_list(KEY_SLIDES); + let m = slides + .insert_container(slides.len(), LoroMap::new()) + .expect("insert slide map"); + m.insert("id", id).expect("set id"); +} + +// ── Round-trip through the public API (external-crate sanity) ───────────────── + +#[test] +fn round_trip_via_public_api() { + let original = Presentation::default(); + let doc = presentation_to_loro(&original).expect("to loro"); + let restored = loro_to_presentation(&doc).expect("from loro"); + assert_eq!(restored, original); +} + +// ── Structural merges: different keys / different slides ────────────────────── + +#[test] +fn concurrent_metadata_edits_on_different_keys_converge() { + let a = presentation_to_loro(&Presentation::default()).expect("to loro"); + let b = fork(&a); + + // A sets the title; B sets the author — concurrent, disjoint keys. + a.get_map(KEY_METADATA) + .insert("title", "From A") + .expect("set title"); + b.get_map(KEY_METADATA) + .insert("author", "From B") + .expect("set author"); + + sync(&a, &b); + + assert_eq!(a.get_deep_value(), b.get_deep_value()); + let p = loro_to_presentation(&a).expect("from loro"); + assert_eq!(p.meta.title.as_deref(), Some("From A")); + assert_eq!(p.meta.author.as_deref(), Some("From B")); +} + +#[test] +fn concurrent_slide_appends_converge_with_both_slides() { + let a = presentation_to_loro(&Presentation::default()).expect("to loro"); + let b = fork(&a); + let base_count = loro_to_presentation(&a).unwrap().slide_count(); + + append_slide(&a, "slide-from-a"); + append_slide(&b, "slide-from-b"); + + sync(&a, &b); + + assert_eq!( + a.get_deep_value(), + b.get_deep_value(), + "replicas must converge after concurrent slide appends" + ); + let p = loro_to_presentation(&a).expect("from loro"); + assert_eq!( + p.slide_count(), + base_count + 2, + "both concurrently-appended slides must survive the merge" + ); + let ids: Vec<&str> = p.slides.iter().map(|s| s.id.as_str()).collect(); + assert!(ids.contains(&"slide-from-a"), "lost A's slide: {ids:?}"); + assert!(ids.contains(&"slide-from-b"), "lost B's slide: {ids:?}"); +} + +// ── Snapshot register semantics (documented MVP limitation) ─────────────────── + +#[test] +fn concurrent_same_key_metadata_edit_is_last_writer_wins() { + // Two peers set the *same* metadata key concurrently. A CRDT register + // converges to ONE deterministic value (ordered by peer id), never a blend. + let a = presentation_to_loro(&Presentation::default()).expect("to loro"); + let b = fork(&a); + + a.get_map(KEY_METADATA) + .insert("title", "Title-A") + .expect("a"); + b.get_map(KEY_METADATA) + .insert("title", "Title-B") + .expect("b"); + + sync(&a, &b); + + assert_eq!(a.get_deep_value(), b.get_deep_value()); + let title = loro_to_presentation(&a).unwrap().meta.title; + // Exactly one of the two writes wins — and both replicas agree on which. + assert!( + matches!(title.as_deref(), Some("Title-A") | Some("Title-B")), + "expected one writer to win deterministically, got {title:?}" + ); +} + +// ── Pathological / edge states ──────────────────────────────────────────────── + +#[test] +fn snapshot_import_into_fresh_doc_preserves_state() { + let src = presentation_to_loro(&Presentation::default()).expect("to loro"); + src.get_map(KEY_METADATA) + .insert("title", "Persisted") + .expect("set title"); + append_slide(&src, "extra"); + src.commit(); + + let snapshot = src.export(ExportMode::snapshot()).expect("snapshot"); + let fresh = LoroDoc::new(); + fresh.import(&snapshot).expect("import snapshot"); + + assert_eq!( + src.get_deep_value(), + fresh.get_deep_value(), + "a snapshot imported into a fresh doc must reproduce the state exactly" + ); + let p = loro_to_presentation(&fresh).expect("from loro"); + assert_eq!(p.meta.title.as_deref(), Some("Persisted")); +} + +#[test] +fn idempotent_reimport_is_a_noop() { + let a = presentation_to_loro(&Presentation::default()).expect("to loro"); + let b = fork(&a); + append_slide(&b, "only-once"); + sync(&a, &b); + + let before = a.get_deep_value(); + let again = b.export(ExportMode::all_updates()).expect("re-export"); + a.import(&again).expect("re-import"); + a.commit(); + + assert_eq!(before, a.get_deep_value(), "re-import must be idempotent"); + let count = loro_to_presentation(&a) + .unwrap() + .slides + .iter() + .filter(|s| s.id.as_str() == "only-once") + .count(); + assert_eq!(count, 1, "the appended slide must not be duplicated"); +} diff --git a/loki-presentation/Cargo.toml b/loki-presentation/Cargo.toml index 07cd787d..3f6c2f99 100644 --- a/loki-presentation/Cargo.toml +++ b/loki-presentation/Cargo.toml @@ -33,6 +33,9 @@ loki-layout = { path = "../loki-layout" } loki-vello = { path = "../loki-vello" } # Shared AppThere design tokens and UI components. appthere-ui = { path = "../appthere-ui" } +loki-app-shell = { path = "../loki-app-shell" } +# Bundled UI font (Atkinson Hyperlegible Next) + Android fallback faces. +loki-fonts.workspace = true # Cross-platform file picker. loki-file-access.workspace = true # Typed error derivation. @@ -50,12 +53,12 @@ peniko = "0.5" # Kurbo geometry primitives. kurbo = "0.12" # Font config. -fontique = { version = "0.8", features = ["fontconfig-dlopen"] } +fontique = { version = "0.10", features = ["fontconfig-dlopen"] } loro = "1.11.1" # Unicode segmentation. unicode-segmentation = "1" # Platform-specific paths. -dirs = "5" +dirs = "6" # Serialisation. serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/loki-presentation/src/app.rs b/loki-presentation/src/app.rs index 7268a53e..528df83b 100644 --- a/loki-presentation/src/app.rs +++ b/loki-presentation/src/app.rs @@ -20,7 +20,8 @@ pub fn App() -> Element { let active_tab: Signal = use_signal(|| 0usize); // 0 = Home tab // Recent-documents list. - let recent_docs: Signal = use_signal(RecentDocuments::load); + let recent_docs: Signal = + use_signal(|| RecentDocuments::load(crate::recent_documents::RECENT_FILE)); provide_context(tabs); provide_context(active_tab); @@ -40,14 +41,11 @@ pub fn App() -> Element { " } + // UI font, embedded as a `data:` URI so it bundles into the binary and + // loads on every platform (see `loki_fonts::ui_face_css`). document::Style { - "@font-face {{ - font-family: 'Atkinson Hyperlegible Next'; - src: url('dioxus:///assets/fonts/AtkinsonHyperlegibleNext-VF.ttf') - format('truetype'); - font-weight: 100 900; - font-style: normal; - }}" + r#type: "text/css", + "{loki_fonts::ui_face_css()}" } div { diff --git a/loki-presentation/src/new_document.rs b/loki-presentation/src/new_document.rs index 68192eaa..5dd792dc 100644 --- a/loki-presentation/src/new_document.rs +++ b/loki-presentation/src/new_document.rs @@ -1,30 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -//! Blank presentation creation and the `untitled-` path prefix. +//! Blank-document creation for `loki-presentation` — re-exported from the shared +//! [`loki_app_shell::new_document`] / [`loki_app_shell::untitled`] modules. -use crate::tabs::OpenTab; -use loki_i18n::fl; -use std::sync::atomic::{AtomicU32, Ordering}; - -pub const UNTITLED_SCHEME: &str = "untitled-"; -static UNTITLED_COUNTER: AtomicU32 = AtomicU32::new(1); - -pub fn is_untitled(path: &str) -> bool { - path.starts_with(UNTITLED_SCHEME) -} - -pub fn new_blank_tab() -> OpenTab { - let n = UNTITLED_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = format!("{}{}", UNTITLED_SCHEME, n); - let title = if n == 1 { - fl!("editor-untitled") - } else { - fl!("editor-untitled-n", n = n as i64) - }; - OpenTab { - title, - path, - is_dirty: true, - is_discarded: false, - } -} +pub use loki_app_shell::new_document::new_blank_tab; +pub use loki_app_shell::untitled::{UNTITLED_SCHEME, is_untitled}; diff --git a/loki-presentation/src/recent_documents.rs b/loki-presentation/src/recent_documents.rs index 04c3200a..8117040c 100644 --- a/loki-presentation/src/recent_documents.rs +++ b/loki-presentation/src/recent_documents.rs @@ -1,114 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -//! Recent-presentations list — persisted as JSON in the platform data directory. +//! Recent-documents persistence for `loki-presentation` — re-exported from the +//! shared [`loki_app_shell::recent_documents`] module, with this app's storage +//! file name. Pass [`RECENT_FILE`] to `RecentDocuments::load`. -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -#[cfg(target_os = "android")] -use std::sync::OnceLock; +pub use loki_app_shell::recent_documents::*; -use crate::new_document; - -// ── Android data-dir override ───────────────────────────────────────────────── -// On Android, dirs::data_dir() returns None. android_main() calls -// set_android_data_dir() with the value from AndroidApp::internal_data_path() -// before Dioxus launches, giving recent_file_path() a writable location. - -#[cfg(target_os = "android")] -static ANDROID_DATA_DIR: OnceLock = OnceLock::new(); - -/// Store the Android internal data path before `dioxus::launch`. -/// Safe to call multiple times (ignored after the first call). -#[cfg(target_os = "android")] -pub fn set_android_data_dir(path: PathBuf) { - let _ = ANDROID_DATA_DIR.set(path); -} - -const MAX_RECENT: usize = 20; -const RECENT_FILE: &str = "AppThere/Loki/recent_presentation.json"; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RecentEntry { - pub path: String, - pub title: String, - pub modified_at: String, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RecentDocuments { - pub entries: Vec, -} - -impl RecentDocuments { - pub fn load() -> Self { - recent_file_path() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() - } - - pub fn save(&self) { - let Some(path) = recent_file_path() else { - return; - }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string_pretty(self) { - let _ = std::fs::write(path, json); - } - } - - pub fn record(&mut self, path: String, title: String) { - if new_document::is_untitled(&path) { - return; - } - self.entries.retain(|e| e.path != path); - self.entries.insert( - 0, - RecentEntry { - path, - title, - modified_at: today_iso(), - }, - ); - self.entries.truncate(MAX_RECENT); - } - - pub fn remove(&mut self, path: &str) { - self.entries.retain(|e| e.path != path); - } -} - -// On Android CPU the cfg-gated return is always taken; "" fallback is -// unreachable on that target but reachable on desktop/GPU. -#[allow(unreachable_code)] -fn recent_file_path() -> Option { - #[cfg(target_os = "android")] - return ANDROID_DATA_DIR.get().map(|d| d.join(RECENT_FILE)); - - dirs::data_dir().map(|d| d.join(RECENT_FILE)) -} - -fn today_iso() -> String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let (y, m, d) = days_to_ymd(secs / 86400); - format!("{y:04}-{m:02}-{d:02}") -} - -fn days_to_ymd(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d) -} +/// Relative path under the platform data directory for this app's recent list. +pub const RECENT_FILE: &str = "AppThere/Loki/recent_presentation.json"; diff --git a/loki-presentation/src/tabs.rs b/loki-presentation/src/tabs.rs index 03950624..c33bda40 100644 --- a/loki-presentation/src/tabs.rs +++ b/loki-presentation/src/tabs.rs @@ -1,16 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -//! Open-tab state for the `loki-presentation` editor shell. +//! Open-tab state for `loki-presentation` — re-exported from [`loki_app_shell::tabs`]. -/// Represents a single open document tab. -#[derive(Clone, PartialEq)] -pub struct OpenTab { - /// Display title shown in the tab bar. - pub title: String, - /// The serialised file access token / path used by the editor. - pub path: String, - /// Whether the document has unsaved changes. - pub is_dirty: bool, - /// Whether this tab has been discarded from memory. - pub is_discarded: bool, -} +pub use loki_app_shell::tabs::OpenTab; diff --git a/loki-render-cache/Cargo.toml b/loki-render-cache/Cargo.toml index b591593f..99edf8be 100644 --- a/loki-render-cache/Cargo.toml +++ b/loki-render-cache/Cargo.toml @@ -3,17 +3,13 @@ name = "loki-render-cache" version = "0.1.0" edition = "2024" license = "Apache-2.0" -description = "Tiered page render cache policy for Loki's Vello renderer" +description = "Page render primitives (PageSource trait + GPU texture handle) for Loki's Vello renderer" [dependencies] thiserror = "2" -tracing = "0.1" wgpu = { version = "26", optional = true } [features] -# `gpu` enables wgpu-backed texture utilities (GpuTexture, allocate_texture, -# downsample_texture) and the PageSource trait used by loki-renderer. +# `gpu` enables the wgpu-backed `GpuTexture` handle and the `PageSource` trait +# used by loki-renderer. gpu = ["dep:wgpu"] - -[dev-dependencies] -pollster = "0.4" diff --git a/loki-render-cache/src/blit.rs b/loki-render-cache/src/blit.rs deleted file mode 100644 index 685936ec..00000000 --- a/loki-render-cache/src/blit.rs +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Cached bilinear-blit pipeline for GPU texture downsampling. -//! -//! [`BlitPipeline`] builds the wgpu shader, bind-group layout, pipeline layout, -//! render pipeline, and sampler once in [`BlitPipeline::new`], then reuses them -//! across [`BlitPipeline::downsample`] calls. This avoids the per-call pipeline -//! construction overhead that the [`crate::downsample_texture`] free function -//! previously incurred. -//! -//! **Device affinity**: `BlitPipeline` must be used with the same -//! `wgpu::Device` that was passed to [`BlitPipeline::new`]. Passing a -//! different device to `downsample` produces a wgpu validation error. - -use crate::texture::{GpuTexture, allocate_texture}; - -/// Fullscreen-triangle blit shader (WGSL). -/// -/// Samples the source texture with a linear sampler and writes to the -/// destination. UV coordinates cover [0, 1]² exactly over the output. -const BLIT_WGSL: &str = " -struct VO { - @builtin(position) pos: vec4, - @location(0) uv: vec2, -}; - -@vertex -fn vs_main(@builtin(vertex_index) vi: u32) -> VO { - var xy = array, 3>( - vec2(-1.0, 1.0), - vec2(-1.0, -3.0), - vec2( 3.0, 1.0), - ); - var uv = array, 3>( - vec2(0.0, 0.0), - vec2(0.0, 2.0), - vec2(2.0, 0.0), - ); - var out: VO; - out.pos = vec4(xy[vi], 0.0, 1.0); - out.uv = uv[vi]; - return out; -} - -@group(0) @binding(0) var t_src: texture_2d; -@group(0) @binding(1) var s_src: sampler; - -@fragment -fn fs_main(in: VO) -> @location(0) vec4 { - return textureSample(t_src, s_src, in.uv); -} -"; - -/// Cached wgpu pipeline for bilinear texture downsampling. -/// -/// Create once per device via [`BlitPipeline::new`] and reuse across multiple -/// [`BlitPipeline::downsample`] calls. -pub struct BlitPipeline { - bind_group_layout: wgpu::BindGroupLayout, - render_pipeline: wgpu::RenderPipeline, - sampler: wgpu::Sampler, -} - -impl BlitPipeline { - /// Compiles the blit shader and builds all pipeline objects for `device`. - #[must_use] - pub fn new(device: &wgpu::Device) -> Self { - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("blit"), - source: wgpu::ShaderSource::Wgsl(BLIT_WGSL.into()), - }); - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("blit-bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("blit-pl"), - bind_group_layouts: &[&bind_group_layout], - push_constant_ranges: &[], - }); - let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("blit-rp"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: Some(wgpu::BlendState::REPLACE), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState::default(), - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview: None, - cache: None, - }); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("blit-sampler"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); - Self { - bind_group_layout, - render_pipeline, - sampler, - } - } - - /// Downsamples `src` into a new texture at `scale × src` dimensions. - /// - /// Uses the cached bilinear-filtered pipeline built in [`BlitPipeline::new`]. - /// `scale` must be in `(0.0, 1.0]`; values > 1.0 are clamped to 1.0 so - /// this method never upsamples. - #[must_use] - pub fn downsample( - &self, - device: &wgpu::Device, - queue: &wgpu::Queue, - src: &GpuTexture, - scale: f32, - ) -> GpuTexture { - let scale = scale.clamp(f32::EPSILON, 1.0); - let dst_w = ((src.width as f32 * scale).ceil() as u32).max(1); - let dst_h = ((src.height as f32 * scale).ceil() as u32).max(1); - let dst = allocate_texture(device, dst_w, dst_h, Some("blit-dst")); - - let src_view = src - .inner - .create_view(&wgpu::TextureViewDescriptor::default()); - let dst_view = dst - .inner - .create_view(&wgpu::TextureViewDescriptor::default()); - - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("blit-bg"), - layout: &self.bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&src_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.sampler), - }, - ], - }); - - let mut encoder = device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("blit-pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &dst_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - }); - pass.set_pipeline(&self.render_pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.draw(0..3, 0..1); - } - queue.submit(Some(encoder.finish())); - dst - } -} diff --git a/loki-render-cache/src/key.rs b/loki-render-cache/src/key.rs index f4bc2653..39aaf859 100644 --- a/loki-render-cache/src/key.rs +++ b/loki-render-cache/src/key.rs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 AppThere Loki contributors -//! [`CacheKey`] marker trait for generic page-cache key types. +//! [`CacheKey`] marker trait for generic page key types. -/// Marker trait for types that can serve as cache keys in [`crate::PageCache`]. +/// Marker trait for types that can identify a page, used as +/// [`crate::PageSource::Key`]. /// /// Any type that satisfies `Hash + Eq + Copy + Send + Sync + 'static` /// automatically implements `CacheKey` via the blanket impl below — no manual @@ -11,7 +12,7 @@ /// /// # Provided implementations /// -/// - [`crate::PageIndex`] — the default key for document-page caches. +/// - [`crate::PageIndex`] — the default key for document pages. /// - `u32`, `u64`, and any other primitive that satisfies the bounds. pub trait CacheKey: std::hash::Hash + Eq + Copy + Send + Sync + 'static {} diff --git a/loki-render-cache/src/lib.rs b/loki-render-cache/src/lib.rs index 377ef1f8..51bf5328 100644 --- a/loki-render-cache/src/lib.rs +++ b/loki-render-cache/src/lib.rs @@ -3,39 +3,28 @@ #![forbid(unsafe_code)] -//! Tiered page render cache for Loki's Vello renderer. +//! Page render primitives for Loki's Vello renderer. //! -//! The cache stores only tier assignments and dirty flags — GPU textures are -//! owned by `LokiPageSource` instances inside Blitz's `CustomPaintSource` -//! frame loop. The `gpu` feature enables wgpu texture utilities used by the -//! rendering layer in `loki-renderer`. +//! The [`PageSource`] trait (and [`RenderError`]) abstracts rendering a single +//! page to a GPU texture; GPU textures themselves are owned by `LokiPageSource` +//! instances inside Blitz's `CustomPaintSource` frame loop. The `gpu` feature +//! enables the wgpu-backed [`texture`] utilities used by the rendering layer in +//! `loki-renderer`. pub mod key; -pub mod page_cache; pub mod page_source; -pub mod retier; -pub mod scroll_state; -pub mod tier_policy; -#[cfg(feature = "gpu")] -pub mod blit; #[cfg(feature = "gpu")] pub mod texture; pub use key::CacheKey; -pub use page_cache::{CachedPage, PageCache}; pub use page_source::RenderError; -pub use retier::RetierResult; -pub use scroll_state::{SETTLE_DURATION, ScrollPhase, ScrollState}; -pub use tier_policy::{CacheTier, PageGeometry, assign_tier}; -#[cfg(feature = "gpu")] -pub use blit::BlitPipeline; #[cfg(feature = "gpu")] pub use page_source::PageSource; #[cfg(feature = "gpu")] -pub use texture::{GpuTexture, allocate_texture, downsample_texture}; +pub use texture::GpuTexture; -/// Opaque index identifying a document page within the cache. +/// Opaque index identifying a document page. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PageIndex(pub u32); diff --git a/loki-render-cache/src/page_cache.rs b/loki-render-cache/src/page_cache.rs deleted file mode 100644 index 7c7e7111..00000000 --- a/loki-render-cache/src/page_cache.rs +++ /dev/null @@ -1,154 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Generic per-page tier-and-dirty metadata store. -//! -//! `CachedPage` no longer holds a GPU texture — GPU resources are owned by -//! `LokiPageSource` instances inside Blitz's `CustomPaintSource` frame loop. -//! The cache stores only the tier assignment and dirty flag needed by the -//! scroll-settle retier logic. - -use std::collections::HashMap; - -use crate::key::CacheKey; -use crate::tier_policy::CacheTier; - -/// Metadata entry for a single cached page. -#[derive(Debug)] -pub struct CachedPage { - /// The tier at which this page should currently be rendered. - pub tier: CacheTier, - /// `true` when document content has changed since the page was last - /// rendered and a re-render is required. - pub dirty: bool, -} - -/// Tier-and-dirty metadata store for all document pages. -/// -/// `K` is the cache key type — use [`crate::PageIndex`] for document pages, -/// or any other type that satisfies [`CacheKey`]. -#[derive(Debug)] -pub struct PageCache { - pub(crate) pages: HashMap, -} - -impl PageCache { - /// Creates an empty cache. - #[must_use] - pub fn new() -> Self { - Self { - pages: HashMap::new(), - } - } - - /// Inserts or replaces the tier entry for `index`, marking the page clean. - pub fn insert(&mut self, index: K, tier: CacheTier) { - self.pages.insert(index, CachedPage { tier, dirty: false }); - } - - /// Marks the page at `index` as dirty. - /// - /// Has no effect if the page is not in the cache. - pub fn mark_dirty(&mut self, index: K) { - if let Some(page) = self.pages.get_mut(&index) { - page.dirty = true; - } - } - - /// Marks every cached page as dirty (e.g. after a document mutation). - pub fn mark_all_dirty(&mut self) { - for page in self.pages.values_mut() { - page.dirty = true; - } - } - - /// Returns a shared reference to the cached page at `index`, or `None` - /// if the page is not cached. - #[must_use] - pub fn get(&self, index: K) -> Option<&CachedPage> { - self.pages.get(&index) - } - - /// Returns `(hot, warm, cold)` page counts across all tiers. - /// - /// Useful for tracing and diagnostics. - #[must_use] - pub fn page_count_by_tier(&self) -> (usize, usize, usize) { - let mut hot = 0usize; - let mut warm = 0usize; - let mut cold = 0usize; - for page in self.pages.values() { - match page.tier { - CacheTier::Hot => hot += 1, - CacheTier::Warm => warm += 1, - CacheTier::Cold => cold += 1, - } - } - (hot, warm, cold) - } -} - -impl Default for PageCache { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::PageIndex; - - #[test] - fn insert_then_get_returns_page_at_correct_tier() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Hot); - let page = cache.get(PageIndex(0)).expect("page should be cached"); - assert_eq!(page.tier, CacheTier::Hot); - assert!(!page.dirty); - } - - #[test] - fn mark_dirty_sets_dirty_on_target_page_only() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Hot); - cache.insert(PageIndex(1), CacheTier::Hot); - cache.mark_dirty(PageIndex(0)); - assert!(cache.get(PageIndex(0)).unwrap().dirty); - assert!(!cache.get(PageIndex(1)).unwrap().dirty); - } - - #[test] - fn mark_all_dirty_sets_dirty_on_every_page() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Hot); - cache.insert(PageIndex(1), CacheTier::Warm); - cache.insert(PageIndex(2), CacheTier::Cold); - cache.mark_all_dirty(); - for idx in 0..3u32 { - assert!( - cache.get(PageIndex(idx)).unwrap().dirty, - "page {idx} not dirty" - ); - } - } - - #[test] - fn get_on_missing_page_returns_none() { - let cache: PageCache = PageCache::new(); - assert!(cache.get(PageIndex(99)).is_none()); - } - - #[test] - fn page_count_by_tier_returns_correct_counts() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Hot); - cache.insert(PageIndex(1), CacheTier::Hot); - cache.insert(PageIndex(2), CacheTier::Warm); - cache.insert(PageIndex(3), CacheTier::Cold); - let (hot, warm, cold) = cache.page_count_by_tier(); - assert_eq!(hot, 2); - assert_eq!(warm, 1); - assert_eq!(cold, 1); - } -} diff --git a/loki-render-cache/src/page_source.rs b/loki-render-cache/src/page_source.rs index 591c564b..8ef63367 100644 --- a/loki-render-cache/src/page_source.rs +++ b/loki-render-cache/src/page_source.rs @@ -30,7 +30,7 @@ pub trait PageSource: Send + Sync { fn page_size_px(&self, index: Self::Key) -> (u32, u32); /// Renders the page at `scale × page_size_px` and returns the rasterised - /// texture. `scale` comes from [`crate::tier_policy::CacheTier::scale_factor`]. + /// texture. `scale` is the caller's device-pixel scale factor. /// /// # Errors /// diff --git a/loki-render-cache/src/retier.rs b/loki-render-cache/src/retier.rs deleted file mode 100644 index 6a9d9e35..00000000 --- a/loki-render-cache/src/retier.rs +++ /dev/null @@ -1,212 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Tier reassignment logic. -//! -//! [`PageCache::retier`] decides what must be re-rendered and what can be -//! downsampled (tier demotion) based on the current scroll position. -//! GPU memory is now managed by Blitz via `CustomPaintSource`, so byte-budget -//! eviction is no longer performed here. - -use crate::key::CacheKey; -use crate::page_cache::PageCache; -use crate::tier_policy::{CacheTier, assign_tier}; -use crate::{PageGeometry, ScrollState}; - -/// The outcome of a single [`PageCache::retier`] call. -#[derive(Debug)] -pub struct RetierResult { - /// Pages whose tier has changed to a finer resolution (or that are not yet - /// cached). `LokiPageSource` will re-render these at the new tier. - pub rerender: Vec<(K, CacheTier)>, - /// Pages whose tier has been demoted (e.g. Hot→Warm). `LokiPageSource` - /// will re-render at lower quality on the next frame. - pub downsample: Vec, -} - -impl Default for RetierResult { - fn default() -> Self { - Self { - rerender: Vec::new(), - downsample: Vec::new(), - } - } -} - -/// Numeric quality rank: higher = finer resolution. -fn quality(tier: CacheTier) -> u8 { - match tier { - CacheTier::Hot => 2, - CacheTier::Warm => 1, - CacheTier::Cold => 0, - } -} - -impl PageCache { - /// Reassigns tiers for all `pages` based on `scroll`. - /// - /// Call when [`ScrollState::tick`] returns `true` (→ Settling). - /// - /// Not cached → `rerender`; same tier clean → skip; same tier dirty → - /// `rerender`; finer tier (e.g. Cold→Hot) → `rerender`; coarser tier - /// (e.g. Hot→Warm) → `downsample`. - pub fn retier(&mut self, pages: &[PageGeometry], scroll: &ScrollState) -> RetierResult { - let mut result = RetierResult::default(); - - for page_geom in pages { - let idx = page_geom.index; - let new_tier = assign_tier(page_geom, scroll); - - match self.pages.get_mut(&idx) { - None => { - result.rerender.push((idx, new_tier)); - } - Some(cached) => { - let old_quality = quality(cached.tier); - let new_quality = quality(new_tier); - - if new_quality > old_quality { - cached.tier = new_tier; - cached.dirty = true; - result.rerender.push((idx, new_tier)); - } else if new_quality < old_quality { - cached.tier = new_tier; - result.downsample.push(idx); - } else if cached.dirty { - result.rerender.push((idx, cached.tier)); - } - } - } - } - - result - } -} - -#[cfg(test)] -mod tests { - use std::time::Instant; - - use crate::scroll_state::{SETTLE_DURATION, ScrollState}; - use crate::{CacheTier, PageCache, PageGeometry, PageIndex}; - - /// Scroll state with the viewport so far down that all test pages - /// (placed near y=0) fall into the Cold tier. - fn cold_scroll() -> ScrollState { - let mut s = ScrollState::new(800.0); - s.viewport_top_px = 100_000.0; - s - } - - /// Scroll state with the viewport centred on y=0..200 so those pages - /// are in the Hot zone. - fn hot_scroll() -> ScrollState { - let mut s = ScrollState::new(800.0); - s.viewport_top_px = 0.0; - s - } - - fn page(index: u32) -> PageGeometry { - let top = f64::from(index) * 300.0; - PageGeometry { - index: PageIndex(index), - top_px: top, - bottom_px: top + 200.0, - } - } - - #[test] - fn uncached_page_appears_in_rerender() { - let mut cache = PageCache::new(); - let result = cache.retier(&[page(0)], &hot_scroll()); - assert_eq!(result.rerender.len(), 1); - assert_eq!(result.rerender[0].0, PageIndex(0)); - assert!(result.downsample.is_empty()); - } - - #[test] - fn cached_clean_same_tier_produces_no_action() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Hot); - let result = cache.retier(&[page(0)], &hot_scroll()); - assert!(result.rerender.is_empty()); - assert!(result.downsample.is_empty()); - } - - #[test] - fn cached_dirty_same_tier_appears_in_rerender() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Hot); - cache.mark_dirty(PageIndex(0)); - let result = cache.retier(&[page(0)], &hot_scroll()); - assert_eq!(result.rerender.len(), 1); - assert_eq!(result.rerender[0].0, PageIndex(0)); - assert!(result.downsample.is_empty()); - } - - #[test] - fn cold_to_hot_promotion_appears_in_rerender_not_downsample() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Cold); - let result = cache.retier(&[page(0)], &hot_scroll()); - assert_eq!(result.rerender.len(), 1); - assert_eq!(result.rerender[0], (PageIndex(0), CacheTier::Hot)); - assert!(result.downsample.is_empty()); - } - - #[test] - fn hot_to_warm_demotion_appears_in_downsample_not_rerender() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Hot); - let mut scroll = ScrollState::new(800.0); - scroll.viewport_top_px = 2000.0; // page 0 → Warm - let result = cache.retier(&[page(0)], &scroll); - assert_eq!(result.downsample.len(), 1); - assert_eq!(result.downsample[0], PageIndex(0)); - assert!(result.rerender.is_empty()); - } - - #[test] - fn repeated_retier_on_same_coarser_tier_produces_no_action() { - let mut cache = PageCache::new(); - cache.insert(PageIndex(0), CacheTier::Hot); - let mut scroll = ScrollState::new(800.0); - scroll.viewport_top_px = 2000.0; // page 0 → Warm - - let r1 = cache.retier(&[page(0)], &scroll); - assert_eq!(r1.downsample.len(), 1); - - let r2 = cache.retier(&[page(0)], &scroll); - assert!(r2.rerender.is_empty()); - assert!(r2.downsample.is_empty()); - } - - #[test] - fn retier_called_on_settling_transition() { - let mut scroll = ScrollState::new(800.0); - let t0 = Instant::now(); - scroll.on_scroll(100.0); - - let settled = scroll.tick(t0 + SETTLE_DURATION + std::time::Duration::from_millis(10)); - assert!(settled); - - let mut cache = PageCache::new(); - let result = cache.retier(&[page(0)], &scroll); - assert!(!result.rerender.is_empty()); - } - - #[test] - fn cold_pages_no_longer_evicted() { - // With byte-budget eviction removed, Cold pages are never evicted - // by retier — GPU memory is managed by Blitz. - let mut cache = PageCache::new(); - for i in 0..4u32 { - cache.insert(PageIndex(i), CacheTier::Cold); - } - let pages: Vec<_> = (0..4).map(page).collect(); - let result = cache.retier(&pages, &cold_scroll()); - // No evictions — all pages stay cached. - assert_eq!(cache.pages.len(), 4); - let _ = result; // downsample/rerender counts may vary - } -} diff --git a/loki-render-cache/src/scroll_state.rs b/loki-render-cache/src/scroll_state.rs deleted file mode 100644 index 877c8677..00000000 --- a/loki-render-cache/src/scroll_state.rs +++ /dev/null @@ -1,185 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Scroll phase tracking and hot-zone geometry. - -use std::time::{Duration, Instant}; - -/// Scroll must be idle for this long before the phase transitions to -/// [`ScrollPhase::Settling`]. -pub const SETTLE_DURATION: Duration = Duration::from_millis(120); - -/// The lifecycle phase of the current scroll gesture. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollPhase { - /// Scroll events are arriving; the viewport is moving. - Active, - /// No new scroll event has arrived for [`SETTLE_DURATION`]; the cache - /// should begin promoting pages to higher-resolution tiers. - Settling, - /// Fully quiescent — no in-flight scroll gesture. - Idle, -} - -/// Tracks scroll position and phase, and computes the hot cache zone. -#[derive(Debug)] -pub struct ScrollState { - /// Current scroll phase. - pub phase: ScrollPhase, - /// Y coordinate of the top edge of the visible area, in logical pixels. - pub viewport_top_px: f64, - /// Height of the visible area, in logical pixels. - pub viewport_height_px: f64, - /// Wall-clock time of the most recent scroll event. `None` until the - /// first [`ScrollState::on_scroll`] call. - last_event: Option, -} - -impl ScrollState { - /// Creates a new [`ScrollState`] in the [`ScrollPhase::Idle`] phase with - /// the viewport scrolled to the top. - #[must_use] - pub fn new(viewport_height_px: f64) -> Self { - Self { - phase: ScrollPhase::Idle, - viewport_top_px: 0.0, - viewport_height_px, - last_event: None, - } - } - - /// Records a new scroll position, transitioning to [`ScrollPhase::Active`]. - /// - /// The timestamp is captured from the wall clock. Call - /// [`ScrollState::tick`] on each frame to detect when the gesture settles. - pub fn on_scroll(&mut self, new_top: f64) { - self.on_scroll_at(new_top, Instant::now()); - } - - /// Internal timestamped variant used in tests to avoid real-time sleeps. - fn on_scroll_at(&mut self, new_top: f64, now: Instant) { - self.viewport_top_px = new_top; - self.phase = ScrollPhase::Active; - self.last_event = Some(now); - } - - /// Advances the phase state machine. - /// - /// Returns `true` exactly once — on the frame where `phase` first - /// transitions from [`ScrollPhase::Active`] to [`ScrollPhase::Settling`]. - /// Returns `false` on all subsequent calls until the next scroll gesture. - /// - /// Pass the current wall-clock time so callers can drive this - /// deterministically in tests without sleeping. - pub fn tick(&mut self, now: Instant) -> bool { - if self.phase != ScrollPhase::Active { - return false; - } - let Some(last) = self.last_event else { - return false; - }; - if now.duration_since(last) >= SETTLE_DURATION { - self.phase = ScrollPhase::Settling; - return true; - } - false - } - - /// Returns the half-open pixel range `[start, end)` of the *hot zone*: - /// a region 2× the viewport height centred on the visible area. - /// - /// `start = viewport_top_px − 0.5 × viewport_height_px` - /// `end = viewport_top_px + 1.5 × viewport_height_px` - /// - /// Pages that overlap this range are candidates for full-resolution - /// rendering. - #[must_use] - pub fn hot_range_px(&self) -> (f64, f64) { - let half = self.viewport_height_px * 0.5; - ( - self.viewport_top_px - half, - self.viewport_top_px + self.viewport_height_px + half, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_state() -> ScrollState { - ScrollState::new(800.0) - } - - // ── tick behaviour ──────────────────────────────────────────────────────── - - #[test] - fn tick_before_settle_duration_returns_false_and_stays_active() { - let mut state = make_state(); - let t0 = Instant::now(); - state.on_scroll_at(0.0, t0); - - let just_before = t0 + SETTLE_DURATION - Duration::from_millis(1); - assert!(!state.tick(just_before)); - assert_eq!(state.phase, ScrollPhase::Active); - } - - #[test] - fn tick_after_settle_duration_returns_true_and_transitions_to_settling() { - let mut state = make_state(); - let t0 = Instant::now(); - state.on_scroll_at(0.0, t0); - - let after = t0 + SETTLE_DURATION; - assert!(state.tick(after)); - assert_eq!(state.phase, ScrollPhase::Settling); - } - - #[test] - fn second_tick_after_transition_returns_false_no_double_fire() { - let mut state = make_state(); - let t0 = Instant::now(); - state.on_scroll_at(0.0, t0); - - let after = t0 + SETTLE_DURATION; - assert!(state.tick(after)); - // Second call — phase is already Settling - assert!(!state.tick(after + Duration::from_millis(10))); - assert_eq!(state.phase, ScrollPhase::Settling); - } - - #[test] - fn tick_when_idle_returns_false() { - let mut state = make_state(); - assert_eq!(state.phase, ScrollPhase::Idle); - assert!(!state.tick(Instant::now())); - } - - // ── hot_range_px ────────────────────────────────────────────────────────── - - #[test] - fn hot_range_px_centred_on_viewport() { - // viewport_top = 400, viewport_height = 800 - // half_vp = 400 - // expected: (400 - 400, 400 + 1200) = (0, 1600) - let mut state = ScrollState::new(800.0); - state.viewport_top_px = 400.0; - - let (start, end) = state.hot_range_px(); - assert_eq!(start, 0.0); - assert_eq!(end, 1600.0); - } - - #[test] - fn hot_range_px_matches_spec_formula() { - // Spec: (top - half_vp, top + 1.5 * vp_height) - let top = 200.0_f64; - let vp = 600.0_f64; - let mut state = ScrollState::new(vp); - state.viewport_top_px = top; - - let (start, end) = state.hot_range_px(); - assert!((start - (top - vp * 0.5)).abs() < f64::EPSILON); - assert!((end - (top + vp * 1.5)).abs() < f64::EPSILON); - } -} diff --git a/loki-render-cache/src/texture.rs b/loki-render-cache/src/texture.rs index cd17f585..47211213 100644 --- a/loki-render-cache/src/texture.rs +++ b/loki-render-cache/src/texture.rs @@ -1,15 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 AppThere Loki contributors -//! GPU texture allocation and downsampling blit. +//! GPU texture handle returned by [`crate::PageSource`]. //! //! Only compiled when the `gpu` feature is active. /// A GPU texture together with its pixel dimensions. /// -/// Wraps [`wgpu::Texture`] and records `width`/`height` so byte-budget -/// calculations (see [`crate::page_cache::PageCache::cold_bytes`]) don't -/// need a separate GPU query. +/// Wraps [`wgpu::Texture`] and records `width`/`height` so callers don't need a +/// separate GPU query to recover the texture's dimensions. #[derive(Debug)] pub struct GpuTexture { /// The underlying wgpu texture object. @@ -19,66 +18,3 @@ pub struct GpuTexture { /// Height of the texture in pixels. pub height: u32, } - -impl GpuTexture { - /// Returns the approximate GPU memory footprint in bytes (RGBA8 = 4 bytes - /// per pixel, no mip-maps). - #[must_use] - pub fn byte_size(&self) -> u64 { - self.width as u64 * self.height as u64 * 4 - } -} - -/// Allocates a blank RGBA8 texture suitable for both rendering and sampling. -/// -/// The texture has [`wgpu::TextureUsages::RENDER_ATTACHMENT`], -/// [`wgpu::TextureUsages::TEXTURE_BINDING`], and -/// [`wgpu::TextureUsages::COPY_SRC`]. -#[must_use] -pub fn allocate_texture( - device: &wgpu::Device, - width: u32, - height: u32, - label: Option<&str>, -) -> GpuTexture { - let inner = device.create_texture(&wgpu::TextureDescriptor { - label, - size: wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - GpuTexture { - inner, - width, - height, - } -} - -/// Downsamples `src` into a new texture at `scale` × `src` dimensions. -/// -/// Convenience wrapper that creates a temporary [`crate::blit::BlitPipeline`] -/// for a single call. For repeated downsampling, prefer constructing a -/// `BlitPipeline` once and calling [`crate::blit::BlitPipeline::downsample`] -/// directly to avoid per-call pipeline compilation. -/// -/// `scale` must be in `(0.0, 1.0]`; values > 1.0 are clamped to 1.0 so this -/// function never upsamples. -#[must_use] -pub fn downsample_texture( - device: &wgpu::Device, - queue: &wgpu::Queue, - src: &GpuTexture, - scale: f32, -) -> GpuTexture { - crate::blit::BlitPipeline::new(device).downsample(device, queue, src, scale) -} diff --git a/loki-render-cache/src/tier_policy.rs b/loki-render-cache/src/tier_policy.rs deleted file mode 100644 index b7477c1c..00000000 --- a/loki-render-cache/src/tier_policy.rs +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Tier assignment policy for cached page textures. -//! -//! The three tiers trade render quality for memory: -//! -//! | Tier | Scale factor | Texture size (1080 p page) | -//! |------|-------------|---------------------------| -//! | Hot | 1.0 | ~12 MB | -//! | Warm | 0.5 | ~3 MB | -//! | Cold | 0.25 | ~0.75 MB | - -use crate::key::CacheKey; -use crate::scroll_state::ScrollState; - -/// The render quality tier for a cached page texture. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum CacheTier { - /// Full-resolution texture — pages currently visible or just off-screen. - Hot, - /// Half-resolution texture — pages within the warm margin. - Warm, - /// Quarter-resolution thumbnail — pages far from the viewport. - Cold, -} - -impl CacheTier { - /// Linear scale factor applied to page dimensions when rendering at this - /// tier. The resulting texture has `(scale × width) × (scale × height)` - /// pixels. - #[must_use] - pub fn scale_factor(self) -> f32 { - match self { - CacheTier::Hot => 1.0, - CacheTier::Warm => 0.5, - CacheTier::Cold => 0.25, - } - } -} - -/// The layout geometry of a single document page in scroll-space. -/// -/// `K` is the cache key type — use [`crate::PageIndex`] for document pages. -#[derive(Debug, Clone, Copy)] -pub struct PageGeometry { - /// The cache key that identifies this page. - pub index: K, - /// Y coordinate of the top edge of the page, in logical pixels. - pub top_px: f64, - /// Y coordinate of the bottom edge of the page, in logical pixels. - pub bottom_px: f64, -} - -/// Assigns a [`CacheTier`] to `page` based on its distance from the visible -/// area described by `scroll`. -/// -/// # Tier boundaries -/// -/// - **Hot**: the page overlaps the *hot zone* — a band of `2× viewport height` -/// centred on the visible area (see [`ScrollState::hot_range_px`]). -/// - **Warm**: the page overlaps the *warm zone* — the hot zone extended by -/// `3× viewport height` on each side. -/// - **Cold**: anything beyond the warm zone. -/// -/// A page that partially overlaps a zone boundary is assigned the higher- -/// priority tier (Hot beats Warm beats Cold). -#[must_use] -pub fn assign_tier(page: &PageGeometry, scroll: &ScrollState) -> CacheTier { - let (hot_start, hot_end) = scroll.hot_range_px(); - - if overlaps(page.top_px, page.bottom_px, hot_start, hot_end) { - return CacheTier::Hot; - } - - let margin = scroll.viewport_height_px * 3.0; - let warm_start = hot_start - margin; - let warm_end = hot_end + margin; - - if overlaps(page.top_px, page.bottom_px, warm_start, warm_end) { - CacheTier::Warm - } else { - CacheTier::Cold - } -} - -/// Returns `true` when `[top_px, bottom_px)` overlaps `[zone_start, zone_end)`. -fn overlaps(top_px: f64, bottom_px: f64, zone_start: f64, zone_end: f64) -> bool { - bottom_px > zone_start && top_px < zone_end -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::scroll_state::ScrollState; - - /// Build a scroll state with the viewport at the given top offset. - fn scroll_at(top: f64, height: f64) -> ScrollState { - let mut s = ScrollState::new(height); - s.viewport_top_px = top; - s - } - - /// Build a page spanning `[top, bottom)`. `u32` is used as a minimal - /// `CacheKey` since `assign_tier` does not examine the key value. - fn page(top: f64, bottom: f64) -> PageGeometry { - PageGeometry { - index: 0u32, - top_px: top, - bottom_px: bottom, - } - } - - // viewport_top = 1000, viewport_height = 800 - // hot zone: (1000 - 400, 1000 + 1200) = (600, 2200) - // warm zone: (600 - 2400, 2200 + 2400) = (-1800, 4600) - - #[test] - fn page_fully_inside_hot_zone_is_hot() { - let scroll = scroll_at(1000.0, 800.0); - // page [800, 1200] — fully inside hot zone [600, 2200] - assert_eq!(assign_tier(&page(800.0, 1200.0), &scroll), CacheTier::Hot); - } - - #[test] - fn page_partially_overlapping_hot_zone_boundary_is_hot() { - let scroll = scroll_at(1000.0, 800.0); - // page [400, 700] — bottom (700) > hot_start (600), so overlaps - assert_eq!(assign_tier(&page(400.0, 700.0), &scroll), CacheTier::Hot); - } - - #[test] - fn page_just_outside_hot_zone_inside_warm_is_warm() { - let scroll = scroll_at(1000.0, 800.0); - // page [100, 590] — does NOT overlap hot [600, 2200], - // but overlaps warm [-1800, 4600] - assert_eq!(assign_tier(&page(100.0, 590.0), &scroll), CacheTier::Warm); - } - - #[test] - fn page_beyond_warm_margin_is_cold() { - let scroll = scroll_at(1000.0, 800.0); - // warm_start = 600 - 2400 = -1800; page [-2500, -1850] is outside - assert_eq!( - assign_tier(&page(-2500.0, -1850.0), &scroll), - CacheTier::Cold - ); - } - - #[test] - fn page_above_scroll_follows_same_tier_rules() { - // Viewport at 5000, looking downward. - // hot zone: (5000 - 400, 5000 + 1200) = (4600, 6200) - // warm zone: (4600 - 2400, 6200 + 2400) = (2200, 8600) - let scroll = scroll_at(5000.0, 800.0); - - // Already-scrolled-past page at [300, 500] — before warm zone - assert_eq!(assign_tier(&page(300.0, 500.0), &scroll), CacheTier::Cold); - - // Page at [2500, 3000] — inside warm zone - assert_eq!(assign_tier(&page(2500.0, 3000.0), &scroll), CacheTier::Warm); - - // Page at [4700, 5200] — inside hot zone - assert_eq!(assign_tier(&page(4700.0, 5200.0), &scroll), CacheTier::Hot); - } - - // ── CacheTier::scale_factor ─────────────────────────────────────────────── - - #[test] - fn scale_factors_are_ordered_hot_warm_cold() { - assert!(CacheTier::Hot.scale_factor() > CacheTier::Warm.scale_factor()); - assert!(CacheTier::Warm.scale_factor() > CacheTier::Cold.scale_factor()); - assert_eq!(CacheTier::Hot.scale_factor(), 1.0_f32); - } -} diff --git a/loki-render-cache/tests/gpu_integration.rs b/loki-render-cache/tests/gpu_integration.rs deleted file mode 100644 index 2c170f40..00000000 --- a/loki-render-cache/tests/gpu_integration.rs +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Integration tests that require a real wgpu device. -//! -//! Gated on the `gpu` feature. Run with: -//! ```text -//! cargo test -p loki-render-cache --no-default-features --features gpu -//! ``` - -#![cfg(feature = "gpu")] - -use loki_render_cache::{ - CacheTier, PageCache, PageGeometry, PageIndex, ScrollState, allocate_texture, - texture::downsample_texture, -}; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Try to obtain a wgpu adapter + device. Returns `None` when no GPU or -/// software rasteriser is available (CI without GPU support). -fn try_wgpu() -> Option<(wgpu::Device, wgpu::Queue)> { - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); - let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::None, - compatible_surface: None, - force_fallback_adapter: true, - })) - .ok()?; - pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_defaults(), - ..Default::default() - })) - .ok() -} - -fn page_geom(index: u32) -> PageGeometry { - let top = f64::from(index) * 210.0; - PageGeometry { - index: PageIndex(index), - top_px: top, - bottom_px: top + 200.0, - } -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[test] -fn retier_marks_pages_for_rerender_and_cache_is_updated() { - // Tier-policy logic runs without GPU; this test verifies the metadata path. - let mut cache = PageCache::new(); - - let pages = [page_geom(0), page_geom(1)]; - let scroll = ScrollState::new(800.0); - let result = cache.retier(&pages, &scroll); - - // Both pages are uncached → retier should list them in `rerender`. - assert_eq!(result.rerender.len(), 2, "both pages should need re-render"); - assert!(result.downsample.is_empty()); - - // Simulate LokiPageSource inserting after render. - for (idx, tier) in &result.rerender { - cache.insert(*idx, *tier); - } - - for i in 0..2u32 { - let page = cache - .get(PageIndex(i)) - .expect("page should be in cache after insert"); - assert!(!page.dirty, "page {i} should be clean after insert"); - assert_eq!(page.tier, CacheTier::Hot, "page {i} should be Hot"); - } -} - -#[test] -fn allocate_and_downsample_texture_succeeds() { - let Some((device, queue)) = try_wgpu() else { - eprintln!("gpu_integration: no wgpu adapter — skipping"); - return; - }; - - let full = allocate_texture(&device, 100, 200, Some("test-full")); - assert_eq!(full.width, 100); - assert_eq!(full.height, 200); - - let half = downsample_texture(&device, &queue, &full, 0.5); - assert_eq!(half.width, 50); - assert_eq!(half.height, 100); -} diff --git a/loki-renderer/src/document_view.rs b/loki-renderer/src/document_view.rs index 5f422ee5..53934022 100644 --- a/loki-renderer/src/document_view.rs +++ b/loki-renderer/src/document_view.rs @@ -5,7 +5,6 @@ use std::sync::{Arc, Mutex}; -use appthere_canvas::ScrollState; #[cfg(any(not(target_os = "android"), android_gpu))] use appthere_ui::tokens; use dioxus::prelude::*; @@ -21,7 +20,6 @@ use crate::page_tile::PageTile; #[cfg(any(not(target_os = "android"), android_gpu))] use crate::render_layout::RenderMode; use crate::renderer_state::RendererState; -use crate::scroll_driver::{on_scroll_event, use_settle_detector}; // The HTML-flow fallback is only used on the Android CPU path; GPU targets // render reflow mode through the real layout engine (RenderMode::Reflow). @@ -125,13 +123,7 @@ impl PartialEq for DocumentViewProps { /// Root document rendering component. #[component] pub fn DocumentView(props: DocumentViewProps) -> Element { - // use_signal must be called at the top level — not inside use_hook — - // to avoid "hook list already borrowed: BorrowMutError". - let scroll = use_signal(|| ScrollState::new(props.viewport_height_px)); - // Bumped by on_settle after each retier; read below so a settle forces a - // re-render that repaints demoted tiles at their new resolution. - let settle_epoch = use_signal(|| 0u64); - let renderer = use_hook(|| RendererState::new(props.doc.clone(), scroll, settle_epoch)); + let renderer = use_hook(|| RendererState::new(props.doc.clone())); // Push the latest document into the page source on every render. // `update_doc` compares by Arc pointer and returns immediately when // the document has not changed since the last render, so this is @@ -150,17 +142,6 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { let _cursor_holder: Arc>> = use_hook(|| Arc::new(Mutex::new(None))); - let renderer_settle = renderer.clone(); - use_settle_detector(&renderer.phase_tx, move || { - renderer_settle.on_settle(); - }); - - let scroll = renderer.scroll; - let phase_tx = renderer.phase_tx.clone(); - let onscroll = move |evt: Event| { - on_scroll_event(scroll, evt.scroll_top(), &phase_tx); - }; - // ── Android CPU: flat web-style renderer ───────────────────────────────── // All hooks have been called above; early return is safe. #[cfg(all(target_os = "android", not(android_gpu)))] @@ -169,7 +150,6 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { return rsx! { div { style: "width: 100%; height: 100%;", - onscroll: onscroll, ReflowDocView { source: renderer.source.clone(), doc_gen } } }; @@ -202,11 +182,9 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { } let doc_gen = renderer.source.current_generation(); - // No per-generation retier: tile virtualization already bounds rendered - // pages to the viewport neighbourhood, and every mounted tile renders at - // full resolution (see LokiPageSource). Scheduling a retier on every - // mutation previously re-ran on each keystroke and, off a scroll position - // the renderer never receives, downsampled the page being edited. + // Tile virtualization bounds rendered pages to the viewport + // neighbourhood, and every mounted tile renders at full resolution + // (see LokiPageSource) — there is no resolution-tiering cache. const PTS_TO_CSS_PX: f64 = 96.0 / 72.0; let layout_guard = renderer.source.layout_for_generation(doc_gen); @@ -227,12 +205,7 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { }; drop(layout_guard); - let (hot, warm, cold) = renderer - .cache - .lock() - .map(|g| g.page_count_by_tier()) - .unwrap_or((0, 0, 0)); - tracing::debug!(hot, warm, cold, is_reflow, "DocumentView rendered"); + tracing::debug!(is_reflow, "DocumentView rendered"); // Caret + selection flow through for both modes; LokiPageSource paints // them via the page editing data (paginated) or the continuous editing @@ -268,9 +241,6 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { let on_tile_click = props.on_tile_click; let on_reflow_click = props.on_reflow_click; let on_reflow_drag = props.on_reflow_drag; - // Read (and subscribe to) the settle epoch so a scroll-settle retier - // re-renders this component and repaints demoted tiles. - let epoch = settle_epoch(); // White backdrop behind reflow tiles so any hairline seam where two // zero-gap bands meet shows white (matching the page) rather than the @@ -307,7 +277,6 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { return rsx! { div { style: "width: 100%; height: 100%;", - onscroll: onscroll, // PATCH(loki): this root mounts when the document content first // appears inside the editor's scroll container — typically after // an async load, replacing a one-page loading placeholder. The @@ -320,8 +289,8 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { // non-scrollable container (does nothing until a mouse-move forces // a re-resolve) and the scrollbar thumb is sized for one page. // The handler is intentionally empty — its mere presence makes the - // shell resolve layout and re-dispatch `onscroll` with the true - // content height the moment the document mounts. + // shell resolve layout and re-dispatch scroll geometry the moment + // the document mounts. onmounted: move |_| {}, div { style: format!( @@ -333,7 +302,6 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { if visible { PageTile { key: "{idx}", - cache: renderer.cache.clone(), source: renderer.source.clone(), page_index: idx, w, @@ -342,7 +310,6 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { cursor_holder: cursor_holder.clone(), selection, doc_gen, - settle_epoch: epoch, gap_px, // In reflow, hit-test the click here (this component // owns the reflow layout) and report the resolved diff --git a/loki-renderer/src/lib.rs b/loki-renderer/src/lib.rs index e998d064..1b5a8b7d 100644 --- a/loki-renderer/src/lib.rs +++ b/loki-renderer/src/lib.rs @@ -1,14 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 AppThere Loki contributors -//! Dioxus signal integration and orchestration layer for the Loki render cache. +//! Dioxus integration and orchestration layer for the Loki page renderer. //! //! | Module | Responsibility | //! |--------|----------------| -//! | [`scroll_driver`] | Re-exports from `appthere_canvas::dioxus::scroll_driver` | //! | [`doc_page_source`] | Layout + page-size source backed by `loki-doc-model` | //! | [`page_paint_source`] | Per-page `CustomPaintSource` (`LokiPageSource`) | -//! | [`renderer_state`] | [`RendererState`] — Dioxus context holding cache + scroll + renderer | +//! | [`renderer_state`] | [`RendererState`] — Dioxus context holding the page source + renderer | //! | [`document_view`] | [`DocumentView`] root component | #![forbid(unsafe_code)] @@ -26,7 +25,6 @@ pub(crate) mod page_tile; pub(crate) mod reflow_view; pub mod render_layout; pub mod renderer_state; -pub mod scroll_driver; pub(crate) mod vello_init; #[cfg(any(not(target_os = "android"), android_gpu))] pub(crate) mod virtualize; @@ -35,4 +33,3 @@ pub use doc_page_source::DocPageSource; pub use document_view::{DocumentView, DocumentViewProps, RendererCursorPos, ViewMode}; pub use render_layout::{RenderLayout, RenderMode}; pub use renderer_state::RendererState; -pub use scroll_driver::{on_scroll_event, use_settle_detector}; diff --git a/loki-renderer/src/page_paint_source.rs b/loki-renderer/src/page_paint_source.rs index c347fe1e..5f6a56b1 100644 --- a/loki-renderer/src/page_paint_source.rs +++ b/loki-renderer/src/page_paint_source.rs @@ -6,12 +6,15 @@ //! [`LokiPageSource`] implements [`CustomPaintSource`] so that Blitz's frame //! loop drives rendering. On each frame it: //! -//! 1. Reads the current [`CacheTier`] from the shared [`PageCache`]. -//! 2. Reuses the registered [`TextureHandle`] when tier, document generation, -//! and physical size are all unchanged (zero re-render cost). -//! 3. Otherwise unregisters the old texture, re-renders at the new tier's -//! scale via Vello, and registers the fresh texture with Blitz. -//! 4. Updates the cache to record the new tier assignment. +//! 1. Reuses the registered [`TextureHandle`] when the document generation, +//! physical size, and cursor are all unchanged (zero re-render cost). +//! 2. Otherwise unregisters the old texture, re-renders via Vello, and +//! registers the fresh texture with Blitz. +//! +//! Every mounted tile renders at full resolution. Tile virtualization (see +//! `DocumentView`) only mounts pages within ~one screen of the viewport, so +//! texture memory is bounded by mounting rather than by a resolution-tiering +//! cache. use std::sync::{Arc, Mutex}; @@ -19,7 +22,6 @@ use anyrender_vello::wgpu::{ Extent3d, TextureDimension, TextureFormat, TextureUsages, TextureViewDescriptor, }; use anyrender_vello::{CustomPaintCtx, CustomPaintSource, DeviceHandle, TextureHandle}; -use appthere_canvas::{CacheTier, PageCache, PageIndex}; use loki_vello::FontDataCache; use vello::{AaConfig, RenderParams, Scene}; @@ -34,8 +36,6 @@ static FIRST_TILE_RENDERED: std::sync::atomic::AtomicBool = // ── LokiPageSource ──────────────────────────────────────────────────────────── pub(crate) struct LokiPageSource { - /// Shared tier-and-dirty metadata for all pages. - cache: Arc>>, /// Document layout + page-size source. source: Arc, /// 0-based page index this source renders. @@ -53,8 +53,6 @@ pub(crate) struct LokiPageSource { font_cache: FontDataCache, /// Currently registered Blitz texture handle. texture_handle: Option, - /// Tier at which `texture_handle` was rendered. - texture_tier: Option, /// Document generation at which `texture_handle` was rendered. texture_generation: u64, /// Physical pixel dimensions `(w, h)` of `texture_handle`. @@ -68,14 +66,12 @@ pub(crate) struct LokiPageSource { impl LokiPageSource { pub(crate) fn new( - cache: Arc>>, source: Arc, page_index: usize, renderer: Arc>>, cursor_holder: Arc>>, ) -> Self { Self { - cache, source, page_index, renderer, @@ -83,7 +79,6 @@ impl LokiPageSource { wgpu_queue: None, font_cache: FontDataCache::new(), texture_handle: None, - texture_tier: None, texture_generation: 0, texture_size: (0, 0), cursor_holder, @@ -115,10 +110,29 @@ impl CustomPaintSource for LokiPageSource { fn suspend(&mut self) { // Renderer intentionally not dropped on suspend — shared across all page // sources; dropped when RendererState is dropped. + // + // The texture handle is cleared here without unregistering it from the + // renderer because suspend() has no CustomPaintCtx. That is safe for the + // app-level suspend path (the window renderer is recreated on resume, + // dropping every registered texture). Per-source teardown — a tile + // scrolling out of the virtualization window — instead goes through + // `release` below, which DOES unregister the texture; otherwise each + // unmounted page would leak its full-resolution texture (~10+ MB) in the + // renderer's registry, growing RAM without bound as the user scrolls. self.device = None; self.wgpu_queue = None; self.texture_handle = None; - self.texture_tier = None; + self.texture_generation = 0; + self.texture_size = (0, 0); + } + + fn release(&mut self, mut ctx: CustomPaintCtx<'_>) { + // The tile is being unregistered while the renderer is still live, so + // free the GPU texture this source registered. Without this the texture + // outlives the source in the renderer's registry (see suspend()). + if let Some(handle) = self.texture_handle.take() { + ctx.unregister_texture(handle); + } self.texture_generation = 0; self.texture_size = (0, 0); } @@ -134,30 +148,22 @@ impl CustomPaintSource for LokiPageSource { return None; }; - // Step 1: every mounted tile renders at full resolution. - // - // Tile virtualization (see DocumentView) only mounts pages within ~one - // screen of the viewport, so the resolution-tiering that downsampled - // far pages is now both redundant (memory is bounded by mounting) and - // wrong (it tiered off a scroll position the renderer never receives, - // downsampling the very page being viewed/edited). Always render Hot. - let current_tier = CacheTier::Hot; - - // Step 2: compute target physical texture dimensions. - let scale_factor = current_tier.scale_factor(); - let w_phys = ((width as f32 * scale_factor).ceil() as u32).max(1); - let h_phys = ((height as f32 * scale_factor).ceil() as u32).max(1); + // Step 1: target physical texture dimensions. Every mounted tile renders + // at full resolution — the canvas's own physical pixel size — so the + // texture is 1:1 with what Blitz composites; virtualization bounds memory + // by limiting which pages mount. + let w_phys = width.max(1); + let h_phys = height.max(1); - // Step 3: read current document generation. + // Step 2: read current document generation. let current_generation = self.source.current_generation(); // Read the current caret + selection from the shared holder. let current_sel: Option = self.cursor_holder.lock().ok().and_then(|g| *g); - // Step 4: reuse guard — return existing handle when nothing changed. + // Step 3: reuse guard — return existing handle when nothing changed. if self.texture_handle.is_some() - && self.texture_tier == Some(current_tier) && self.texture_generation == current_generation && self.texture_size == (w_phys, h_phys) && self.cursor_at_render == current_sel @@ -165,7 +171,7 @@ impl CustomPaintSource for LokiPageSource { return self.texture_handle.clone(); } - // Step 5: unregister stale texture before reallocating. + // Step 4: unregister stale texture before reallocating. if let Some(old) = self.texture_handle.take() { ctx.unregister_texture(old); } @@ -191,8 +197,8 @@ impl CustomPaintSource for LokiPageSource { }); let view = texture.create_view(&TextureViewDescriptor::default()); - // Step 7: build Vello scene for this page. - let render_scale = scale as f32 * scale_factor * (96.0 / 72.0); + // Step 6: build Vello scene for this page. + let render_scale = scale as f32 * (96.0 / 72.0); // Compute cursor paint data in a scoped block so the layout guard is // dropped before the second layout_for_generation call below. @@ -269,7 +275,6 @@ impl CustomPaintSource for LokiPageSource { if let Err(e) = renderer.render_to_texture(device, queue, &scene, &view, ¶ms) { tracing::error!( page = self.page_index, - tier = ?current_tier, error = %e, "LokiPageSource: render_to_texture failed", ); @@ -290,24 +295,17 @@ impl CustomPaintSource for LokiPageSource { ); } - // Step 9: register with Blitz and cache the handle. + // Step 7: register with Blitz and record the reuse-guard state. let handle = ctx.register_texture(texture); self.texture_handle = Some(handle.clone()); - self.texture_tier = Some(current_tier); self.texture_generation = current_generation; self.texture_size = (w_phys, h_phys); self.cursor_at_render = current_sel; - // Step 10: update cache metadata. - if let Ok(mut g) = self.cache.lock() { - g.insert(PageIndex(self.page_index as u32), current_tier); - } - tracing::debug!( - page = self.page_index, - tier = ?current_tier, - w = w_phys, - h = h_phys, + page = self.page_index, + w = w_phys, + h = h_phys, "LokiPageSource: rendered", ); diff --git a/loki-renderer/src/page_tile.rs b/loki-renderer/src/page_tile.rs index bf4e6b33..d7245268 100644 --- a/loki-renderer/src/page_tile.rs +++ b/loki-renderer/src/page_tile.rs @@ -7,7 +7,6 @@ use std::sync::{Arc, Mutex}; -use appthere_canvas::{PageCache, PageIndex}; use dioxus::native::use_wgpu; use dioxus::prelude::*; @@ -17,7 +16,6 @@ use crate::page_paint_source::LokiPageSource; #[derive(Clone, Props)] pub(crate) struct PageTileProps { - pub(crate) cache: Arc>>, pub(crate) source: Arc, pub(crate) page_index: usize, pub(crate) w: f64, @@ -28,9 +26,6 @@ pub(crate) struct PageTileProps { /// Document generation — incremented on every mutation so that style /// changes that don't move the cursor still dirty the canvas. pub(crate) doc_gen: u64, - /// Settle epoch — incremented after each scroll-settle retier so that a - /// tier change repaints this tile at its new resolution. - pub(crate) settle_epoch: u64, /// Vertical gap below this tile in CSS px — the page gap in paginated /// mode, `0` in reflow mode so bands stitch into a continuous flow. pub(crate) gap_px: f64, @@ -44,15 +39,13 @@ pub(crate) struct PageTileProps { impl PartialEq for PageTileProps { fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.cache, &other.cache) - && Arc::ptr_eq(&self.source, &other.source) + Arc::ptr_eq(&self.source, &other.source) && self.page_index == other.page_index && self.w == other.w && self.h == other.h && Arc::ptr_eq(&self.cursor_holder, &other.cursor_holder) && self.selection == other.selection && self.doc_gen == other.doc_gen - && self.settle_epoch == other.settle_epoch && self.gap_px == other.gap_px // event handlers intentionally excluded — identity does not affect // render output; omitting them avoids spurious re-renders. @@ -62,7 +55,6 @@ impl PartialEq for PageTileProps { /// A single page rendered into a Blitz GPU canvas. #[allow(non_snake_case)] pub(crate) fn PageTile(props: PageTileProps) -> Element { - let cache = props.cache.clone(); let source = props.source.clone(); let page_index = props.page_index; let shared_renderer = props.shared_renderer.clone(); @@ -78,36 +70,29 @@ pub(crate) fn PageTile(props: PageTileProps) -> Element { } let canvas_id = use_wgpu(move || { - LokiPageSource::new( - cache, - source, - page_index, - shared_renderer, - cursor_holder_wgpu, - ) + LokiPageSource::new(source, page_index, shared_renderer, cursor_holder_wgpu) }); // Marks the canvas dirty (so Blitz re-invokes the paint source) on caret, - // selection, document, or tier changes. When a range selection is active it - // is encoded into *every* tile's key — a selection can span bands, so all of + // selection, or document changes. When a range selection is active it is + // encoded into *every* tile's key — a selection can span bands, so all of // them must repaint as it changes. A collapsed caret only dirties the tile // it sits on, keeping plain caret movement cheap. // COMPAT(dioxus-native): data-* attributes confirmed working in Blitz. let data_cursor = match props.selection { Some(sel) if !sel.is_collapsed() => format!( - "s{}-{}-{}-{}-{}-{}", + "s{}-{}-{}-{}-{}", sel.anchor.paragraph_index, sel.anchor.byte_offset, sel.focus.paragraph_index, sel.focus.byte_offset, props.doc_gen, - props.settle_epoch ), Some(sel) if sel.focus.page_index == props.page_index => format!( - "{}-{}-{}-{}", - sel.focus.paragraph_index, sel.focus.byte_offset, props.doc_gen, props.settle_epoch + "{}-{}-{}", + sel.focus.paragraph_index, sel.focus.byte_offset, props.doc_gen ), - _ => format!("{}-{}", props.doc_gen, props.settle_epoch), + _ => format!("{}", props.doc_gen), }; rsx! { diff --git a/loki-renderer/src/render_layout.rs b/loki-renderer/src/render_layout.rs index d99e3041..4f66d18b 100644 --- a/loki-renderer/src/render_layout.rs +++ b/loki-renderer/src/render_layout.rs @@ -263,129 +263,5 @@ impl RenderLayout { } #[cfg(test)] -mod tests { - use super::*; - use loki_layout::ContinuousLayout; - - fn reflow(total_height: f32, tile_width_pt: f32) -> RenderLayout { - RenderLayout::Reflow { - layout: ContinuousLayout { - content_width: 0.0, - total_height, - items: vec![], - paragraphs: vec![], - }, - tile_width_pt, - } - } - - #[test] - fn reflow_tile_count_and_sizes() { - // 768 * 2 + 100 = 1636 → 3 tiles (two full, one 100pt remainder). - let rl = reflow(1636.0, 500.0); - assert_eq!(rl.page_count(), 3); - assert_eq!(rl.page_size_pts(0), Some((500.0, 768.0))); - assert_eq!(rl.page_size_pts(1), Some((500.0, 768.0))); - assert_eq!(rl.page_size_pts(2), Some((500.0, 100.0))); - assert_eq!(rl.page_size_pts(3), None); - assert!(rl.is_reflow()); - assert!(rl.as_paginated().is_none()); - } - - #[test] - fn reflow_always_has_at_least_one_tile() { - let rl = reflow(0.0, 400.0); - assert_eq!(rl.page_count(), 1); - assert_eq!(rl.page_size_pts(0), Some((400.0, 1.0))); - } - - fn one_para_reflow(text: &str, origin: (f32, f32)) -> RenderLayout { - use loki_layout::{ - FontResources, LayoutColor, PageParagraphData, ResolvedParaProps, StyleSpan, - layout_paragraph, - }; - 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, - 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, - }], - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - let height = para.height; - RenderLayout::Reflow { - layout: ContinuousLayout { - content_width: 400.0, - total_height: origin.1 + height, - items: vec![], - paragraphs: vec![PageParagraphData { - block_index: 3, - layout: std::sync::Arc::new(para), - origin, - }], - }, - tile_width_pt: 436.0, - } - } - - #[test] - fn reflow_hit_test_resolves_paragraph_and_offset() { - let rl = one_para_reflow("Hello world", (5.0, 10.0)); - // Click inside the paragraph: returns its block_index and a byte offset. - let (block, byte) = rl.reflow_hit_test(8.0, 12.0).expect("hit"); - assert_eq!(block, 3); - assert!(byte <= "Hello world".len()); - // Far past the end of the line maps to the last offset. - let (_, byte_end) = rl.reflow_hit_test(390.0, 12.0).expect("hit end"); - assert_eq!(byte_end, "Hello world".len()); - // Paginated layouts have no reflow hit-testing. - assert_eq!(reflow(100.0, 400.0).reflow_hit_test(8.0, 12.0), None); - } - - #[test] - fn reflow_caret_is_offset_by_paragraph_origin() { - let rl = one_para_reflow("Hello world", (5.0, 40.0)); - let cr = rl.reflow_cursor_canvas(3, 0).expect("caret at start"); - // Caret at byte 0 sits at the paragraph's canvas origin (x≈5, y≈40). - assert!((cr.x - 5.0).abs() < 2.0, "x={}", cr.x); - assert!((cr.y - 40.0).abs() < 4.0, "y={}", cr.y); - assert!(cr.height > 0.0); - // Unknown paragraph → None. - assert!(rl.reflow_cursor_canvas(99, 0).is_none()); - } - - #[test] - fn render_mode_width_tolerant_equality() { - let a = RenderMode::Reflow { - available_width_pt: 600.0, - }; - let b = RenderMode::Reflow { - available_width_pt: 600.3, - }; - let c = RenderMode::Reflow { - available_width_pt: 620.0, - }; - assert!(a.matches(&b)); - assert!(!a.matches(&c)); - assert!(!a.matches(&RenderMode::Paginated)); - } -} +#[path = "render_layout_tests.rs"] +mod tests; diff --git a/loki-renderer/src/render_layout_tests.rs b/loki-renderer/src/render_layout_tests.rs new file mode 100644 index 00000000..e8b0a164 --- /dev/null +++ b/loki-renderer/src/render_layout_tests.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `render_layout`. + +use super::*; +use loki_layout::ContinuousLayout; + +fn reflow(total_height: f32, tile_width_pt: f32) -> RenderLayout { + RenderLayout::Reflow { + layout: ContinuousLayout { + content_width: 0.0, + total_height, + items: vec![], + paragraphs: vec![], + }, + tile_width_pt, + } +} + +#[test] +fn reflow_tile_count_and_sizes() { + // 768 * 2 + 100 = 1636 → 3 tiles (two full, one 100pt remainder). + let rl = reflow(1636.0, 500.0); + assert_eq!(rl.page_count(), 3); + assert_eq!(rl.page_size_pts(0), Some((500.0, 768.0))); + assert_eq!(rl.page_size_pts(1), Some((500.0, 768.0))); + assert_eq!(rl.page_size_pts(2), Some((500.0, 100.0))); + assert_eq!(rl.page_size_pts(3), None); + assert!(rl.is_reflow()); + assert!(rl.as_paginated().is_none()); +} + +#[test] +fn reflow_always_has_at_least_one_tile() { + let rl = reflow(0.0, 400.0); + assert_eq!(rl.page_count(), 1); + assert_eq!(rl.page_size_pts(0), Some((400.0, 1.0))); +} + +fn one_para_reflow(text: &str, origin: (f32, f32)) -> RenderLayout { + use loki_layout::{ + FontResources, LayoutColor, PageParagraphData, ResolvedParaProps, StyleSpan, + layout_paragraph, + }; + 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, + }], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + ); + let height = para.height; + RenderLayout::Reflow { + layout: ContinuousLayout { + content_width: 400.0, + total_height: origin.1 + height, + items: vec![], + paragraphs: vec![PageParagraphData { + block_index: 3, + layout: std::sync::Arc::new(para), + origin, + }], + }, + tile_width_pt: 436.0, + } +} + +#[test] +fn reflow_hit_test_resolves_paragraph_and_offset() { + let rl = one_para_reflow("Hello world", (5.0, 10.0)); + // Click inside the paragraph: returns its block_index and a byte offset. + let (block, byte) = rl.reflow_hit_test(8.0, 12.0).expect("hit"); + assert_eq!(block, 3); + assert!(byte <= "Hello world".len()); + // Far past the end of the line maps to the last offset. + let (_, byte_end) = rl.reflow_hit_test(390.0, 12.0).expect("hit end"); + assert_eq!(byte_end, "Hello world".len()); + // Paginated layouts have no reflow hit-testing. + assert_eq!(reflow(100.0, 400.0).reflow_hit_test(8.0, 12.0), None); +} + +#[test] +fn reflow_caret_is_offset_by_paragraph_origin() { + let rl = one_para_reflow("Hello world", (5.0, 40.0)); + let cr = rl.reflow_cursor_canvas(3, 0).expect("caret at start"); + // Caret at byte 0 sits at the paragraph's canvas origin (x≈5, y≈40). + assert!((cr.x - 5.0).abs() < 2.0, "x={}", cr.x); + assert!((cr.y - 40.0).abs() < 4.0, "y={}", cr.y); + assert!(cr.height > 0.0); + // Unknown paragraph → None. + assert!(rl.reflow_cursor_canvas(99, 0).is_none()); +} + +#[test] +fn render_mode_width_tolerant_equality() { + let a = RenderMode::Reflow { + available_width_pt: 600.0, + }; + let b = RenderMode::Reflow { + available_width_pt: 600.3, + }; + let c = RenderMode::Reflow { + available_width_pt: 620.0, + }; + assert!(a.matches(&b)); + assert!(!a.matches(&c)); + assert!(!a.matches(&RenderMode::Paginated)); +} diff --git a/loki-renderer/src/renderer_state.rs b/loki-renderer/src/renderer_state.rs index 2bf02478..063881ae 100644 --- a/loki-renderer/src/renderer_state.rs +++ b/loki-renderer/src/renderer_state.rs @@ -1,144 +1,33 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 AppThere Loki contributors -//! [`RendererState`] — Dioxus context holding the render cache, scroll signal, -//! phase sender, and shared Vello renderer. +//! [`RendererState`] — Dioxus context holding the page source and shared Vello +//! renderer. use std::sync::{Arc, Mutex}; -use appthere_canvas::{PageCache, PageGeometry, PageIndex, ScrollPhase, ScrollState}; -use dioxus::prelude::{ReadableExt, Signal, WritableExt}; use loki_doc_model::document::Document; -use tokio::sync::watch; use crate::doc_page_source::DocPageSource; // ── RendererState ───────────────────────────────────────────────────────────── -/// Dioxus context that wires together the page cache, scroll signal, and -/// shared Vello renderer. +/// Dioxus context that wires together the page source and shared Vello renderer. #[derive(Clone)] pub struct RendererState { - /// Shared page tier-and-dirty metadata store. - pub cache: Arc>>, - /// Scroll position and phase signal. - pub scroll: Signal, /// Document layout and page-size source. pub source: Arc, - /// Watch sender for scroll phase — passed to `on_scroll_event` to drive - /// the event-based settle detector. - pub phase_tx: Arc>, /// Shared Vello renderer — created lazily by the first `LokiPageSource` /// to call `resume()`. All page sources for the same document share this. pub shared_renderer: Arc>>, - /// Incremented by [`RendererState::on_settle`] after each retier so that - /// `DocumentView` re-renders and every page tile's invalidation key - /// changes — this forces Blitz to repaint demoted pages at their new - /// (smaller) tier resolution, which is what actually frees texture memory. - pub settle_epoch: Signal, } impl RendererState { - /// Creates a new [`RendererState`] from values already initialised at the - /// component top level. - /// - /// `scroll` must be a `Signal` created with `use_signal` in the calling - /// component before this function is called. Hooks must not be called - /// inside `use_hook` closures; this function therefore accepts `scroll` - /// as a parameter instead of creating it internally. - pub fn new(doc: Arc, scroll: Signal, settle_epoch: Signal) -> Self { - let source = Arc::new(DocPageSource::new(doc)); - let cache = Arc::new(Mutex::new(PageCache::new())); - // The receiver is created on demand via `phase_tx.subscribe()` in the - // settle detector; the sender lives here for the view's lifetime. - let (tx, _rx) = watch::channel(ScrollPhase::Idle); - let phase_tx = Arc::new(tx); - let shared_renderer = Arc::new(Mutex::new(None)); + /// Creates a new [`RendererState`] for `doc`. + pub fn new(doc: Arc) -> Self { Self { - cache, - scroll, - source, - phase_tx, - shared_renderer, - settle_epoch, + source: Arc::new(DocPageSource::new(doc)), + shared_renderer: Arc::new(Mutex::new(None)), } } - - /// Called by the settle detector after each scroll gesture ends. - #[tracing::instrument(skip(self), fields(page_count = tracing::field::Empty))] - pub fn on_settle(&self) { - let doc_gen = self.source.current_generation(); - let layout_guard = self.source.layout_for_generation(doc_gen); - let Some((_, layout)) = layout_guard.as_ref() else { - tracing::warn!("RendererState::on_settle: layout unavailable"); - return; - }; - // loki-layout page dimensions are in typographic points (1pt = 1/72 inch). - // CSS pixels assume 96dpi. Conversion: 1pt = 96/72 CSS px. - // ScrollState.viewport_top_px is in CSS px; these must match. - const PTS_TO_CSS_PX: f64 = 96.0 / 72.0; - // Reflow tiles stack with zero gap (a continuous flow); paginated - // pages are separated by the page gap. Must match DocumentView's CSS. - let gap_px = if layout.is_reflow() { - 0.0 - } else { - appthere_ui::tokens::PAGE_GAP_PX as f64 - }; - let page_count = layout.page_count(); - let mut current_top = 0.0; - let mut pages = Vec::with_capacity(page_count); - for i in 0..page_count { - let h_px = layout - .page_size_pts(i) - .map(|(_, h)| h as f64 * PTS_TO_CSS_PX) - .unwrap_or(0.0); - pages.push(PageGeometry { - index: PageIndex(i as u32), - top_px: current_top, - bottom_px: current_top + h_px, - }); - current_top += h_px + gap_px; - } - drop(layout_guard); - tracing::Span::current().record("page_count", pages.len()); - - let scroll_guard = self.scroll.read(); - let mut cache = match self.cache.lock() { - Ok(g) => g, - Err(e) => { - tracing::warn!("RendererState::on_settle: cache lock poisoned; using inner"); - e.into_inner() - } - }; - - let result = cache.retier(&pages, &scroll_guard); - drop(cache); - drop(scroll_guard); - - let (hot, warm, cold) = self - .cache - .lock() - .map(|g| g.page_count_by_tier()) - .unwrap_or((0, 0, 0)); - - // If any page's tier changed, bump the settle epoch so DocumentView - // re-renders and the affected tiles repaint at their new resolution. - // Demoted (Hot→Warm/Cold) pages re-render smaller, freeing texture - // memory; promoted pages re-render at full resolution. Unchanged pages - // hit LokiPageSource's reuse guard and cost nothing. - if !result.rerender.is_empty() || !result.downsample.is_empty() { - let mut epoch = self.settle_epoch; - let next = epoch.peek().wrapping_add(1); - epoch.set(next); - } - - tracing::info!( - rerender = result.rerender.len(), - downsample = result.downsample.len(), - hot, - warm, - cold, - "retier complete", - ); - } } diff --git a/loki-renderer/src/scroll_driver.rs b/loki-renderer/src/scroll_driver.rs deleted file mode 100644 index 48be289b..00000000 --- a/loki-renderer/src/scroll_driver.rs +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 AppThere Loki contributors - -//! Re-exports of the event-driven scroll helpers from `appthere-canvas`. -//! -//! The implementation lives in [`appthere_canvas::dioxus::scroll_driver`]. -//! This module exists for API stability — existing code importing from -//! `loki_renderer::scroll_driver` continues to compile unchanged. - -pub use appthere_canvas::dioxus::scroll_driver::{on_scroll_event, use_settle_detector}; diff --git a/loki-sheet-model/Cargo.toml b/loki-sheet-model/Cargo.toml index f1a5f9ad..797516f7 100644 --- a/loki-sheet-model/Cargo.toml +++ b/loki-sheet-model/Cargo.toml @@ -21,3 +21,5 @@ serde_json = { version = "1", optional = true } [dev-dependencies] serde_json = "1" +# Integration tests drive the LoroDoc directly to exercise concurrent merges. +loro = "1.11.1" diff --git a/loki-sheet-model/tests/loro_concurrency_tests.rs b/loki-sheet-model/tests/loro_concurrency_tests.rs new file mode 100644 index 00000000..a222e54d --- /dev/null +++ b/loki-sheet-model/tests/loro_concurrency_tests.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Concurrent-edit, merge-convergence, and pathological-state tests for the +//! `loki-sheet-model` Loro CRDT bridge (audit T-6). +//! +//! The bridge stores each sheet's cells in a `cells` [`LoroMap`] keyed by +//! `"row,col"`, with each cell its own nested map. That structure means +//! concurrent edits to *different* cells merge cleanly (disjoint map keys), +//! while concurrent edits to the *same* cell converge to one deterministic +//! value (a CRDT register). These tests pin down both behaviours and the +//! snapshot/idempotency properties relied on for persistence and sync. + +use loki_sheet_model::loro_bridge::{KEY_METADATA, KEY_SHEETS, loro_to_workbook, workbook_to_loro}; +use loki_sheet_model::workbook::Workbook; +use loro::{ExportMode, LoroDoc, LoroMap}; + +/// Exchanges all operations between two replicas so both observe the full op +/// set. After this returns the two docs must converge. +fn sync(a: &LoroDoc, b: &LoroDoc) { + a.commit(); + b.commit(); + let a_updates = a.export(ExportMode::all_updates()).expect("export a"); + let b_updates = b.export(ExportMode::all_updates()).expect("export b"); + a.import(&b_updates).expect("a imports b"); + b.import(&a_updates).expect("b imports a"); + a.commit(); + b.commit(); +} + +/// Forks `base` into an independent replica that shares the same history. +fn fork(base: &LoroDoc) -> LoroDoc { + base.commit(); + base.fork() +} + +/// Returns the `cells` map of sheet 0 (always present after `workbook_to_loro`). +fn cells_map(doc: &LoroDoc) -> LoroMap { + let sheets = doc.get_list(KEY_SHEETS); + let sheet = sheets + .get(0) + .and_then(|v| v.into_container().ok()) + .and_then(|c| c.into_map().ok()) + .expect("sheet 0 map"); + sheet + .get("cells") + .and_then(|v| v.into_container().ok()) + .and_then(|c| c.into_map().ok()) + .expect("cells map") +} + +/// Sets the value of cell (`row`, `col`) on sheet 0 directly via the CRDT +/// containers — the bridge has no incremental cell-set helper yet. +fn set_cell_value(doc: &LoroDoc, row: u32, col: u32, value: &str) { + let cm = cells_map(doc); + let cell = cm + .insert_container(format!("{row},{col}").as_str(), LoroMap::new()) + .expect("insert cell map"); + cell.insert("value", value).expect("set value"); +} + +fn cell_value(wb: &Workbook, row: u32, col: u32) -> Option { + wb.get_sheet(0)?.get_cell(row, col).map(|c| c.value.clone()) +} + +// ── Round-trip through the public API (external-crate sanity) ───────────────── + +#[test] +fn round_trip_via_public_api() { + let original = Workbook::new(); + let doc = workbook_to_loro(&original).expect("to loro"); + let restored = loro_to_workbook(&doc).expect("from loro"); + assert_eq!(restored, original); +} + +// ── Structural merges: different cells / different keys ─────────────────────── + +#[test] +fn concurrent_edits_to_different_cells_converge() { + let a = workbook_to_loro(&Workbook::new()).expect("to loro"); + let b = fork(&a); + + set_cell_value(&a, 0, 0, "from-A"); + set_cell_value(&b, 5, 3, "from-B"); + + sync(&a, &b); + + assert_eq!( + a.get_deep_value(), + b.get_deep_value(), + "replicas must converge after concurrent edits to disjoint cells" + ); + let wb = loro_to_workbook(&a).expect("from loro"); + assert_eq!(cell_value(&wb, 0, 0).as_deref(), Some("from-A")); + assert_eq!(cell_value(&wb, 5, 3).as_deref(), Some("from-B")); +} + +#[test] +fn concurrent_metadata_edits_on_different_keys_converge() { + let a = workbook_to_loro(&Workbook::new()).expect("to loro"); + let b = fork(&a); + + a.get_map(KEY_METADATA) + .insert("title", "Budget") + .expect("set title"); + b.get_map(KEY_METADATA) + .insert("creator", "Ada") + .expect("set creator"); + + sync(&a, &b); + + assert_eq!(a.get_deep_value(), b.get_deep_value()); + let wb = loro_to_workbook(&a).expect("from loro"); + assert_eq!(wb.meta.title.as_deref(), Some("Budget")); + assert_eq!(wb.meta.creator.as_deref(), Some("Ada")); +} + +// ── Register semantics: same cell edited concurrently ───────────────────────── + +#[test] +fn concurrent_edit_of_same_cell_is_deterministic() { + let a = workbook_to_loro(&Workbook::new()).expect("to loro"); + let b = fork(&a); + + set_cell_value(&a, 2, 2, "value-A"); + set_cell_value(&b, 2, 2, "value-B"); + + sync(&a, &b); + + assert_eq!(a.get_deep_value(), b.get_deep_value()); + let wb = loro_to_workbook(&a).expect("from loro"); + let v = cell_value(&wb, 2, 2); + assert!( + matches!(v.as_deref(), Some("value-A") | Some("value-B")), + "one writer must win deterministically, got {v:?}" + ); +} + +// ── Pathological / edge states ──────────────────────────────────────────────── + +#[test] +fn snapshot_import_into_fresh_doc_preserves_state() { + let src = workbook_to_loro(&Workbook::new()).expect("to loro"); + set_cell_value(&src, 1, 1, "persisted"); + src.get_map(KEY_METADATA) + .insert("title", "Saved") + .expect("set title"); + src.commit(); + + let snapshot = src.export(ExportMode::snapshot()).expect("snapshot"); + let fresh = LoroDoc::new(); + fresh.import(&snapshot).expect("import snapshot"); + + assert_eq!( + src.get_deep_value(), + fresh.get_deep_value(), + "a snapshot imported into a fresh doc must reproduce the state exactly" + ); + let wb = loro_to_workbook(&fresh).expect("from loro"); + assert_eq!(cell_value(&wb, 1, 1).as_deref(), Some("persisted")); + assert_eq!(wb.meta.title.as_deref(), Some("Saved")); +} + +#[test] +fn idempotent_reimport_is_a_noop() { + let a = workbook_to_loro(&Workbook::new()).expect("to loro"); + let b = fork(&a); + set_cell_value(&b, 9, 9, "once"); + sync(&a, &b); + + let before = a.get_deep_value(); + let again = b.export(ExportMode::all_updates()).expect("re-export"); + a.import(&again).expect("re-import"); + a.commit(); + + assert_eq!(before, a.get_deep_value(), "re-import must be idempotent"); + let wb = loro_to_workbook(&a).expect("from loro"); + assert_eq!(cell_value(&wb, 9, 9).as_deref(), Some("once")); +} diff --git a/loki-spreadsheet/Cargo.toml b/loki-spreadsheet/Cargo.toml index 1d55419a..ae1a4f5d 100644 --- a/loki-spreadsheet/Cargo.toml +++ b/loki-spreadsheet/Cargo.toml @@ -32,6 +32,9 @@ loki-layout = { path = "../loki-layout" } loki-vello = { path = "../loki-vello" } # Shared AppThere design tokens and UI components. appthere-ui = { path = "../appthere-ui" } +loki-app-shell = { path = "../loki-app-shell" } +# Bundled UI font (Atkinson Hyperlegible Next) + Android fallback faces. +loki-fonts.workspace = true # Cross-platform file picker. loki-file-access.workspace = true # Typed error derivation. @@ -49,12 +52,12 @@ peniko = "0.5" # Kurbo geometry primitives. kurbo = "0.12" # Font config. -fontique = { version = "0.8", features = ["fontconfig-dlopen"] } +fontique = { version = "0.10", features = ["fontconfig-dlopen"] } loro = "1.11.1" # Unicode segmentation. unicode-segmentation = "1" # Platform-specific paths. -dirs = "5" +dirs = "6" # Serialisation. serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/loki-spreadsheet/src/app.rs b/loki-spreadsheet/src/app.rs index ef48fb57..28cf6742 100644 --- a/loki-spreadsheet/src/app.rs +++ b/loki-spreadsheet/src/app.rs @@ -20,7 +20,8 @@ pub fn App() -> Element { let active_tab: Signal = use_signal(|| 0usize); // 0 = Home tab // Recent-documents list. - let recent_docs: Signal = use_signal(RecentDocuments::load); + let recent_docs: Signal = + use_signal(|| RecentDocuments::load(crate::recent_documents::RECENT_FILE)); provide_context(tabs); provide_context(active_tab); @@ -40,14 +41,11 @@ pub fn App() -> Element { " } + // UI font, embedded as a `data:` URI so it bundles into the binary and + // loads on every platform (see `loki_fonts::ui_face_css`). document::Style { - "@font-face {{ - font-family: 'Atkinson Hyperlegible Next'; - src: url('dioxus:///assets/fonts/AtkinsonHyperlegibleNext-VF.ttf') - format('truetype'); - font-weight: 100 900; - font-style: normal; - }}" + r#type: "text/css", + "{loki_fonts::ui_face_css()}" } div { diff --git a/loki-spreadsheet/src/new_document.rs b/loki-spreadsheet/src/new_document.rs index b7c8d2b7..3033f59f 100644 --- a/loki-spreadsheet/src/new_document.rs +++ b/loki-spreadsheet/src/new_document.rs @@ -1,30 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -//! Blank spreadsheet creation and the `untitled-` path prefix. +//! Blank-document creation for `loki-spreadsheet` — re-exported from the shared +//! [`loki_app_shell::new_document`] / [`loki_app_shell::untitled`] modules. -use crate::tabs::OpenTab; -use loki_i18n::fl; -use std::sync::atomic::{AtomicU32, Ordering}; - -pub const UNTITLED_SCHEME: &str = "untitled-"; -static UNTITLED_COUNTER: AtomicU32 = AtomicU32::new(1); - -pub fn is_untitled(path: &str) -> bool { - path.starts_with(UNTITLED_SCHEME) -} - -pub fn new_blank_tab() -> OpenTab { - let n = UNTITLED_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = format!("{}{}", UNTITLED_SCHEME, n); - let title = if n == 1 { - fl!("editor-untitled") - } else { - fl!("editor-untitled-n", n = n as i64) - }; - OpenTab { - title, - path, - is_dirty: true, - is_discarded: false, - } -} +pub use loki_app_shell::new_document::new_blank_tab; +pub use loki_app_shell::untitled::{UNTITLED_SCHEME, is_untitled}; diff --git a/loki-spreadsheet/src/recent_documents.rs b/loki-spreadsheet/src/recent_documents.rs index d4a73183..11815ba3 100644 --- a/loki-spreadsheet/src/recent_documents.rs +++ b/loki-spreadsheet/src/recent_documents.rs @@ -1,114 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -//! Recent-spreadsheets list — persisted as JSON in the platform data directory. +//! Recent-documents persistence for `loki-spreadsheet` — re-exported from the +//! shared [`loki_app_shell::recent_documents`] module, with this app's storage +//! file name. Pass [`RECENT_FILE`] to `RecentDocuments::load`. -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -#[cfg(target_os = "android")] -use std::sync::OnceLock; +pub use loki_app_shell::recent_documents::*; -use crate::new_document; - -// ── Android data-dir override ───────────────────────────────────────────────── -// On Android, dirs::data_dir() returns None. android_main() calls -// set_android_data_dir() with the value from AndroidApp::internal_data_path() -// before Dioxus launches, giving recent_file_path() a writable location. - -#[cfg(target_os = "android")] -static ANDROID_DATA_DIR: OnceLock = OnceLock::new(); - -/// Store the Android internal data path before `dioxus::launch`. -/// Safe to call multiple times (ignored after the first call). -#[cfg(target_os = "android")] -pub fn set_android_data_dir(path: PathBuf) { - let _ = ANDROID_DATA_DIR.set(path); -} - -const MAX_RECENT: usize = 20; -const RECENT_FILE: &str = "AppThere/Loki/recent_spreadsheet.json"; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RecentEntry { - pub path: String, - pub title: String, - pub modified_at: String, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RecentDocuments { - pub entries: Vec, -} - -impl RecentDocuments { - pub fn load() -> Self { - recent_file_path() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() - } - - pub fn save(&self) { - let Some(path) = recent_file_path() else { - return; - }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string_pretty(self) { - let _ = std::fs::write(path, json); - } - } - - pub fn record(&mut self, path: String, title: String) { - if new_document::is_untitled(&path) { - return; - } - self.entries.retain(|e| e.path != path); - self.entries.insert( - 0, - RecentEntry { - path, - title, - modified_at: today_iso(), - }, - ); - self.entries.truncate(MAX_RECENT); - } - - pub fn remove(&mut self, path: &str) { - self.entries.retain(|e| e.path != path); - } -} - -// On Android CPU the cfg-gated return is always taken; "" fallback is -// unreachable on that target but reachable on desktop/GPU. -#[allow(unreachable_code)] -fn recent_file_path() -> Option { - #[cfg(target_os = "android")] - return ANDROID_DATA_DIR.get().map(|d| d.join(RECENT_FILE)); - - dirs::data_dir().map(|d| d.join(RECENT_FILE)) -} - -fn today_iso() -> String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let (y, m, d) = days_to_ymd(secs / 86400); - format!("{y:04}-{m:02}-{d:02}") -} - -fn days_to_ymd(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d) -} +/// Relative path under the platform data directory for this app's recent list. +pub const RECENT_FILE: &str = "AppThere/Loki/recent_spreadsheet.json"; diff --git a/loki-spreadsheet/src/tabs.rs b/loki-spreadsheet/src/tabs.rs index 1678ef8f..cf7e4483 100644 --- a/loki-spreadsheet/src/tabs.rs +++ b/loki-spreadsheet/src/tabs.rs @@ -1,20 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -//! Open-tab state for the `loki-spreadsheet` editor shell. -//! -//! [`OpenTab`] is injected into Dioxus context at the [`crate::app::App`] root -//! as a `Signal>`, alongside a `Signal` for the active -//! tab index (0 = Home tab). +//! Open-tab state for `loki-spreadsheet` — re-exported from [`loki_app_shell::tabs`]. -/// Represents a single open document tab. -#[derive(Clone, PartialEq)] -pub struct OpenTab { - /// Display title shown in the tab bar (filename stem, decoded). - pub title: String, - /// The serialised file access token / path used by the editor. - pub path: String, - /// Whether the document has unsaved changes. - pub is_dirty: bool, - /// Whether this tab has been discarded from memory. - pub is_discarded: bool, -} +pub use loki_app_shell::tabs::OpenTab; diff --git a/loki-templates/Cargo.toml b/loki-templates/Cargo.toml new file mode 100644 index 00000000..1155f2bc --- /dev/null +++ b/loki-templates/Cargo.toml @@ -0,0 +1,12 @@ +# Copyright 2026 AppThere Loki contributors +[package] +name = "loki-templates" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Bundled document templates (Markdown, APA, MLA, Screenplay, Resume) shipped as .dotx assets" + +[dependencies] +loki-doc-model = { path = "../loki-doc-model" } +loki-ooxml = { path = "../loki-ooxml" } +loki-primitives = { path = "../loki-primitives" } diff --git a/loki-templates/assets/apa.dotx b/loki-templates/assets/apa.dotx new file mode 100644 index 00000000..3fa4458c Binary files /dev/null and b/loki-templates/assets/apa.dotx differ diff --git a/loki-templates/assets/markdown.dotx b/loki-templates/assets/markdown.dotx new file mode 100644 index 00000000..756a52df Binary files /dev/null and b/loki-templates/assets/markdown.dotx differ diff --git a/loki-templates/assets/mla.dotx b/loki-templates/assets/mla.dotx new file mode 100644 index 00000000..343019d3 Binary files /dev/null and b/loki-templates/assets/mla.dotx differ diff --git a/loki-templates/assets/resume.dotx b/loki-templates/assets/resume.dotx new file mode 100644 index 00000000..6d038113 Binary files /dev/null and b/loki-templates/assets/resume.dotx differ diff --git a/loki-templates/assets/screenplay.dotx b/loki-templates/assets/screenplay.dotx new file mode 100644 index 00000000..eaad0dac Binary files /dev/null and b/loki-templates/assets/screenplay.dotx differ diff --git a/loki-templates/src/apa.rs b/loki-templates/src/apa.rs new file mode 100644 index 00000000..8d6ed751 --- /dev/null +++ b/loki-templates/src/apa.rs @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! APA 7th-edition student paper template. + +use loki_doc_model::document::Document; +use loki_doc_model::style::props::para_props::ParagraphAlignment::{Center, Left}; + +use crate::helpers::{Char, Para, assemble, letter_layout, p, style}; + +const SERIF: &str = "Times New Roman"; + +/// Builds the APA 7 paper template (Times New Roman 12 pt, double-spaced, +/// 1-inch margins, title-page block and level-1/2/3 headings). +pub(crate) fn build() -> Document { + let body_char = || Char { + font: Some(SERIF), + size: Some(12.0), + ..Default::default() + }; + let dbl = || Para { + line: Some(2.0), + ..Default::default() + }; + + let styles = vec![ + style( + "Normal", + "Normal", + None, + None, + &body_char(), + &Para { + line: Some(2.0), + indent_first: Some(36.0), + ..Default::default() + }, + ), + // Title page elements are centered, no first-line indent. + style( + "APATitle", + "Title", + Some("Normal"), + Some("Normal"), + &Char { + font: Some(SERIF), + size: Some(12.0), + bold: true, + ..Default::default() + }, + &Para { + align: Some(Center), + line: Some(2.0), + space_before: Some(96.0), + ..Default::default() + }, + ), + style( + "APACenter", + "Title Page Line", + Some("Normal"), + Some("APACenter"), + &body_char(), + &Para { + align: Some(Center), + line: Some(2.0), + ..Default::default() + }, + ), + // Headings (APA level 1 centered bold, 2 left bold, 3 left bold italic). + style( + "Heading1", + "Heading 1", + Some("Normal"), + Some("Normal"), + &Char { + font: Some(SERIF), + size: Some(12.0), + bold: true, + ..Default::default() + }, + &Para { + align: Some(Center), + outline: Some(1), + line: Some(2.0), + ..Default::default() + }, + ), + style( + "Heading2", + "Heading 2", + Some("Normal"), + Some("Normal"), + &Char { + font: Some(SERIF), + size: Some(12.0), + bold: true, + ..Default::default() + }, + &Para { + align: Some(Left), + outline: Some(2), + line: Some(2.0), + ..Default::default() + }, + ), + style( + "Heading3", + "Heading 3", + Some("Normal"), + Some("Normal"), + &Char { + font: Some(SERIF), + size: Some(12.0), + bold: true, + italic: true, + ..Default::default() + }, + &Para { + align: Some(Left), + outline: Some(3), + line: Some(2.0), + ..dbl() + }, + ), + ]; + + let body = vec![ + p("APACenter", ""), + p("APACenter", ""), + p("APATitle", "The Title of Your Paper"), + p("APACenter", "Your Name"), + p("APACenter", "Department of Psychology, University Name"), + p("APACenter", "PSY 101: Course Name"), + p("APACenter", "Instructor Name"), + p("APACenter", "Due Date"), + p("Heading1", "The Title of Your Paper"), + p( + "Normal", + "Begin the body of your paper here. The first line of each \ + paragraph is indented half an inch and the whole document is double-spaced \ + in 12-point Times New Roman, as APA 7 requires.", + ), + p("Heading2", "A Level 2 Heading"), + p( + "Normal", + "Use the heading styles to structure your sections; they carry \ + the correct APA alignment and emphasis.", + ), + ]; + + assemble("APA Paper", letter_layout(1.0), styles, body) +} diff --git a/loki-templates/src/assets.rs b/loki-templates/src/assets.rs new file mode 100644 index 00000000..82d50b34 --- /dev/null +++ b/loki-templates/src/assets.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Embedded `.dotx` template assets. +//! +//! The bundled templates ship as real `.dotx` files (template content type) so +//! they are genuine, inspectable Word templates. They are regenerated from the +//! builders by the `gen_templates` binary and imported here at runtime. The +//! builders use only properties the DOCX round-trip preserves, so importing the +//! asset reproduces the authored template faithfully. + +use std::io::Cursor; + +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentImport; +use loki_ooxml::docx::import::{DocxImport, DocxImportOptions}; + +/// Raw bytes of the bundled `.dotx` for template `id`. +fn asset_bytes(id: &str) -> Option<&'static [u8]> { + Some(match id { + "markdown" => include_bytes!("../assets/markdown.dotx"), + "apa" => include_bytes!("../assets/apa.dotx"), + "mla" => include_bytes!("../assets/mla.dotx"), + "screenplay" => include_bytes!("../assets/screenplay.dotx"), + "resume" => include_bytes!("../assets/resume.dotx"), + _ => return None, + }) +} + +/// Imports template `id`'s bundled `.dotx` asset into a [`Document`]. +pub(crate) fn document_from_asset(id: &str) -> Option { + let bytes = asset_bytes(id)?; + DocxImport::import(Cursor::new(bytes), DocxImportOptions::default()).ok() +} diff --git a/loki-templates/src/bin/gen_templates.rs b/loki-templates/src/bin/gen_templates.rs new file mode 100644 index 00000000..43f70009 --- /dev/null +++ b/loki-templates/src/bin/gen_templates.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Regenerates the bundled `.dotx` template assets from the builders. +//! +//! Run with `cargo run -p loki-templates --bin gen_templates`. Writes one +//! `assets/.dotx` per entry in `loki_templates::TEMPLATES`. + +use std::io::Cursor; +use std::path::Path; + +use loki_doc_model::io::DocumentExport; +use loki_ooxml::DocxTemplateExport; + +fn main() { + let out_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets"); + std::fs::create_dir_all(&out_dir).expect("create assets dir"); + + for t in loki_templates::TEMPLATES { + let doc = loki_templates::build_document(t.id).expect("known template id"); + let mut buf = Cursor::new(Vec::new()); + DocxTemplateExport::export(&doc, &mut buf, ()).expect("export .dotx"); + let path = out_dir.join(format!("{}.dotx", t.id)); + std::fs::write(&path, buf.into_inner()).expect("write asset"); + println!("wrote {}", path.display()); + } +} diff --git a/loki-templates/src/helpers.rs b/loki-templates/src/helpers.rs new file mode 100644 index 00000000..f9c5e739 --- /dev/null +++ b/loki-templates/src/helpers.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Shared builder helpers for the bundled templates. +//! +//! Templates are authored using only the properties that the DOCX exporter and +//! importer round-trip faithfully (font, size, bold/italic/underline, +//! alignment, indentation, spacing, line height, and named styles), so the +//! generated `.dotx` assets re-import losslessly. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::{Block, StyledParagraph}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::layout::page::{PageLayout, PageMargins, PageSize}; +use loki_doc_model::layout::section::Section; +use loki_doc_model::meta::DocumentMeta; +use loki_doc_model::style::catalog::{StyleCatalog, StyleId}; +use loki_doc_model::style::para_style::ParagraphStyle; +use loki_doc_model::style::props::char_props::{CharProps, UnderlineStyle}; +use loki_doc_model::style::props::para_props::{ + LineHeight, ParaProps, ParagraphAlignment, Spacing, +}; +use loki_primitives::units::Points; + +/// Points in `v` inches (1 in = 72 pt). +#[must_use] +pub(crate) fn inches(v: f64) -> Points { + Points::new(v * 72.0) +} + +/// A US-Letter page layout with uniform `margin_in`-inch margins. +#[must_use] +pub(crate) fn letter_layout(margin_in: f64) -> PageLayout { + let m = inches(margin_in); + PageLayout { + page_size: PageSize::letter(), + margins: PageMargins { + top: m, + bottom: m, + left: m, + right: m, + header: inches(0.5), + footer: inches(0.5), + gutter: Points::new(0.0), + }, + ..PageLayout::default() + } +} + +/// Character-formatting spec for a style (only round-trip-safe properties). +#[derive(Default, Clone)] +pub(crate) struct Char { + pub font: Option<&'static str>, + pub size: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +impl Char { + fn to_props(&self) -> CharProps { + CharProps { + font_name: self.font.map(str::to_string), + font_size: self.size.map(Points::new), + bold: self.bold.then_some(true), + italic: self.italic.then_some(true), + underline: self.underline.then_some(UnderlineStyle::Single), + ..Default::default() + } + } +} + +/// Paragraph-formatting spec for a style (measurements in points; `line` is a +/// line-spacing ratio, e.g. `2.0` for double spacing). +#[derive(Default, Clone)] +pub(crate) struct Para { + pub align: Option, + pub indent_first: Option, + pub indent_left: Option, + pub hanging: Option, + pub space_before: Option, + pub space_after: Option, + pub line: Option, + pub outline: Option, +} + +impl Para { + fn to_props(&self) -> ParaProps { + ParaProps { + alignment: self.align, + indent_first_line: self.indent_first.map(Points::new), + indent_start: self.indent_left.map(Points::new), + indent_hanging: self.hanging.map(Points::new), + space_before: self.space_before.map(|v| Spacing::Exact(Points::new(v))), + space_after: self.space_after.map(|v| Spacing::Exact(Points::new(v))), + line_height: self.line.map(LineHeight::Multiple), + outline_level: self.outline, + ..Default::default() + } + } +} + +/// Builds a named paragraph style. `Normal` and `Heading1`–`Heading6` are marked +/// built-in; every other id is a custom style. +#[must_use] +pub(crate) fn style( + id: &str, + name: &str, + parent: Option<&str>, + next: Option<&str>, + ch: &Char, + pa: &Para, +) -> ParagraphStyle { + let is_builtin = id == "Normal" || (id.starts_with("Heading") && id.len() == 8); + ParagraphStyle { + id: StyleId::new(id), + display_name: Some(name.to_string()), + parent: parent.map(StyleId::new), + linked_char_style: None, + next_style_id: next.map(str::to_string), + para_props: pa.to_props(), + char_props: ch.to_props(), + is_default: id == "Normal", + is_custom: !is_builtin, + extensions: Default::default(), + } +} + +/// A single plain-text inline run. +#[must_use] +pub(crate) fn inline(s: &str) -> Vec { + vec![Inline::Str(s.to_string())] +} + +/// A semantic heading block at `level` (1–6) with plain text `s`. Renders via +/// the catalog's `Heading{level}` style. +#[must_use] +pub(crate) fn heading_block(level: u8, s: &str) -> Block { + Block::Heading(level, NodeAttr::default(), inline(s)) +} + +/// A paragraph carrying named style `style_id` with plain text `s` (empty `s` +/// yields an empty paragraph used for vertical spacing). +#[must_use] +pub(crate) fn p(style_id: &str, s: &str) -> Block { + Block::StyledPara(StyledParagraph { + style_id: Some(StyleId::new(style_id)), + direct_para_props: None, + direct_char_props: None, + inlines: if s.is_empty() { + vec![] + } else { + vec![Inline::Str(s.to_string())] + }, + attr: NodeAttr::default(), + }) +} + +/// Assembles a single-section [`Document`] from a title, layout, styles, and body. +#[must_use] +pub(crate) fn assemble( + title: &str, + layout: PageLayout, + styles: Vec, + blocks: Vec, +) -> Document { + let mut catalog = StyleCatalog::new(); + for s in styles { + catalog.paragraph_styles.insert(s.id.clone(), s); + } + Document { + meta: DocumentMeta { + title: Some(title.to_string()), + ..Default::default() + }, + styles: catalog, + sections: vec![Section::with_layout_and_blocks(layout, blocks)], + settings: None, + comments: Vec::new(), + source: None, + } +} diff --git a/loki-templates/src/lib.rs b/loki-templates/src/lib.rs new file mode 100644 index 00000000..031005e1 --- /dev/null +++ b/loki-templates/src/lib.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Bundled document templates for the Loki suite. +//! +//! Five built-in templates — a Markdown-styled blank, an APA 7 paper, an MLA 9 +//! paper, a Hollywood screenplay, and a basic resume — are authored as +//! [`loki_doc_model::document::Document`] builders and shipped as generated +//! `.dotx` assets (regenerate with `cargo run -p loki-templates --bin +//! gen_templates`). [`document`] returns the bundled document for a template id; +//! the editor opens it as a new untitled document. + +#![forbid(unsafe_code)] + +mod apa; +mod assets; +mod helpers; +mod markdown; +mod mla; +mod resume; +mod screenplay; + +use loki_doc_model::document::Document; + +/// Stable id + presentation metadata for a bundled template. +pub struct TemplateInfo { + /// Short stable id, used in untitled paths and `document(id)`. + pub id: &'static str, + /// English display name (apps may localise via their own i18n keys). + pub name: &'static str, + /// Short format label for the gallery card. + pub format: &'static str, +} + +/// All bundled templates, in gallery order. +pub const TEMPLATES: &[TemplateInfo] = &[ + TemplateInfo { + id: "markdown", + name: "Markdown", + format: "DOTX", + }, + TemplateInfo { + id: "apa", + name: "APA Paper", + format: "DOTX", + }, + TemplateInfo { + id: "mla", + name: "MLA Paper", + format: "DOTX", + }, + TemplateInfo { + id: "screenplay", + name: "Screenplay", + format: "DOTX", + }, + TemplateInfo { + id: "resume", + name: "Resume", + format: "DOTX", + }, +]; + +/// Builds template `id`'s document programmatically — the source of truth from +/// which the `.dotx` assets are generated. Returns `None` for an unknown id. +#[must_use] +pub fn build_document(id: &str) -> Option { + Some(match id { + "markdown" => markdown::build(), + "apa" => apa::build(), + "mla" => mla::build(), + "screenplay" => screenplay::build(), + "resume" => resume::build(), + _ => return None, + }) +} + +/// Returns the bundled document for template `id`, imported from its `.dotx` +/// asset, or `None` for an unknown id. +#[must_use] +pub fn document(id: &str) -> Option { + assets::document_from_asset(id) +} diff --git a/loki-templates/src/markdown.rs b/loki-templates/src/markdown.rs new file mode 100644 index 00000000..3c79088a --- /dev/null +++ b/loki-templates/src/markdown.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Blank document with Markdown-inspired styles. + +use loki_doc_model::document::Document; +use loki_doc_model::style::para_style::ParagraphStyle; +use loki_doc_model::style::props::para_props::ParagraphAlignment; + +use crate::helpers::{Char, Para, assemble, heading_block, letter_layout, p, style}; + +const SANS: &str = "Arial"; +const MONO: &str = "Courier New"; + +/// Builds the Markdown-styled blank template. +pub(crate) fn build() -> Document { + let styles = vec![ + style( + "Normal", + "Normal", + None, + None, + &Char { + font: Some(SANS), + size: Some(11.0), + ..Default::default() + }, + &Para { + line: Some(1.45), + space_after: Some(8.0), + ..Default::default() + }, + ), + heading("Heading1", "Heading 1", 26.0, 1, 18.0, 6.0), + heading("Heading2", "Heading 2", 20.0, 2, 16.0, 5.0), + heading("Heading3", "Heading 3", 16.0, 3, 14.0, 4.0), + heading("Heading4", "Heading 4", 13.0, 4, 12.0, 4.0), + style( + "Blockquote", + "Block Quote", + Some("Normal"), + Some("Blockquote"), + &Char { + font: Some(SANS), + size: Some(11.0), + italic: true, + ..Default::default() + }, + &Para { + indent_left: Some(24.0), + space_after: Some(8.0), + line: Some(1.45), + ..Default::default() + }, + ), + style( + "CodeBlock", + "Code Block", + Some("Normal"), + Some("CodeBlock"), + &Char { + font: Some(MONO), + size: Some(10.0), + ..Default::default() + }, + &Para { + indent_left: Some(12.0), + space_after: Some(8.0), + ..Default::default() + }, + ), + ]; + + let body = vec![ + heading_block(1, "Document Title"), + p( + "Normal", + "Start writing here. This template ships paragraph and heading \ + styles inspired by rendered Markdown, so structure stays consistent.", + ), + heading_block(2, "A Section"), + p( + "Normal", + "Body text uses a clean sans-serif face with comfortable line \ + spacing. Apply the heading styles from the ribbon to build an outline.", + ), + p("Blockquote", "Block quotes are indented and italicised."), + p("CodeBlock", "code blocks use a monospace face"), + ]; + + assemble("Markdown Document", letter_layout(1.0), styles, body) +} + +/// A heading style with the given size, outline level, before/after spacing. +fn heading( + id: &str, + name: &str, + size: f64, + outline: u8, + before: f64, + after: f64, +) -> ParagraphStyle { + style( + id, + name, + Some("Normal"), + Some("Normal"), + &Char { + font: Some(SANS), + size: Some(size), + bold: true, + ..Default::default() + }, + &Para { + align: Some(ParagraphAlignment::Left), + outline: Some(outline), + space_before: Some(before), + space_after: Some(after), + ..Default::default() + }, + ) +} diff --git a/loki-templates/src/mla.rs b/loki-templates/src/mla.rs new file mode 100644 index 00000000..f7c2f310 --- /dev/null +++ b/loki-templates/src/mla.rs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! MLA 9th-edition paper template. + +use loki_doc_model::document::Document; +use loki_doc_model::style::props::para_props::ParagraphAlignment::Center; + +use crate::helpers::{Char, Para, assemble, letter_layout, p, style}; + +const SERIF: &str = "Times New Roman"; + +/// Builds the MLA 9 paper template (Times New Roman 12 pt, double-spaced, +/// 1-inch margins, four-line heading block, centered title, half-inch +/// first-line indent, and a hanging-indent Works Cited style). +pub(crate) fn build() -> Document { + let body = || Char { + font: Some(SERIF), + size: Some(12.0), + ..Default::default() + }; + + let styles = vec![ + // Body: double-spaced, half-inch first-line indent. + style( + "Normal", + "Normal", + None, + None, + &body(), + &Para { + line: Some(2.0), + indent_first: Some(36.0), + ..Default::default() + }, + ), + // The name/instructor/course/date block: double-spaced, no indent. + style( + "MLAHeading", + "Heading Block", + Some("Normal"), + Some("MLAHeading"), + &body(), + &Para { + line: Some(2.0), + ..Default::default() + }, + ), + // Centered title, no indent. + style( + "MLATitle", + "Title", + Some("Normal"), + Some("Normal"), + &body(), + &Para { + align: Some(Center), + line: Some(2.0), + ..Default::default() + }, + ), + // Works Cited entries: hanging indent of half an inch. + style( + "WorksCited", + "Works Cited Entry", + Some("Normal"), + Some("WorksCited"), + &body(), + &Para { + line: Some(2.0), + indent_left: Some(36.0), + hanging: Some(36.0), + ..Default::default() + }, + ), + ]; + + let body_blocks = vec![ + p("MLAHeading", "Your Name"), + p("MLAHeading", "Instructor Name"), + p("MLAHeading", "Course Number"), + p("MLAHeading", "Day Month Year"), + p("MLATitle", "The Title of Your Paper"), + p( + "Normal", + "Begin your essay here. MLA style uses 12-point Times New Roman, \ + double spacing throughout, and a half-inch first-line indent on each \ + paragraph. The four-line heading block above is flush left.", + ), + p("MLATitle", "Works Cited"), + p( + "WorksCited", + "Author Last, First. Title of Source. Publisher, Year.", + ), + ]; + + assemble("MLA Paper", letter_layout(1.0), styles, body_blocks) +} diff --git a/loki-templates/src/resume.rs b/loki-templates/src/resume.rs new file mode 100644 index 00000000..1c40209a --- /dev/null +++ b/loki-templates/src/resume.rs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Basic single-column resume template. + +use loki_doc_model::document::Document; +use loki_doc_model::style::props::para_props::ParagraphAlignment::Center; + +use crate::helpers::{Char, Para, assemble, letter_layout, p, style}; + +const SANS: &str = "Arial"; + +/// Builds the basic resume template (clean sans-serif, name banner, section +/// headings, and entry styles) on US Letter with 0.8-inch margins. +pub(crate) fn build() -> Document { + let body = || Char { + font: Some(SANS), + size: Some(10.5), + ..Default::default() + }; + + let styles = vec![ + style( + "Normal", + "Normal", + None, + None, + &body(), + &Para { + space_after: Some(4.0), + line: Some(1.15), + ..Default::default() + }, + ), + // Name banner. + style( + "ResumeName", + "Name", + Some("Normal"), + Some("ResumeContact"), + &Char { + font: Some(SANS), + size: Some(24.0), + bold: true, + ..Default::default() + }, + &Para { + align: Some(Center), + space_after: Some(2.0), + ..Default::default() + }, + ), + // Contact line under the name. + style( + "ResumeContact", + "Contact", + Some("Normal"), + Some("Normal"), + &Char { + font: Some(SANS), + size: Some(9.5), + ..Default::default() + }, + &Para { + align: Some(Center), + space_after: Some(10.0), + ..Default::default() + }, + ), + // Section heading (Experience, Education, …). + style( + "ResumeSection", + "Section Heading", + Some("Normal"), + Some("Normal"), + &Char { + font: Some(SANS), + size: Some(12.0), + bold: true, + ..Default::default() + }, + &Para { + outline: Some(1), + space_before: Some(10.0), + space_after: Some(4.0), + ..Default::default() + }, + ), + // Entry title (job / degree) and its detail line. + style( + "ResumeEntry", + "Entry Title", + Some("Normal"), + Some("Normal"), + &Char { + font: Some(SANS), + size: Some(11.0), + bold: true, + ..Default::default() + }, + &Para { + space_after: Some(1.0), + ..Default::default() + }, + ), + style( + "ResumeDetail", + "Entry Detail", + Some("Normal"), + Some("ResumeDetail"), + &Char { + font: Some(SANS), + size: Some(10.0), + italic: true, + ..Default::default() + }, + &Para { + space_after: Some(4.0), + ..Default::default() + }, + ), + ]; + + let body_blocks = vec![ + p("ResumeName", "Your Name"), + p( + "ResumeContact", + "City, State \u{2022} email@example.com \u{2022} (555) 123-4567 \u{2022} linkedin.com/in/you", + ), + p("ResumeSection", "Experience"), + p("ResumeEntry", "Job Title \u{2014} Company"), + p("ResumeDetail", "Location \u{2022} Start Date – End Date"), + p( + "Normal", + "Describe an accomplishment with a measurable result. Lead with a \ + strong verb and quantify the impact where you can.", + ), + p("ResumeSection", "Education"), + p("ResumeEntry", "Degree, Field of Study"), + p("ResumeDetail", "University Name \u{2022} Graduation Year"), + p("ResumeSection", "Skills"), + p( + "Normal", + "List relevant tools, languages, and competencies, separated by commas.", + ), + ]; + + assemble("Resume", letter_layout(0.8), styles, body_blocks) +} diff --git a/loki-templates/src/screenplay.rs b/loki-templates/src/screenplay.rs new file mode 100644 index 00000000..13d0861c --- /dev/null +++ b/loki-templates/src/screenplay.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Hollywood screenplay template (US-standard Courier formatting). +//! +//! Scene headings, character cues, and transitions are conventionally typed in +//! uppercase; the sample content uses literal capitals (rather than an all-caps +//! style property, which the DOCX round-trip does not preserve) so the bundled +//! asset re-imports faithfully. + +use loki_doc_model::document::Document; +use loki_doc_model::style::props::para_props::ParagraphAlignment::Right; + +use crate::helpers::{Char, Para, assemble, inches, letter_layout, p, style}; + +const MONO: &str = "Courier New"; + +fn courier() -> Char { + Char { + font: Some(MONO), + size: Some(12.0), + ..Default::default() + } +} + +/// Builds the screenplay template. Margins follow the US standard: 1.5-inch +/// left, 1-inch elsewhere. Element indents are measured from the text margin. +pub(crate) fn build() -> Document { + let styles = vec![ + // Action / Normal: full measure. + style( + "Normal", + "Action", + None, + None, + &courier(), + &Para { + space_after: Some(12.0), + ..Default::default() + }, + ), + // Scene heading (slug line): bold, space before. + style( + "SceneHeading", + "Scene Heading", + Some("Normal"), + Some("Normal"), + &Char { + font: Some(MONO), + size: Some(12.0), + bold: true, + ..Default::default() + }, + &Para { + space_before: Some(12.0), + space_after: Some(12.0), + outline: Some(1), + ..Default::default() + }, + ), + // Character cue: indented ~2.2in from the text margin. + style( + "Character", + "Character", + Some("Normal"), + Some("Dialogue"), + &courier(), + &Para { + indent_left: Some(158.0), + ..Default::default() + }, + ), + // Parenthetical: indented ~1.6in. + style( + "Parenthetical", + "Parenthetical", + Some("Normal"), + Some("Dialogue"), + &courier(), + &Para { + indent_left: Some(115.0), + ..Default::default() + }, + ), + // Dialogue: indented 1in, right inset ~1.5in. + style( + "Dialogue", + "Dialogue", + Some("Normal"), + Some("Dialogue"), + &courier(), + &Para { + indent_left: Some(72.0), + space_after: Some(12.0), + ..Default::default() + }, + ), + // Transition: right-aligned. + style( + "Transition", + "Transition", + Some("Normal"), + Some("SceneHeading"), + &courier(), + &Para { + align: Some(Right), + space_before: Some(12.0), + space_after: Some(12.0), + ..Default::default() + }, + ), + ]; + + let body = vec![ + p("SceneHeading", "INT. COFFEE SHOP - DAY"), + p( + "Normal", + "A quiet corner cafe. RAIN streaks the window. ALEX sits alone, \ + staring at a laptop that refuses to cooperate.", + ), + p("Character", "ALEX"), + p("Parenthetical", "(under their breath)"), + p("Dialogue", "Come on. Just compile. One time."), + p( + "Normal", + "The screen flickers. A cursor blinks, patient and unbothered.", + ), + p("Transition", "CUT TO:"), + ]; + + // US standard: 1.5-inch left margin, 1 inch elsewhere. + let mut layout = letter_layout(1.0); + layout.margins.left = inches(1.5); + assemble("Screenplay", layout, styles, body) +} diff --git a/loki-templates/tests/round_trip.rs b/loki-templates/tests/round_trip.rs new file mode 100644 index 00000000..417a796d --- /dev/null +++ b/loki-templates/tests/round_trip.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Each bundled `.dotx` asset must import back into a document whose authored +//! styles survived the DOCX round-trip. This guards both the asset bytes and +//! the export/import fidelity the builders rely on. + +use loki_doc_model::style::catalog::StyleId; +use loki_doc_model::style::props::para_props::{LineHeight, ParagraphAlignment}; + +fn style<'a>( + doc: &'a loki_doc_model::document::Document, + id: &str, +) -> &'a loki_doc_model::style::para_style::ParagraphStyle { + doc.styles + .paragraph_styles + .get(&StyleId::new(id)) + .unwrap_or_else(|| panic!("style {id} missing after round-trip")) +} + +#[test] +fn every_template_id_imports() { + for t in loki_templates::TEMPLATES { + assert!( + loki_templates::document(t.id).is_some(), + "template {} must import from its bundled asset", + t.id + ); + assert!(loki_templates::build_document(t.id).is_some()); + } + assert!(loki_templates::document("nope").is_none()); +} + +#[test] +fn markdown_carries_code_and_quote_styles() { + let doc = loki_templates::document("markdown").unwrap(); + assert_eq!( + style(&doc, "CodeBlock").char_props.font_name.as_deref(), + Some("Courier New") + ); + assert!(style(&doc, "Blockquote").char_props.italic == Some(true)); +} + +#[test] +fn apa_is_double_spaced_with_first_line_indent() { + let doc = loki_templates::document("apa").unwrap(); + let normal = style(&doc, "Normal"); + assert!( + matches!(normal.para_props.line_height, Some(LineHeight::Multiple(m)) if (m - 2.0).abs() < 0.01), + "APA body must be double-spaced, got {:?}", + normal.para_props.line_height + ); + assert_eq!( + normal + .para_props + .indent_first_line + .map(|p| p.value().round()), + Some(36.0) + ); + assert_eq!( + style(&doc, "Heading1").para_props.alignment, + Some(ParagraphAlignment::Center) + ); +} + +#[test] +fn mla_has_hanging_works_cited() { + let doc = loki_templates::document("mla").unwrap(); + let wc = style(&doc, "WorksCited"); + assert_eq!( + wc.para_props.indent_hanging.map(|p| p.value().round()), + Some(36.0) + ); +} + +#[test] +fn screenplay_uses_courier_and_indented_dialogue() { + let doc = loki_templates::document("screenplay").unwrap(); + assert_eq!( + style(&doc, "Normal").char_props.font_name.as_deref(), + Some("Courier New") + ); + assert_eq!( + style(&doc, "Dialogue") + .para_props + .indent_start + .map(|p| p.value().round()), + Some(72.0) + ); +} + +#[test] +fn resume_name_is_large_and_bold() { + let doc = loki_templates::document("resume").unwrap(); + let name = style(&doc, "ResumeName"); + assert_eq!( + name.char_props.font_size.map(|p| p.value().round()), + Some(24.0) + ); + assert_eq!(name.char_props.bold, Some(true)); +} diff --git a/loki-text/Cargo.toml b/loki-text/Cargo.toml index ba13fa80..fa29cae1 100644 --- a/loki-text/Cargo.toml +++ b/loki-text/Cargo.toml @@ -24,6 +24,8 @@ loki-doc-model = { path = "../loki-doc-model" } loki-ooxml = { path = "../loki-ooxml" } # ODT/ODF import — converts OpenDocument Text files to loki_doc_model::Document. loki-odf = { path = "../loki-odf" } +# Bundled document templates (Markdown, APA, MLA, Screenplay, Resume). +loki-templates = { path = "../loki-templates" } # PDF / PDF-X export for the Publish ribbon tab. loki-pdf = { path = "../loki-pdf" } # EPUB 3.3 export for the Publish ribbon tab. @@ -34,6 +36,7 @@ loki-layout = { path = "../loki-layout" } loki-vello = { path = "../loki-vello" } # Shared AppThere design tokens and UI components. appthere-ui = { path = "../appthere-ui" } +loki-app-shell = { path = "../loki-app-shell" } # Cross-platform file picker (crates.io). loki-file-access.workspace = true # Typed error derivation. @@ -50,19 +53,19 @@ vello = "0.6" peniko = "0.5" # Kurbo geometry primitives (Rect, Stroke, Affine) for scene construction. kurbo = "0.12" -# Force fontconfig-dlopen on fontique v0.8 so it uses the same dlopen path as +# Force fontconfig-dlopen on fontique so it uses the same dlopen path as # fontique v0.6 (pulled in by blitz-dom), which activates # yeslogic-fontconfig-sys/dlopen for the whole workspace. Without this, -# fontique v0.8 would try to use static C function symbols that are absent when +# fontique would try to use static C function symbols that are absent when # dlopen mode is in effect. -fontique = { version = "0.8", features = ["fontconfig-dlopen"] } +fontique = { version = "0.10", features = ["fontconfig-dlopen"] } loro = "1.11.1" # oneshot channel used to await the off-main-thread document-open layout. futures-channel = "0.3" # Unicode grapheme cluster segmentation for correct backspace/delete behaviour. unicode-segmentation = "1" # Platform-specific data/config directory paths for recent-documents persistence. -dirs = "5" +dirs = "6" # JSON serialisation for recent-documents file. serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/loki-text/src/app.rs b/loki-text/src/app.rs index 905515c7..674f3107 100644 --- a/loki-text/src/app.rs +++ b/loki-text/src/app.rs @@ -90,7 +90,8 @@ pub fn App() -> Element { let active_tab: Signal = use_signal(|| 0usize); // 0 = Home tab // Recent-documents list — loaded once from disk at startup. - let recent_docs: Signal = use_signal(RecentDocuments::load); + let recent_docs: Signal = + use_signal(|| RecentDocuments::load(crate::recent_documents::RECENT_FILE)); // Stashed editing sessions for inactive document tabs — unsaved edits // survive tab switches by round-tripping through this map. @@ -119,37 +120,15 @@ pub fn App() -> Element { " } - // Register Atkinson Hyperlegible Next via CSS @font-face. - // - // Blitz processes @font-face rules and fetches the font URL through its - // net provider. The `dioxus://` URL is resolved by - // `dioxus_asset_resolver::native::serve_asset`, which looks for the - // file relative to the running executable's directory: - // debug builds: target/debug/assets/fonts/AtkinsonHyperlegibleNext-VF.ttf - // (loki-text/build.rs creates a symlink from target/debug/assets → - // loki-text/assets/ so the file is reachable without copying.) - // - // The variable font covers all weights (100–900) in a single file, - // so one @font-face rule covers all weight variants. - // - // // TODO(font): verify Atkinson Hyperlegible Next is rendering correctly - // in the running app — check that chrome text is NOT in system-ui. - // If the dioxus:// URL fails to resolve (e.g. symlink missing), the - // FONT_FAMILY_UI fallback chain ("Atkinson Hyperlegible, system-ui") - // applies automatically. + // Register Atkinson Hyperlegible Next as the UI font, embedded as a + // `data:` URI so it is bundled into the binary and decoded by blitz_net + // on every platform. The previous `dioxus:///assets/...` URL resolved + // relative to the executable and did not load on Android/ChromeOS (and + // silently relied on a system-installed copy on desktop). See + // `loki_fonts::ui_face_css`. document::Style { - // COMPAT(dioxus-native): CSS @font-face with dioxus:// URLs is - // confirmed supported — Blitz's net.rs fetch_font_face triggers - // parley FontContext.collection.register_fonts() on success. - // The dioxus:// scheme is handled by DioxusNativeNetProvider which - // delegates to dioxus_asset_resolver::native::serve_asset. - "@font-face {{ - font-family: 'Atkinson Hyperlegible Next'; - src: url('dioxus:///assets/fonts/AtkinsonHyperlegibleNext-VF.ttf') - format('truetype'); - font-weight: 100 900; - font-style: normal; - }}" + r#type: "text/css", + "{loki_fonts::ui_face_css()}" } // Bundled fallback fonts for the Android CPU renderer — Carlito (Calibri), diff --git a/loki-text/src/editing/hit_test.rs b/loki-text/src/editing/hit_test.rs index 93e76e65..8c65171c 100644 --- a/loki-text/src/editing/hit_test.rs +++ b/loki-text/src/editing/hit_test.rs @@ -183,6 +183,7 @@ mod tests { font_name: None, font_size: 12.0, bold: false, + weight: 400, italic: false, color: LayoutColor::BLACK, underline: None, @@ -195,6 +196,7 @@ mod tests { word_spacing: None, shadow: false, link_url: None, + math: None, }], &ResolvedParaProps::default(), 400.0, @@ -222,6 +224,7 @@ mod tests { 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), diff --git a/loki-text/src/editing/navigation.rs b/loki-text/src/editing/navigation.rs index be8945d7..d6c4ed24 100644 --- a/loki-text/src/editing/navigation.rs +++ b/loki-text/src/editing/navigation.rs @@ -330,6 +330,7 @@ mod tests { font_name: None, font_size: 12.0, bold: false, + weight: 400, italic: false, color: LayoutColor::BLACK, underline: None, @@ -342,6 +343,7 @@ mod tests { word_spacing: None, shadow: false, link_url: None, + math: None, }], &ResolvedParaProps::default(), 400.0, @@ -369,6 +371,7 @@ mod tests { 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), @@ -479,6 +482,7 @@ mod tests { font_name: None, font_size: 12.0, bold: false, + weight: 400, italic: false, color: LayoutColor::BLACK, underline: None, @@ -491,6 +495,7 @@ mod tests { word_spacing: None, shadow: false, link_url: None, + math: None, }; let para0 = layout_paragraph( &mut resources, @@ -539,6 +544,7 @@ mod tests { 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), diff --git a/loki-text/src/editing/reflow_nav.rs b/loki-text/src/editing/reflow_nav.rs index 2d65c665..4f33e209 100644 --- a/loki-text/src/editing/reflow_nav.rs +++ b/loki-text/src/editing/reflow_nav.rs @@ -169,6 +169,7 @@ mod tests { font_name: None, font_size: 12.0, bold: false, + weight: 400, italic: false, color: LayoutColor::BLACK, underline: None, @@ -181,6 +182,7 @@ mod tests { word_spacing: None, shadow: false, link_url: None, + math: None, }], &ResolvedParaProps::default(), 400.0, diff --git a/loki-text/src/editing/relayout.rs b/loki-text/src/editing/relayout.rs index 6b53e71b..ae8f3768 100644 --- a/loki-text/src/editing/relayout.rs +++ b/loki-text/src/editing/relayout.rs @@ -51,8 +51,16 @@ pub(crate) fn relayout_paginated( } /// Page count and CSS-pixel page dimensions (points × 96/72) for a layout. +/// +/// When any page carries comment-panel items, the width is widened by the +/// comment gutter so the panel to the right of the page is reachable. pub(crate) fn page_metrics(layout: &PaginatedLayout) -> (usize, f32, f32) { - let w = layout.page_size.width * (96.0 / 72.0); + let has_comments = layout.pages.iter().any(|p| !p.comment_items.is_empty()); + let mut width_pt = layout.page_size.width; + if has_comments { + width_pt += loki_layout::COMMENT_GUTTER_WIDTH; + } + let w = width_pt * (96.0 / 72.0); let h = layout.page_size.height * (96.0 / 72.0); (layout.pages.len(), w, h) } diff --git a/loki-text/src/editing/state.rs b/loki-text/src/editing/state.rs index e1911eb3..f46a0fa3 100644 --- a/loki-text/src/editing/state.rs +++ b/loki-text/src/editing/state.rs @@ -241,11 +241,11 @@ pub fn apply_mutation_and_relayout( None => return false, }; if let Some(orig) = &state.document { - // Styles and source are not stored in the CRDT, so carry them - // forward. Metadata *is* round-tripped through Loro (read back by - // `loro_to_document`), so it is intentionally not carried forward - // here — the Loro snapshot is the source of truth. - doc.styles = orig.styles.clone(); + // `source` is not stored in the CRDT, so carry it forward. Metadata + // and the style catalog *are* round-tripped through Loro (read back + // by `loro_to_document`), so they are intentionally not carried + // forward here — the Loro snapshot is the source of truth, which is + // what makes style edits undoable. doc.source = orig.source.clone(); } doc @@ -278,6 +278,7 @@ pub fn apply_mutation_and_relayout( let (page_count, page_width_px, page_height_px) = page_metrics(&laid_out.layout); // Step 4: Publish. + let block_count: usize = doc.sections.iter().map(|s| s.blocks.len()).sum(); let Ok(mut state) = doc_state.lock() else { tracing::warn!("apply_mutation_and_relayout: doc_state lock poisoned (publish)"); return false; @@ -289,5 +290,41 @@ pub fn apply_mutation_and_relayout( state.page_width_px = page_width_px; state.page_height_px = page_height_px; state.generation = state.generation.wrapping_add(1); + drop(state); + + log_memory_counters(loro_doc, page_count, block_count); true } + +/// Throttled, opt-in memory instrumentation for the edit session. +/// +/// The Loro oplog and rich-text tombstones grow with edit history and never +/// auto-compact (see `docs/memory-audit-2026-06-12.md`, Finding 6), so a long +/// editing session can balloon resident memory. Because the headless build has +/// no profiler, this logs the cheap Loro op/change counters (and the document's +/// stable page/block counts for contrast) on the edit path so the grower can be +/// identified on-device: +/// +/// ```text +/// RUST_LOG=loki_text::mem=info cargo run -p loki-text --release +/// ``` +/// +/// `loro_ops` climbing without bound while `pages`/`blocks` stay flat confirms +/// the history is the leak. Throttled to one log per 64 mutations to stay cheap. +fn log_memory_counters(loro_doc: &loro::LoroDoc, page_count: usize, block_count: usize) { + use std::sync::atomic::{AtomicU64, Ordering}; + static MUTATIONS: AtomicU64 = AtomicU64::new(0); + let n = MUTATIONS.fetch_add(1, Ordering::Relaxed); + if !n.is_multiple_of(64) { + return; + } + tracing::info!( + target: "loki_text::mem", + mutations = n, + loro_ops = loro_doc.len_ops(), + loro_changes = loro_doc.len_changes(), + pages = page_count, + blocks = block_count, + "edit-session memory counters", + ); +} diff --git a/loki-text/src/new_document.rs b/loki-text/src/new_document.rs index 191f1f18..f2c866a9 100644 --- a/loki-text/src/new_document.rs +++ b/loki-text/src/new_document.rs @@ -1,57 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -//! Blank document creation and the `untitled-` path prefix. -//! -//! Every new blank document gets a unique `untitled-N` path for its session -//! lifetime. The path is never persisted or added to the recent-documents list. -//! -//! ## Why not `untitled://`? -//! -//! Dioxus Router's PATH_ASCII_SET does not encode `:` or `/`, so a path like -//! `"untitled://1"` would serialise to the URL `/editor/untitled://1`. The -//! router splits URLs by `/` and would see four segments instead of the two -//! that `#[route("/editor/:path")]` expects, causing a match failure. -//! `"untitled-1"` is a plain alphanumeric string that is safe as a URL segment. +//! Blank-document creation for `loki-text` — re-exported from the shared +//! [`loki_app_shell::new_document`] / [`loki_app_shell::untitled`] modules. -use std::sync::atomic::{AtomicU32, Ordering}; - -use loki_i18n::fl; - -use crate::tabs::OpenTab; - -/// Path prefix for unsaved blank documents. -/// -/// Produces paths like `"untitled-1"`, `"untitled-2"` — URL-safe alphanumeric -/// strings with a hyphen separator. -pub const UNTITLED_SCHEME: &str = "untitled-"; - -/// Session-scoped counter — incremented once per [`new_blank_tab`] call. -static UNTITLED_COUNTER: AtomicU32 = AtomicU32::new(1); - -/// Returns `true` if `path` refers to an unsaved blank document. -pub fn is_untitled(path: &str) -> bool { - path.starts_with(UNTITLED_SCHEME) -} - -/// Creates a new blank-document tab with a unique `untitled-N` path. -/// -/// The tab is pre-marked `is_dirty: true` because the document has never been -/// saved; the title bar will show the unsaved-changes indicator immediately. -/// -/// Title convention matches macOS: first blank doc is "Untitled", subsequent -/// ones are "Untitled 2", "Untitled 3", … -pub fn new_blank_tab() -> OpenTab { - let n = UNTITLED_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = format!("{}{}", UNTITLED_SCHEME, n); - let title = if n == 1 { - fl!("editor-untitled") - } else { - fl!("editor-untitled-n", n = n as i64) - }; - OpenTab { - title, - path, - is_dirty: true, - is_discarded: false, - } -} +pub use loki_app_shell::new_document::{new_blank_tab, new_import_tab, new_template_tab}; +pub use loki_app_shell::untitled::{ + NewDocSource, UNTITLED_SCHEME, is_untitled, parse_new_doc_source, +}; diff --git a/loki-text/src/recent_documents.rs b/loki-text/src/recent_documents.rs index d5ce87cd..e3752875 100644 --- a/loki-text/src/recent_documents.rs +++ b/loki-text/src/recent_documents.rs @@ -1,143 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -//! Recent-documents list — persisted as JSON in the platform data directory. -//! -//! The list is stored at `{data_dir}/AppThere/Loki/recent.json` and capped at -//! [`MAX_RECENT`] entries. `untitled-N` paths are never recorded. +//! Recent-documents persistence for `loki-text` — re-exported from the shared +//! [`loki_app_shell::recent_documents`] module, with this app's storage file +//! name. Pass [`RECENT_FILE`] to `RecentDocuments::load`. -use std::path::PathBuf; -#[cfg(target_os = "android")] -use std::sync::OnceLock; +pub use loki_app_shell::recent_documents::*; -use serde::{Deserialize, Serialize}; - -use crate::new_document; - -// ── Android data-dir override ───────────────────────────────────────────────── -// On Android, dirs::data_dir() returns None. android_main() calls -// set_android_data_dir() with the value from AndroidApp::internal_data_path() -// before Dioxus launches, giving recent_file_path() a writable location. - -#[cfg(target_os = "android")] -static ANDROID_DATA_DIR: OnceLock = OnceLock::new(); - -/// Store the Android internal data path before `dioxus::launch`. -/// Safe to call multiple times (ignored after the first call). -#[cfg(target_os = "android")] -pub fn set_android_data_dir(path: PathBuf) { - let _ = ANDROID_DATA_DIR.set(path); -} - -const MAX_RECENT: usize = 20; -const RECENT_FILE: &str = "AppThere/Loki/recent.json"; - -/// A single entry in the recent-documents list. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RecentEntry { - /// The serialised file-access token used as the editor route path. - pub path: String, - /// Human-readable document title (filename stem). - pub title: String, - /// ISO 8601 date of the last open, e.g. `"2026-05-13"`. - pub modified_at: String, -} - -/// In-memory recent-documents list. -/// -/// Inject as a `Signal` at the app root so all components -/// can read and mutate the list reactively. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct RecentDocuments { - pub entries: Vec, -} - -impl RecentDocuments { - /// Load from the platform data directory, or return an empty list on any - /// error (missing file, parse failure, permissions). - pub fn load() -> Self { - recent_file_path() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() - } - - /// Persist to the platform data directory. Errors are silently ignored - /// (disk full, read-only FS, etc.) — the app continues without crashing. - pub fn save(&self) { - let Some(path) = recent_file_path() else { - return; - }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string_pretty(self) { - let _ = std::fs::write(path, json); - } - } - - /// Record a document open. - /// - /// Moves the entry to the front if already present; otherwise inserts at - /// the front. Caps the list at [`MAX_RECENT`] entries. - /// `untitled-N` paths are silently ignored. - pub fn record(&mut self, path: String, title: String) { - if new_document::is_untitled(&path) { - return; - } - self.entries.retain(|e| e.path != path); - self.entries.insert( - 0, - RecentEntry { - path, - title, - modified_at: today_iso(), - }, - ); - self.entries.truncate(MAX_RECENT); - } - - /// Remove a recent entry by path (e.g. after a "remove from list" action). - pub fn remove(&mut self, path: &str) { - self.entries.retain(|e| e.path != path); - } -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -// On Android CPU the cfg-gated return is always taken; "" fallback is -// unreachable on that target but reachable on desktop/GPU. -#[allow(unreachable_code)] -fn recent_file_path() -> Option { - #[cfg(target_os = "android")] - return ANDROID_DATA_DIR.get().map(|d| d.join(RECENT_FILE)); - - dirs::data_dir().map(|d| d.join(RECENT_FILE)) -} - -/// Returns today's date as `"YYYY-MM-DD"` using only `std`. -fn today_iso() -> String { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let (y, m, d) = days_to_ymd(secs / 86400); - format!("{y:04}-{m:02}-{d:02}") -} - -/// Civil-calendar conversion from days-since-Unix-epoch to (year, month, day). -/// -/// Algorithm: Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms" -/// -fn days_to_ymd(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d) -} +/// Relative path under the platform data directory for this app's recent list. +pub const RECENT_FILE: &str = "AppThere/Loki/recent.json"; diff --git a/loki-text/src/routes/editor/editor_canvas.rs b/loki-text/src/routes/editor/editor_canvas.rs index aba62e84..cdb0ae60 100644 --- a/loki-text/src/routes/editor/editor_canvas.rs +++ b/loki-text/src/routes/editor/editor_canvas.rs @@ -22,11 +22,8 @@ //! collects the nodes whose offsets changed (`scroll_node_by_collect`), //! blitz-shell forwards them via `Document::handle_scroll_changes`, and //! dioxus-native-dom dispatches `scroll` events with `NativeScrollData` — -//! the `onscroll` handler below receives them. -//! -//! TODO(partial-render): also feed scroll_offset into DocumentView's -//! ScrollState (appthere_canvas::on_scroll_event) so cache tiering tracks -//! the real viewport instead of assuming the top of the document. +//! the `onscroll` handler below receives them. The scroll offset is passed to +//! `DocumentView` as `viewport_top_px`, which drives tile virtualization. //! //! Click-to-cursor-position is handled by `make_mousedown_handler` in //! `editor_pointer.rs`, which calls `hit_test_document` and updates the cursor. diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index f629f8b8..1e88feae 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -19,6 +19,7 @@ //! 3. All per-document state is reset synchronously when path changes so the //! reset happens before `use_resource` evaluates. +use std::rc::Rc; use std::sync::Arc; use appthere_ui::tokens; @@ -42,9 +43,12 @@ use super::editor_path_sync::{ }; use super::editor_publish::{publish_panel, publish_tab_content}; use super::editor_ribbon::home_tab_content; -use super::editor_save::{export_document_to_token, save_document_to_path}; +use super::editor_save::{ + export_document_to_token, export_template_to_token, save_document_to_path, +}; use super::editor_state::{EditorState, use_editor_state}; 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; @@ -58,6 +62,9 @@ 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"; +/// MIME type used by the "Save as Template" flow (Word `.dotx`). +const DOTX_MIME: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.template"; + /// Viewport width (logical px) below which the editor defaults to the /// reflowable view: a US-Letter page (~816px) plus margins no longer fits, so /// paginated view would otherwise force horizontal scrolling. The user can @@ -251,6 +258,15 @@ pub(super) fn EditorInner(path: String) -> Element { let doc_state_render = Arc::clone(&doc_state); let doc_state_scroll = Arc::clone(&doc_state); + // Enumerate the available font families once per editor (system + bundled + + // document-embedded), memoised for the style editor's font picker. Scanning + // the Fontique collection on every render would be wasteful; the trade-off + // is that faces embedded after mount are not reflected until reopen. + let font_families: Rc> = { + let ds = Arc::clone(&doc_state); + use_hook(move || Rc::new(available_font_families(&ds))) + }; + // ── Document load — reactive on path_signal ─────────────────────────────── let document_load: Resource<(String, Result)> = use_resource(move || { let p = path_signal(); @@ -506,6 +522,38 @@ pub(super) fn EditorInner(path: String) -> Element { }); }); + // ── Save as Template (.dotx) ─────────────────────────────────────────────── + // + // Exports the current document as a Word template to a picked destination. + // Unlike Save As this does not repoint the tab — the user keeps editing their + // document; the template is a separate artifact. + let doc_state_savetmpl = Arc::clone(&doc_state); + let save_as_template = use_callback(move |_: ()| { + let doc_state = Arc::clone(&doc_state_savetmpl); + let mut save_message = save_message; + let suggested = format!("{}.dotx", display_title_from_path(&path_signal.peek())); + spawn(async move { + let picker = FilePicker::new(); + let opts = SaveOptions { + mime_type: Some(DOTX_MIME.to_string()), + suggested_name: Some(suggested), + }; + match picker.pick_file_to_save(opts).await { + Ok(Some(token)) => { + let msg = match export_template_to_token(&token, &doc_state) { + Ok(()) => fl!("editor-save-template-success"), + Err(e) => fl!("editor-save-error", reason = e.to_string()), + }; + save_message.set(Some(msg)); + } + Ok(None) => { /* user cancelled — no-op */ } + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + } + } + }); + }); + // ── Ctrl+S handler ─────────────────────────────────────────────────────── // // The keydown handler bumps `save_request`; perform the save here, where the @@ -817,8 +865,15 @@ pub(super) fn EditorInner(path: String) -> Element { if editing_style_draft.read().is_some() { {style_editor_panel( doc_state_style_editor, - loro_doc, editing_style_draft, + Rc::clone(&font_families), + super::editor_style_editor::StyleEditorSync { + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + }, )} } @@ -883,12 +938,11 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Ribbon (formatting controls) ────────────────────────────────── AtRibbon { + // Only Home and Publish have controls today; the former Insert/ + // Format/Review/View tabs had no content of their own (they fell + // through to Home's controls) and are omitted until they do. 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-publish"), is_contextual: false, aria_label: None }, ], active_tab: active_ribbon_tab(), @@ -905,7 +959,7 @@ pub(super) fn EditorInner(path: String) -> Element { fl!("ribbon-collapse-aria") }, tab_content: match active_ribbon_tab() { - 5 => publish_tab_content( + 1 => publish_tab_content( &doc_state_publish, path_signal, save_message, @@ -931,6 +985,7 @@ pub(super) fn EditorInner(path: String) -> Element { save_message, editing_style_draft, save_as, + save_as_template, baseline_gen, ), }, diff --git a/loki-text/src/routes/editor/editor_load.rs b/loki-text/src/routes/editor/editor_load.rs index e79af7c0..6fe62bea 100644 --- a/loki-text/src/routes/editor/editor_load.rs +++ b/loki-text/src/routes/editor/editor_load.rs @@ -36,8 +36,12 @@ pub(super) fn detect_format(token: &FileAccessToken) -> DocumentFormat { .map(|e| e.to_ascii_lowercase()) .as_deref() { - Some("docx") => DocumentFormat::Docx, - Some("odt") => DocumentFormat::Odt, + // `.dotx` / `.dotm` are Word *templates*: structurally DOCX (same + // `officeDocument` relationship), so the DOCX importer reads them as-is. + Some("docx" | "dotx" | "dotm") => DocumentFormat::Docx, + // `.ott` is a LibreOffice text *template*: structurally ODT (only the + // package `mimetype` differs, which the importer now accepts). + Some("odt" | "ott") => DocumentFormat::Odt, Some(ext) => DocumentFormat::Unsupported(ext.to_string()), None => DocumentFormat::Unsupported(String::new()), } @@ -50,14 +54,28 @@ pub(super) fn detect_format(token: &FileAccessToken) -> DocumentFormat { /// All I/O is synchronous; called inside an `async move` block in /// [`use_resource`] so loading does not block the initial render of the shell. pub(super) fn load_document(path: String) -> Result { - if new_document::is_untitled(&path) { - return Ok(Document::new_blank()); + use loki_app_shell::NewDocSource; + + // Untitled paths encode how to build their initial content (blank, a bundled + // template, or an imported external file) — see `loki_app_shell::untitled`. + match new_document::parse_new_doc_source(&path) { + Some(NewDocSource::Blank) => return Ok(Document::new_blank()), + Some(NewDocSource::Template(id)) => return build_template(&id), + Some(NewDocSource::Import(token)) => return import_token(&token), + None => {} // real file path — fall through } + import_token(&path) +} + +/// Deserialises `serialized` as a file token, detects its format, and imports it. +/// Shared by the real-file open path and the "open external template as a fresh +/// document" path (both ultimately read a file token). +fn import_token(serialized: &str) -> Result { // Open-path timing: file read + format import, logged under `loki_text::open` // so the read/import portion of open latency is measurable on-device. The // dominant open cost is the layout pass that follows (see `state::seed_*`). let started = std::time::Instant::now(); - let token = FileAccessToken::deserialize(&path)?; + let token = FileAccessToken::deserialize(serialized)?; let format = detect_format(&token); let reader = token.open_read()?; let doc = match format { @@ -81,3 +99,11 @@ pub(super) fn load_document(path: String) -> Result { ); Ok(doc) } + +/// Builds a bundled template document from its short `id` (see `loki-templates`). +/// +/// An unknown id degrades to a blank document so a stale path never fails to +/// open a tab. +fn build_template(id: &str) -> Result { + Ok(loki_templates::document(id).unwrap_or_else(Document::new_blank)) +} diff --git a/loki-text/src/routes/editor/editor_ribbon.rs b/loki-text/src/routes/editor/editor_ribbon.rs index efce7348..c24bc858 100644 --- a/loki-text/src/routes/editor/editor_ribbon.rs +++ b/loki-text/src/routes/editor/editor_ribbon.rs @@ -9,9 +9,9 @@ use std::sync::{Arc, Mutex}; use appthere_ui::{ - AtIcon, AtRibbonGroup, AtRibbonIconButton, AtRibbonSelect, LUCIDE_BOLD, LUCIDE_ITALIC, - LUCIDE_PILCROW, LUCIDE_REDO, LUCIDE_SAVE, LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, - LUCIDE_SUPERSCRIPT, LUCIDE_UNDERLINE, LUCIDE_UNDO, + AtIcon, AtRibbonGroup, AtRibbonIconButton, AtRibbonSelect, LUCIDE_BOLD, LUCIDE_DOWNLOAD, + LUCIDE_ITALIC, LUCIDE_LAYOUT_TEMPLATE, LUCIDE_PILCROW, LUCIDE_REDO, LUCIDE_SAVE, + LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, LUCIDE_SUPERSCRIPT, LUCIDE_UNDERLINE, LUCIDE_UNDO, }; use dioxus::prelude::*; use loki_i18n::fl; @@ -57,6 +57,7 @@ pub(super) fn home_tab_content( mut save_message: 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. @@ -110,7 +111,17 @@ pub(super) fn home_tab_content( on_click: move |_| { save_as.call(()); }, - AtIcon { path_d: LUCIDE_SAVE.to_string() } + AtIcon { path_d: LUCIDE_DOWNLOAD.to_string() } + } + + AtRibbonIconButton { + aria_label: fl!("ribbon-save-as-template-aria"), + is_active: false, + is_disabled: false, + on_click: move |_| { + save_as_template.call(()); + }, + AtIcon { path_d: LUCIDE_LAYOUT_TEMPLATE.to_string() } } } diff --git a/loki-text/src/routes/editor/editor_save.rs b/loki-text/src/routes/editor/editor_save.rs index 9afaca00..f1f99188 100644 --- a/loki-text/src/routes/editor/editor_save.rs +++ b/loki-text/src/routes/editor/editor_save.rs @@ -11,7 +11,8 @@ use std::sync::{Arc, Mutex}; use loki_doc_model::document::Document; use loki_doc_model::io::DocumentExport; use loki_file_access::FileAccessToken; -use loki_ooxml::DocxExport; +use loki_odf::odt::export::{OdtExport, OdtExportOptions}; +use loki_ooxml::{DocxExport, DocxTemplateExport}; use crate::editing::state::DocumentState; use crate::new_document::is_untitled; @@ -58,11 +59,12 @@ pub(super) fn save_document_to_path( export_document_to_token(&token, doc_state) } -/// Exports the current document to `token` as DOCX. +/// Exports the current document to `token`, choosing DOCX or ODT by the +/// destination's extension. /// /// Shared by [`save_document_to_path`] (titled save) and the Save As flow, -/// which passes a freshly-picked destination token directly. Rejects ODT and -/// unknown formats; buffers the bytes in memory before a single write to avoid +/// which passes a freshly-picked destination token directly. Rejects unknown +/// formats; buffers the bytes in memory before a single write to avoid /// partial-write corruption. pub(super) fn export_document_to_token( token: &FileAccessToken, @@ -70,11 +72,43 @@ pub(super) fn export_document_to_token( ) -> Result<(), SaveError> { use super::editor_load::{DocumentFormat, detect_format}; + let arc_doc = current_document(doc_state)?; + let mut buf = Cursor::new(Vec::::new()); + match detect_format(token) { + DocumentFormat::Docx => { + DocxExport::export(&arc_doc, &mut buf, ()) + .map_err(|e| SaveError::Export(e.to_string()))?; + } + DocumentFormat::Odt => { + OdtExport::export(&arc_doc, &mut buf, OdtExportOptions::default()) + .map_err(|e| SaveError::Export(e.to_string()))?; + } + DocumentFormat::Unsupported(ext) => { + return Err(SaveError::UnsupportedFormat(format!( + "unknown format: {ext}" + ))); + } + } + write_all_to_token(token, &buf.into_inner()) +} + +/// Exports the current document to `token` as a Word **template** (`.dotx`). +/// +/// Used by the "Save as Template" flow. Identical to [`export_document_to_token`] +/// except it writes the template content type, so Office treats the saved file +/// as a template (new documents are created from it) rather than editing it in +/// place. Rejects ODT (no OTT export yet) and unknown formats. +pub(super) fn export_template_to_token( + token: &FileAccessToken, + doc_state: &Arc>, +) -> Result<(), SaveError> { + use super::editor_load::{DocumentFormat, detect_format}; + match detect_format(token) { DocumentFormat::Docx => {} DocumentFormat::Odt => { return Err(SaveError::UnsupportedFormat( - "ODT saving is not yet supported".to_string(), + "OTT template export is not yet supported".to_string(), )); } DocumentFormat::Unsupported(ext) => { @@ -84,23 +118,31 @@ pub(super) fn export_document_to_token( } } - let arc_doc: Arc = doc_state + let arc_doc = current_document(doc_state)?; + let mut buf = Cursor::new(Vec::::new()); + DocxTemplateExport::export(&arc_doc, &mut buf, ()) + .map_err(|e| SaveError::Export(e.to_string()))?; + write_all_to_token(token, &buf.into_inner()) +} + +/// Clones the currently-loaded document out of `doc_state`. +fn current_document(doc_state: &Arc>) -> Result, SaveError> { + doc_state .lock() .map_err(|_| SaveError::NoDocument)? .document .clone() - .ok_or(SaveError::NoDocument)?; - - let mut buf = Cursor::new(Vec::::new()); - DocxExport::export(&arc_doc, &mut buf, ()).map_err(|e| SaveError::Export(e.to_string()))?; + .ok_or(SaveError::NoDocument) +} - let bytes = buf.into_inner(); +/// Writes `bytes` to `token` in a single call (buffered upstream to avoid +/// partial-write corruption). +fn write_all_to_token(token: &FileAccessToken, bytes: &[u8]) -> Result<(), SaveError> { let mut writer = token .open_write() .map_err(|e| SaveError::Io(e.to_string()))?; writer - .write_all(&bytes) + .write_all(bytes) .map_err(|e| SaveError::Io(e.to_string()))?; - Ok(()) } diff --git a/loki-text/src/routes/editor/editor_state.rs b/loki-text/src/routes/editor/editor_state.rs index 8ac91dc5..c0cbe000 100644 --- a/loki-text/src/routes/editor/editor_state.rs +++ b/loki-text/src/routes/editor/editor_state.rs @@ -27,23 +27,58 @@ use crate::editing::touch::TouchInteractionState; /// `None` on the outer signal → editor closed. `Some(draft)` → editor open, /// editing the catalog style identified by `draft.id`. String fields use an /// empty string to represent `None` so they bind cleanly to text inputs. -#[derive(Clone, PartialEq, Default)] +/// +/// `font_weight` carries the numeric OpenType weight (1–1000; 400 = Regular, +/// 700 = Bold) the selector edits; bold is derived from it on save. Numeric +/// fields are stored as their string form so they bind directly to text +/// inputs; an empty string represents "inherit / unset". +#[derive(Clone, PartialEq)] pub(super) struct StyleDraft { pub id: String, pub name: String, pub parent: String, pub next: String, pub alignment: String, + pub font_name: String, pub font_size_str: String, - pub bold: bool, + pub font_weight: u16, pub italic: bool, pub underline: bool, pub space_before_str: String, pub space_after_str: String, + pub line_height_str: String, + pub indent_start_str: String, + pub indent_end_str: String, pub indent_first_str: String, + pub indent_hanging_str: String, pub is_custom: bool, } +impl Default for StyleDraft { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + parent: String::new(), + next: String::new(), + alignment: String::new(), + font_name: String::new(), + font_size_str: String::new(), + font_weight: 400, + italic: false, + underline: false, + space_before_str: String::new(), + space_after_str: String::new(), + line_height_str: String::new(), + indent_start_str: String::new(), + indent_end_str: String::new(), + indent_first_str: String::new(), + indent_hanging_str: String::new(), + is_custom: false, + } + } +} + /// All per-document signals for the editor, grouped for ergonomic initialisation. pub(super) struct EditorState { pub doc_state: Arc>, diff --git a/loki-text/src/routes/editor/editor_style_catalog.rs b/loki-text/src/routes/editor/editor_style_catalog.rs index cc7dc060..adfb696e 100644 --- a/loki-text/src/routes/editor/editor_style_catalog.rs +++ b/loki-text/src/routes/editor/editor_style_catalog.rs @@ -9,16 +9,29 @@ use loki_doc_model::style::catalog::StyleId; use crate::editing::state::DocumentState; -/// Inserts or replaces a style in the document's style catalog. -pub(super) fn upsert_catalog_style(doc_state: &Arc>, style: ParagraphStyle) { - let Ok(mut state) = doc_state.lock() else { - return; - }; - let Some(arc_doc) = state.document.as_mut() else { - return; - }; - let doc = Arc::make_mut(arc_doc); - doc.styles.paragraph_styles.insert(style.id.clone(), style); +/// Inserts or replaces a paragraph style in the catalog and persists the result +/// through the Loro CRDT, committing it as a discrete, undoable transaction. +/// +/// The catalog is the Loro snapshot's responsibility (see `loro_bridge::styles`), +/// so the edit is written there rather than mutated in place on `state.document` +/// — the subsequent `apply_mutation_and_relayout` re-derives the catalog from +/// Loro. Starting from the current catalog preserves every other style. The +/// caller refreshes undo bookkeeping via `post_mutation_sync`. +pub(super) fn commit_style_to_loro( + loro: &loro::LoroDoc, + doc_state: &Arc>, + style: ParagraphStyle, +) { + let mut catalog = doc_state + .lock() + .ok() + .and_then(|s| s.document.as_ref().map(|d| d.styles.clone())) + .unwrap_or_default(); + catalog.paragraph_styles.insert(style.id.clone(), style); + if let Err(e) = loki_doc_model::loro_bridge::write_document_styles(loro, &catalog) { + tracing::warn!("failed to persist style catalog to Loro: {e}"); + } + loro.commit(); } /// Generates a unique id string for a new custom style. @@ -55,6 +68,21 @@ pub(super) fn get_catalog_style( .cloned() } +/// Returns the font families available for layout (system + bundled + +/// document-embedded), sorted, for the style editor's font picker. +/// +/// Enumerates the editor's shared Fontique collection. Intended to be called +/// once (memoised) per editor rather than per render. +pub(super) fn available_font_families(doc_state: &Arc>) -> Vec { + let Ok(state) = doc_state.lock() else { + return vec![]; + }; + let Ok(mut fr) = state.shared_font_resources.lock() else { + return vec![]; + }; + fr.available_font_families() +} + /// Returns `(style_id, display_name)` pairs for all catalog styles, sorted by display name. pub(super) fn catalog_style_list(doc_state: &Arc>) -> Vec<(String, String)> { let Ok(state) = doc_state.lock() else { diff --git a/loki-text/src/routes/editor/editor_style_editor.rs b/loki-text/src/routes/editor/editor_style_editor.rs deleted file mode 100644 index 00f0e8ab..00000000 --- a/loki-text/src/routes/editor/editor_style_editor.rs +++ /dev/null @@ -1,497 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Paragraph style catalog editor panel for the document editor. -//! -//! `style_editor_panel` renders a two-column panel (style list + form) above -//! the ribbon when `editing_style_draft` is `Some`. Changes are written to a -//! [`StyleDraft`] signal and committed to the catalog via the Apply button. - -use std::sync::{Arc, Mutex}; - -use appthere_ui::tokens; -use dioxus::prelude::*; -use loki_doc_model::content::attr::ExtensionBag; -use loki_doc_model::loki_primitives::units::Points; -use loki_doc_model::style::ParagraphStyle; -use loki_doc_model::style::catalog::StyleId; -use loki_doc_model::style::props::char_props::UnderlineStyle; -use loki_doc_model::style::props::para_props::{ParagraphAlignment, Spacing}; -use loki_doc_model::style::props::{CharProps, ParaProps}; -use loki_i18n::fl; - -use super::editor_state::StyleDraft; -use super::editor_style_catalog::{ - catalog_style_list, get_catalog_style, new_custom_style_id, upsert_catalog_style, -}; -use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; - -/// Height of the open style editor panel in CSS pixels. -pub(super) const STYLE_EDITOR_HEIGHT_PX: f32 = 240.0; - -/// Converts a catalog `ParagraphStyle` to an editable `StyleDraft`. -pub(super) fn style_to_draft(style: &ParagraphStyle) -> StyleDraft { - StyleDraft { - id: style.id.as_str().to_string(), - name: style - .display_name - .clone() - .unwrap_or_else(|| style.id.as_str().to_string()), - parent: style - .parent - .as_ref() - .map(|p| p.as_str().to_string()) - .unwrap_or_default(), - next: style.next_style_id.clone().unwrap_or_default(), - alignment: match style.para_props.alignment { - Some(ParagraphAlignment::Center) => "Center", - Some(ParagraphAlignment::Right) => "Right", - Some(ParagraphAlignment::Justify) => "Justify", - _ => "Left", - } - .to_string(), - font_size_str: style - .char_props - .font_size - .map(|s| format!("{:.0}", s.value())) - .unwrap_or_default(), - bold: style.char_props.bold.unwrap_or(false), - italic: style.char_props.italic.unwrap_or(false), - underline: style.char_props.underline.is_some(), - space_before_str: match style.para_props.space_before { - Some(Spacing::Exact(pt)) => format!("{:.0}", pt.value()), - _ => String::new(), - }, - space_after_str: match style.para_props.space_after { - Some(Spacing::Exact(pt)) => format!("{:.0}", pt.value()), - _ => String::new(), - }, - indent_first_str: style - .para_props - .indent_first_line - .map(|pt| format!("{:.0}", pt.value())) - .unwrap_or_default(), - is_custom: style.is_custom, - } -} - -/// Converts a `StyleDraft` back to a `ParagraphStyle` for catalog storage. -pub(super) fn draft_to_style(draft: &StyleDraft) -> ParagraphStyle { - let alignment = match draft.alignment.as_str() { - "Center" => Some(ParagraphAlignment::Center), - "Right" => Some(ParagraphAlignment::Right), - "Justify" => Some(ParagraphAlignment::Justify), - "Left" => Some(ParagraphAlignment::Left), - _ => None, - }; - ParagraphStyle { - id: StyleId::new(&draft.id), - display_name: if draft.name.is_empty() { - None - } else { - Some(draft.name.clone()) - }, - parent: if draft.parent.is_empty() { - None - } else { - Some(StyleId::new(&draft.parent)) - }, - linked_char_style: None, - next_style_id: if draft.next.is_empty() { - None - } else { - Some(draft.next.clone()) - }, - para_props: ParaProps { - alignment, - space_before: draft - .space_before_str - .trim() - .parse::() - .ok() - .filter(|&s| s >= 0.0) - .map(|s| Spacing::Exact(Points::new(s))), - space_after: draft - .space_after_str - .trim() - .parse::() - .ok() - .filter(|&s| s >= 0.0) - .map(|s| Spacing::Exact(Points::new(s))), - indent_first_line: draft - .indent_first_str - .trim() - .parse::() - .ok() - .map(Points::new), - ..Default::default() - }, - char_props: CharProps { - bold: Some(draft.bold), - italic: Some(draft.italic), - underline: if draft.underline { - Some(UnderlineStyle::Single) - } else { - None - }, - font_size: draft - .font_size_str - .trim() - .parse::() - .ok() - .filter(|&s| s > 0.0) - .map(Points::new), - ..Default::default() - }, - is_default: false, - is_custom: draft.is_custom, - extensions: ExtensionBag::default(), - } -} - -/// Renders the inline style catalog editor panel. -/// -/// Plain function — no hooks. Left column shows all catalog styles; right -/// column shows an edit form for the currently selected draft. Apply commits -/// the draft to the catalog and triggers a full relayout. -pub(super) fn style_editor_panel( - doc_state: Arc>, - loro_doc: Signal>, - mut editing_style_draft: Signal>, -) -> Element { - let draft = match editing_style_draft.read().clone() { - Some(d) => d, - None => return rsx! {}, - }; - - let styles = catalog_style_list(&doc_state); - let ds_list = Arc::clone(&doc_state); - let ds_new = Arc::clone(&doc_state); - let ds_apply = Arc::clone(&doc_state); - let draft_alignment = draft.alignment.clone(); - let biu = [ - ("B", draft.bold), - ("I", draft.italic), - ("U", draft.underline), - ]; - - rsx! { - div { - style: format!( - "height: {h}px; min-height: {h}px; max-height: {h}px; \ - display: flex; flex-direction: column; flex-shrink: 0; \ - background: {bg}; border-top: 1px solid {border};", - h = STYLE_EDITOR_HEIGHT_PX, - bg = tokens::COLOR_SURFACE_1, - border = tokens::COLOR_BORDER_CHROME, - ), - - // ── Header ──────────────────────────────────────────────────────── - div { - style: format!( - "display: flex; flex-direction: row; align-items: center; \ - justify-content: space-between; padding: 0 {p}px; \ - flex-shrink: 0; height: 28px;", - p = tokens::SPACE_4, - ), - span { - style: format!( - "font-family: {ff}; font-size: {fs}px; font-weight: {fw}; color: {fg};", - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_LABEL, - fw = tokens::FONT_WEIGHT_MEDIUM, - fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, - ), - { fl!("ribbon-style-editor-heading") } - } - button { - style: format!( - "background: transparent; border: none; font-size: {fs}px; \ - color: {fg}; cursor: pointer; padding: {p}px;", - fs = tokens::FONT_SIZE_LABEL, - fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, - p = tokens::SPACE_1, - ), - aria_label: fl!("editor-style-editor-close-aria"), - onclick: move |_| editing_style_draft.set(None), - "\u{2715}" - } - } - - // ── Two-column body ──────────────────────────────────────────────── - div { - style: "display: flex; flex-direction: row; flex: 1; overflow: hidden;", - - // ── Left: catalog style list ─────────────────────────────────── - div { - style: format!( - "width: 160px; min-width: 160px; overflow-y: auto; \ - border-right: 1px solid {border}; display: flex; \ - flex-direction: column; gap: 2px; padding: {p}px;", - border = tokens::COLOR_BORDER_CHROME, - p = tokens::SPACE_2, - ), - - {styles.into_iter().map(|(id, display)| { - let is_active = id == draft.id; - let ds_c = Arc::clone(&ds_list); - let id_cap = id.clone(); - rsx! { - button { - key: "{id}", - style: format!( - "text-align: left; padding: {p}px {p2}px; \ - border-radius: 3px; border: 1px solid {border}; \ - cursor: pointer; font-family: {ff}; \ - font-size: {fs}px; background: {bg}; color: {fg};", - p = tokens::SPACE_1, p2 = tokens::SPACE_2, - border = if is_active { tokens::COLOR_TAB_ACTIVE_INDICATOR } else { tokens::COLOR_BORDER_CHROME }, - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_LABEL, - bg = if is_active { tokens::COLOR_SURFACE_3 } else { tokens::COLOR_SURFACE_2 }, - fg = tokens::COLOR_TEXT_ON_CHROME, - ), - onclick: move |_| { - if let Some(s) = get_catalog_style(&ds_c, &id_cap) { - editing_style_draft.set(Some(style_to_draft(&s))); - } - }, - "{display}" - } - } - })} - - button { - style: format!( - "padding: {p}px {p2}px; border-radius: 3px; margin-top: {mt}px; \ - border: 1px solid {border}; cursor: pointer; \ - font-family: {ff}; font-size: {fs}px; \ - background: {bg}; color: {fg};", - p = tokens::SPACE_1, p2 = tokens::SPACE_2, - mt = tokens::SPACE_2, - border = tokens::COLOR_BORDER_DEFAULT, - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_LABEL, - bg = tokens::COLOR_SURFACE_2, - fg = tokens::COLOR_TEXT_ON_CHROME, - ), - aria_label: fl!("ribbon-style-new-aria"), - onclick: move |_| { - let new_id = new_custom_style_id(&ds_new); - editing_style_draft.set(Some(StyleDraft { - id: new_id.clone(), - name: new_id, - is_custom: true, - alignment: "Left".to_string(), - ..StyleDraft::default() - })); - }, - "+ New" - } - } - - // ── Right: edit form ─────────────────────────────────────────── - div { - style: format!( - "flex: 1; display: flex; flex-direction: column; \ - gap: {g}px; padding: {p}px; overflow-y: auto;", - g = tokens::SPACE_2, - p = tokens::SPACE_3, - ), - - // Name - div { - style: "display: flex; flex-direction: row; align-items: center; gap: 8px;", - span { - style: format!( - "font-family: {ff}; font-size: {fs}px; color: {fg}; min-width: 64px;", - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_LABEL, - fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, - ), - "Name" - } - input { - r#type: "text", - value: "{draft.name}", - oninput: move |evt| { - let v = editing_style_draft.read().clone(); - if let Some(mut d) = v { d.name = evt.value(); editing_style_draft.set(Some(d)); } - }, - style: format!( - "flex: 1; height: 24px; padding: 0 {p}px; \ - background: {bg}; border: 1px solid {border}; \ - border-radius: {r}px; font-family: {ff}; \ - font-size: {fs}px; color: {fg}; box-sizing: border-box;", - p = tokens::SPACE_2, - bg = tokens::COLOR_SURFACE_2, - border = tokens::COLOR_BORDER_DEFAULT, - r = tokens::RADIUS_SM, - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_BODY, - fg = tokens::COLOR_TEXT_ON_CHROME, - ), - } - } - - // Based on + Next style - div { - style: "display: flex; flex-direction: row; align-items: center; gap: 16px;", - div { - style: "display: flex; flex-direction: row; align-items: center; gap: 8px; flex: 1;", - span { - style: format!("font-family: {ff}; font-size: {fs}px; color: {fg}; min-width: 56px;", ff = tokens::FONT_FAMILY_UI, fs = tokens::FONT_SIZE_LABEL, fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY), - { fl!("editor-style-based-on-label") } - } - input { - r#type: "text", - value: "{draft.parent}", - oninput: move |evt| { - let v = editing_style_draft.read().clone(); - if let Some(mut d) = v { d.parent = evt.value(); editing_style_draft.set(Some(d)); } - }, - style: format!("flex: 1; height: 24px; padding: 0 {p}px; background: {bg}; border: 1px solid {border}; border-radius: {r}px; font-family: {ff}; font-size: {fs}px; color: {fg}; box-sizing: border-box;", p = tokens::SPACE_2, bg = tokens::COLOR_SURFACE_2, border = tokens::COLOR_BORDER_DEFAULT, r = tokens::RADIUS_SM, ff = tokens::FONT_FAMILY_UI, fs = tokens::FONT_SIZE_BODY, fg = tokens::COLOR_TEXT_ON_CHROME), - } - } - div { - style: "display: flex; flex-direction: row; align-items: center; gap: 8px; flex: 1;", - span { - style: format!("font-family: {ff}; font-size: {fs}px; color: {fg}; min-width: 56px;", ff = tokens::FONT_FAMILY_UI, fs = tokens::FONT_SIZE_LABEL, fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY), - { fl!("editor-style-next-style-label") } - } - input { - r#type: "text", - value: "{draft.next}", - oninput: move |evt| { - let v = editing_style_draft.read().clone(); - if let Some(mut d) = v { d.next = evt.value(); editing_style_draft.set(Some(d)); } - }, - style: format!("flex: 1; height: 24px; padding: 0 {p}px; background: {bg}; border: 1px solid {border}; border-radius: {r}px; font-family: {ff}; font-size: {fs}px; color: {fg}; box-sizing: border-box;", p = tokens::SPACE_2, bg = tokens::COLOR_SURFACE_2, border = tokens::COLOR_BORDER_DEFAULT, r = tokens::RADIUS_SM, ff = tokens::FONT_FAMILY_UI, fs = tokens::FONT_SIZE_BODY, fg = tokens::COLOR_TEXT_ON_CHROME), - } - } - } - - // Alignment + Font size + B/I/U - div { - style: "display: flex; flex-direction: row; align-items: center; gap: 16px; flex-wrap: wrap;", - div { - style: "display: flex; flex-direction: row; align-items: center; gap: 4px;", - span { - style: format!("font-family: {ff}; font-size: {fs}px; color: {fg}; margin-right: 4px;", ff = tokens::FONT_FAMILY_UI, fs = tokens::FONT_SIZE_LABEL, fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY), - { fl!("editor-style-align-label") } - } - {["Left", "Center", "Right", "Justify"].iter().map(|&aval| { - let is_a = draft_alignment.as_str() == aval; - rsx! { - button { - key: "{aval}", - style: format!( - "padding: 2px 6px; border-radius: 3px; border: 1px solid {border}; \ - cursor: pointer; font-family: {ff}; font-size: {fs}px; \ - background: {bg}; color: {fg};", - border = if is_a { tokens::COLOR_TAB_ACTIVE_INDICATOR } else { tokens::COLOR_BORDER_CHROME }, - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_LABEL, - bg = if is_a { tokens::COLOR_SURFACE_3 } else { tokens::COLOR_SURFACE_2 }, - fg = tokens::COLOR_TEXT_ON_CHROME, - ), - onclick: move |_| { - let v = editing_style_draft.read().clone(); - if let Some(mut d) = v { d.alignment = aval.to_string(); editing_style_draft.set(Some(d)); } - }, - "{aval}" - } - } - })} - } - div { - style: "display: flex; flex-direction: row; align-items: center; gap: 4px;", - span { - style: format!("font-family: {ff}; font-size: {fs}px; color: {fg};", ff = tokens::FONT_FAMILY_UI, fs = tokens::FONT_SIZE_LABEL, fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY), - "Size (pt)" - } - input { - r#type: "text", - value: "{draft.font_size_str}", - oninput: move |evt| { - let v = editing_style_draft.read().clone(); - if let Some(mut d) = v { d.font_size_str = evt.value(); editing_style_draft.set(Some(d)); } - }, - style: format!("width: 40px; height: 24px; padding: 0 {p}px; background: {bg}; border: 1px solid {border}; border-radius: {r}px; font-family: {ff}; font-size: {fs}px; color: {fg}; box-sizing: border-box;", p = tokens::SPACE_2, bg = tokens::COLOR_SURFACE_2, border = tokens::COLOR_BORDER_DEFAULT, r = tokens::RADIUS_SM, ff = tokens::FONT_FAMILY_UI, fs = tokens::FONT_SIZE_BODY, fg = tokens::COLOR_TEXT_ON_CHROME), - } - } - div { - style: "display: flex; flex-direction: row; align-items: center; gap: 4px;", - {biu.into_iter().map(|(lbl, active)| { - rsx! { - button { - key: "{lbl}", - style: format!( - "padding: 2px 8px; border-radius: 3px; border: 1px solid {border}; \ - cursor: pointer; font-family: {ff}; font-size: {fs}px; \ - font-weight: {fw}; font-style: {fi}; text-decoration: {td}; \ - background: {bg}; color: {fg};", - border = if active { tokens::COLOR_TAB_ACTIVE_INDICATOR } else { tokens::COLOR_BORDER_CHROME }, - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_LABEL, - fw = if lbl == "B" { "700" } else { "400" }, - fi = if lbl == "I" { "italic" } else { "normal" }, - td = if lbl == "U" { "underline" } else { "none" }, - bg = if active { tokens::COLOR_SURFACE_3 } else { tokens::COLOR_SURFACE_2 }, - fg = tokens::COLOR_TEXT_ON_CHROME, - ), - onclick: move |_| { - let v = editing_style_draft.read().clone(); - if let Some(mut d) = v { - match lbl { - "B" => d.bold = !d.bold, - "I" => d.italic = !d.italic, - "U" => d.underline = !d.underline, - _ => {} - } - editing_style_draft.set(Some(d)); - } - }, - "{lbl}" - } - } - })} - } - } - - // Apply - div { - style: "display: flex; flex-direction: row; gap: 8px; margin-top: auto;", - button { - style: format!( - "padding: {p}px {p2}px; border-radius: {r}px; \ - border: 1px solid {border}; cursor: pointer; \ - font-family: {ff}; font-size: {fs}px; \ - background: {bg}; color: {fg};", - p = tokens::SPACE_1, - p2 = tokens::SPACE_3, - r = tokens::RADIUS_SM, - border = tokens::COLOR_TAB_ACTIVE_INDICATOR, - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_BODY, - bg = tokens::COLOR_SURFACE_3, - fg = tokens::COLOR_TEXT_ON_CHROME, - ), - onclick: move |_| { - let v = editing_style_draft.read().clone(); - if let Some(draft_val) = v { - let style = draft_to_style(&draft_val); - upsert_catalog_style(&ds_apply, style); - let ldoc_guard = loro_doc.read(); - if let Some(ldoc) = ldoc_guard.as_ref() { - apply_mutation_and_relayout(&ds_apply, ldoc); - } - } - }, - { fl!("ribbon-style-apply-changes") } - } - } - } - } - } - } -} diff --git a/loki-text/src/routes/editor/editor_style_editor/draft.rs b/loki-text/src/routes/editor/editor_style_editor/draft.rs new file mode 100644 index 00000000..8f31b1eb --- /dev/null +++ b/loki-text/src/routes/editor/editor_style_editor/draft.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Conversions between a catalog [`ParagraphStyle`] and the editable +//! [`StyleDraft`] bound to the style editor's form inputs. + +use loki_doc_model::content::attr::ExtensionBag; +use loki_doc_model::loki_primitives::units::Points; +use loki_doc_model::style::ParagraphStyle; +use loki_doc_model::style::catalog::StyleId; +use loki_doc_model::style::props::char_props::UnderlineStyle; +use loki_doc_model::style::props::para_props::{LineHeight, ParagraphAlignment, Spacing}; +use loki_doc_model::style::props::{CharProps, ParaProps}; + +use super::super::editor_state::StyleDraft; + +/// Formats an optional point measurement as a whole-number string (empty for `None`). +fn points_str(pt: Option) -> String { + pt.map(|p| format!("{:.0}", p.value())).unwrap_or_default() +} + +/// Parses a points field; blank or invalid input becomes `None` (inherit/unset). +fn parse_points(s: &str) -> Option { + s.trim().parse::().ok().map(Points::new) +} + +/// Converts a catalog `ParagraphStyle` to an editable `StyleDraft`. +pub(crate) fn style_to_draft(style: &ParagraphStyle) -> StyleDraft { + let cp = &style.char_props; + let pp = &style.para_props; + StyleDraft { + id: style.id.as_str().to_string(), + name: style + .display_name + .clone() + .unwrap_or_else(|| style.id.as_str().to_string()), + parent: style + .parent + .as_ref() + .map(|p| p.as_str().to_string()) + .unwrap_or_default(), + next: style.next_style_id.clone().unwrap_or_default(), + alignment: match pp.alignment { + Some(ParagraphAlignment::Center) => "Center", + Some(ParagraphAlignment::Right) => "Right", + Some(ParagraphAlignment::Justify) => "Justify", + _ => "Left", + } + .to_string(), + font_name: cp.font_name.clone().unwrap_or_default(), + font_size_str: cp + .font_size + .map(|s| format!("{:.0}", s.value())) + .unwrap_or_default(), + font_weight: cp + .font_weight + .unwrap_or(if cp.bold == Some(true) { 700 } else { 400 }), + italic: cp.italic.unwrap_or(false), + underline: cp.underline.is_some(), + space_before_str: match pp.space_before { + Some(Spacing::Exact(pt)) => format!("{:.0}", pt.value()), + _ => String::new(), + }, + space_after_str: match pp.space_after { + Some(Spacing::Exact(pt)) => format!("{:.0}", pt.value()), + _ => String::new(), + }, + line_height_str: match pp.line_height { + Some(LineHeight::Multiple(ratio)) => format!("{ratio:.2}"), + _ => String::new(), + }, + indent_start_str: points_str(pp.indent_start), + indent_end_str: points_str(pp.indent_end), + indent_first_str: points_str(pp.indent_first_line), + indent_hanging_str: points_str(pp.indent_hanging), + is_custom: style.is_custom, + } +} + +/// Converts a `StyleDraft` back to a `ParagraphStyle` for catalog storage. +/// +/// `font_weight` is the source of truth; `bold` is derived from it (≥ 600 ⇒ +/// bold) so a DOCX round-trip — which has no numeric weight — still collapses +/// to the right boolean. A weight of exactly 400 stores as `None` (inherit / +/// regular) so the style does not pin every run to Regular. +pub(crate) fn draft_to_style(draft: &StyleDraft) -> ParagraphStyle { + let alignment = match draft.alignment.as_str() { + "Center" => Some(ParagraphAlignment::Center), + "Right" => Some(ParagraphAlignment::Right), + "Justify" => Some(ParagraphAlignment::Justify), + "Left" => Some(ParagraphAlignment::Left), + _ => None, + }; + // `Multiple` is a ratio (1.5 = one-and-a-half spacing), matching the layout + // engine and OOXML mapper — NOT a percentage. See `resolve.rs` line_height. + let line_height = draft + .line_height_str + .trim() + .parse::() + .ok() + .filter(|&m| m > 0.0) + .map(LineHeight::Multiple); + ParagraphStyle { + id: StyleId::new(&draft.id), + display_name: if draft.name.is_empty() { + None + } else { + Some(draft.name.clone()) + }, + parent: if draft.parent.is_empty() { + None + } else { + Some(StyleId::new(&draft.parent)) + }, + linked_char_style: None, + next_style_id: if draft.next.is_empty() { + None + } else { + Some(draft.next.clone()) + }, + para_props: ParaProps { + alignment, + indent_start: parse_points(&draft.indent_start_str), + indent_end: parse_points(&draft.indent_end_str), + indent_first_line: parse_points(&draft.indent_first_str), + indent_hanging: parse_points(&draft.indent_hanging_str), + space_before: draft + .space_before_str + .trim() + .parse::() + .ok() + .filter(|&s| s >= 0.0) + .map(|s| Spacing::Exact(Points::new(s))), + space_after: draft + .space_after_str + .trim() + .parse::() + .ok() + .filter(|&s| s >= 0.0) + .map(|s| Spacing::Exact(Points::new(s))), + line_height, + ..Default::default() + }, + char_props: CharProps { + font_name: if draft.font_name.trim().is_empty() { + None + } else { + Some(draft.font_name.trim().to_string()) + }, + bold: Some(draft.font_weight >= 600), + font_weight: if draft.font_weight == 400 { + None + } else { + Some(draft.font_weight) + }, + italic: Some(draft.italic), + underline: if draft.underline { + Some(UnderlineStyle::Single) + } else { + None + }, + font_size: draft + .font_size_str + .trim() + .parse::() + .ok() + .filter(|&s| s > 0.0) + .map(Points::new), + ..Default::default() + }, + is_default: false, + is_custom: draft.is_custom, + extensions: ExtensionBag::default(), + } +} diff --git a/loki-text/src/routes/editor/editor_style_editor/form.rs b/loki-text/src/routes/editor/editor_style_editor/form.rs new file mode 100644 index 00000000..6dcb1f6e --- /dev/null +++ b/loki-text/src/routes/editor/editor_style_editor/form.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Right-column edit form for the style editor panel. +//! +//! `style_form` lays out every editable property of the selected draft (name, +//! based-on / next, font family, weight, size, italic / underline, alignment, +//! indentation and spacing) and an Apply button that commits the draft to the +//! catalog and triggers a relayout. + +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +use appthere_ui::tokens; +use dioxus::prelude::*; +use loki_i18n::fl; + +use super::super::editor_keydown_ctrl::post_mutation_sync; +use super::super::editor_state::StyleDraft; +use super::super::editor_style_catalog::commit_style_to_loro; +use super::StyleEditorSync; +use super::draft::draft_to_style; +use super::form_font::{font_picker, input_style, label_style, weight_selector}; +use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; + +/// A label + text input row whose value is written back to a draft field via +/// `set`. `width_css` controls the input width (e.g. `"flex: 1"`). +fn field_row( + label: String, + value: String, + width_css: &str, + mut editing_style_draft: Signal>, + set: impl Fn(&mut StyleDraft, String) + 'static, +) -> Element { + rsx! { + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 6px;", + span { style: label_style(), "{label}" } + input { + r#type: "text", + value: "{value}", + oninput: move |evt| { + let v = editing_style_draft.read().clone(); + if let Some(mut d) = v { + set(&mut d, evt.value()); + editing_style_draft.set(Some(d)); + } + }, + style: input_style(width_css), + } + } + } +} + +/// Italic / underline toggle buttons (bold is handled by the weight selector). +fn iu_buttons( + mut editing_style_draft: Signal>, + italic: bool, + underline: bool, +) -> Element { + let items = [("I", italic, true), ("U", underline, false)]; + rsx! { + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 4px;", + {items.into_iter().map(|(lbl, active, is_italic)| { + rsx! { + button { + key: "{lbl}", + style: format!( + "padding: 2px 8px; border-radius: 3px; border: 1px solid {border}; \ + cursor: pointer; font-family: {ff}; font-size: {fs}px; \ + font-style: {fi}; text-decoration: {td}; background: {bg}; color: {fg};", + border = if active { tokens::COLOR_TAB_ACTIVE_INDICATOR } else { tokens::COLOR_BORDER_CHROME }, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + fi = if is_italic { "italic" } else { "normal" }, + td = if is_italic { "none" } else { "underline" }, + bg = if active { tokens::COLOR_SURFACE_3 } else { tokens::COLOR_SURFACE_2 }, + fg = tokens::COLOR_TEXT_ON_CHROME, + ), + onclick: move |_| { + let v = editing_style_draft.read().clone(); + if let Some(mut d) = v { + if is_italic { d.italic = !d.italic; } else { d.underline = !d.underline; } + editing_style_draft.set(Some(d)); + } + }, + "{lbl}" + } + } + })} + } + } +} + +/// Alignment selector buttons (Left / Center / Right / Justify). +fn alignment_buttons( + mut editing_style_draft: Signal>, + current: String, +) -> Element { + let aligns = [ + ("Left", fl!("editor-style-align-left")), + ("Center", fl!("editor-style-align-center")), + ("Right", fl!("editor-style-align-right")), + ("Justify", fl!("editor-style-align-justify")), + ]; + rsx! { + {aligns.into_iter().map(|(val, label)| { + let is_a = current.as_str() == val; + rsx! { + button { + key: "{val}", + style: format!( + "padding: 2px 6px; border-radius: 3px; border: 1px solid {border}; \ + cursor: pointer; font-family: {ff}; font-size: {fs}px; \ + background: {bg}; color: {fg};", + border = if is_a { tokens::COLOR_TAB_ACTIVE_INDICATOR } else { tokens::COLOR_BORDER_CHROME }, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + bg = if is_a { tokens::COLOR_SURFACE_3 } else { tokens::COLOR_SURFACE_2 }, + fg = tokens::COLOR_TEXT_ON_CHROME, + ), + onclick: move |_| { + let v = editing_style_draft.read().clone(); + if let Some(mut d) = v { + d.alignment = val.to_string(); + editing_style_draft.set(Some(d)); + } + }, + "{label}" + } + } + })} + } +} + +/// Renders the right-column edit form for the active draft. +pub(super) fn style_form( + doc_state: Arc>, + editing_style_draft: Signal>, + draft: StyleDraft, + font_families: Rc>, + sync: StyleEditorSync, +) -> Element { + let ds_apply = Arc::clone(&doc_state); + let align_cur = draft.alignment.clone(); + rsx! { + div { + style: format!( + "flex: 1; display: flex; flex-direction: column; gap: {g}px; \ + padding: {p}px; overflow-y: auto;", + g = tokens::SPACE_2, + p = tokens::SPACE_3, + ), + + { field_row(fl!("editor-style-name-label"), draft.name.clone(), "flex: 1", editing_style_draft, |d, v| d.name = v) } + + div { + style: "display: flex; flex-direction: row; gap: 16px;", + { field_row(fl!("editor-style-based-on-label"), draft.parent.clone(), "flex: 1", editing_style_draft, |d, v| d.parent = v) } + { field_row(fl!("editor-style-next-style-label"), draft.next.clone(), "flex: 1", editing_style_draft, |d, v| d.next = v) } + } + + { font_picker(editing_style_draft, draft.font_name.clone(), font_families) } + + { weight_selector(editing_style_draft, draft.font_weight) } + + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 16px; flex-wrap: wrap;", + { field_row(fl!("editor-style-size-label"), draft.font_size_str.clone(), "width: 48px", editing_style_draft, |d, v| d.font_size_str = v) } + { iu_buttons(editing_style_draft, draft.italic, draft.underline) } + } + + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 4px; flex-wrap: wrap;", + span { style: format!("{} margin-right: 4px;", label_style()), { fl!("editor-style-align-label") } } + { alignment_buttons(editing_style_draft, align_cur) } + } + + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 12px; flex-wrap: wrap;", + span { style: label_style(), { fl!("editor-style-indent-label") } } + { field_row(fl!("editor-style-indent-left"), draft.indent_start_str.clone(), "width: 48px", editing_style_draft, |d, v| d.indent_start_str = v) } + { field_row(fl!("editor-style-indent-right"), draft.indent_end_str.clone(), "width: 48px", editing_style_draft, |d, v| d.indent_end_str = v) } + { field_row(fl!("editor-style-indent-first"), draft.indent_first_str.clone(), "width: 48px", editing_style_draft, |d, v| d.indent_first_str = v) } + { field_row(fl!("editor-style-indent-hanging"), draft.indent_hanging_str.clone(), "width: 48px", editing_style_draft, |d, v| d.indent_hanging_str = v) } + } + + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 12px; flex-wrap: wrap;", + span { style: label_style(), { fl!("editor-style-spacing-label") } } + { field_row(fl!("editor-style-spacing-before"), draft.space_before_str.clone(), "width: 48px", editing_style_draft, |d, v| d.space_before_str = v) } + { field_row(fl!("editor-style-spacing-after"), draft.space_after_str.clone(), "width: 48px", editing_style_draft, |d, v| d.space_after_str = v) } + { field_row(fl!("editor-style-line-spacing"), draft.line_height_str.clone(), "width: 48px", editing_style_draft, |d, v| d.line_height_str = v) } + } + + div { + style: "display: flex; flex-direction: row; gap: 8px; margin-top: auto;", + button { + style: format!( + "padding: {p}px {p2}px; border-radius: {r}px; \ + border: 1px solid {border}; cursor: pointer; \ + font-family: {ff}; font-size: {fs}px; \ + background: {bg}; color: {fg};", + p = tokens::SPACE_1, + p2 = tokens::SPACE_3, + r = tokens::RADIUS_SM, + border = tokens::COLOR_TAB_ACTIVE_INDICATOR, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_BODY, + bg = tokens::COLOR_SURFACE_3, + fg = tokens::COLOR_TEXT_ON_CHROME, + ), + onclick: move |_| { + let Some(draft_val) = editing_style_draft.read().clone() else { + return; + }; + let style = draft_to_style(&draft_val); + // Persist through Loro then re-derive (which reads the + // catalog back from the CRDT) so the edit is durable and + // undoable. Drop the read guard before the undo refresh. + let applied = { + let guard = sync.loro_doc.read(); + if let Some(ldoc) = guard.as_ref() { + commit_style_to_loro(ldoc, &ds_apply, style); + apply_mutation_and_relayout(&ds_apply, ldoc); + true + } else { + false + } + }; + if applied { + post_mutation_sync( + &ds_apply, + sync.loro_doc, + sync.cursor_state, + sync.undo_manager, + sync.can_undo, + sync.can_redo, + ); + } + }, + { fl!("ribbon-style-apply-changes") } + } + } + } + } +} diff --git a/loki-text/src/routes/editor/editor_style_editor/form_font.rs b/loki-text/src/routes/editor/editor_style_editor/form_font.rs new file mode 100644 index 00000000..c5c5f62d --- /dev/null +++ b/loki-text/src/routes/editor/editor_style_editor/form_font.rs @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Font-family picker and numeric weight selector for the style editor form, +//! plus the shared label / text-input style helpers used across the form. + +use std::rc::Rc; + +use appthere_ui::tokens; +use dioxus::prelude::*; +use loki_i18n::fl; + +use super::super::editor_state::StyleDraft; + +/// Numeric weights offered by the selector (OpenType 1–1000 scale). +pub(super) const WEIGHTS: [u16; 7] = [100, 300, 400, 500, 600, 700, 900]; + +/// Maps a numeric weight to its localised display label. +fn weight_label(w: u16) -> String { + match w { + 100 => fl!("editor-style-weight-thin"), + 300 => fl!("editor-style-weight-light"), + 500 => fl!("editor-style-weight-medium"), + 600 => fl!("editor-style-weight-semibold"), + 700 => fl!("editor-style-weight-bold"), + 900 => fl!("editor-style-weight-black"), + _ => fl!("editor-style-weight-regular"), + } +} + +/// Inline font-family picker: a text input that doubles as a substring filter +/// over the available families, with a scrollable result list beneath it. +/// +/// COMPAT(dioxus-native): a native dropdown / `position: absolute` popover is +/// unsupported in Blitz, so the candidate list renders inline below the input. +/// +/// Touch targets: each family row is a full-width button; with 4px vertical +/// padding inside the 84px scroll area the rows clear the 44×44 logical-pixel +/// minimum on touch builds where the base font is scaled up. +pub(super) fn font_picker( + mut editing_style_draft: Signal>, + selected: String, + font_families: Rc>, +) -> Element { + let query = selected.trim().to_lowercase(); + let matches: Vec = font_families + .iter() + .filter(|f| query.is_empty() || f.to_lowercase().contains(&query)) + .take(50) + .cloned() + .collect(); + + rsx! { + div { + style: "display: flex; flex-direction: column; gap: 4px;", + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 8px;", + span { style: label_style(), { fl!("editor-style-font-label") } } + input { + r#type: "text", + value: "{selected}", + oninput: move |evt| { + let v = editing_style_draft.read().clone(); + if let Some(mut d) = v { + d.font_name = evt.value(); + editing_style_draft.set(Some(d)); + } + }, + style: input_style("flex: 1"), + } + } + div { + style: format!( + "max-height: 84px; overflow-y: auto; display: flex; \ + flex-direction: column; gap: 2px; border: 1px solid {border}; \ + border-radius: {r}px; padding: 2px;", + border = tokens::COLOR_BORDER_CHROME, + r = tokens::RADIUS_SM, + ), + {matches.into_iter().map(|name| { + let is_sel = name.eq_ignore_ascii_case(selected.trim()); + let name_cap = name.clone(); + rsx! { + button { + key: "{name}", + style: format!( + "text-align: left; padding: 4px 6px; border-radius: 3px; \ + border: none; cursor: pointer; font-family: {ff}; \ + font-size: {fs}px; background: {bg}; color: {fg};", + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + bg = if is_sel { tokens::COLOR_SURFACE_3 } else { "transparent" }, + fg = tokens::COLOR_TEXT_ON_CHROME, + ), + onclick: move |_| { + let v = editing_style_draft.read().clone(); + if let Some(mut d) = v { + d.font_name = name_cap.clone(); + editing_style_draft.set(Some(d)); + } + }, + "{name}" + } + } + })} + } + } + } +} + +/// Row of weight buttons (Thin … Black); each sets the draft's numeric weight. +/// Each button renders its label in the weight it selects, previewing the face. +pub(super) fn weight_selector( + mut editing_style_draft: Signal>, + current: u16, +) -> Element { + rsx! { + div { + style: "display: flex; flex-direction: row; align-items: center; gap: 4px; flex-wrap: wrap;", + span { + style: format!("{} margin-right: 4px;", label_style()), + { fl!("editor-style-weight-label") } + } + {WEIGHTS.into_iter().map(|w| { + let is_w = current == w; + rsx! { + button { + key: "{w}", + style: format!( + "padding: 2px 6px; border-radius: 3px; border: 1px solid {border}; \ + cursor: pointer; font-family: {ff}; font-size: {fs}px; \ + font-weight: {fw}; background: {bg}; color: {fg};", + border = if is_w { tokens::COLOR_TAB_ACTIVE_INDICATOR } else { tokens::COLOR_BORDER_CHROME }, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + fw = w, + bg = if is_w { tokens::COLOR_SURFACE_3 } else { tokens::COLOR_SURFACE_2 }, + fg = tokens::COLOR_TEXT_ON_CHROME, + ), + onclick: move |_| { + let v = editing_style_draft.read().clone(); + if let Some(mut d) = v { + d.font_weight = w; + editing_style_draft.set(Some(d)); + } + }, + { weight_label(w) } + } + } + })} + } + } +} + +/// Shared label `` style for the form's field captions. +pub(super) fn label_style() -> String { + format!( + "font-family: {ff}; font-size: {fs}px; color: {fg};", + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + ) +} + +/// Shared text-input style; `extra` injects width / flex rules per call site. +pub(super) fn input_style(extra: &str) -> String { + format!( + "{extra}; height: 24px; padding: 0 {p}px; background: {bg}; \ + border: 1px solid {border}; border-radius: {r}px; font-family: {ff}; \ + font-size: {fs}px; color: {fg}; box-sizing: border-box;", + p = tokens::SPACE_2, + bg = tokens::COLOR_SURFACE_2, + border = tokens::COLOR_BORDER_DEFAULT, + r = tokens::RADIUS_SM, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_BODY, + fg = tokens::COLOR_TEXT_ON_CHROME, + ) +} diff --git a/loki-text/src/routes/editor/editor_style_editor/mod.rs b/loki-text/src/routes/editor/editor_style_editor/mod.rs new file mode 100644 index 00000000..b1053db6 --- /dev/null +++ b/loki-text/src/routes/editor/editor_style_editor/mod.rs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Paragraph style catalog editor panel for the document editor. +//! +//! `style_editor_panel` renders a two-column panel above the ribbon when +//! `editing_style_draft` is `Some`. The left column lists every catalog style +//! (plus a "+ New" button to create a custom style); the right column +//! ([`form::style_form`]) edits the selected draft, which the Apply button +//! commits to the catalog and relays out. + +mod draft; +mod form; +mod form_font; + +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +use appthere_ui::tokens; +use dioxus::prelude::*; +use loki_i18n::fl; + +use super::editor_state::StyleDraft; +use super::editor_style_catalog::{catalog_style_list, get_catalog_style, new_custom_style_id}; +use crate::editing::cursor::CursorState; +use crate::editing::state::DocumentState; + +pub(super) use draft::style_to_draft; + +/// Height of the open style editor panel in CSS pixels. +pub(super) const STYLE_EDITOR_HEIGHT_PX: f32 = 360.0; + +/// Signals the style editor needs to persist edits through Loro and refresh the +/// undo/redo state. Grouped to keep the function signature manageable (mirrors +/// `editor_metadata_panel::MetaPanelSync`). +#[derive(Clone, Copy)] +pub(super) struct StyleEditorSync { + /// The document's Loro CRDT handle. + pub loro_doc: Signal>, + /// Cursor state (mirrors the document generation for dirty tracking). + pub cursor_state: Signal, + /// Undo manager, refreshed after the style mutation. + pub undo_manager: Signal>, + /// Whether undo is available. + pub can_undo: Signal, + /// Whether redo is available. + pub can_redo: Signal, +} + +/// Renders the inline style catalog editor panel. +/// +/// Plain function — no hooks. `font_families` is enumerated once per editor +/// (memoised by the caller) and threaded into the form's font picker. +pub(super) fn style_editor_panel( + doc_state: Arc>, + mut editing_style_draft: Signal>, + font_families: Rc>, + sync: StyleEditorSync, +) -> Element { + let draft = match editing_style_draft.read().clone() { + Some(d) => d, + None => return rsx! {}, + }; + + let styles = catalog_style_list(&doc_state); + let active_id = draft.id.clone(); + let ds_list = Arc::clone(&doc_state); + let ds_new = Arc::clone(&doc_state); + + rsx! { + div { + style: format!( + "height: {h}px; min-height: {h}px; max-height: {h}px; \ + display: flex; flex-direction: column; flex-shrink: 0; \ + background: {bg}; border-top: 1px solid {border};", + h = STYLE_EDITOR_HEIGHT_PX, + bg = tokens::COLOR_SURFACE_1, + border = tokens::COLOR_BORDER_CHROME, + ), + + // ── Header ──────────────────────────────────────────────────────── + div { + style: format!( + "display: flex; flex-direction: row; align-items: center; \ + justify-content: space-between; padding: 0 {p}px; \ + flex-shrink: 0; height: 28px;", + p = tokens::SPACE_4, + ), + span { + style: format!( + "font-family: {ff}; font-size: {fs}px; font-weight: {fw}; color: {fg};", + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + fw = tokens::FONT_WEIGHT_MEDIUM, + fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + ), + { fl!("ribbon-style-editor-heading") } + } + button { + style: format!( + "background: transparent; border: none; font-size: {fs}px; \ + color: {fg}; cursor: pointer; padding: {p}px;", + fs = tokens::FONT_SIZE_LABEL, + fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + p = tokens::SPACE_1, + ), + aria_label: fl!("editor-style-editor-close-aria"), + onclick: move |_| editing_style_draft.set(None), + "\u{2715}" + } + } + + // ── Two-column body ──────────────────────────────────────────────── + div { + style: "display: flex; flex-direction: row; flex: 1; overflow: hidden;", + + // ── Left: catalog style list ─────────────────────────────────── + div { + style: format!( + "width: 160px; min-width: 160px; overflow-y: auto; \ + border-right: 1px solid {border}; display: flex; \ + flex-direction: column; gap: 2px; padding: {p}px;", + border = tokens::COLOR_BORDER_CHROME, + p = tokens::SPACE_2, + ), + + {styles.into_iter().map(|(id, display)| { + let is_active = id == active_id; + let ds_c = Arc::clone(&ds_list); + let id_cap = id.clone(); + rsx! { + button { + key: "{id}", + style: format!( + "text-align: left; padding: {p}px {p2}px; \ + border-radius: 3px; border: 1px solid {border}; \ + cursor: pointer; font-family: {ff}; \ + font-size: {fs}px; background: {bg}; color: {fg};", + p = tokens::SPACE_1, p2 = tokens::SPACE_2, + border = if is_active { tokens::COLOR_TAB_ACTIVE_INDICATOR } else { tokens::COLOR_BORDER_CHROME }, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + bg = if is_active { tokens::COLOR_SURFACE_3 } else { tokens::COLOR_SURFACE_2 }, + fg = tokens::COLOR_TEXT_ON_CHROME, + ), + onclick: move |_| { + if let Some(s) = get_catalog_style(&ds_c, &id_cap) { + editing_style_draft.set(Some(style_to_draft(&s))); + } + }, + "{display}" + } + } + })} + + button { + style: format!( + "padding: {p}px {p2}px; border-radius: 3px; margin-top: {mt}px; \ + border: 1px solid {border}; cursor: pointer; \ + font-family: {ff}; font-size: {fs}px; \ + background: {bg}; color: {fg};", + p = tokens::SPACE_1, p2 = tokens::SPACE_2, + mt = tokens::SPACE_2, + border = tokens::COLOR_BORDER_DEFAULT, + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_LABEL, + bg = tokens::COLOR_SURFACE_2, + fg = tokens::COLOR_TEXT_ON_CHROME, + ), + aria_label: fl!("ribbon-style-new-aria"), + onclick: move |_| { + let new_id = new_custom_style_id(&ds_new); + editing_style_draft.set(Some(StyleDraft { + id: new_id.clone(), + name: new_id, + is_custom: true, + alignment: "Left".to_string(), + ..StyleDraft::default() + })); + }, + { fl!("editor-style-new") } + } + } + + // ── Right: edit form ─────────────────────────────────────────── + { form::style_form(doc_state, editing_style_draft, draft, font_families, sync) } + } + } + } +} diff --git a/loki-text/src/routes/home.rs b/loki-text/src/routes/home.rs index ee2c6d68..06408dc2 100644 --- a/loki-text/src/routes/home.rs +++ b/loki-text/src/routes/home.rs @@ -14,7 +14,7 @@ use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, PickOptions, PickerError, SaveOptions}; use loki_i18n::fl; -use crate::new_document::new_blank_tab; +use crate::new_document::{new_blank_tab, new_import_tab, new_template_tab}; use crate::recent_documents::RecentDocuments; use crate::routes::Route; use crate::tabs::OpenTab; @@ -25,11 +25,18 @@ use crate::utils::display_title_from_path; const MIME_TYPES: &[&str] = &[ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.oasis.opendocument.text", + // Templates — opened as fresh, detached documents. + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", // .dotx + "application/vnd.ms-word.template.macroEnabled.12", // .dotm + "application/vnd.oasis.opendocument.text-template", // .ott ]; // ── Template data ───────────────────────────────────────────────────────────── +// Gallery card 0 is the plain Blank document; cards 1..=5 are the bundled +// templates, in the same order as the `on_template_select` match below. fn make_templates() -> Vec { + let dotx = || fl!("home-template-format-dotx"); vec![ BuiltinTemplate { name: fl!("home-template-blank"), @@ -37,24 +44,29 @@ fn make_templates() -> Vec { format_label: fl!("home-template-blank-format"), }, BuiltinTemplate { - name: fl!("home-template-letter"), - description: fl!("home-template-letter-description"), - format_label: fl!("home-template-letter-format"), + name: fl!("home-template-markdown"), + description: fl!("home-template-markdown-description"), + format_label: dotx(), }, BuiltinTemplate { - name: fl!("home-template-report"), - description: fl!("home-template-report-description"), - format_label: fl!("home-template-report-format"), + name: fl!("home-template-apa"), + description: fl!("home-template-apa-description"), + format_label: dotx(), }, BuiltinTemplate { - name: fl!("home-template-resume"), - description: fl!("home-template-resume-description"), - format_label: fl!("home-template-resume-format"), + name: fl!("home-template-mla"), + description: fl!("home-template-mla-description"), + format_label: dotx(), }, BuiltinTemplate { - name: fl!("home-template-invoice"), - description: fl!("home-template-invoice-description"), - format_label: fl!("home-template-invoice-format"), + name: fl!("home-template-screenplay"), + description: fl!("home-template-screenplay-description"), + format_label: dotx(), + }, + BuiltinTemplate { + name: fl!("home-template-resume"), + description: fl!("home-template-resume-description"), + format_label: dotx(), }, ] } @@ -93,6 +105,28 @@ fn close_tab_for_path(mut tabs: Signal>, mut active_tab: Signal 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(); @@ -124,17 +158,18 @@ pub fn Home() -> Element { // Index 0 = "Blank" — opens a new blank document. // All other indices are deferred (templates not yet implemented). let on_template_select = move |idx: usize| { - if idx == 0 { - let tab = new_blank_tab(); - let path = tab.path.clone(); - let nav = navigator; - let mut t = tabs; - let mut a = active_tab; - t.write().push(tab); - *a.write() = t.read().len(); // new tab is last; +1 for Home - nav.push(Route::Editor { path }); - } - // TODO(templates): Apply the selected built-in template (idx > 0). + // Order must match `make_templates`: 0 = Blank, 1..=5 = bundled templates. + let tab = match idx { + 0 => new_blank_tab(), + 1 => new_template_tab("markdown", fl!("home-template-markdown")), + 2 => new_template_tab("apa", fl!("home-template-apa")), + 3 => new_template_tab("mla", fl!("home-template-mla")), + 4 => new_template_tab("screenplay", fl!("home-template-screenplay")), + 5 => new_template_tab("resume", fl!("home-template-resume")), + _ => return, + }; + let path = push_new_tab(tabs, active_tab, tab); + navigator.push(Route::Editor { path }); }; // ── on_open_file ────────────────────────────────────────────────────────── @@ -152,6 +187,15 @@ pub fn Home() -> Element { multi: false, }; match picker.pick_file_to_open(opts).await { + Ok(Some(token)) if is_template_name(token.display_name()) => { + // A template (.dotx/.dotm/.ott/.ots): open it as a fresh, + // detached document so saving prompts Save As rather than + // overwriting the template, and it is not added to recents. + let serialized = token.serialize(); + let title = display_title_from_path(&serialized); + let path = push_new_tab(tabs, active_tab, new_import_tab(&serialized, title)); + nav.push(Route::Editor { path }); + } Ok(Some(token)) => { let path = token.serialize(); let title = display_title_from_path(&path); diff --git a/loki-text/src/tabs.rs b/loki-text/src/tabs.rs index 850e0aaa..d0db16bb 100644 --- a/loki-text/src/tabs.rs +++ b/loki-text/src/tabs.rs @@ -1,20 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -//! Open-tab state for the `loki-text` editor shell. -//! -//! [`OpenTab`] is injected into Dioxus context at the [`crate::app::App`] root -//! as a `Signal>`, alongside a `Signal` for the active -//! tab index (0 = Home tab). +//! Open-tab state for `loki-text` — re-exported from [`loki_app_shell::tabs`]. -/// Represents a single open document tab. -#[derive(Clone, PartialEq)] -pub struct OpenTab { - /// Display title shown in the tab bar (filename stem, decoded). - pub title: String, - /// The serialised file access token / path used by the editor. - pub path: String, - /// Whether the document has unsaved changes. - pub is_dirty: bool, - /// Whether this tab has been discarded from memory. - pub is_discarded: bool, -} +pub use loki_app_shell::tabs::OpenTab; diff --git a/loki-vello/Cargo.toml b/loki-vello/Cargo.toml index d0785cba..2abf37ad 100644 --- a/loki-vello/Cargo.toml +++ b/loki-vello/Cargo.toml @@ -17,8 +17,6 @@ kurbo = "0.12" thiserror = "2" image = "0.25" base64 = "0.22" -read-fonts = "0.37" -skrifa = "0.40" [dev-dependencies] wgpu = "26" diff --git a/loki-vello/src/glyph.rs b/loki-vello/src/glyph.rs index 24438508..329b5b5f 100644 --- a/loki-vello/src/glyph.rs +++ b/loki-vello/src/glyph.rs @@ -13,12 +13,16 @@ use crate::font_cache::FontDataCache; /// Paint a single [`PositionedGlyphRun`] into a Vello scene. /// /// `scale` is the display scale factor (1.0 for 1× displays, 2.0 for HiDPI). +/// `offset` is the layout-space `(dx, dy)` translation applied to the run's +/// origin before scaling (the page/scroll offset), so callers do not need to +/// clone and pre-translate the run just to shift it. /// Glyph runs with empty `font_data` or empty `glyphs` are silently skipped. pub fn paint_glyph_run( scene: &mut vello::Scene, run: &PositionedGlyphRun, font_cache: &mut FontDataCache, scale: f32, + offset: (f32, f32), ) { if run.glyphs.is_empty() { return; @@ -31,9 +35,12 @@ pub fn paint_glyph_run( .get_or_insert(&run.font_data, run.font_index) .clone(); - // Translate to the run's baseline origin in scaled (pixel) space. - let transform = - kurbo::Affine::translate(((run.origin.x * scale) as f64, (run.origin.y * scale) as f64)); + // Translate to the run's baseline origin in scaled (pixel) space, applying + // the layout offset first: (origin + offset) * scale. + let transform = kurbo::Affine::translate(( + ((run.origin.x + offset.0) * scale) as f64, + ((run.origin.y + offset.1) * scale) as f64, + )); let brush = crate::color::to_brush(&run.color); diff --git a/loki-vello/src/scene.rs b/loki-vello/src/scene.rs index fbdb319f..b255809b 100644 --- a/loki-vello/src/scene.rs +++ b/loki-vello/src/scene.rs @@ -237,6 +237,9 @@ pub fn paint_single_page( ); paint_items(scene, &page.header_items, font_cache, page_origin, scale); paint_items(scene, &page.footer_items, font_cache, page_origin, scale); + // Comment-panel items are page-local but extend into the gutter to the + // right of the page. + paint_items(scene, &page.comment_items, font_cache, page_origin, scale); // Cursor and selection highlights — painted after content so they appear // on top of glyphs. @@ -457,6 +460,7 @@ pub fn paint_paginated( ); paint_items(scene, &page.header_items, font_cache, page_origin, scale); paint_items(scene, &page.footer_items, font_cache, page_origin, scale); + paint_items(scene, &page.comment_items, font_cache, page_origin, scale); y_cursor += page_height + PAGE_GAP_PT; } @@ -487,66 +491,43 @@ pub(crate) fn paint_items( scale: f32, ) { for item in items { - let mut item = item.clone(); - translate_item(&mut item, offset.0, offset.1); - match &item { + // Fast paths for the variants whose clone would allocate: a `GlyphRun` + // carries a `Vec` and the groups carry a child `Vec`, so the + // old `item.clone()` copied the whole run / subtree just to shift its + // origin. Paint these with an explicit `offset` instead. The cheap, + // all-`Copy` leaf variants (rects, decorations, rules) still take the + // clone-and-translate path below — their clone is a stack copy. + match item { PositionedItem::GlyphRun(r) => { // Link visual hint (gap #11): paint a translucent blue underlay // behind runs that carry a hyperlink URL. // TODO(link-click): interactive hit-testing deferred. if r.link_url.is_some() { - paint_link_hint(scene, r, scale); + paint_link_hint(scene, r, scale, offset); } - crate::glyph::paint_glyph_run(scene, r, font_cache, scale); - } - PositionedItem::FilledRect(r) => { - crate::rect::paint_filled_rect(scene, r, scale); - } - PositionedItem::BorderRect(r) => { - crate::rect::paint_border_rect(scene, r, scale); - } - PositionedItem::Image(img) => { - // Ignore image errors during layout rendering; a failed image - // leaves the scene unchanged. - let _ = crate::image::paint_image(scene, img, scale); - } - PositionedItem::Decoration(d) => { - crate::decor::paint_decoration(scene, d, scale); - } - PositionedItem::HorizontalRule(r) => { - // Render as a thin grey filled rectangle. - let rule = PositionedRect { - rect: r.rect, - color: LayoutColor { - r: 0.7, - g: 0.7, - b: 0.7, - a: 1.0, - }, - }; - crate::rect::paint_filled_rect(scene, &rule, scale); + crate::glyph::paint_glyph_run(scene, r, font_cache, scale, offset); + continue; } PositionedItem::ClippedGroup { clip_rect, items } => { // ADR 004 open question 1: verified Vello 0.6 push_layer signature: // fn push_layer(&mut self, blend: impl Into, alpha: f32, // transform: Affine, clip: &impl Shape) - // This matches the ADR §2 design exactly. clip_rect.origin is already - // translated by `translate_item` above, so no further offset is needed. + // The clip rect is offset inline ((coord + offset) * scale); child + // items inherit the same `offset` (no pre-translation / no clone). scene.push_layer( BlendMode::default(), 1.0, Affine::IDENTITY, &vello::kurbo::Rect::new( - (clip_rect.x() * scale) as f64, - (clip_rect.y() * scale) as f64, - (clip_rect.max_x() * scale) as f64, - (clip_rect.max_y() * scale) as f64, + ((clip_rect.x() + offset.0) * scale) as f64, + ((clip_rect.y() + offset.1) * scale) as f64, + ((clip_rect.max_x() + offset.0) * scale) as f64, + ((clip_rect.max_y() + offset.1) * scale) as f64, ), ); - // Child items were already translated by `translate_item` above; - // pass offset (0, 0) so they are not translated a second time. - paint_items(scene, items, font_cache, (0.0, 0.0), scale); + paint_items(scene, items, font_cache, offset, scale); scene.pop_layer(); + continue; } PositionedItem::RotatedGroup { origin, @@ -555,12 +536,16 @@ pub(crate) fn paint_items( content_height, items, } => { + // Origin is offset inline; the rotated children are painted into a + // temporary scene with no offset and appended under the rotation. + 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 => (origin.x + cy_local, origin.y + cx_local), - _ => (origin.x + cx_local, origin.y + cy_local), + 90 | 270 => (ox + cy_local, oy + cx_local), + _ => (ox + cx_local, oy + cy_local), }; let angle = (*degrees as f64).to_radians(); @@ -591,9 +576,47 @@ pub(crate) fn paint_items( paint_items(&mut rotated_scene, items, font_cache, (0.0, 0.0), scale); scene.append(&rotated_scene, Some(transform)); scene.pop_layer(); + continue; + } + // Cheap leaf variants fall through to the clone-and-translate path. + _ => {} + } + + // Leaf variants are small, all-`Copy` structs (or a rare image): cloning + // one is a stack copy, so translate a clone in place and paint it. + let mut item = item.clone(); + translate_item(&mut item, offset.0, offset.1); + match &item { + PositionedItem::FilledRect(r) => { + crate::rect::paint_filled_rect(scene, r, scale); + } + PositionedItem::BorderRect(r) => { + crate::rect::paint_border_rect(scene, r, scale); + } + PositionedItem::Image(img) => { + // Ignore image errors during layout rendering; a failed image + // leaves the scene unchanged. + let _ = crate::image::paint_image(scene, img, scale); + } + PositionedItem::Decoration(d) => { + crate::decor::paint_decoration(scene, d, scale); + } + PositionedItem::HorizontalRule(r) => { + // Render as a thin grey filled rectangle. + let rule = PositionedRect { + rect: r.rect, + color: LayoutColor { + r: 0.7, + g: 0.7, + b: 0.7, + a: 1.0, + }, + }; + crate::rect::paint_filled_rect(scene, &rule, scale); } _ => { - // `PositionedItem` is `#[non_exhaustive]`; ignore unknown variants. + // GlyphRun / groups handled above; `PositionedItem` is + // `#[non_exhaustive]`, so ignore any other variant. } } } @@ -605,7 +628,12 @@ pub(crate) fn paint_items( /// `PositionedGlyphRun` does not carry font metrics directly; a fixed-height /// estimate based on font size is used (ascent ≈ 0.8 × font_size, descent ≈ /// 0.2 × font_size). This is approximate but sufficient for the visual hint. -fn paint_link_hint(scene: &mut vello::Scene, r: &PositionedGlyphRun, scale: f32) { +fn paint_link_hint( + scene: &mut vello::Scene, + r: &PositionedGlyphRun, + scale: f32, + offset: (f32, f32), +) { let ascent = r.font_size * 0.8; let descent = r.font_size * 0.2; // Sum advance of all glyphs for the run width. @@ -614,7 +642,12 @@ fn paint_link_hint(scene: &mut vello::Scene, r: &PositionedGlyphRun, scale: f32) return; } let hint = PositionedRect { - rect: LayoutRect::new(r.origin.x, r.origin.y - ascent, width, ascent + descent), + rect: LayoutRect::new( + r.origin.x + offset.0, + r.origin.y - ascent + offset.1, + width, + ascent + descent, + ), color: LayoutColor { r: 0.0, g: 0.4, diff --git a/patches/anyrender_vello/src/custom_paint_source.rs b/patches/anyrender_vello/src/custom_paint_source.rs index d183c1ab..9bff050c 100644 --- a/patches/anyrender_vello/src/custom_paint_source.rs +++ b/patches/anyrender_vello/src/custom_paint_source.rs @@ -13,6 +13,16 @@ pub trait CustomPaintSource: 'static { height: u32, scale: f64, ) -> Option; + + /// Called when the source is being unregistered while the renderer is still + /// active, giving it a [`CustomPaintCtx`] so it can `unregister_texture` any + /// texture it registered. Textures handed to the renderer via + /// [`CustomPaintCtx::register_texture`] live in the renderer's registry until + /// explicitly unregistered; `suspend` cannot do this (it has no ctx), so + /// without this hook a source's last texture would leak when the source is + /// dropped (e.g. a virtualized tile scrolling out of view). The default is a + /// no-op for sources that register no textures. + fn release(&mut self, _ctx: CustomPaintCtx<'_>) {} } pub struct CustomPaintCtx<'r> { diff --git a/patches/anyrender_vello/src/window_renderer.rs b/patches/anyrender_vello/src/window_renderer.rs index 9bf58357..fde6e850 100644 --- a/patches/anyrender_vello/src/window_renderer.rs +++ b/patches/anyrender_vello/src/window_renderer.rs @@ -15,7 +15,7 @@ use wgpu_context::{ DeviceHandle, SurfaceRenderer, SurfaceRendererConfiguration, TextureConfiguration, WGPUContext, }; -use crate::{CustomPaintSource, DEFAULT_THREADS, VelloScenePainter}; +use crate::{CustomPaintCtx, CustomPaintSource, DEFAULT_THREADS, VelloScenePainter}; static PAINT_SOURCE_ID: AtomicU64 = AtomicU64::new(0); @@ -119,6 +119,14 @@ impl VelloWindowRenderer { pub fn unregister_custom_paint_source(&mut self, id: u64) { if let Some(mut source) = self.custom_paint_sources.remove(&id) { + // Give the source a chance to release any textures it registered + // with the live renderer before it is dropped. The renderer retains + // registered textures until `unregister_texture`; `suspend` cannot + // call it (no ctx), so without this the source's last texture would + // leak in the renderer's registry until the renderer is recreated. + if let RenderState::Active(state) = &mut self.render_state { + source.release(CustomPaintCtx::new(&mut state.renderer)); + } source.suspend(); drop(source); } @@ -260,3 +268,93 @@ impl WindowRenderer for VelloWindowRenderer { self.scene.reset(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CustomPaintCtx, TextureHandle}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Records how many times each teardown hook fired, so a test can assert the + /// unregister path drives source teardown. + #[derive(Default)] + struct Calls { + suspend: AtomicUsize, + release: AtomicUsize, + } + + struct SpySource(Arc); + + impl CustomPaintSource for SpySource { + fn resume(&mut self, _device_handle: &DeviceHandle) {} + fn suspend(&mut self) { + self.0.suspend.fetch_add(1, Ordering::SeqCst); + } + fn render( + &mut self, + _ctx: CustomPaintCtx<'_>, + _width: u32, + _height: u32, + _scale: f64, + ) -> Option { + None + } + fn release(&mut self, _ctx: CustomPaintCtx<'_>) { + self.0.release.fetch_add(1, Ordering::SeqCst); + } + } + + // Constructing a `VelloWindowRenderer` only creates a wgpu `Instance` (no + // adapter/device), so these run headlessly. The renderer starts `Suspended`; + // the `release` (texture-unregister) path requires an `Active` renderer with + // a real GPU surface and is verified on-device by watching RSS plateau while + // scrolling a long document. + + #[test] + fn unregister_tears_down_and_removes_the_source() { + let mut renderer = VelloWindowRenderer::new(); + let calls = Arc::new(Calls::default()); + let id = renderer.register_custom_paint_source(Box::new(SpySource(calls.clone()))); + assert_eq!(renderer.custom_paint_sources.len(), 1); + + renderer.unregister_custom_paint_source(id); + + // The source is removed and suspended. (When suspended there is no live + // renderer to unregister textures from, so `release` is correctly skipped; + // the active-state release path is exercised on-device.) + assert!(renderer.custom_paint_sources.is_empty()); + assert_eq!(calls.suspend.load(Ordering::SeqCst), 1, "suspend not called"); + assert_eq!(calls.release.load(Ordering::SeqCst), 0, "release on suspended"); + } + + #[test] + fn unregister_unknown_id_is_a_noop() { + let mut renderer = VelloWindowRenderer::new(); + renderer.unregister_custom_paint_source(123); + assert!(renderer.custom_paint_sources.is_empty()); + } + + #[test] + fn default_release_impl_is_a_noop() { + // A source that registers no textures uses the default `release`, which + // must compile and do nothing. + struct Bare; + impl CustomPaintSource for Bare { + fn resume(&mut self, _d: &DeviceHandle) {} + fn suspend(&mut self) {} + fn render( + &mut self, + _c: CustomPaintCtx<'_>, + _w: u32, + _h: u32, + _s: f64, + ) -> Option { + None + } + } + let mut renderer = VelloWindowRenderer::new(); + let id = renderer.register_custom_paint_source(Box::new(Bare)); + renderer.unregister_custom_paint_source(id); // must not panic + assert!(renderer.custom_paint_sources.is_empty()); + } +} diff --git a/patches/blitz-dom/src/document.rs b/patches/blitz-dom/src/document.rs index 3e14e389..55e9ff65 100644 --- a/patches/blitz-dom/src/document.rs +++ b/patches/blitz-dom/src/document.rs @@ -1054,6 +1054,31 @@ impl BaseDocument { self.has_canvas | self.has_active_animations } + /// Whether the shell should re-request a redraw *every frame* (a continuous + /// animation loop). + /// + /// PATCH(loki): this deliberately excludes `has_canvas`, unlike + /// [`Self::is_animating`]. The shell's redraw loop re-requests a redraw on + /// every frame while its predicate is true. Loki paints every document page + /// as a `` custom-paint tile, so `has_canvas` is permanently + /// true — using `is_animating()` there spins the app in a continuous idle + /// render loop (high CPU/battery, and per-frame GPU resource churn that grows + /// RSS without bound even when nothing is happening). + /// + /// Loki's canvas tiles are static between events: their content only changes + /// via DOM mutations (the tile's `data-cursor`/generation attribute, + /// scroll-driven remounts, viewport resize), each of which already schedules + /// a redraw through Blitz's normal dirty path, and the custom paint source's + /// reuse guard makes a redundant repaint a no-op. Only genuine CSS + /// animations/transitions need per-frame ticks. + /// + /// COMPAT(blitz): upstream treats any canvas as perpetually animating + /// (live/video canvases). Re-validate if a Loki surface ever needs to repaint + /// without an accompanying DOM mutation. + pub fn needs_animation_tick(&self) -> bool { + self.has_active_animations + } + /// Update the device and reset the stylist to process the new size pub fn set_stylist_device(&mut self, device: Device) { let origins = { diff --git a/patches/blitz-shell/src/window.rs b/patches/blitz-shell/src/window.rs index d6910dbb..3182a73e 100644 --- a/patches/blitz-shell/src/window.rs +++ b/patches/blitz-shell/src/window.rs @@ -327,7 +327,13 @@ impl View { self.renderer .render(|scene| paint_scene(scene, &self.doc, scale, width, height)); - if self.is_visible && self.doc.is_animating() { + // PATCH(loki): re-arm a per-frame redraw only for genuine CSS + // animations/transitions — NOT for the mere presence of a ``. + // Loki paints every page as a static custom-paint canvas tile, so + // `is_animating()` (which includes `has_canvas`) would spin a continuous + // idle render loop. Canvas content updates arrive via DOM mutations, + // which already schedule redraws. See `BaseDocument::needs_animation_tick`. + if self.is_visible && self.doc.needs_animation_tick() { self.request_redraw(); } } diff --git a/patches/fontique/.cargo-ok b/patches/fontique/.cargo-ok deleted file mode 100644 index 5f8b7958..00000000 --- a/patches/fontique/.cargo-ok +++ /dev/null @@ -1 +0,0 @@ -{"v":1} \ No newline at end of file diff --git a/patches/fontique/.cargo_vcs_info.json b/patches/fontique/.cargo_vcs_info.json deleted file mode 100644 index 32cc84ab..00000000 --- a/patches/fontique/.cargo_vcs_info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "git": { - "sha1": "8dbecc0545a0c97eb605937b928bc186d2d1295c" - }, - "path_in_vcs": "fontique" -} \ No newline at end of file diff --git a/patches/fontique/Cargo.lock b/patches/fontique/Cargo.lock deleted file mode 100644 index b5110b43..00000000 --- a/patches/fontique/Cargo.lock +++ /dev/null @@ -1,387 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "font-types" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73829a7b5c91198af28a99159b7ae4afbb252fb906159ff7f189f3a2ceaa3df2" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "fontique" -version = "0.8.0" -dependencies = [ - "hashbrown", - "linebender_resource_handle", - "memmap2", - "objc2", - "objc2-core-foundation", - "objc2-core-text", - "objc2-foundation", - "parlance", - "read-fonts", - "roxmltree", - "smallvec", - "windows", - "windows-core", - "yeslogic-fontconfig-sys", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash", -] - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[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 = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "linebender_resource_handle" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[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-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags", - "objc2-core-foundation", -] - -[[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", - "objc2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "parlance" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[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 = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "read-fonts" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" -dependencies = [ - "bytemuck", - "core_maths", - "font-types", -] - -[[package]] -name = "roxmltree" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" -dependencies = [ - "memchr", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[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 = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] diff --git a/patches/fontique/Cargo.toml b/patches/fontique/Cargo.toml deleted file mode 100644 index 3c2355ea..00000000 --- a/patches/fontique/Cargo.toml +++ /dev/null @@ -1,195 +0,0 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - -[package] -edition = "2021" -rust-version = "1.88" -name = "fontique" -version = "0.8.0" -build = false -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false -description = "Font enumeration and fallback." -readme = "README.md" -keywords = [ - "font", - "text", -] -categories = [ - "gui", - "os", -] -license = "Apache-2.0 OR MIT" -repository = "https://github.com/linebender/parley" - -[package.metadata.docs.rs] -all-features = true - -[features] -bytemuck = ["parlance/bytemuck"] -default = ["system"] -fontconfig-dlopen = ["yeslogic-fontconfig-sys?/dlopen"] -libm = ["read-fonts/libm"] -std = [ - "read-fonts/std", - "dep:memmap2", - "parlance/std", -] -system = [ - "std", - "dep:windows", - "dep:windows-core", - "dep:objc2", - "dep:objc2-core-foundation", - "dep:objc2-core-text", - "dep:objc2-foundation", - "dep:yeslogic-fontconfig-sys", - "dep:roxmltree", - "fontconfig-dlopen", -] - -[lib] -name = "fontique" -path = "src/lib.rs" - -[[example]] -name = "generic_families" -path = "examples/generic_families.rs" - -[dependencies.hashbrown] -version = "0.16.1" -features = [ - "default-hasher", - "raw-entry", -] -default-features = false - -[dependencies.linebender_resource_handle] -version = "0.1.1" -default-features = false - -[dependencies.memmap2] -version = "0.9.10" -optional = true - -[dependencies.parlance] -version = "0.1.0" -default-features = false - -[dependencies.read-fonts] -version = "0.37.0" -default-features = false - -[dependencies.smallvec] -version = "1.15.1" - - -[target.'cfg(target_os = "android")'.dependencies.roxmltree] -version = "0.21.1" -optional = true - -[target.'cfg(target_os = "linux")'.dependencies.yeslogic-fontconfig-sys] -version = "6.0.0" -optional = true - -[target.'cfg(target_os = "windows")'.dependencies.windows] -version = "0.62.2" -features = ["Win32_Graphics_DirectWrite"] -optional = true - -[target.'cfg(target_os = "windows")'.dependencies.windows-core] -version = "0.62.2" -optional = true - -[target.'cfg(target_vendor = "apple")'.dependencies.objc2] -version = "0.6.4" -features = [ - "std", - "relax-sign-encoding", -] -optional = true - -[target.'cfg(target_vendor = "apple")'.dependencies.objc2-core-foundation] -version = "0.3.2" -features = [ - "CFBase", - "CFURL", -] -optional = true -default-features = false - -[target.'cfg(target_vendor = "apple")'.dependencies.objc2-core-text] -version = "0.3.2" -features = [ - "CTFont", - "CTFontDescriptor", - "CTFontCollection", -] -optional = true -default-features = false - -[target.'cfg(target_vendor = "apple")'.dependencies.objc2-foundation] -version = "0.3.2" -features = [ - "alloc", - "NSArray", - "NSEnumerator", - "NSPathUtilities", - "NSString", -] -optional = true -default-features = false - -[lints.clippy] -allow_attributes_without_reason = "warn" -cargo_common_metadata = "warn" -cast_possible_truncation = "warn" -collection_is_never_read = "warn" -dbg_macro = "warn" -debug_assert_with_mut_call = "warn" -default_trait_access = "warn" -doc_markdown = "warn" -fn_to_numeric_cast_any = "warn" -infinite_loop = "warn" -large_stack_arrays = "warn" -mismatching_type_param_order = "warn" -missing_assert_message = "warn" -missing_fields_in_debug = "warn" -negative_feature_names = "warn" -redundant_feature_names = "warn" -same_functions_in_if_condition = "warn" -semicolon_if_nothing_returned = "warn" -should_panic_without_expect = "warn" -todo = "warn" -too_many_arguments = "allow" -unseparated_literal_suffix = "warn" -use_self = "warn" -wildcard_dependencies = "warn" - -[lints.rust] -elided_lifetimes_in_paths = "warn" -keyword_idents_2024 = "forbid" -missing_debug_implementations = "warn" -missing_docs = "warn" -non_ascii_idents = "forbid" -non_local_definitions = "forbid" -trivial_numeric_casts = "warn" -unnameable_types = "warn" -unreachable_pub = "warn" -unsafe_code = "deny" -unsafe_op_in_unsafe_fn = "forbid" -unused_import_braces = "warn" -unused_lifetimes = "warn" -unused_macro_rules = "warn" -unused_qualifications = "warn" diff --git a/patches/fontique/Cargo.toml.orig b/patches/fontique/Cargo.toml.orig deleted file mode 100644 index 1367bb96..00000000 --- a/patches/fontique/Cargo.toml.orig +++ /dev/null @@ -1,80 +0,0 @@ -[package] -name = "fontique" -version.workspace = true -description = "Font enumeration and fallback." -keywords = ["font", "text"] -categories = ["gui", "os"] -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[package.metadata.docs.rs] -all-features = true - -[lints] -workspace = true - -[features] -default = ["system"] -std = ["read-fonts/std", "dep:memmap2", "parlance/std"] -libm = ["read-fonts/libm"] -bytemuck = ["parlance/bytemuck"] -# Enables support for system font backends -system = [ - "std", - "dep:windows", - "dep:windows-core", - "dep:objc2", - "dep:objc2-core-foundation", - "dep:objc2-core-text", - "dep:objc2-foundation", - "dep:yeslogic-fontconfig-sys", - "dep:roxmltree", - "fontconfig-dlopen", -] - -# Use dlopen to load the fontconfig library. This allows Fontique to compile even if the -# system does not have fontconfig installed, although the font fallback to system fonts -# will not work properly in this case (no such fallback will occur) -fontconfig-dlopen = ["yeslogic-fontconfig-sys?/dlopen"] - -[dependencies] -read-fonts = { workspace = true } -linebender_resource_handle = { workspace = true } -smallvec = "1.15.1" -memmap2 = { version = "0.9.10", optional = true } -hashbrown = { workspace = true } -parlance = { workspace = true } - -[target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.62.2", features = ["Win32_Graphics_DirectWrite"], optional = true } -windows-core = { version = "0.62.2", optional = true } - -[target.'cfg(target_vendor = "apple")'.dependencies] -# FIX: Enable relax-sign-encoding to prevent the bug described in this issue: https://github.com/madsmtm/objc2/issues/566 -objc2 = { version = "0.6.4", optional = true, features = ["std", "relax-sign-encoding"] } -# NOTE: When updating objc2-foundation, objc2-core-foundation, or objc2-core-text make sure to use the version of objc2 -# that they reference to prevent crate duplication. -objc2-foundation = { version = "0.3.2", optional = true, default-features = false, features = [ - "alloc", - "NSArray", - "NSEnumerator", - "NSPathUtilities", - "NSString", -] } -objc2-core-foundation = { version = "0.3.2", optional = true, default-features = false, features = [ - "CFBase", - "CFURL", -] } -objc2-core-text = { version = "0.3.2", optional = true, default-features = false, features = [ - "CTFont", - "CTFontDescriptor", - "CTFontCollection", -] } - -[target.'cfg(target_os = "linux")'.dependencies] -yeslogic-fontconfig-sys = { version = "6.0.0", optional = true } - -[target.'cfg(target_os = "android")'.dependencies] -roxmltree = { version = "0.21.1", optional = true } diff --git a/patches/fontique/LICENSE-APACHE b/patches/fontique/LICENSE-APACHE deleted file mode 100644 index 16fe87b0..00000000 --- a/patches/fontique/LICENSE-APACHE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/patches/fontique/LICENSE-MIT b/patches/fontique/LICENSE-MIT deleted file mode 100644 index 6601faa0..00000000 --- a/patches/fontique/LICENSE-MIT +++ /dev/null @@ -1,25 +0,0 @@ -Copyright 2024 the Parley Authors - -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/fontique/README.md b/patches/fontique/README.md deleted file mode 100644 index 6c66e142..00000000 --- a/patches/fontique/README.md +++ /dev/null @@ -1,54 +0,0 @@ -
- -# Fontique - -**Font enumeration and fallback** - -[![Latest published fontique version.](https://img.shields.io/crates/v/fontique.svg)](https://crates.io/crates/fontique) -[![Documentation build status.](https://img.shields.io/docsrs/fontique.svg)](https://docs.rs/fontique) -[![Dependency staleness status.](https://deps.rs/crate/fontique/latest/status.svg)](https://deps.rs/crate/fontique) -[![Linebender Zulip chat.](https://img.shields.io/badge/Linebender-%23parley-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/205635-parley) -[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) - -
- -Fontique provides font enumeration and fallback. - -## Minimum supported Rust Version (MSRV) - -This version of Fontique has been verified to compile with **Rust 1.88** and later. - -Future versions of Fontique might increase the Rust version requirement. -It will not be treated as a breaking change and as such can even happen with small patch releases. - -
-Click here if compiling fails. - -As time has passed, some of Fontique's dependencies could have released versions with a higher Rust requirement. -If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. - -```sh -# Use the problematic dependency's name and version -cargo update -p package_name --precise 0.1.1 -``` -
- -## Community - -Discussion of Fontique development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#parley channel](https://xi.zulipchat.com/#narrow/channel/205635-parley). -All public content can be read without logging in. - -Contributions are welcome by pull request. The [Rust code of conduct] applies. - -Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache 2.0 license, shall be licensed as noted in the [License](#license) section, without any additional terms or conditions. - -## License - -Licensed under either of - -- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) -- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) - -at your option. - -[Rust code of conduct]: https://www.rust-lang.org/policies/code-of-conduct diff --git a/patches/fontique/examples/generic_families.rs b/patches/fontique/examples/generic_families.rs deleted file mode 100644 index 7d47a37c..00000000 --- a/patches/fontique/examples/generic_families.rs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2024 the Parley Authors -// SPDX-License-Identifier: Apache-2.0 OR MIT - -//! Print an enumeration of discovered fonts for each `GenericFamily`. - -use fontique::{Collection, CollectionOptions, GenericFamily::*}; - -fn main() { - let mut collection = Collection::new(CollectionOptions::default()); - - for gf in [ - Serif, - SansSerif, - Monospace, - Cursive, - Fantasy, - SystemUi, - UiSerif, - UiSansSerif, - UiMonospace, - UiRounded, - Emoji, - Math, - FangSong, - ] { - println!("GenericFamily::{gf:?}:"); - - let ids = collection.generic_families(gf).collect::>(); - for id in ids { - if let Some(name) = collection.family_name(id) { - println!("{name}"); - } - } - - println!(); - } -} diff --git a/patches/fontique/src/attributes.rs b/patches/fontique/src/attributes.rs deleted file mode 100644 index 5cbc699d..00000000 --- a/patches/fontique/src/attributes.rs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2024 the Parley Authors -// SPDX-License-Identifier: Apache-2.0 OR MIT - -//! Properties for specifying font matching attributes. - -use core::fmt; - -use parlance::{FontStyle, FontWeight, FontWidth}; - -/// Primary attributes for font matching: [`FontWidth`], [`FontStyle`] and [`FontWeight`]. -/// -/// These are used to [configure] a [`Query`]. -/// -/// [configure]: crate::Query::set_attributes -/// [`Query`]: crate::Query -#[derive(Copy, Clone, PartialEq, Default, Debug)] -pub struct Attributes { - pub width: FontWidth, - pub style: FontStyle, - pub weight: FontWeight, -} - -impl Attributes { - /// Creates new attributes from the given width, style and weight. - pub fn new(width: FontWidth, style: FontStyle, weight: FontWeight) -> Self { - Self { - width, - style, - weight, - } - } -} - -impl fmt::Display for Attributes { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "width: {}, style: {}, weight: {}", - self.width, self.style, self.weight - ) - } -} diff --git a/patches/fontique/src/backend/android.rs b/patches/fontique/src/backend/android.rs deleted file mode 100644 index a9bf78d0..00000000 --- a/patches/fontique/src/backend/android.rs +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright 2024 the Parley Authors -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use alloc::{ - boxed::Box, - str::FromStr, - string::{String, ToString}, - sync::Arc, - vec, - vec::Vec, -}; -use std::path::Path; - -use hashbrown::HashMap; -use roxmltree::{Document, Node}; - -use super::{ - FallbackKey, FamilyId, FamilyInfo, FamilyNameMap, GenericFamily, GenericFamilyMap, Language, - Script, scan, -}; - -// TODO: Use actual generic families here, where available, when fonts.xml is properly parsed. -// system-ui should map to `variant="compact"` in some scripts during fallback resolution. -const DEFAULT_GENERIC_FAMILIES: &[(GenericFamily, &[&str])] = &[ - ( - GenericFamily::SansSerif, - &["Roboto Flex", "Roboto", "Noto Sans"], - ), - (GenericFamily::Serif, &["Noto Serif"]), - (GenericFamily::Monospace, &["monospace"]), - (GenericFamily::Cursive, &["Dancing Script"]), - (GenericFamily::Fantasy, &["Noto Serif"]), - ( - GenericFamily::SystemUi, - &["Roboto Flex", "Roboto", "Noto Sans"], - ), - (GenericFamily::Emoji, &["Noto Color Emoji"]), - (GenericFamily::Math, &["Noto Sans Math", "Noto Sans"]), -]; - -pub(crate) struct SystemFonts { - pub(crate) name_map: Arc, - pub(crate) generic_families: Arc, - family_map: HashMap, - locale_fallback: Box<[(Language, FamilyId)]>, - script_fallback: Box<[(Script, FamilyId)]>, -} - -impl SystemFonts { - pub(crate) fn new() -> Self { - let android_root: String = std::env::var("ANDROID_ROOT").unwrap_or("/system".to_string()); - - let scan::ScannedCollection { - family_names: mut name_map, - families: family_map, - postscript_names, - .. - } = scan::ScannedCollection::from_paths(Path::new(&android_root).join("fonts").to_str(), 8); - let mut generic_families = GenericFamilyMap::default(); - for (family, names) in DEFAULT_GENERIC_FAMILIES { - generic_families.set( - *family, - names - .iter() - .filter_map(|name| name_map.get(name)) - .map(|name| name.id()), - ); - } - - let mut locale_fallback = vec![]; - let mut script_fallback = vec![]; - - // Try to get generic info from fonts.xml - if let Ok(s) = std::fs::read_to_string(Path::new(&android_root).join("etc/fonts.xml")) { - if let Ok(doc) = Document::parse(s.clone().as_str()) { - let root = doc.root_element(); - if root.tag_name().name() == "familyset" - || root - .attribute("version") - .is_some_and(|v| usize::from_str(v).is_ok_and(|x| x >= 21)) - { - for child in root.children() { - match child.tag_name().name() { - "alias" => { - if let Some((name, to)) = - child.attribute("name").zip(child.attribute("to")) - { - if child.attribute("weight").is_some() { - // weight aliases are an Android quirk and are not in­ - // teresting for use cases other than Android legacy. - continue; - } - let to_n = name_map.get_or_insert(to); - name_map.add_alias(to_n.id(), name); - } - } - "family" => { - if let Some(name) = child.attribute("name") { - let f = name_map.get_or_insert(name); - let _id = f.id(); - for _child in child.children() { - // TODO: map using postScriptName when available other­ - // wise use the file name, and perhaps if necess­ - // ary (e.g. if it's a collection), do something - // smarter, or something dumb that meets expecta­ - // tions on Android. - } - } else if let Some(langs) = child - .attribute("lang") - .map(|s| s.split(',').collect::>()) - { - let (_has_for, hasnt_for): ( - Vec>, - Vec>, - ) = child - .children() - .partition(|c| c.attribute("fallbackFor").is_some()); - { - // general fallback families - let (ps_named, _ps_unnamed): ( - Vec>, - Vec>, - ) = hasnt_for - .iter() - .partition(|c| c.attribute("postScriptName").is_some()); - - if let Some(family) = ps_named.iter().find_map(|x| { - postscript_names - .get(x.attribute("postScriptName").unwrap()) - }) { - for lang in langs { - if let Some(scr) = lang.strip_prefix("und-") { - // Undefined lang for script-only fallbacks - script_fallback.push(( - scr.parse().unwrap_or(Script::UNKNOWN), - *family, - )); - } else if let Ok(locale) = Language::parse(lang) { - if let Some(scr) = locale - .script() - .and_then(|s| s.parse::