Skip to content

Refactor property mappers into submodules; add ODT export, math layout, and tests#22

Merged
kevincarlson merged 48 commits into
mainfrom
claude/funny-ptolemy-gctq6j
Jun 23, 2026
Merged

Refactor property mappers into submodules; add ODT export, math layout, and tests#22
kevincarlson merged 48 commits into
mainfrom
claude/funny-ptolemy-gctq6j

Conversation

@kevincarlson

Copy link
Copy Markdown
Member

Summary

This is a large refactoring and feature-addition PR that:

  1. Splits the monolithic loki-odf/src/odt/mapper/props.rs (925 lines) into focused submodules (paragraph.rs, character.rs, cell.rs) to comply with the 300-line ceiling
  2. Implements ODT export (loki-odf/src/odt/write/) with serializers for styles, content, properties, tables, media, and inline runs
  3. Adds math typesetting (loki-layout/src/math/) with MathML parsing, token shaping, box composition, and layout
  4. Adds comprehensive test coverage across ODF, OOXML, layout, and model layers
  5. Adds document templates (loki-templates/) with APA, MLA, resume, screenplay, and markdown templates
  6. Removes obsolete code (fontique patch, scroll_driver, render-cache tier logic)
  7. Adds comment support (read/write for DOCX and ODT, Loro bridge integration)
  8. Adds field support (DOCX field mapping and OMML math round-trip)

Key Changes

Code Organization

  • loki-odf/src/odt/mapper/props/ — Split 925-line monolith into:
    • mod.rs — public re-exports
    • paragraph.rs (211 lines) — OdfParaPropsParaProps + border parsing
    • character.rs (195 lines) — OdfTextPropsCharProps
    • cell.rs (124 lines) — table cell properties
    • tests.rs (410 lines) — comprehensive unit tests
  • loki-odf/src/odt/write/ — New ODT export serializers:
    • styles.rs — named styles, page layout, master pages
    • content.rs — document body, automatic styles, embedded images
    • inlines.rs — text spans, links, footnotes, bookmarks, fields
    • para_props.rs — paragraph property serialization
    • props.rs — character property serialization
    • tables.rs, media.rs, auto.rs, xml.rs — supporting modules
  • loki-layout/src/math/ — New math typesetter:
    • mod.rs — public API (layout_math)
    • parse.rs — minimal MathML parser
    • shape.rs — token shaping
    • compose.rs — box composition
    • math_tests.rs — structural layout tests

Feature Additions

  • ODT export round-trip (loki-odf/tests/odt_export_round_trip.rs) — validates styles, page geometry, and metadata survive export/import
  • Math support — MathML parsing and layout in loki-layout; OMML ↔ MathML conversion in loki-ooxml
  • Comments — read/write for DOCX (loki-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)
  • Fields — DOCX field mapping (loki-ooxml/src/docx/mapper/fields.rs) and serialization (write/fields.rs)
  • Custom properties — DOCX custom properties read/write (loki-ooxml/src/docx/reader/custom_props.rs, write/custom_props.rs)
  • Document templates (loki-templates/) — APA, MLA, resume, screenplay, markdown with asset generation
  • Comment panel layout (loki-layout/src/flow_comments.rs) — margin comment rendering
  • Column layout

https://claude.ai/code/session_01VQT8nGqwCyAkFGmSCiqYQL

claude added 30 commits June 21, 2026 01:48
… 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
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
claude added 16 commits June 22, 2026 09:27
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
@kevincarlson kevincarlson self-assigned this Jun 23, 2026
claude added 2 commits June 23, 2026 11:35
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
@kevincarlson kevincarlson merged commit 7d50d6a into main Jun 23, 2026
2 checks passed
@kevincarlson kevincarlson deleted the claude/funny-ptolemy-gctq6j branch June 23, 2026 12:15
@AppThere AppThere locked as resolved and limited conversation to collaborators Jun 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants