Refactor property mappers into submodules; add ODT export, math layout, and tests#22
Merged
Conversation
… and drop fontique patch Bump Loki's own linebender crates to the latest generation, which is decoupled from the patched, dioxus-0.7.9-pinned blitz/vello stack via the neutral type boundary in loki-layout (parley output is converted to plain f32/u16/raw font bytes before reaching the vello renderer): - loki-layout: parley 0.8 -> 0.10, fontique 0.8 -> 0.10 - loki-ooxml (dev-deps): parley 0.8 -> 0.10, read-fonts 0.37 -> 0.40 - appthere-canvas: read-fonts 0.37 -> 0.40 - loki-text/spreadsheet/presentation: fontique 0.8 -> 0.10 parley 0.10 API migration in loki-layout/src/para.rs: - InlineBox gained a `kind` field (InlineBoxKind::InFlow for tab-stop boxes) - Layout::align now takes (alignment, options); width comes from break_all_lines - LineMetrics min_coord/max_coord -> block_min_coord/block_max_coord Remove the fontique [patch.crates-io] entry and patches/fontique/. fontique 0.10 restores the fontconfig_sys alias that the 0.8.0 crates.io publish dropped, so the patch's primary fix is no longer needed. The dlopen feature-unification concern is handled without a patch by enabling `fontconfig-dlopen` directly on loki-layout's fontique dependency, so crates whose graph excludes blitz-dom (e.g. loki-vello) still link. Cleanups and other low-risk updates: - Remove unused read-fonts/skrifa deps from loki-vello (no references anywhere) - criterion 0.5 -> 0.8 (bench harness), dirs 5 -> 6 (recent-documents) - cargo update refresh of caret deps (loro -> 1.13.1, etc.) The vello/peniko/kurbo/wgpu generation and the blitz+dioxus stack are intentionally left unchanged: their newer releases only exist as blitz 0.3.0-alpha / dioxus 0.8.0-alpha pre-releases, which would require re-vendoring all remaining patches against moving-target alphas. Verified: cargo check --workspace --all-targets, cargo clippy --workspace -- -D warnings, cargo fmt --all --check, and cargo test -p loki-layout (130 passed) all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…de, tests, CI) Document findings from a read-only audit of the workspace for a future remediation session. Each finding carries a location, the issue, a proposed fix, and a priority. Highlights (verified against source): - Security: OOXML reader lacks the global nesting-depth guard that ODF has (recursive parse_*/map_* → stack-exhaustion DoS on hostile DOCX). Other untrusted-input defenses (zip-bomb caps, path-traversal, ODS cell caps) are in place and solid. - Tests: appthere-ui / appthere-canvas / loki-fonts have zero tests; ODT export is an unimplemented stub (contradicting the docs); exporters (PPTX/PDF/EPUB) lack round-trip integration tests; ACID acid_pptx.pptx fixture is missing. - Process: CI runs only build + test — neither clippy -D warnings nor fmt --check (both mandated by CLAUDE.md) are enforced; no coverage. - Quality: 43 production files exceed the 300-line ceiling (CLAUDE.md lists 3); ~17 near-duplicate app-shell modules across the 3 binaries; 301 `let _ =` discard write Results in the OOXML writers. - Performance: per-paint clone of every PositionedItem (loki-vello scene.rs), per-keystroke style/CharProps cloning in the layout hot path, and the Loro incremental path falling back to full reconstruction on footnotes/multi-section. - Docs: corrected/flagged drift (ODT export claim, ceiling tech-debt table). Report: docs/audit-2026-06.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…est crates Addresses the P0 findings from docs/audit-2026-06.md (and corrects one). CI (C-1): .github/workflows/rust.yml now enforces the project's own gates — a `lint` job runs `cargo fmt --all --check` and `cargo clippy --workspace -- -D warnings`, alongside a `build-and-test` job, both with Swatinem/rust-cache. (Previously CI ran only build + test.) Docs (T-2): correct the ODT-export drift — CLAUDE.md and the loki-odf crate description now state ODT export is not yet implemented (odt/export.rs returns NotImplemented); only ODS export ships. Implementing the exporter remains a separate feature. Tests (T-1): add unit tests to the three zero-test crates — - appthere-canvas: sfnt bitmap-table stripping (each tag, non-bitmap tags untouched, truncated buffers, TTC collections) plus the font-cache get_coords/pointer-identity-cache paths. - appthere-ui: theme default variant, spacing scale invariants incl. the WCAG 2.5.8 44px touch-target minimum, and color-token format validity. - loki-fonts: the non-Android empty-CSS no-op contract. Security (S-1): WITHDRAWN as a false positive. The original audit claimed the OOXML reader could stack-overflow on deeply nested input, but the import path does not recurse unboundedly: parse_table_cell does not re-enter parse_table (nested tables are dropped), tracked-change/hyperlink parsers collect runs without re-entering, PPTX group shapes are skip_subtree'd, and the skip helpers are iterative. No guard added (it would be dead defensive code). A genuine correctness bug found in the process — nested tables silently dropped/mis-parsed — is recorded as S-1b (P2) for future work. Verified: cargo fmt --all --check, cargo clippy --workspace -- -D warnings, and the new per-crate tests all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…ech-debt Addresses the tractable P1 findings from docs/audit-2026-06.md; re-assesses the rest with rationale. Perf (P-1): loki-vello::scene::paint_items no longer clones every PositionedItem to translate it. The heap-allocating variants are fast-pathed without cloning — a GlyphRun (Vec<GlyphEntry>) paints 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 down 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 and its 8 page-background callers untouched). The transform is arithmetically identical: (coord + offset) * scale. loki-vello lib tests (14) pass; clippy --workspace -D warnings and fmt --check are clean. Docs (Q-1 / D7-2): the CLAUDE.md "300-line ceiling violations" tech-debt table was stale (listed a since-deleted file, a now-under-ceiling file, and a wrong line count). Replaced with the real top offenders and a pointer to the audit's full 43-file list. The split-pass itself remains open. Re-assessed (Q-3, audit #6): the 301 `let _ =` in the OOXML writers discard Results from writes to an in-memory Vec, which cannot fail. Threading `?` through all 301 sites is churn for errors that cannot occur, so this is downgraded to a low-priority cleanup (prefer a single error-recording write wrapper if ever done). Recorded as P-1b in the audit. Still open (documented as dedicated efforts): the 43-file ceiling split (Q-1), the shared app-shell crate extraction (Q-2), the CharProps/StyleSpan CoW/Rc refactor (P-2/#9), the Loro incremental extension (P-4), and the exporter/CRDT test gaps (T-3/T-5/T-6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
… path walk_inlines previously cloned the entire `effective` CharProps for every nested formatting inline (Strong/Emph/Underline/Strikeout/Super/Subscript/SmallCaps, and the footnote mark). CharProps carries three `Option<String>` font-name fields, so each clone heap-allocates — O(spans × nesting) allocations per paragraph on the resolve hot path. It now takes `&mut CharProps` and uses mutate-and-restore: each variant sets its single field via `Option::replace`, recurses, then restores the prior value. The net effect on `effective` is unchanged across the call, and `base` at the top-level caller is a throwaway local, so the in-place mutation is not observable. Zero allocations for the simple formatting runs; StyledRun still builds its merged CharProps (inherent). Two regression tests lock in the restore semantics that the optimisation depends on: a plain run after a Strong is not bold (restore), and nested Strong(Emph) accumulates then unwinds correctly. The secondary site (StyleSpan clone in clean_text_and_spans) is left as a smaller follow-up: it is entangled with the shaping cache (callers pass borrowed `&[StyleSpan]`) and its one heap field is overwritten by font resolution immediately after, so it is not a clean local change. Documented in the audit. Verified: 126 loki-layout lib tests pass (124 + 2 new); cargo fmt --all --check and the gate-equivalent cargo clippy -p loki-layout -p loki-vello -- -D warnings are clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
The three suite binaries (loki-text, loki-spreadsheet, loki-presentation) each
carried a byte-identical copy of the same shell plumbing. This extracts the
cohesive, app-agnostic cluster into a new `loki-app-shell` crate:
- tabs: the `OpenTab` model
- untitled: the `untitled-` path scheme + `is_untitled`
- new_document: blank-tab creation (`new_blank_tab`)
- recent_documents: the persisted recent-documents store
The store's only per-app difference — the JSON file name — is now a
`#[serde(skip)]` field set by `RecentDocuments::load(recent_file)`, so the
`save()` / `record()` / `remove()` call sites across the apps are unchanged
(only the single `load` call per app passes the app's `RECENT_FILE`). Each
binary keeps a thin re-export shim so existing paths (`crate::tabs::OpenTab`,
`crate::new_document::is_untitled`, `crate::recent_documents::RecentDocuments`,
…) still resolve, plus its own `RECENT_FILE` constant.
Net: ~3 × 110 lines of duplicated logic collapse into one crate with 8 unit
tests (record dedup/promote/cap/untitled-skip, remove, is_untitled, and a
serde round-trip asserting the file name is not persisted). On-disk format is
unchanged (`{ "entries": [...] }`).
The duplicated UI/route modules (routes/home.rs, routes/shell.rs, error.rs,
utils.rs) are a separate, larger pass — they differ more between apps and are
RSX/route logic — and remain open (tracked in the audit).
Verified: loki-app-shell tests pass (8); all three binaries compile;
cargo fmt --all --check and cargo clippy --workspace -- -D warnings are clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
IncrementalReader::try_incremental could only map a diff's changed containers to blocks in section 0; a change reaching any other section forced a full O(document) rebuild. This generalizes it to all sections: it collects every section's blocks-list container up front and maps each changed container to a (section, block) pair (the blocks-list's position gives the section, the following Seq index gives the block), patching cached.sections[s].blocks[n]. Structural changes (to the sections list or any blocks list) and changes outside any block's contents still fall back to a full rebuild, so the result remains byte-identical to loro_to_document. Added a `last_update_was_incremental()` observability hook so the fast path is testable (it previously was not — existing tests only asserted correctness, so a silent regression to "always full rebuild" would have passed). New tests prove: a section-1 edit now patches incrementally (it was a full rebuild before this change) and stays consistent; cross-section edits stay incremental; a plain text edit takes the fast path; and a block split correctly reports a fallback. Scope/accuracy note (also corrected in the audit): the original P-4 framing was overstated. Footnotes do not cause per-keystroke fallback (they live inside block content, so editing main text never touches them), and section-1+ edits were unreachable via the editing path anyway because the mutation helpers are themselves hardcoded to sections.get(0). The real value of this change is for collaborative/remote edits and any future multi-section editing feature; making the mutation layer section-aware is separate, out-of-scope work. Verified: 12 incremental tests (was 8) and the full loki-doc-model suite pass; cargo fmt --all --check and the gate-equivalent clippy -p loki-doc-model -- -D warnings are clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Editing was section-0-only and silently wrong for multi-section documents (DOCX/ODT with section breaks produce multiple sections): layout assigned a *section-relative* block_index that collided across sections, and the loro_mutation helpers were hardcoded to sections.get(0). So a click/edit in section 1+ resolved to the wrong block in section 0. This makes block indices document-global end to end: - loki-layout: layout_document (continuous) and layout_paginated_full (paginated) now offset each section's paragraph block_index by the cumulative block count of preceding sections, so PageParagraphData::block_index is global (document order). Single-section output is unchanged (base offset is 0). - loki-doc-model/loro_mutation: a new resolve_section_blocks(global) walks the sections accumulating block counts and returns the containing section's blocks list plus the block's local index. get_loro_text_for_block and get_block_map_and_list (now also returning the local index) use it, so every text/mark/style/split/merge mutation targets the correct section. split inserts at the local index; merge deletes at the local index and rejects a cross-section merge (local index 0 with a predecessor in an earlier section) with the new MutationError::CrossSectionMerge rather than silently removing the section break. The editor (loki-text) needed no changes — it is already section-agnostic and addresses blocks by the flat, now-global index. Tests: multi-section mutation tests (insert/split/merge target the right section; cross-section merge rejected; out-of-range global index errors) and a layout test asserting block_index is global across sections. Full loki-doc-model (151) and loki-layout (133) suites pass; cargo fmt --all --check and cargo clippy --workspace -- -D warnings are clean. Known limitation (documented): merging across a section break (backspace at the very start of a section's first paragraph) is rejected, not yet implemented as a section-join. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
First file of the 300-line-ceiling split-pass backlog. Pure refactor — no
behaviour change. `loki-odf/src/odt/mapper/props.rs` (925 lines) becomes a
`props/` directory split at the file's section markers:
- props/mod.rs (20) — module decls + pub(crate) re-exports of the entry
points (map_para_props, map_cell_props,
map_text_props, …) so `props::*` paths are unchanged
- props/paragraph.rs (211) — map_para_props + parse_odf_border
- props/cell.rs (124) — map_cell_props, vertical/writing mode, tab helpers
- props/character.rs (195) — map_text_props + underline/strike/position helpers
- props/tests.rs (410) — the inline test module, via
`#[cfg(test)] #[path = "tests.rs"] mod tests;`
(test files are exempt from the production ceiling)
Cross-section helpers shared between submodules (parse_odf_border, map_tab_stop,
map_text_align) are `pub(super)` and imported explicitly. All code files are now
under the 300-line ceiling.
This establishes the repeatable pattern for the remaining ~42 files (documented
in CLAUDE.md). Verified: loki-odf full test suite passes (no behaviour change),
cargo clippy -p loki-odf -- -D warnings clean, cargo fmt --all --check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Nine production files were over the 300-line ceiling only because of a large
inline `#[cfg(test)] mod tests { … }` block. Each is brought under the ceiling
by the safest possible move — relocating the test module to a sibling
`<name>_tests.rs` referenced via `#[cfg(test)] #[path = "<name>_tests.rs"]
mod tests;`. The production module is byte-identical apart from that one
declaration; the test content moves verbatim (its `use super::*` still resolves
to the same parent), so there is no behaviour change and no risk to production
code.
Files (production lines, before → after):
- loki-doc-model/src/content/block.rs 319 → 287
- loki-ooxml/src/docx/mapper/paragraph.rs 322 → 114
- loki-ooxml/src/docx/mapper/numbering.rs 401 → 226
- loki-ooxml/src/docx/mapper/mod.rs 404 → 271
- loki-ooxml/src/docx/mapper/table.rs 685 → 285 (a top-16 offender)
- loki-odf/src/odt/import.rs 354 → 264
- loki-odf/src/odt/mapper/lists.rs 449 → 215
- loki-layout/src/result.rs 459 → 257
- loki-renderer/src/render_layout.rs 391 → 267
(Test files are exempt from the production-line count.)
With the earlier props.rs directory split, 10 of the 43 ceiling violations are
now resolved (~33 remain). Verified: loki-doc-model / loki-ooxml / loki-odf /
loki-layout / loki-renderer test suites pass, cargo clippy --workspace
-- -D warnings clean, cargo fmt --all --check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…s for all three Loro bridges Closes the T-6 test-coverage gap from the 2026-06 audit: none of the three CRDT bridges (doc, sheet, presentation) had tests exercising concurrent-edit merge convergence, undo/redo, or degenerate states — only single-peer round-trips. Adds three integration suites that fork a replica, apply divergent edits, and assert byte-identical convergence on LoroDoc::get_deep_value(): - loki-doc-model: concurrent same-block/different-block inserts, insert vs delete, split/merge vs text edit, three-way order-independent convergence, undo+redo, snapshot-into-fresh-doc, and idempotent re-import. - loki-sheet-model: concurrent disjoint-cell edits merge; same-cell edits are a deterministic register; metadata, snapshot, and idempotency. - loki-presentation-model (previously had no tests/ dir): concurrent metadata/slide-append merges, plus a characterization test documenting the coarse-grained snapshot register (same-key edits are last-writer-wins). loro and loki-graphics added as dev-dependencies where integration tests drive the LoroDoc directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…ted tests in CI Addresses the T-3 test-coverage gap from the 2026-06 audit: the exporters had only inline substring checks and no integration tests that re-open and validate the output structure. - loki-epub/tests/epub_structure.rs: re-opens the OCF ZIP, asserts the mimetype entry is first and stored uncompressed, parses every XML part for well-formedness with quick-xml, and checks manifest/spine/nav consistency. - loki-pdf/tests/pdf_structure.rs: multi-page export with the page-tree /Count validated against the actual leaf /Page objects, the startxref offset checked to point at the xref table, trailer /Root + /ID, and all three PDF/X levels verified structurally. - loki-ooxml/tests/pptx_round_trip.rs (gated on the pptx feature): a multi-slide deck through export -> re-import (count/order/size/idempotency) plus an OPC re-open asserting one slide part per slide and correct presentation->slide relationships. CI: build and test now run with --all-features so the feature-gated pptx/xlsx suites actually execute — under the default feature set `cargo test --workspace` silently skipped every `#![cfg(feature = ...)]` test. Fixed a latent clippy::collapsible_if in loki-opc that the `strict` feature exposed. Updated docs/audit-2026-06.md (T-3 partly addressed, T-6 addressed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…h through the harness Addresses the T-5 ACID-harness gap: the 29 catalogued PPTX cases could not run because no acid_pptx.pptx fixture existed and the harness had no presentation import path. - fixtures.rs: add Fixture::Pptx, Imported::Presentation, and PptxImport dispatch; mark the format as having an importer. - report.rs: add a slide_count field, populated for presentations. - examples/gen_acid_pptx.rs: regenerable generator that builds a 3-slide deck (text/run formatting, preset geometries, placeholders) and writes the asset. - assets/acid_pptx.pptx: the generated fixture (valid PPTX, 3 slides). - structural.rs: a presentation_fixture_has_slides canary asserting the fixture imports with the expected slide count and the report surfaces it. Provenance is documented honestly in the generator and the pptx catalog note: the fixture is written by Loki's own exporter (a round-trip fixture covering only what Loki can emit), an explicit placeholder until a PowerPoint-authored deck and golden renders exist for the gradient/SmartArt/chart/animation cases. Also upgrades the CI clippy gate to --all-features (now that the loki-opc collapsible_if is fixed) so feature-gated code is linted, and updates docs/audit-2026-06.md (T-5 partly addressed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
The EPUB exporter already emitted basic <table>/<img> markup (the audit's "TODO placeholders" note was stale), but the table renderer dropped three things the reflowable target can honour: - Caption: table.caption.full now renders as <caption>. - Column widths: table.col_specs now emits a <colgroup>, with fixed widths in points and proportional widths normalised to CSS percentages; the group is omitted when no column declares a width. - Alignment: each cell resolves its horizontal alignment (cell override falling back to the column default, tracked through colspans) and vertical alignment, emitted as inline style. Table-level width is emitted on <table>. Also adds a base table/figure/img stylesheet, removes the dead .loki-table-placeholder CSS class, and corrects the stale lib.rs conformance note. New unit tests cover caption/colgroup/alignment/colspan tracking; a new integration test drives a table + embedded image through the full export and re-opens the ZIP to confirm the packaged image and manifest entry. Updates docs/audit-2026-06.md (T-3 EPUB tables/images marked done). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…M leak) Scrolling a long document grew RSS without bound (~500 MB → ~1.3 GB) and never recovered. Tile virtualization correctly unmounts off-screen PageTiles and removes their paint sources, but each LokiPageSource hands its full-resolution page texture (~10+ MB) to the Vello renderer via register_texture, and that registry only releases on an explicit unregister_texture. The only teardown hook a CustomPaintSource had was suspend(), which takes no CustomPaintCtx and so cannot unregister. On unmount, unregister_custom_paint_source called suspend() and dropped the source, leaving its last texture stranded in the renderer's registry forever. (App-level suspend did not leak because the whole renderer is recreated on resume; only per-source unregister was affected — which is exactly the scroll path.) Root-cause fix at the custom-paint API layer: - anyrender_vello: add `CustomPaintSource::release(&mut self, CustomPaintCtx)` (default no-op); `unregister_custom_paint_source` now calls it, while the renderer is Active, before suspending/dropping the source. - loki-renderer: LokiPageSource::release unregisters its page texture. Adds headless spy tests for the teardown wiring (the active-state texture-free path needs a GPU and is verified on-device by watching RSS plateau while scrolling). Documents the patch addition in docs/patches.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
A previous session bounded GPU texture memory with viewport tile virtualization and changed LokiPageSource to always render at full resolution (CacheTier::Hot), noting the older resolution-tiering was "redundant and wrong". But it left the entire producer side in place: every scroll-settle still ran a retier that computed Hot/Warm/Cold tiers and bumped a settle epoch nothing consumed (the paint path ignored the tier), so the work — and a whole GPU downsample/blit path — was dead weight. Removed, since nothing varies by tier anymore: - loki-render-cache: tier_policy (CacheTier/assign_tier/PageGeometry), retier, page_cache (PageCache/CachedPage), scroll_state (ScrollState/ScrollPhase/hot_range_px), and the blit/downsample GPU utilities (BlitPipeline, allocate_texture, downsample_texture, GpuTexture::byte_size). The crate now exposes just the PageSource trait, RenderError, the GpuTexture handle, CacheKey, and PageIndex. Dropped the now-unused tracing/pollster deps and the gpu_integration test (it only exercised removed code). - appthere-canvas: deleted the `dioxus` scroll-driver module (on_scroll_event / use_settle_detector) and the `dioxus` feature (+ tokio/dioxus deps); trimmed the re-exports. - loki-renderer: RendererState drops cache/scroll/phase_tx/settle_epoch/ on_settle; DocumentView drops the settle detector, the scroll signal, and the onscroll handler; PageTile drops the cache and settle_epoch props; LokiPageSource drops the cache and tier fields and the always-1.0 scale factor. The reuse guard and virtualization are unchanged, so render output is identical. Behavior is preserved: tiles already always rendered Hot and memory is bounded by virtualization. Net -1399 lines. (Verified: cargo check/clippy --all-features -D warnings and the full test suite pass; on-device scroll behavior to be confirmed on macOS.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…session leak A long Loki Text editing session still climbs resident memory (>3 GB reported), slower than the now-fixed texture leak and unrelated to rendering. Structural review rules out the usual caches: the paragraph shaping cache is capped (2048×2), DocumentState/IncrementalReader are single-slot, the incremental layout shares unchanged pages by Arc, and the UndoManager is bounded (Loro defaults max_undo_steps to 100). The remaining unbounded-with-editing structure is the Loro oplog + rich-text tombstones, which never compact — exactly the memory-audit's Finding 6. Since this box has no GPU/profiler, "diagnose before you change": add throttled, opt-in counters (one log per 64 mutations, target `loki_text::mem`) reporting loro_ops/loro_changes against the stable pages/blocks. loro_ops climbing while pages/blocks stay flat confirms the history is the grower: RUST_LOG=loki_text::mem=info <run loki-text> Updated docs/memory-audit-2026-06-12.md: Finding 6 raised to High (now the dominant grower), with the proposed shallow-snapshot compaction fix and its risks (recreate UndoManager/IncrementalReader, re-point the loro_doc signal, truncated undo) recorded as tech debt — not applied blind since it can't be validated headlessly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…vas tiles The app grew RAM to >3 GB while completely idle (no typing, no edits). Root cause: blitz-dom's `is_animating()` returns `has_canvas | has_active_animations`, and blitz-shell's `redraw()` re-requests a redraw every frame while that is true. Loki paints every document page as a `<canvas src>` custom-paint tile, so `has_canvas` is permanently true — the app ran a vsync-rate render loop forever, re-compositing an unchanged scene. The page-texture reuse guard keeps that from re-registering textures, so it is not a Rust-side growing collection; it is the per-frame GPU/driver resource churn accumulating under sustained idle rendering (plus the obvious CPU/battery waste). Fix: add `BaseDocument::needs_animation_tick()` (only `has_active_animations`) and re-arm blitz-shell's `redraw()` on that instead of `is_animating()`. A static canvas no longer forces per-frame redraws. Loki's canvas content always changes through a DOM mutation (the tile's data-cursor/generation attribute, scroll remounts, viewport resize), each of which already schedules a redraw, so updates stay correct while idle frames stop. `is_animating()` is left intact for any other consumer. This is distinct from the earlier (edit-driven) Loro-history growth — that instrumentation stays useful but was not the idle cause. Documented in docs/patches.md (blitz-dom item 6) and docs/memory-audit-2026-06-12.md (Finding 7). Needs on-device confirmation: idle CPU drops to ~0 and RSS plateaus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Two ribbon issues reported by the user: - The Save and Save As buttons used the same floppy-disk icon, so they looked identical. Save As now uses a distinct download (tray + down-arrow) glyph (LUCIDE_DOWNLOAD). - The Atkinson Hyperlegible Next UI font was loaded via a `dioxus:///assets/...` @font-face URL, which resolves relative to the executable and does not load on Android/ChromeOS (and silently fell back to a system-installed copy on desktop). It is now embedded as a base64 `data:` URI via the new `loki_fonts::ui_face_css()` — decoded by blitz_net on every platform, so the chrome renders in the intended face everywhere with no on-disk asset dependency. Applied to all three apps (text/presentation/ spreadsheet); the ~112 KB variable font is OFL-licensed and bundled. Shared the base64 encoder with the existing Android fallback-font CSS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Those four tabs had no content of their own — selecting them fell through to the
Home tab's controls, so they appeared to duplicate Home. The ribbon now shows
only the two tabs that have real content, Home and Publish (Publish moves from
index 5 to 1). Removed the now-dead `ribbon-tab-{insert,format,review,view}` and
the unused `ribbon-coming-soon` i18n strings. The active-tab signal defaults to
0 (Home), so no stale index can point past the shortened tab list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
…r on Android The document layout engine discovers its bundled metric-compatible fonts (Carlito/Caladea/Arimo/Cousine/Tinos) from the executable-relative `assets/fonts/` directory. That path does not resolve on Android, so on a device a document's Calibri/Arial/Times text — which `resolve_font_name` substitutes to those families — did not find them in the Fontique collection and fell back to an Android system font (Roboto/Noto) with different glyph metrics. That is the likely cause of the reported "numbers rendered with extra horizontal padding": the Android fallback face has different digit advances than the desktop font. Fix: `loki-fonts` exposes the bundled face bytes via `fallback_font_blobs()` (Android-only), and `FontResources::new()` registers them directly into the Parley collection on Android. Gated entirely to the Android target via a `[target.'cfg(target_os = "android")'.dependencies]` entry, so desktop builds neither depend on loki-fonts here nor embed the ~7 MB. Verified the gated code compiles and lints clean for aarch64-linux-android. Also fixed a latent write_with_newline clippy lint in the (Android-only) fallback CSS builder that the Android-target clippy surfaced. Needs on-device confirmation: a document in a substituted family (Calibri/Arial/ Times/Cambria/Courier New) should now render with the bundled face's metrics instead of the system fallback. Fonts outside that set still fall back to the system face. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Expand the style catalog editor so a paragraph style can be fully edited and new custom styles created with font family, numeric weight, size, italic/underline, alignment, indentation and line spacing. Model/layout: - Add `CharProps.font_weight: Option<u16>` (OpenType 1–1000). It supersedes boolean `bold` when set and renders via Parley `FontWeight`; `bold` is derived (>= 600) for DOCX round-trips that have no numeric weight. Threaded through `StyleSpan`/resolve/para. - `FontResources::available_font_families()` enumerates the Fontique collection (system + bundled + embedded) for the picker. UI (loki-text): - Split the 497-line `editor_style_editor.rs` into a directory (`mod`/`draft`/`form`/`form_font`), each under the 300-line ceiling. - Inline font-family picker (text filter + scrollable list — Blitz has no `position: absolute` dropdown), weight selector buttons (Thin…Black, each previewed in its own weight), indentation (left/right/first-line/hanging) and spacing (before/after/line ×) inputs. - `StyleDraft` gains the new fields; font list is memoised once per editor and threaded into the panel. Add i18n keys for the new labels; update the fidelity registry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Make the paragraph style catalog — custom styles created in the style editor and edits to built-in Normal/Heading1-6 — survive a DOCX export → import round-trip with every editor-editable field intact. Export (loki-ooxml): - Split the over-ceiling styles writer: move the pPr/rPr serializers into a new `write/style_props.rs`; `styles.rs` keeps catalog orchestration. Both now under the 300-line ceiling. - Emit Normal and Heading1-6 from the catalog (with full props) when present, so style-editor edits persist instead of reverting to the hard-coded defaults; synthesize defaults only when absent. - `write_para_props_elem` now emits first-line indent (`w:firstLine`), line spacing (`w:line`/`w:lineRule`), and outline level; the style element emits `w:next`, `w:customStyle`, and `w:link`. - Fix outline-level export: model is 1-indexed, OOXML 0-indexed — the writer now mirrors the importer's `+1` (was writing the raw value, shifting every heading by one level on round-trip). Import (loki-ooxml): - Stop hard-coding `is_custom: false` in the styles mapper; carry the parsed `w:customStyle` flag through so custom styles stay custom. Editor (loki-text): - Fix line-spacing conversion: `LineHeight::Multiple` is a ratio (1.5 = 1.5x), matching the layout engine and OOXML mapper, not a percentage. The previous *100 would have rendered 150x spacing. Add an integration test asserting a custom style and an edited heading round-trip through DOCX. Update the fidelity registry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Make style-editor edits undoable (Ctrl+Z/Ctrl+Y) by storing the style catalog in the Loro CRDT instead of carrying it forward by cloning the previous document. Bridge (loki-doc-model): - Add `loro_bridge::styles`, mirroring `loro_bridge::meta`: the whole `StyleCatalog` is a lossless JSON snapshot under the (previously reserved) `style_catalog` map. Public `write_document_styles` / `read_document_styles`. - `document_to_loro` writes the catalog; `loro_to_document` reads it back into `doc.styles`. The incremental reader already falls back to a full re-derive on any non-block change, so a catalog edit is picked up automatically. Editor (loki-text): - `apply_mutation_and_relayout` no longer carries `doc.styles` forward — the Loro snapshot is the source of truth (as it already is for metadata), which is what makes the catalog undoable. - Replace `upsert_catalog_style` (in-place mutation) with `commit_style_to_loro`: clone the current catalog, upsert the style, write it to Loro, and commit as a discrete transaction. - The style-editor Apply button now persists through Loro then re-derives and refreshes undo state via `post_mutation_sync`, mirroring the metadata panel. Undo state is threaded in via a new `StyleEditorSync` struct (loro_doc, cursor, undo manager, can_undo/redo). Add bridge round-trip tests (snapshot + full document) and update the fidelity registry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Add the plumbing to create documents from external Office (.dotx/.dotm) and LibreOffice (.ott) templates: opening one yields a fresh, untitled document detached from the template file (saving prompts Save As rather than overwriting it). - loki-odf: accept the OTT/OTS template mimetypes in package validation (a template is structurally identical to its document form). New MIME_OTT / MIME_OTS constants; tests for accept/reject. - loki-app-shell: extend the `untitled-` path scheme so a new document's path encodes how to build its initial content — Blank, Template(id), or Import(token) — via `parse_new_doc_source` and `template_path` / `import_path`. The content stays reproducible from the path alone, so it survives router round-trips and session restore. Add `new_template_tab` / `new_import_tab`. - loki-text: detect_format routes .dotx/.dotm → DOCX and .ott → ODT (the importers key off the officeDocument relationship / now-accepted mimetype, so they read templates as-is). load_document dispatches on the parsed source; the file-import path is factored into `import_token` and reused for detached template opens. The home picker offers template MIME types and routes a picked template through `new_import_tab`. Bundled-template builders (Template(id)) are stubbed to blank for now — wired to the loki-templates crate in a follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Ship five built-in templates and wire the home gallery so a card creates a new document from a bundled template. New `loki-templates` crate: - Programmatic builders for a Markdown-styled blank, APA 7 paper, MLA 9 paper, Hollywood screenplay, and basic resume — authored with only the properties the DOCX round-trip preserves (fonts, sizes, bold/italic/ underline, alignment, indents, spacing, line height, named styles). - The builders are generated into real `.dotx` assets (template content type) by the `gen_templates` binary; `document(id)` imports the embedded asset at runtime, so the bundled files are genuine, inspectable Word templates. Round-trip tests assert each asset re-imports with its authored styles intact. loki-ooxml: add `DocxTemplateExport` — same as `DocxExport` but writes the main part with the `template.main+xml` content type (`.dotx`). This backs both the asset generator and a future Save-as-Template flow. loki-text: `load_document` builds a bundled template via `loki_templates` for `untitled-N-tpl-<id>` paths; the home gallery now offers Blank plus the five templates and opens the selected one as a new untitled document. Add i18n strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Let the user save the current document as a Word template (`.dotx`). - loki-ooxml export already gained `DocxTemplateExport`; add a test asserting the saved package declares the template content type (and not the document one). - loki-text: `export_template_to_token` writes the document through the template exporter; a `save_as_template` callback picks a `.dotx` destination and exports without repointing the tab (the working document is untouched — the template is a separate artifact). Factor the shared doc-fetch / write-to-token helpers out of the save path. - A new ribbon button (layout-template icon) in the Document group invokes it; add the icon to appthere-ui and the i18n strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Replace the `OdtExport` stub with a real exporter: a Document is written to a complete ODT package (mimetype, manifest, content.xml, styles.xml, meta.xml). ODF 1.3. New `odt/write/` module: - props: CharProps / ParaProps → `style:text-properties` / `style:paragraph-properties` attribute strings, mirroring the import mappers so styles round-trip (font, size, weight, italic, underline, strike, caps, vertical-align, colour, indents, spacing, line height, alignment, breaks, borders, language). - auto: dedup collector for the automatic text/paragraph styles emitted for direct inline/paragraph formatting. - content: the body — paragraphs, headings (text:h + outline level), styled paragraphs, bullet/ordered/definition lists, block quotes, code blocks, line blocks, tables, and inline runs (bold/italic/…, links, footnotes/endnotes), with nested spans for formatting. - styles: the named style catalog plus the page-layout / master-page carrying page size, margins, and orientation. - meta: Dublin Core core properties. Editor: saving now picks DOCX or ODT by the destination extension, so editing an opened `.odt` and saving round-trips to ODT (previously this returned UnsupportedFormat). Add an export→import round-trip integration test (styles, page geometry, metadata) and update the fidelity registry / workspace notes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Substantially raise ODT export fidelity so saving a document preserves its content and formatting. Character properties — now emits the full set the importer reads back (complex / East-Asian fonts and sizes, outline, shadow, word spacing, kerning, text scale, complex / East-Asian languages) in addition to the previous subset. Paragraph properties — now emits borders (per side), padding, tab stops (with alignment + leader), widow/orphan control, and bidi, alongside the existing alignment / indents / spacing / line-height / break flags. Tab stops are written as `style:tab-stop` children, so the property writer emits full `<style:paragraph-properties>` elements. Inline content — no longer dropped: - Embedded images: the `data:` URI carried in the model is decoded and written as a `Pictures/imageN.<ext>` part (manifest updated), with the `draw:frame`/`draw:image` referencing it — image bytes survive a save. - Fields (page number/count, date/time, title/subject/author/file name/ word count, cross-references) and bookmarks (start/end). - Generated TOC/index blocks emit their rendered body rather than nothing. Refactor: split the writer into `props` (char) + `para_props` (paragraph), and `content` (blocks) + `inlines`, with a `media` collector and a shared `Cx` context; all files stay under the 300-line ceiling. Round-trip tests cover the full char/para property set and the bookmark/field/image inline path. Update the fidelity registry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Write the document's page header/footer content into the ODT master page,
so saving no longer drops it.
- styles.rs now renders the master page's `style:header` / `style:footer`
(plus `-first` and `-left`/even variants) from `PageLayout`'s
header/footer fields, running the body block writer over their blocks.
Their automatic styles are collected into styles.xml's own
`office:automatic-styles` (master-page styles must live there, not in
content.xml), and a `style:header-style` / `style:footer-style` is
declared on the page layout so apps reserve the space.
- Header/footer images are embedded too: a second media collector with a
distinct `Pictures/` prefix avoids filename collisions with the body's
images; `styles_xml` now returns a `Rendered { xml, media }` (shared
with `content_xml`) and the package writer emits both sets of parts and
lists them in the manifest.
Add a round-trip test covering default/first/even headers and footers,
and update the fidelity registry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Even-page headers/footers were dropped on DOCX round-trip: the exporter wrote `even`-typed `headerReference`/`footerReference` entries but never emitted `word/settings.xml`, so `w:evenAndOddHeaders` was never set — Word and our own importer (which gates the `even` variant on that flag) both ignored them. The first-page flag `w:titlePg` was also emitted for even-only documents, spuriously promoting content to a first page. Fixes: - Emit `word/settings.xml` with `<w:evenAndOddHeaders/>` (plus its part, content-type override, and a freshly-reserved document relationship) whenever any section carries an even-page header or footer. - Restrict `w:titlePg` to documents that actually have a first-page variant. - Add `ExportCollector::reserve_r_id` so document-level relationships wired during package assembly draw collision-free IDs from the same counter. Refactor to honour the 300-line ceiling (assembly.rs was at the limit): - New `write/settings.rs` builds + wires the settings part. - New `write/rels.rs` owns all document-level relationship wiring, called once from `assembly.rs` (now 229 lines). Adds `export_headers_footers.rs` round-trip test asserting default + even header/footer text survives and that an even-only document gains no first-page variant. Updates the fidelity-status registry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
`Inline::Field` was silently dropped on DOCX export (the `write_inline`
match had no arm, so it fell through `_ => {}`). Body-text fields —
PAGE, NUMPAGES, DATE/TIME, TITLE, AUTHOR, etc. — vanished on save.
Add a `write/fields.rs` module that serializes a `Field` as the standard
OOXML complex-field run sequence (begin -> instrText -> [separate ->
result] -> end, ECMA-376 §17.16.18). The complex form is emitted (not
`w:fldSimple`) because the importer's field state machine only reads
complex fields, so this is what round-trips. `field_instruction` is the
exact inverse of the importer's `parse_field_instruction`, covering
PAGE/NUMPAGES/DATE/TIME (with `\@` format switch)/TITLE/AUTHOR/SUBJECT/
FILENAME/NUMWORDS/REF/PAGEREF and verbatim `Raw` instructions. The cached
result snapshot (`current_value`) is written between `separate` and `end`
only when present, so a field imported without one round-trips back to
`None`. Inherited run formatting is applied to the result run.
`RunProps` and `write_text_run` become `pub(super)` so the new module can
reuse them; the dispatch is a single new arm in `document.rs` (already
over the line ceiling — kept to one line).
Tests: `fields_tests.rs` (instruction-string inverse + emitted XML shape)
and `export_fields.rs` (full Document -> DOCX -> re-import asserting every
field kind and the PAGE snapshot survive). Updates the fidelity registry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
ODT export only ever wrote the first section's geometry: `styles_xml`
took `doc.sections.first()` and emitted a single `style:page-layout`
(`PL1`) + `style:master-page` ("Standard"). Documents with multiple
sections lost the geometry (and headers/footers) of sections 2..N, even
though the ODF importer already reconstructs sections from master-page
transitions.
Emit one `style:page-layout` + `style:master-page` per section. ODF has
no explicit section element, so a section break is expressed by attaching
`style:master-page-name` to the first paragraph of each section after the
first — exactly the form the importer reads back (it merges content.xml
automatic styles and follows the style's master-page-name, walking the
parent chain). Section 0 keeps the conventional "Standard" master (the
importer's initial master); later sections use "MP{idx}".
- `xml.rs`: shared `master_page_name` / `page_layout_name` helpers so
content.xml and styles.xml agree on names.
- `auto.rs`: `para_style_master` emits a paragraph automatic style
carrying `style:master-page-name` (factored a shared `para_style_inner`).
- `content.rs`: route the first block of each later section through
`write_block_with_master`; paragraph-like blocks carry the reference
directly, other blocks (table/list) get a minimal carrier paragraph.
- `styles.rs`: `render_master_page` / `write_page_layout` are now
parameterised by name and called once per section.
- Extracted table serialisation into a new `tables.rs` to keep
`content.rs` under the 300-line ceiling.
Tests: `multi_section_page_geometry_round_trips` (ODT — two sections of
differing size/orientation survive with their own geometry and body text)
and `export_multisection.rs` (DOCX — confirms the already-working
per-`w:sectPr` path round-trips). Updates the fidelity registry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
`SectionColumns` (count/gap/separator) existed in the model but was
dropped on both export and import in both formats. Wire it through end to
end so multi-column section geometry round-trips.
OOXML (`w:cols`, ECMA-376 §17.6.4):
- model: add `DocxCols { num, space, sep }` + `DocxSectPr.cols`.
- reader: parse `w:cols` (`@w:num` / `@w:space` twips / `@w:sep`).
- mapper: map to `SectionColumns` (gap = space/20) for num >= 2.
- writer: emit `<w:cols w:num w:space w:equalWidth [w:sep]>`.
ODF (`style:columns`, ODF 1.3 §16.27.10):
- model: add `OdfColumns { count, gap, separator }` + page-layout field.
- reader: scan `style:page-layout-properties` children for `style:columns`
(`fo:column-count` / `fo:column-gap`) and a `style:column-sep` child.
- mapper: map to `SectionColumns` for count >= 2 (gap via `parse_length`).
- writer: nest `<style:columns>` inside page-layout-properties, with a
`<style:column-sep>` when a separator is requested.
To avoid growing two files already over the 300-line ceiling, extracted
the DOCX `w:sectPr` writer into `write/section.rs` (document.rs 1170 ->
1068) and the ODF column reader into `reader/columns.rs` (styles.rs stays
at its prior size). Both `write_sect_pr` and the H/F reference writer were
moved verbatim aside from the new `w:cols` emission.
Tests: `export_columns.rs` (DOCX: 3-col+separator, 2-col, and the
single-column no-op) and `multi_column_section_round_trips` /
`no_columns_means_no_column_layout` (ODT). Updates the fidelity registry
(new "Multi-column Sections" row; both export columns marked Yes).
Note: columns round-trip through save/load but are not yet honoured at
layout time — text still flows single-column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Multi-column section geometry round-tripped through save/load but was ignored by the layout engine — text always flowed single-column. Make the paginated flow engine honour `SectionColumns`. Mechanism (new `flow_columns.rs` submodule): - `new_flow_state` divides the content area into `count` equal columns and sets `content_width` to the column width; columns apply in paginated mode only (continuous/reflow scroll has no fixed page height to break at). - Content fills each column top-to-bottom. When a column runs out of vertical space, `break_column` advances to the next column (resetting the y-cursor) instead of starting a page; only the last column triggers a real page break. Items/editing-data placed in a column are shifted to the column's horizontal offset when it is finished (`position_current_column`), so the renderer needs no changes — column positions are baked into item coordinates (and `ClippedGroup` clip rects translate with their items). - Explicit page breaks (`page_break_before`/`after`) still call `finish_page` directly to skip any remaining columns. - When the section requests a separator, `emit_column_separators` draws a thin full-height rule in each used inter-column gap at page-finish time. The five space-exhaustion `finish_page` calls in `flow_para.rs` and the table row-overflow call now route through `break_column`; for single-column flows `break_column` is exactly `finish_page`. Incremental-relayout checkpoints are unaffected (they fire only at true page tops: `cursor_y == 0 && current_items empty`, which a mid-page column advance is not). Table cells remain single-column. Tests (`flow_tests.rs`): `text_flows_down_columns_before_paging` (two columns fit more per page and the second band is used), `column_separator_line_is_drawn`, and `single_column_keeps_content_in_one_band`. Updates the fidelity registry (Multi-column Sections layout/render → Yes). Known limitations: fill-first columns (no height balancing) and footnotes flow in the last active column. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
The importer only read complex fields (`w:fldChar`/`w:instrText`); the
compact `w:fldSimple` form Word also emits was silently dropped, losing
the field entirely.
- model: add `DocxParaChild::SimpleField { instr, runs }`.
- reader: parse `w:fldSimple` in `parse_paragraph` — both the element form
(instruction + cached-result runs) and the self-closing form (instruction
only). Nested simple fields flatten their result text.
- mapper: map `SimpleField` to `Inline::Field` via the shared
`parse_field_instruction`, with the cached result as `current_value` —
producing exactly the same `Inline::Field` a complex field would. Since
export always writes the complex form, a `w:fldSimple` round-trips through
export/re-import.
Refactor to keep the two touched files (both already over the 300-line
ceiling) from growing:
- moved the three run-sequence collectors (hyperlink / tracked / fldSimple)
into a new `reader/runs.rs`; `reader/document.rs` 1126 -> 1105.
- moved the field-instruction parser (+ `fld_simple_text`) and its unit
tests into a new `mapper/fields.rs`, shared by the complex-field machine
and the simple-field mapper; `mapper/inline.rs` production 404 -> 355.
Tests: `fld_simple.rs` (import + full round-trip of a PAGE field with a
cached value and a self-closing TITLE) and unit tests in `mapper/fields.rs`
and `mapper/inline.rs` for the `SimpleField` mapping. Updates the fidelity
registry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Document metadata was dropped on save: DOCX wrote no metadata part at all,
and ODT wrote only the core fields. The extended Dublin Core fields
(publisher, contributors, rights, license, identifier, …) round-tripped
through the Loro CRDT but never reached the files. Wire them through both
formats.
Shared (loki-doc-model):
- `DublinCoreMeta::to_named_pairs` / `from_named_pairs` flatten the extended
fields to/from `(name, value)` pairs under reserved `dcmi:` names
(repeatable contributors become `dcmi:contributor.{i}`). One source of
truth for both format writers/readers.
DOCX (loki-ooxml):
- Enable the `serde` feature on the `loki-opc` dependency — without it the
OPC core-properties reader/writer are no-op stubs, so core.xml never
round-tripped. (This is why metadata silently vanished.)
- `write/metadata.rs`: populate OPC `CoreProperties` from `DocumentMeta`
(the OPC layer emits docProps/core.xml, its relationship, and content
type). `dc:identifier` uses the native core.xml element.
- `write/custom_props.rs` + `reader/custom_props.rs`: extended fields go to
docProps/custom.xml (custom properties, fmtid + `vt:lpwstr`), read back
via the package custom-properties relationship.
- `map_meta` now also maps language, revision, and identifier from core.xml.
ODT (loki-odf):
- `meta.xml` writer emits each extended field as a `meta:user-defined`
entry; the reader collects `meta:user-defined` into `OdfMeta.user_defined`
and the mapper rebuilds `dublin_core` from them.
Tests: `metadata_round_trip.rs` (DOCX core + extended) and
`extended_dublin_core_round_trips` (ODT), plus `to_named_pairs`/
`from_named_pairs` unit tests. Updates the fidelity registry (new Document
Metadata row; ODT export and the metadata-editor rows refreshed).
Not yet written: custom user properties, editing-duration, docProps/app.xml.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
The model had `Inline::Comment(CommentRef)` anchors and a `Comment` type
but no document-level storage and no import/export, so comments were
dropped on load and save in both formats.
Model (loki-doc-model):
- Add `Document.comments: Vec<Comment>` to hold comment bodies (the content
flow carries only `CommentRef` anchors). Updated all constructors and the
~5 explicit `Document { .. }` literals across the workspace.
DOCX (loki-ooxml):
- Reader: parse `w:commentRangeStart`/`w:commentRangeEnd` (paragraph-level
markers) → `Inline::Comment` start/end; new `reader/comments.rs` parses
`word/comments.xml` into `Comment`s (author/date/plain-text body).
- Writer: new `write/comments.rs` emits the in-flow anchors (range markers +
a `CommentReference` run) and the `word/comments.xml` part; assembly adds
the part, its `REL_COMMENTS` relationship (via `AuxParts`), and content
type.
ODT (loki-odf):
- Reader: new `reader/annotations.rs` parses `office:annotation` (with
`dc:creator`/`dc:date` and body) and `office:annotation-end` →
`OdfParagraphChild::Annotation`/`AnnotationEnd`; the mapper turns them into
`Inline::Comment` anchors and collects the bodies.
- Writer: `office:annotation` (looked up by id from `Document.comments` via a
new `Cx.comments` map) at the start anchor, `office:annotation-end` at the
end; added the `dc:` namespace to content.xml.
Tests: `comments_round_trip.rs` (DOCX) and `comments_round_trip` (ODT) check
that the start/end anchors and the comment body/author/date survive a full
round-trip. Updates the fidelity registry (new Comments row).
Known limits (noted in the registry): comments don't affect layout, aren't
yet persisted through the Loro CRDT, and multi-paragraph bodies are
flattened to plain text (paragraphs joined by `\n`).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Replace `Comment.body_raw: Vec<u8>` (plain text joined by `\n`) with `Comment.body: Vec<Block>`, so comment bodies preserve their paragraph structure instead of being flattened. Added `Comment::with_plain_body` for the common text case. - DOCX `word/comments.xml`: each `w:p` round-trips as a `Block::Para` (reader builds one block per paragraph; writer emits one `w:p` per block, always at least one). - ODT `office:annotation`: the reader collects each `text:p` into a body paragraph (`OdfParagraphChild::Annotation.body: Vec<String>`), the mapper builds `Block::Para`s, and the writer emits a `text:p` per block. (Inline formatting inside comments is still flattened to plain text; only paragraph structure is preserved.) Round-trip tests now use two-paragraph bodies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Comments round-tripped through DOCX/ODT files but were lost when the document passed through the Loro CRDT (the editor's persistence/undo path). Add a `loro_bridge::comments` module that stores `Document.comments` as a lossless `serde` JSON snapshot under `KEY_COMMENTS_JSON` — the same strategy already used for metadata and the style catalog. `document_to_loro` / `loro_to_document` now write and read it, and the public `write_document_comments` / `read_document_comments` let the editor persist comment edits as a CRDT mutation (durable, undoable). The in-flow `CommentRef` anchors already round-trip with the block text. Tested in `loro_bridge::comments` (snapshot round-trip incl. multi-paragraph body; empty when absent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
With comment anchors and bodies now in the model, render them. In paginated mode each `Inline::Comment` start anchor records its page y; when the page is finished, every anchored comment is laid out as a tinted card (author line + body) in a gutter to the right of the page, stacked so cards never overlap (`flow_comments.rs`). - `LayoutPage` gains `comment_items` (page-local; x extends past the page right edge). `flow_section` takes the document comments; `finish_page` builds the panel via the existing reflow block layout. - `loki-vello` paints `comment_items` alongside header/footer items. - `loki-layout` exposes `COMMENT_GUTTER_WIDTH`; `loki-text`'s `page_metrics` widens the canvas by it when any page has comments, so the gutter is reachable. Tests: `comment_panel_renders_in_gutter` (a card with text appears past the page edge) and `no_comment_panel_without_anchors`. Limits: rendered on full relayout only (incremental-reuse pages pass an empty comment set); cross-paragraph ranges anchor at the start paragraph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Mathematical equations are now imported and exported through DOCX. The format-neutral model stores math as a single MathML `<math>` string in `Inline::Math` (the W3C interchange standard and ODF's native form); a new `docx/omml` module converts bidirectionally between OMML (`m:oMath` / `m:oMathPara`) and MathML. The converter is mutually inverse over the common construct set: text runs (`m:r`/`m:t` ⇄ `<mi>`/`<mn>`/`<mo>`), fractions, super/subscripts, and radicals (sqrt + nth root). Display math round-trips via the `m:oMathPara` wrapper. Loro already preserves `Inline::Math` losslessly as an opaque block snapshot, so no bridge change is needed. - model: document `Inline::Math` as MathML; stop leaking MathML into plain-text extraction (DOCX alt-text and the Loro flat-text helper). - reader/model/mapper: parse `m:oMath`/`m:oMathPara` into `Inline::Math`. - writer: emit OMML and declare `xmlns:m` on the document/header/footer roots. - tests: 7 converter unit tests + 3 DOCX export→import round-trip tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Mathematical equations now round-trip through ODT. ODF stores math as an embedded formula sub-document, so an `Inline::Math` (MathML) is written as a `draw:frame`/`draw:object` referencing an `Object N/content.xml` part holding the MathML, and listed in the package manifest with the `…opendocument.formula` media type. On import the object is resolved and the MathML canonicalised back into the model (`odt::math`). - package: collect embedded object `content.xml` sub-documents alongside images. - model/reader: parse `draw:object` into a new `OdfFrameKind::Object`. - mapper: resolve a formula object to `Inline::Math` (inline math; ODF does not distinguish display math); non-formula / unresolvable objects fall back to a `DroppedFrame` warning as before (preserves the OLE-object behaviour). - writer/export: emit the frame, the object sub-document, and its two manifest entries (directory + content.xml). - tests: 3 (de)serialiser unit tests + 2 ODT export→import round-trip tests. - docs: add a Math row to the fidelity status registry; note that rendering is still deferred (no math typesetting engine yet). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Equations now appear in the layout, not just on disk. A new `loki-layout::math` module typesets the model's MathML into positioned glyph runs (tokens shaped via Parley) plus fraction-bar/radical rules, and `layout_paragraph` places each equation inline on the text baseline through a Parley inline box — the same mechanism already used for tab stops. Because the output reuses the existing `PositionedItem` glyph/rect types, `loki-vello` paints math with no renderer change. Coverage matches what the importers produce: identifiers/numbers/operators, rows, fractions (mfrac), scripts (msup/msub/msubsup), and radicals (msqrt/mroot). Single-letter identifiers render italic per MathML convention; scripts/indices use a 0.7x script size. - math/: a tiny hand-rolled MathML parser (no new crate dep), a Parley token shaper, and box composition (hbox/frac/scripts/radical). - StyleSpan gains a `math` field: resolve.rs emits an empty-range placeholder span carrying the MathML; layout_paragraph typesets it, reserves an inline box (probe + final passes), and emits the draw items at the resolved box position. The cache key tracks the field automatically via its Debug. - tests: 7 typesetter unit tests + a layout-integration test asserting a paragraph with a fraction emits the bar rule and extra glyph runs. - docs: Math fidelity row updated (Render: Partial) with the approximations (no stretchy radicals/delimiters, simple spacing, baseline-anchored box, no matrices/n-ary/accents). Also fixes pre-existing `LayoutPage`/`StyleSpan` literals in loki-text editing tests that were missing the `comment_items` field from the comments work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Two follow-ups to the first-pass math typesetter. **Baseline alignment.** Parley 0.10 aligns an inline box's bottom to the text baseline and counts its whole height as ascent (`finish_line`). So sizing the math inline box to the equation's *ascent only* lands the box top at `baseline − ascent`; drawing the equation there puts its baseline exactly on the text baseline, with the descent hanging below into the line like inline text. Previously the box reserved ascent+descent, leaving the equation floating a descent above the baseline. The paragraph height now grows to cover an equation whose denominator hangs below the last line, so the next block can't overlap it. **Stretchy radicals and delimiters.** Radical signs and fence delimiters now scale to their content height via uniform glyph scaling (`stretchy_glyph`): the `√` spans from the overbar to the radicand foot, and a row wrapped in matching fence operators — or an `<mfenced>` — gets parentheses/brackets sized to and centred on the enclosed content. Uniform scaling also widens the glyph (an approximation of true extensible glyphs), documented as such. No renderer change — it reuses the existing glyph-run / rect items. - shape.rs: `stretchy_glyph` (re-shape a glyph at a larger size for a target height). - compose.rs: `radical` stretches the surd; new `delimiters` composer; `MBox` gains `shift_v` (vertical shift that keeps ascent/descent consistent). - mod.rs: `mfenced` dispatch + fence-operator detection in `row`. - para.rs: inline box height = ascent (baseline alignment); paragraph height covers below-baseline math depth. - tests: stretchy radical, stretchy delimiters, and baseline-alignment (`inline_math_baseline_aligns_with_text`). - docs: Math fidelity row updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
CI (clippy 1.96 / cargo test) surfaced two classes of failure on this branch:
1. build-and-test: a `StyleSpan` literal in `loki-renderer`'s
`render_layout_tests.rs` was missing the `math` field added by this PR
(the other StyleSpan literals across the workspace use struct-update spread
and were already covered). Add `math: None`.
2. lint: CI runs clippy 1.96, whose `collapsible_match` now flags
`match { Pat => { if cond { .. } } }`, suggesting a match-arm guard. This
fires on pre-existing spreadsheet-importer code unrelated to math
(`loki-odf` ods/import.rs ×3, `loki-ooxml` xlsx/import.rs ×1) — surfaced
only because the CI toolchain is newer than the local one. Converted each
to the suggested `Pat if cond =>` guard (behavior-preserving; verified with
the 1.96 toolchain and the odf/ooxml test suites).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
Indented paragraphs (e.g. the Screenplay template's Character, Parenthetical, and Dialogue blocks, which set `indent_left`) drew their glyphs shifted right by `indent_start` (applied as `indent_x` when emitting glyph runs), but `cursor_rect`, `hit_test_point`, and `selection_rects` read the Parley layout's un-indented coordinate space. The caret therefore appeared at the page's content-left rather than where the text is drawn, and clicks/selection were off by the indent. `ParagraphLayout` now retains `indent_start` / `indent_hanging`, and the three geometry methods apply the same per-line indent the renderer uses (the first line of a hanging-indent paragraph keeps its `indent_start - indent_hanging` offset): - `cursor_rect`: add the line's indent to the caret x. - `hit_test_point`: subtract it from the incoming x before hitting Parley. - `selection_rects`: add it per selection line (Parley reports the line index). The editor layer is unchanged — it already adds the flow nesting indent (`origin.0`) and page margins on top, so vertical navigation's goal column is now the true visual x and maps correctly across blocks with different indents. Tested by `left_indent_included_in_cursor_and_hit_test`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This is a large refactoring and feature-addition PR that:
loki-odf/src/odt/mapper/props.rs(925 lines) into focused submodules (paragraph.rs,character.rs,cell.rs) to comply with the 300-line ceilingloki-odf/src/odt/write/) with serializers for styles, content, properties, tables, media, and inline runsloki-layout/src/math/) with MathML parsing, token shaping, box composition, and layoutloki-templates/) with APA, MLA, resume, screenplay, and markdown templatesKey Changes
Code Organization
loki-odf/src/odt/mapper/props/— Split 925-line monolith into:mod.rs— public re-exportsparagraph.rs(211 lines) —OdfParaProps→ParaProps+ border parsingcharacter.rs(195 lines) —OdfTextProps→CharPropscell.rs(124 lines) — table cell propertiestests.rs(410 lines) — comprehensive unit testsloki-odf/src/odt/write/— New ODT export serializers:styles.rs— named styles, page layout, master pagescontent.rs— document body, automatic styles, embedded imagesinlines.rs— text spans, links, footnotes, bookmarks, fieldspara_props.rs— paragraph property serializationprops.rs— character property serializationtables.rs,media.rs,auto.rs,xml.rs— supporting modulesloki-layout/src/math/— New math typesetter:mod.rs— public API (layout_math)parse.rs— minimal MathML parsershape.rs— token shapingcompose.rs— box compositionmath_tests.rs— structural layout testsFeature Additions
loki-odf/tests/odt_export_round_trip.rs) — validates styles, page geometry, and metadata survive export/importloki-layout; OMML ↔ MathML conversion inloki-ooxmlloki-ooxml/src/docx/reader/comments.rs,write/comments.rs) and ODT (loki-odf/src/odt/reader/annotations.rs); Loro bridge integration (loki-doc-model/src/loro_bridge/comments.rs)loki-ooxml/src/docx/mapper/fields.rs) and serialization (write/fields.rs)loki-ooxml/src/docx/reader/custom_props.rs,write/custom_props.rs)loki-templates/) — APA, MLA, resume, screenplay, markdown with asset generationloki-layout/src/flow_comments.rs) — margin comment renderinghttps://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL