Refactor large modules into focused submodules (300-line ceiling)#31
Merged
Conversation
Implement two Phase 4 / 4a.4 (Spec 03) responsive sub-items from the deferred-features plan: Real Viewport.zoom — the status-bar zoom now drives the shared responsive Viewport::zoom. `editor_responsive` gains pure `zoom_fraction` / `desired_view_mode` helpers wired into the page-fit effect and the context-publish effect, so zooming a page past the point a full column fits the viewport flips the editor into the reflow renderer (and back on zoom-out) instead of forcing horizontal scroll. Hit-testing uses a separate local Viewport and is unaffected. 5 regression tests. Metadata-panel label stacking (R-13g) — FieldRow becomes a #[component] that reads use_viewport() per ADR-0013; below 250 px the field label stacks above its input (flex column) so the input keeps a usable width on narrow/split-screen viewports. Pure `stack_labels` helper + 3 tests. Docs: plan 4a.4 rows and fidelity-status reflow row updated. editor_inner.rs kept net-neutral against the file-ceiling ratchet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…a.5) Insert → Table left the caret in the paragraph *before* the table; the user had to click into a cell to start typing. It now lands in the first cell automatically (Word behaviour). - `insert_table_after_cursor` returns the first-cell caret via the new `first_cell_caret` helper (flat cell 0 / block 0 — top-left, since a `Table::grid` has no head/foot), instead of a bare block index. - The Insert-tab `run_insert` gains an `InsertResult` enum carrying an optional caret target; after relayout it collapses the cursor there via `set_collapsed_cursor`, which re-derives the page from the fresh layout (plan 4b.1). Footnote insert leaves the caret at the anchor, matching Word. - Extracted the async Insert → Image flow into `editor_ribbon_insert_image.rs` so `editor_ribbon_insert.rs` stays under the 300-line ceiling. Tests: 3 new in editor_insert_tests.rs (first-cell caret shape, table lands at block 1 with an empty first cell, no-cursor no-op). Full loki-text suite green; fmt + clippy clean; file-ceiling gate passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Pressing Enter on an empty list item used to insert another empty bullet; it now removes the list formatting and drops the paragraph back to body text, matching Word / LibreOffice. Model (loki-doc-model): - `get_block_list_id` reads a block's direct `list_id` para-prop. - `clear_block_list` removes `list_id` + `list_level` from a block's para_props (a no-op when absent). Both top-level, section 0. Editor (loki-text): - `is_empty_list_item_exit` — a pure predicate: plain caret (no selection) on an empty, top-level list item. `handle_enter_key` calls it, then clears the list props, relays out, and keeps the caret in the now-plain paragraph (page re-derived per 4b.1) instead of splitting. Nested list items (table cell / note) keep the split — the block API is top-level only. Tests: 4 model tests (read/clear + round-trip + no-op) and 5 predicate tests (empty/non-empty/plain/selection/nested). Both crates' suites green (441 tests); fmt + clippy clean; file-ceiling gate passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…a.2) Adds the contextual-tab half of Spec 04 M5 / plan 4a.2: a ribbon tab that appears only while the relevant object is selected. - `editing/selected_object.rs`: pure `selected_object(&CursorState) -> SelectedObject` — `Table` when the caret's path descends through a table cell, else `None`. Unit-tested (5) without a Dioxus runtime. - `editor_ribbon_table::use_ribbon_tabs` derives it via `use_memo`, appends an amber Table contextual tab while the caret is in a table, and resets the active tab to Write when the caret leaves so the ribbon never shows an orphaned contextual selection. Pure `ribbon_tabs` tested (3). - Table tab action "Delete Table" removes the whole table block via a new `delete_block` model mutation, disabled when the table is the document's only block; the caret re-homes to the neighbouring block. `delete_block` tested (3, incl. cross-section + out-of-range). - New `LUCIDE_TRASH_2` icon + `ribbon-tab-table` / `ribbon-group-table` / `ribbon-table-delete-aria` strings. Ceiling: `delete_block`/`insert_block_after` moved into a new `loro_mutation/block_edit.rs` (block.rs was over 300); `editor_inner.rs` kept net-neutral by routing the wiring through `use_ribbon_tabs` and collapsing two single-expression ribbon closures. Both crates' suites green (481 tests); fmt + clippy clean; ceiling passes. Remaining 4a.2 tail: the Layout/References/Review non-contextual tabs (need page-setup / TOC / track-changes backing that doesn't exist yet). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Adds insert/delete row and column, wired into the Table contextual tab's new "Rows & Columns" group. Model (loki-doc-model, `loro_mutation/table_ops.rs`): - `insert_table_row` / `delete_table_row` / `insert_table_column` / `delete_table_column`, plus `table_grid_dims`. - Each rewrites the serde skeleton *and* minimally patches the flat `KEY_TABLE_CELLS` movable list — surviving cells keep their live CRDT text (the list is patched, never rebuilt), and skeleton/cell order stay in sync. - Scoped to simple grids (one body, no head/foot, no row/col spans, uniform width); any other shape returns the new typed `MutationError::UnsupportedTableStructure`. Deletes refuse the last remaining row/column. - 12 round-trip tests (`tests/table_structural_ops.rs`) covering shape, text preservation, bounds, merged-cell rejection, and composed edits. Editor (loki-text): - `editor_ribbon_table_ops.rs`: `run_table_op` derives the target row/column from the caret's cell, applies the mutation, and re-homes the caret to its shifted cell via the pure, tested `caret_flat_after` (a structural edit changes the flat cell indexing the cursor stores). - Table tab gains four buttons; row/column deletes disable at 1 row/col, and all four disable on a non-simple-grid table (only Delete Table remains). 4 app-custom table-op glyph icons + i18n strings. Both crates' suites green; fmt + clippy clean; file-ceiling gate passes (`delete_table_*` etc. live in their own module). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The Write tab gains an Alignment group (left / centre / right / justify). Alignment was a gap: the model helpers existed but were unused and top-level only. Model (loki-doc-model, new `loro_mutation/align.rs`): - Path-aware `set_block_alignment_at` / `get_block_alignment_at` (plus the moved top-level `set_block_alignment` / `get_block_alignment`), so alignment works inside table cells and note bodies. - Type-aware: a plain `para` is upgraded to `styled_para` (a bare `para` drops props on read), `styled_para` uses `para_props`, and a `heading` uses its OOXML `jc` attribute. 5 tests in `block_alignment.rs` cover top-level, cell (path-aware), heading, round-trips, and the invalid-path error. Editor (loki-text): - `editor_alignment.rs`: `current_alignment` / `apply_alignment` resolve the caret's paragraph(s) via `resolve_format_ranges` (same per-paragraph mapping the inline toggles use), so a multi-paragraph selection aligns uniformly. - The six inline-format buttons and the four alignment buttons live in the new `editor_ribbon_format.rs`; `write_tab_content` calls the two group builders, dropping `editor_ribbon.rs` from the 300-line ceiling to 225. Both crates' suites green (473); fmt + clippy clean; ceiling passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The Write tab gains a Font group that grows/shrinks the selection's font size by one step through a fixed ladder of common point sizes. - `editor_font_size.rs`: font size is a character mark (`MARK_FONT_SIZE_PT`), so grow/shrink apply through the same path-aware `mark_text_at` + `resolve_format_ranges` path the inline toggles use — it works in table cells and across a multi-paragraph selection. The current size is read from the selection's direct size mark, stepping from an 11pt default when a range has no explicit size. - Pure ladder stepping (`grow`/`shrink`) plus end-to-end mark tests (5): ladder edges, off-ladder snapping, apply-over-selection-only, and grow-then-shrink round trip. - `font_group` builder in `editor_ribbon_format.rs`; 2 app-custom "A±" glyph icons + i18n strings. Both crates' suites green (204); fmt + clippy clean; ceiling passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The Write tab gains a Font-colour group: an Automatic (clear) swatch plus six preset colours. - `editor_text_color.rs`: colour is a character mark (`MARK_COLOR`); for an RGB colour the stored value is just its `#RRGGBB` hex — the same form the bridge's colour codec writes — so a preset swatch needs no extra encoding. Apply/clear go through the path-aware `mark_text_at` + `resolve_format_ranges` path, so colour works in table cells and across a multi-paragraph selection; Automatic writes `Null` to drop the direct mark and revert to the style colour. - `editor_ribbon_color.rs`: `font_color_group` renders coloured-square swatches inside `AtRibbonIconButton` (which takes arbitrary children); the active swatch reflects the caret's direct colour. `RibbonEditCtx:: finish` is now `pub(super)` so the colour module shares the relayout/sync path. - 3 tests: apply-over-selection-only, Automatic-clears, and round-trip into `CharProps.color`. Verified the renderer paints `CharProps.color` (resolve → StyleProperty:: Brush), so the colour is visible, not just stored. Both crates' suites green (207); fmt + clippy clean; ceiling passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Adds the first non-contextual Layout tab (Write / Insert / Layout / Publish; the contextual Table tab moves to index 4) with a page orientation toggle. Model (loki-doc-model, new `loro_mutation/page.rs`): - `set_document_orientation(loro, landscape)` and `document_is_landscape`. The layout engine reads a section's effective `page_size` directly and treats orientation as a flag, so the toggle swaps width↔height on every section's page-size map (when it doesn't already match) and writes the orientation flag — idempotent. `apply_mutation_and_relayout` re-derives the geometry and updates `page_width_px`, so the document re-flows at the new size immediately. - 4 round-trip tests (`page_orientation.rs`): fresh doc is portrait, to-landscape swaps dims + sets the flag, toggle-back restores, and same-orientation is idempotent. Editor (loki-text): - `editor_ribbon_layout.rs`: `layout_tab_content` with Portrait/Landscape buttons (active state from `document_is_landscape`). - `ribbon_tabs` gains the Layout entry; `CONTEXTUAL_TAB_INDEX` 3 → 4; `editor_inner`'s content match adds the Layout arm and shifts Publish to index 3 (kept net-neutral against the ceiling). Ribbon-tab tests updated. - 2 app-custom page-rect glyph icons + i18n strings. All three crates' suites green (514); fmt + clippy clean; ceiling passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Adds a Margins group to the Layout tab, alongside orientation. Model (loki-doc-model, `loro_mutation/page.rs`): - `set_document_margins(loro, top, bottom, left, right)` sets those four edges on every section's margins map (creating it when absent) and leaves header/footer/gutter distances untouched; `document_margins` reads section 0's margins for the active-preset check. Same relayout pipeline as orientation. - 3 round-trip tests (`page_margins.rs`): all-edges update, per-edge differences, header distance preserved. Editor (loki-text, `editor_ribbon_layout.rs`): - Margins group with Normal (72pt) / Narrow (36pt) / Wide (72/72/144/144) presets; the active preset highlights via the pure `margin_matches` (½-pt tolerance for import rounding). The orientation and margin buttons now share a single `apply_and_sync` helper (mutation → relayout → sync). - `layout_tab_content` keeps its 6-arg signature so the ceiling-bound `editor_inner` call site is unchanged. 5 `margin_matches`/preset tests. - 3 app-custom page-inset glyph icons (tooltip-disambiguated) + i18n. All three crates' suites green (522); fmt + clippy clean; ceiling passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Adds a Size group to the Layout tab, alongside orientation and margins. Model (loki-doc-model, `loro_mutation/page.rs`): - `set_document_page_size(loro, portrait_w, portrait_h)` sets every section's page size, **preserving each section's orientation** — a landscape section keeps the long edge as its width, so choosing "A4" while landscape gives A4 landscape, not portrait. `document_page_size` reads section 0's size for the active-preset check. Same relayout pipeline as orientation/margins. - 3 round-trip tests (`page_size.rs`): portrait A4, orientation preservation, and Letter→A4 switch. Editor (loki-text, `editor_ribbon_layout.rs`): - Size group with A4 (595.28×841.89pt) / US Letter (612×792pt) presets; the active size highlights via the pure, orientation-independent `page_size_matches` (compares short/long edges within a point). Reuses the shared `apply_and_sync` helper. 4 preset-match tests. - 2 app-custom page-rect glyph icons (aspect-distinguished, tooltip-backed) + i18n. All three crates' suites green (529); fmt + clippy clean; ceiling passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Completes the Layout tab's page-geometry controls with a Columns group. Model (loki-doc-model, `loro_mutation/page.rs`): - `set_document_columns(loro, count)` sets every section's column count (clamped ≥1). A newly-created columns map gets a default 0.5in gap and no separator; an existing one keeps its gap/separator so only the count changes. `document_column_count` reads section 0's count (1 when absent). Same relayout pipeline — in paginated mode the flow engine divides the content into that many columns. - 5 round-trip tests (`page_columns.rs`): fresh doc is single-column, two-columns creates a gap, changing count preserves the gap, back-to-one, and zero clamps to one. Editor (loki-text, `editor_ribbon_layout.rs`): - Columns group with One/Two/Three presets; active state is the exact count. Reuses the shared `apply_and_sync` helper. 3 app-custom page + divider-line glyph icons + i18n. The Layout tab now offers Orientation + Margins + Size + Columns. All three crates' suites green (534); fmt + clippy clean; ceiling passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Write tab: a new Highlight group (None + Yellow/Green/Cyan/Magenta/Red) writes the MARK_HIGHLIGHT_COLOR character mark across the selection, path-aware so it works inside table cells, round-tripping into CharProps.highlight_color. The duplicated font-colour / highlight swatch UI is unified into a generic swatch_group(palette, apply_fn). Table tab: the insert ops are split into four caret-relative variants (InsertRowAbove/Below, InsertColumnLeft/Right). Above/left insert at the caret's own row/col index, below/right at index+1; caret_flat_after re-homes the caret (above shifts it down a row, left shifts it one column right). Two app-custom glyphs for the above/left buttons. Tests: 3 highlight apply/read/round-trip + 2 new caret-math cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Start the progressive collapse cascade with its pure, testable core in
the shared responsive layer: resolve_cascade(metrics, available_px,
prev_level) -> RibbonCascade. Each group declares GroupMetrics
{ priority, full_px, condensed_px }; the engine degrades gracefully by
condensing all groups (lowest priority first) before overflowing any
into the "More" menu, and falls back to horizontal scroll only when even
the fully-overflowed strip cannot fit (§7 steps 1-4).
The decision is hysteretic, mirroring Spec 03's page_fit: it collapses a
step the instant the strip overflows but re-expands only when the looser
layout clears the width by RIBBON_COLLAPSE_HYSTERESIS_PX, so dragging a
window across a fit threshold does not thrash; resolution is idempotent
at a fixed width (the resolved level feeds back in as prev_level).
New tokens RIBBON_OVERFLOW_BUTTON_PX and RIBBON_COLLAPSE_HYSTERESIS_PX;
10 unit tests covering full-fit, priority-ordered condense/overflow, the
scroll floor, the hysteresis dead-band, idempotence, tie-breaking, and
the empty ribbon.
Remaining for M3: per-group width measurement wiring into AtRibbon, the
condensed + overflow-menu UI representations, and R-13e select-width.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Two framework increments on top of the pure cascade engine (Spec 04 M3):
1. use_ribbon_cascade(metrics) -> RibbonCascade binds resolve_cascade to
the live AtResponsiveContext viewport width, holding the resolved
collapse level in a hook-local signal so hysteresis carries across
resizes. It is resilient like use_breakpoint: with no responsive
context the width is treated as unbounded, so every group stays Full
and Presentation/Spreadsheet get a sane full-chrome ribbon.
2. The condensed group representation (§7 step 2) is a pure, tested
decision — group_layout(collapse, has_label) -> GroupLayout — that
AtRibbonGroup applies through a new defaulted `collapse` prop: Full
keeps the label and roomy padding, Condensed drops the label and
tightens padding/gap (never the buttons, so 44 px touch targets
survive), Overflow renders nothing in the strip. The prop defaults to
Full, so every existing call site is unchanged.
+3 group_layout tests (13 total in ribbon_collapse_tests).
Remaining for M3: per-group width measurement into GroupMetrics + the
AtRibbon API change threading a structured group list through the hook,
the overflow ("More") menu UI, and R-13e select-width in the condensed
state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The framework piece that ties the cascade to real tab content:
AtRibbonGroups (components/ribbon/groups.rs). A tab hands it a
Vec<RibbonGroupSpec { metrics, label, aria_label, content }>; it runs
use_ribbon_cascade once for the whole strip, renders each group at its
resolved Full/Condensed state, and moves overflowed groups into a
trailing "More" menu — an upward position: absolute dropdown (confirmed
working in Blitz) that renders the overflowed groups in Full form.
Group widths are declared, not Blitz-measured (per-element measurement
is unreliable): estimate_group_metrics(priority, buttons, has_label)
derives the full/condensed widths from the touch-button count.
The Layout tab is migrated as the first real consumer — its four groups
(Orientation/Margins/Size/Columns, priority descending so Columns
overflows first) now flow through AtRibbonGroups, driven live off the
editor's measured viewport width. New LUCIDE_MORE_HORIZONTAL icon and
ribbon-overflow-aria string. +2 estimator tests (15 in ribbon_collapse).
Remaining for M3: migrate Write/Insert/Publish/Table to RibbonGroupSpec;
outside-click dismiss for the More menu (needs a window-level backdrop
host — position: fixed collapses to absolute in stylo_taffy); R-13e
select-width in the condensed state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Both tabs now build their groups as RibbonGroupSpec lists wrapped in AtRibbonGroups, so the width-driven cascade + overflow "More" menu drive them live. The group-helper fns in editor_ribbon_format / editor_ribbon_color return RibbonGroupSpec (with a threaded priority) instead of a wrapped AtRibbonGroup. Priorities keep the core editing controls (Inline, Alignment, Font, Styles) full the longest; the wide colour-swatch groups overflow first (they also reclaim the most strip width per overflow). R-13e (select-width in the condensed state): AtRibbonGroup now exposes its resolved GroupCollapse to descendants as a *signal* context, and AtRibbonSelect reads it to shrink from RIBBON_SELECT_WIDTH_PX to RIBBON_SELECT_WIDTH_CONDENSED_PX when its group is condensed. A signal (not a plain context value) so prop-memoised selects still re-size reactively when the cascade changes on resize. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…bustness Completes the tab migrations for Spec 04 M3: every Loki Text ribbon tab now builds RibbonGroupSpec lists through AtRibbonGroups, so the width-driven cascade + overflow "More" menu drive them all. - Publish: ribbon content extracted to editor_ribbon_publish.rs, which drops the baselined editor_publish.rs 315 → 236 (off the ceiling backlog); run_export is now pub(super). - Table: delete_current_table extracted to editor_ribbon_table_delete.rs to hold the ceiling after the spec conversion. - The overflow menu auto-closes when a resize removes the overflow, so a widened window can't strand an open menu whose More button is gone. editor_inner.rs held at its baseline (a comment tightened to offset the publish-import split). Remaining M3 tail: true outside-click-to-dismiss for the More menu needs the menu hosted in a shared window-level overlay so a full-viewport backdrop can span the viewport — position: fixed collapses to absolute in stylo_taffy, so an in-place backdrop can't. Toggle-close and auto-close-on-widen ship today; TODO(ribbon) marks the follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
First of the per-family non-paragraph `Default` sources from ADR-0012 Decision 1. Until now a standalone character style's inspector was blind to the document's docDefaults run defaults — a property unset along the style's own chain resolved to FormatDefault, hiding that Word/ODF supply it from docDefaults. Model (loki-doc-model): - New StyleCatalog::default_character_style (serde-default, so it round-trips through the Loro-bridge catalog JSON and stays back-compatible). - resolve_char_chain falls through to it (first_in_char_chain, cycle/depth-guarded), resolving as Provenance::Default — the character-family analogue of the existing paragraph default path. Importer (loki-ooxml): synthesise a `__DocDefaultChar` character style from w:rPrDefault and point default_character_style at it. Leak fixes: the character browser hides `__`-prefixed synthetic styles, and both the DOCX and ODT writers skip them (docDefaults are written as w:docDefaults / style:default-style, never as named styles) — this also closes a latent `__DocDefault` paragraph-style export leak. Tests: 4 model (Default fall-through, local-wins) + 3 mapper (synthesis, absence, end-to-end Default provenance). OOXML/ODF/round-trip suites green. Remaining 4a.3: table/list Default sources, character-style editing form, the Page style family, Table conditional/banding, and the Compact-tree breadcrumb (M7). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The character family was inspect-only; this adds an editable form, the character analogue of the existing paragraph style editor. - Selecting a character style seeds an editable StyleDraft (char_style_to_draft); its character fields are a superset of a character style's properties, so the shared paragraph-form inputs (field_row / iu_buttons / font_picker / weight_selector, all binding a Signal<Option<StyleDraft>>) are reused verbatim in a new char_form.rs for name / based-on / font-family / weight / size / italic / underline. - Apply commits a CharacterStyle to the catalog through Loro (commit_char_style_to_loro, persisted via write_document_styles, so it is durable and undoable) and relays out. - Re-parenting is cycle-guarded by new model helpers char_ancestors / char_reparent_cycles (the character analogue of the paragraph guard). - The editable form renders alongside the read-only provenance inspector — the Spec 05 §6 inspector+edit pairing (inspector shows where inherited values come from, the form edits the locals). 1 model test (character_reparent_cycle_is_detected); doc-model + loki-text suites green. editor_inner held at its 803 baseline (offset a threaded arg with a comment tighten). Remaining 4a.3: table/list Default sources, the Page style family, Table conditional/banding, and the Compact-tree breadcrumb (M7). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
At Compact the paragraph inheritance tree's full indented list is impractical, so it degrades to a breadcrumb + drill-down (Spec 05 §7/§11): - Breadcrumb: the root→selected path (new model para_breadcrumb = para_ancestors reversed, cycle-guarded), each hop clickable to jump up. - Drill-down: the selected style's direct substyles (para_children), clickable to descend. body::left_column renders this via a new tree_nav.rs when posture.stack (Compact); Expanded/Medium keep the indented tree. Navigation loads the target style's draft exactly as the indented tree does. 1 model test (breadcrumb_is_root_first_including_self); doc-model + loki-text suites green. Remaining 4a.3: table/list Default sources, the Page style family, and Table conditional/banding editing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…a.3) The table family had single-parent inheritance in the model but no provenance resolver — only paragraph and character families did. This adds the table analogue, the last of the per-family non-paragraph Default sources (ADR-0012 Decision 1): - resolve_table_chain: Local / Inherited / Default / FormatDefault over the table parent chain, with the Default level supplied by a new StyleCatalog::default_table_style (serde-default, round-trips through the Loro-bridge catalog JSON). Plus table_ancestors / table_reparent_cycles for a future table-style editor. - Lives in a new resolve_table.rs so resolve.rs stays at 299 (under the 300 ceiling); the shared Resolved constructors are now pub(crate) for the split. - The OOXML importer records default_table_style from the table style flagged w:default="1" (e.g. TableNormal). Lists are a non-inheriting family per ADR-0012 Decision 2, so a list Default source doesn't apply (Local/FormatDefault only). Table-style export of the default flag is deferred with the wider table-style writer, which isn't built yet. 3 model tests + 2 mapper tests; doc-model + ooxml suites green. Remaining 4a.3: ODF table default-style import symmetry, the Page style family, and Table conditional/banding editing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…a.3) The model foundation for ADR-0012 Decision 2's page family. - New PageStyle type (style/page_style.rs): a named, NON-inheriting entry (no parent) wrapping the existing rich PageLayout — size, margins, orientation, columns, header/footer master, page numbering. Page styling adopts the ODF model as the unified representation. - New StyleCatalog::page_styles (IndexMap<StyleId, PageStyle>, serde-default so it round-trips through the Loro-bridge catalog JSON and stays back-compatible). - The format-neutral import-mapping core, pure and tested: derive_page_styles(sections) collapses sections with an identical PageLayout into one page style (named PageStyleN in first-seen order, since OOXML has no page-style name), and section_page_style_ids gives the per-section id list — the inverse the DOCX sectPr export needs. No resolver is needed: a non-inheriting family is a chain of length one, so the inspector shows only Local / FormatDefault (per ADR-0012). 7 model tests; full doc-model suite green; loki-ooxml / loki-odf / loki-text / loki-layout all still build against the new catalog field. Remaining page-family work: wire derive_page_styles into the OOXML/ODF importers, the DOCX section-export inverse, ODT native export (style:page-layout + style:master-page), and the flat page panel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The page family is now visible in the style panel, mirroring the read-only list family: a "Page styles" list in the left column (page_browser.rs) plus a geometry inspector column (family_inspector) showing size / orientation / margins / columns. Drift-free by design: the panel DERIVES page styles on demand from the live document's sections (panel_data::page_data -> derive_page_styles) rather than the stored catalog field. The section layouts are the source of truth — the Layout ribbon mutates them directly — so deriving each render keeps the panel from showing stale geometry, the root-cause choice over a stored-but-drifting copy. The inspector rows are pure and tested (style_page_inspector, value-baked like the list inspector: named sizes, uniform-margin collapse). New editing_page_style selection signal wired through the panel; editor_inner held at its 803 baseline via comment tightening; 5 i18n keys. 4 UI tests + the existing model tests; doc-model + loki-text suites green. Remaining page-family work: editing page geometry through the panel (write-back), the DOCX/ODT export naming, and importer population of the stored page_styles field (awaits the section->page-style reference the editing path needs; the panel derives on demand until then). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The ODF half of the character family's Default source, symmetric to the OOXML w:rPrDefault work. The ODT stylesheet mapper now synthesises a `__DefaultChar` character style from `style:default-style style:family="text"` and points default_character_style at it — so a standalone character style resolves the ODF text defaults as Provenance::Default instead of FormatDefault, exactly as on the DOCX side. The ODT writer already skips `__`-prefixed synthetic styles, so it does not leak. The ODF table default is not wired: OdfDefaultStyle carries no table properties and the ODT mapper does not import table styles at all yet — noted for the wider table-style import, not silently skipped. 1 mapper test incl. an end-to-end Provenance::Default resolution. The mapper's inline test module was extracted to styles_tests.rs (the #[path] idiom) to hold the 300-line ceiling (358 -> 148 production). Full loki-odf suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The write-back primitive for LibreOffice-style per-page-style editing: set_page_style_geometry(loro, section_indices, &PageLayout) applies a layout's size / orientation / margins / columns to ONLY the given sections — the ones belonging to a single page style — leaving the other page styles, and each section's headers/footers/gutter/page-numbering, untouched. The per-style analogue of the document-wide set_document_* setters. Design: index-targeting rather than a stored Section.page_style reference + renderer refactor. Page styles are already derived by layout-equality (section_page_style_ids), and an edit keeps a style's sections in sync, so targeting by section index is stable — without touching the fragile CRDT section bridge or the layout engine. 3 integration tests (page_style_geometry.rs): edits only its own sections, applies margins + columns, skips out-of-range indices. Next: the panel edit form that computes the target indices + new layout and calls this mutation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The page panel is now editable per-page-style, the LibreOffice model: selecting a page style shows a preset form (page_form.rs) — Orientation / Size / Margins / Columns buttons matching the Layout ribbon — that applies to ONLY that page style's sections. - apply_preset(&PageLayout, PagePreset) -> PageLayout is a pure, tested transform (orientation/size preserve the other axis; margins keep header/footer/gutter; columns keep the gap). - Each button derives the target section indices on demand (panel_data::page_edit_target -> section_page_style_ids) and writes the edited layout through set_page_style_geometry, then relays out. So editing PageStyle1 changes all its pages and leaves PageStyle2 alone. - Buttons highlight the page style's current geometry (is_active). 4 apply_preset tests; full loki-text suite green; every touched file under the 300-line ceiling. Remaining: user-facing page-style naming/rename (auto-named PageStyleN; real ODF master-page names need a stored section->page-style reference — the deferred model refinement). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The model refinement toward true LibreOffice-style named page styles: Section now names its page style instead of only carrying inline geometry. - Section.page_style: Option<StyleId> — the named reference, persisted through the Loro bridge under KEY_PAGE_STYLE_REF (round-trip tested). - Document::assign_page_styles() normalises a loaded document: dedups sections by layout into catalogued page styles and stores the refs, idempotently — a section that already names a style (a user rename or an ODF style:master-page) is preserved across re-runs. - Wired at load_document, so every opened document gets first-class, stored, renamable page styles. The Section field rippled to ~15 test literals plus the DOCX-mapper and reflow synthetic-section literals; the two baselined files (docx document.rs, flow.rs) were offset back to their baselines with genuine comment tightening, and assign_page_styles lives in style/page_style.rs so document.rs stays at its baseline. 4 model tests (page_style_model.rs); full doc-model / ooxml / odf / layout suites green; loki-text builds; ceiling gate OK. The panel + edit mutation still derive on demand (consistent names), so these stored refs are the foundation the rename UI and the panel-reads-stored migration build on next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Migrate the style panel's page family off derive-by-layout-equality onto the stored `section.page_style` references, and make page styles renamable (LibreOffice-style named page styles, Spec 05 M6 / ADR-0012 Decision 2). - `panel_data::stored_page_styles` groups sections by their stored ref in first-seen order and reads the representative geometry from the first referencing section (`section.layout`, the renderer's truth), so a page style is a stable, renamable identity while its geometry stays drift-free even when the Layout ribbon edits `section.layout` document-wide. `page_data` and `page_edit_target` now read the stored refs. - New `rename_page_style(loro, old, new)` mutation renames the catalog key and every referencing section's stored ref atomically, keeps `PageStyle.id` in sync, and no-ops on name conflict / missing source. - New `PageRenameField` component (owns its draft signal per ADR-0013, keyed by name to reseed on reselection); the page form's Rename button commits through the mutation, relays out, and re-selects under the new name. - 2 rename integration tests + 2 i18n keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Complete the ODF-native round-trip for named page styles (ADR-0012
Decision 2), the page-family tail.
Export: new `odt/write/page_styles.rs` resolves each section's
`style:master-page` / `style:page-layout` names from the stored
`section.page_style` id (sanitised to a valid XML NCName via
`xml::sanitize_ncname`), so a named or renamed page style is written out
under its real name instead of the old positional `MP{idx}`. Sections
sharing a page style collapse to one master page (the first referencing
section's layout is the representative geometry — the choice the panel
makes), matching LibreOffice's shared-master model; sections without a
stored ref keep the positional fallback, so pre-page-style documents
export byte-for-byte as before. `content.xml` and `styles.xml` both read
from the one resolver so their names always agree.
Import: the ODT mapper sets `section.page_style` from the master-page
name each section uses and registers those names as first-class
`page_styles` catalog entries (with `display_name`), so an opened ODT
shows its real page-style names in the panel and they survive re-export.
OOXML needs nothing further (DOCX sections already export as `w:sectPr`
per page style, and `assign_page_styles` names them on import).
5 naming unit tests (`page_styles_tests.rs`) + 1 export->import round-trip
(`named_page_styles_round_trip_as_master_pages`). Docs + fidelity registry
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
… 7.1) styles.rs cut #4. The paragraph-property parsers (parse_para_props_element, parse_para_props_with_children, parse_tab_stops, ~129 lines) move into a new styles_para.rs as the para_props #[path] submodule. The two entry points called by parse_style_props are pub(super); parse_tab_stops stays private (called only by parse_para_props_with_children). The module calls super::skip_element and carries #![allow(dropping_references)]. Two style types (OdfDropCap, OdfTabStop) moved out of styles.rs's imports. styles.rs 892 -> 764 (baseline ratcheted); new file excluded (<=300). loki-odf lib suite green (156), clippy clean, fmt clean. Running total: twenty-four cuts, -4222 lines across fifteen new production submodules + seven test-file extractions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Refresh the plan's 7.1 running total for the format-crate reader/writer cluster cuts (docx write/reader table + odt frame + odt para-props). Full-workspace clippy re-verified clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Extract flow_table's pass 3a (the per-cell content-flowing loop: padding, rotation -> RotatedGroup, vertical alignment, single-page clipping) into a new flow_table_cells.rs as the table_cells submodule (flow_row_cells). flow_table now calls table_cells::flow_row_cells once per row and feeds the returned cell_starts (page + item index per cell) to the pass-3b decoration emitter. row_y_start / row_page were pass-3a-local and became fn locals; cell_flat threads as &mut usize; cell_cols[row_idx] is passed as the row slice. Verbatim move otherwise. This is the concrete unblock for deferred-feature 4b.5 (rotated-cell-editing): its TODO and the RotatedGroup branch now live in flow_table_cells.rs (200 lines, ~100 of headroom), so the editing-data work can be added there without growing the still-baselined flow.rs. flow.rs 1362 -> 1209 (baseline ratcheted); new file excluded (<=300). All 188 lib + 11 integration binaries green (table rotation/spanning/vMerge/ vertical-align/clip are the regression guard), clippy -D warnings clean, fmt clean. Plan 4b.5 row + CLAUDE.md updated. Running total: twenty-five cuts, -4373 lines across sixteen new production submodules + seven test-file extractions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…Phase 7.1) Extract ParagraphLayout's read-only geometry query methods (hit_test_point, line_end_offset, cursor_rect, selection_rects, and the private line_indent helper) out of para.rs into a new para_query.rs as an `impl super::ParagraphLayout` block. As a child module of para it reaches ParagraphLayout's private fields (parley_layout, line_boundaries, orig_to_clean, clean_to_orig) directly; the methods stay pub so all callers are unaffected. Cursor/Selection dropped from para.rs's parley import (only the moved impl used them). This is the concrete unblock for deferred-feature 6.3 (the Option-B y-range item filter that reads line_boundaries to skip painting off-viewport content): para_query.rs is its natural home, at 198 lines with ~100 of headroom under the ceiling, so the filter can be added there without growing the still-baselined para.rs. para.rs 1626 -> 1447 (baseline ratcheted); new file excluded (<=300). All 188 lib + 11 integration binaries green (the hit-test / cursor / selection-rect tests exercise the moved methods), clippy -D warnings clean, fmt clean. Plan 6.3 row updated. Running total: twenty-six cuts, -4552 lines across seventeen new production submodules + seven test-file extractions; deferred-features 4b.5 and 6.3 both unblocked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Rotated table cells were read-only: `flow_cell_blocks` flowed their
content into a throwaway state and discarded the editing paragraphs, so a
click never resolved to the cell. Implement the core of feature 4b.5 on
the sub-ceiling `flow_table_cells.rs` module the Phase-7.1 split created.
Model (loki-layout):
- New `CellRotation` (result_rotation.rs) mirrors the paint-time
`RotatedGroup` affine: `page = pivot_page + Rot(deg)·(local −
pivot_local)`, with `local_to_page` / `page_to_local` inverses.
- `PageParagraphData` gains a `rotation: Option<CellRotation>` field;
when set, `origin` is in the cell's content-local frame. New
`hit_local` / `local_to_page` helpers centralise the (inverse)
transform so rotation lives in exactly one place.
Producer:
- `flow_cell_blocks` now tags each cell paragraph with its
`NestedEditing` path and returns the editing entries alongside items;
the rotated branch records them with a `CellRotation` built from the
same pivots the renderer uses.
Consumers (no regression — upright cells hit the identity path):
- loki-layout `ContinuousLayout::{hit_test, cursor_rect_canvas,
selection_rects}` and loki-text `hit_test_page` route through the
centralised helpers. Clicking a rotated cell now resolves to the
correct character; caret/selection positions are rotation-correct.
Tests: `result_tests` (transform round-trip, `hit_local`) and
`flow_tests::rotated_cell_emits_editing_data_with_rotation` (end-to-end).
loki-layout 192 lib + 11 integration binaries and loki-text 195 lib green;
clippy -D warnings clean; result.rs kept under the ceiling by extracting
the rotation types to result_rotation.rs.
Follow-up (TODO(rotated-cell-caret), documented in fidelity-status.md):
the rendered caret *line* stays upright (a tilted caret needs CursorRect
+ vello to carry rotation) and up/down arrow navigation across rotated
cells still uses raw origin translation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Underlines and strikethroughs previously rendered as a single solid line regardless of the `w:u` / `w:strike` variant: we emit our own decorations (para_emit) but read the geometry from Parley's run decoration, which carries no variant. Recover the variant and draw it. - loki-layout items.rs: `PositionedDecoration` gains a `DecorationStyle` (Solid / Double / Dotted / Dashed / Wave / Thick), orthogonal to `DecorationKind` (which says where the line sits). - para_emit: `span_underline` / `span_strike` look up the variant for the glyph run's text range from our spans (the info Parley dropped) and set it on the underline / strikethrough decoration; spelling squiggles carry `Wave`. - loki-vello decor.rs: `paint_decoration` strokes each style — double is two parallel lines, dotted/dashed use kurbo dash patterns (round caps for dots), wave reuses the squiggle path, thick doubles the width. To respect the 300-line ceiling the two span helpers live in para_emit (their only caller) and `DecorationStyle` is consumed via `loki_layout::items::` rather than growing the baselined lib.rs root re-export; para.rs's stale "renders as Single" comments are corrected. Tested: underline_variant_carries_to_decoration_style + double_strikethrough_carries_double_style (loki-layout) and paint_decoration_every_style_does_not_panic across both kinds and two zooms (loki-vello). loki-layout 194 lib + loki-vello 16 green, clippy -D warnings clean, fmt clean, ceiling holds. Plan 5.2 + fidelity-status updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
A paragraph that exhausts its explicit tab stops now advances on the document's configured default tab-stop grid instead of a hardcoded 36 pt. - Add `LayoutOptions::default_tab_stop_pt`; `layout_document` / `layout_paginated_full` fold it in from `doc.settings` via a new `effective_options` helper (caller-supplied value wins). - Add `ResolvedParaProps::default_tab_stop` (built-in 36 pt fallback); `flow_para` overrides it from the layout options. - `para_tabs::compute_tab_plans` / `next_tab_stop_resolved` take the grid as a parameter, guarding a non-positive interval. - Extract `LayoutOptions` / `SpellState` / `FieldContext` from `lib.rs` into a new `options.rs` module (frees the lib.rs ceiling; keeps public paths stable via re-export). - Tests: default-grid fallback, custom grid via options, and document-settings folding (`tab_stops_tests.rs`). - Update the deferred-features plan (5.1) and fidelity-status Tab Stops row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The spelling squiggle previously anchored to the selection-geometry line-box bottom, which includes leading below the glyphs, so with a generous line height it floated in the inter-line gap. `emit_spelling_squiggles` (para_underlays.rs) now precomputes each line's descender bottom (`baseline + descent`) from Parley `LineMetrics` and positions the wave band there — the run underline zone — so it hugs the text at any line height. Falls back to the line-box bottom if the line index is out of range. Tested by `spelling_squiggle_hugs_the_descender_not_the_line_box` (a 3× line height makes the old anchor a ~2-em drop; the new anchor stays within one em of the baseline). Plan 5.3 + fidelity-status updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
PDF export previously rendered a RotatedGroup (rotated table-cell text) axis-aligned at the group origin, dropping the rotation. It now emits a content-matrix transform so the text exports at its true angle, matching the on-screen loki-vello renderer. The PDF renderer bakes a per-leaf y-flip F into every leaf, so children are already in y-up space. New `page_rotate::rotated_group_ctm` builds the CTM `C = F·M·F`, where `M = T(pivot)·R(θ)·T(-pivot_local)` is the exact loki-vello rotation; rendering children under C with a zero offset places each child at `F(M·p_local)` — the screen placement flipped into PDF space. `page.rs` wraps it in `q cm … Q`. Tested: `page_rotate` matrix unit tests (θ=0 degenerate reproduces the old offset-and-flip, hand-computed 90° matrix, four-corner round-trip against the screen transform) and `rotated_group_emits_transform_and_children` (the content stream carries `cm` + the child fill inside save/restore). Plan 5.5 + fidelity-status "Clipping / rotation in PDF" updated to Yes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…ion (5.7) An ODF document expresses a section break implicitly: a paragraph whose style resolves a `style:master-page-name` different from the running master page begins a new section on a new page (there is no explicit break element). Import already detected these, but had a root-cause bug and no end-to-end coverage. - Fix: a *leading* master-page declaration (the first paragraph naming a non-default master) no longer emits a spurious empty preceding section — the running section is flushed only when blocks have accumulated. - Refactor: extract the body→sections partitioning loop from `document/mod.rs` into a cohesive `document/sections.rs` (`build_sections` + `flush_master_section`), keeping mod.rs under the file-size ceiling. - Each transitioned section carries the new master's page geometry and its `page_style` reference (registered as a named page style, ADR-0012 Decision 2); export already writes `style:master-page-name` per section, so the transition round-trips. - Remove the stale reader TODO; add `master_page_transition_splits_into_sections` and `leading_master_page_declaration_does_not_emit_empty_section` tests. Plan 5.7 + fidelity-status Section Breaks row updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The external-URL grey placeholder was already implemented; this closes the remaining half of 5.6 — detecting the floating class. An image tagged with `FLOATING_CLASS` but carrying no explicit wrap keys (e.g. an anchored DOCX `wp:anchor` whose wrap child was absent or unrecognised — the mapper still adds the class) was read as `None` by `FloatWrap::read` and laid out inline, not floating. - Add `FloatWrap::read_or_class_default`: falls back to a square/both-sides float when the attr is marked floating but has no wrap keys; returns None for a genuinely inline attr. - `resolve.rs` collects images via the new method, so such images now flow as side-wrapping floats through the existing `flow_float` path. - Refresh the stale `float` module doc (the engine does flow text around floats now) and drop the resolved `TODO(floating-image)`. Tested: `class_only_attr_reads_as_default_float`, `inline_attr_reads_or_class_default_is_none`, `explicit_wrap_wins_over_class_default` (doc-model) and `flatten_class_only_floating_image_is_collected_as_float` (layout). Plan 5.6 + fidelity-status Floating Images row updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Link URLs were already threaded onto glyph runs and painted with a blue hint underlay, but there was no way to resolve a point to its link — the layout half of "interactive hyperlink hit-testing". - `PageParagraphData::link_at` scans a paragraph's glyph runs (recursing into exact-line-height ClippedGroups) for one whose hint box — matching loki-vello's `paint_link_hint` (width = summed advances, y from 0.8·font_size above the baseline to 0.2 below) — contains the point, inverting any enclosing cell rotation via the existing `hit_local`. - `ContinuousLayout::link_at` (canvas coords) and `PageEditingData::link_at` (content-area-local, the paginated hit-test frame) delegate to it. - Refresh the `link_url` / renderer TODOs to point at the new API. Tested by 5 `result_tests` cases: a hit over a link, horizontal and vertical misses, plain-text None, rotation-inverted resolution, and the two layout wrappers. The editor's open-on-click gesture (modifier + URL opener) remains as app-side wiring. Plan 5.11 + fidelity-status Hyperlinks row updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Wires the interactive gesture on top of the layout `link_at` primitive. - The page tile reports the Ctrl/Cmd (Meta) modifier through `on_tile_click`, whose payload gains a `bool` (`(usize, f32, f32, bool)`); threaded through `DocumentView`/`view_types`. - On a modifier-click, the loki-text handler calls `hit_test::open_hyperlink_at` — which subtracts the page margins to reach the content-area-local frame, resolves the URL via `PageEditingData::link_at`, and opens it with the cross-platform `webbrowser` crate — then skips caret placement. A plain click still positions the caret, so hyperlink text stays editable. - Reflow-mode open-on-click is left as a small follow-up (`TODO(link-click-reflow)`); the blue-tint hint renders in both modes. Verified: loki-renderer + loki-text build and `cargo clippy -p loki-renderer -p loki-text -- -D warnings` are clean; fmt + file-ceiling green. The URL-open side effect is isolated in `open_hyperlink_at`; the resolution core (`link_at`) is covered by the layout tests. Plan 5.11 + fidelity-status updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…l+render)
`BulletChar::Image` was a dead unit variant that fell back to `•`. This makes
picture bullets first-class in the model and renders them.
- Model: `BulletChar::Image { src: String }` carries the bullet's image
reference (a `data:` URI or part path). Serde/Loro round-trip is automatic.
- Layout: since Parley cannot inline an image, the bullet is placed
out-of-band. Extracted the list-marker synthesis from `flow_para` into a new
`flow_list_marker` module; `synthesize` reports a picture bullet's `src` (and
emits no marker glyph text), and `picture_bullet_item` builds a square image
item sized to line 0, left-aligned in the hanging label box `[indent_start −
label_w, indent_start]`, injected into the paragraph's items so it translates
with the paragraph. (The extraction also dropped `flow_para` 741 → 710.)
- `format_list_marker` returns an empty label for an image bullet (was `•`);
the list inspector still labels it "Image bullet".
Tested: `picture_bullet_emits_image_in_the_hanging_label_box` (end-to-end flow:
the image carries the src, is a square within the label box, and sits one
hanging-indent left of the wrapped text) + `format_marker_picture_bullet_has_no_text`.
Import of the bullet image (OOXML `w:numPicBullet` / ODF
`text:list-level-style-image`) follows in the next commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Resolve `w:numPicBullet` / `w:lvlPicBulletId` into `BulletChar::Image`.
- Model: `DocxNumbering.pic_bullets` (`DocxNumPicBullet { id, rel_id, src }`)
and `DocxLevel.lvl_pic_bullet_id`, plus `pic_bullet_src`.
- Reader: parse `w:numPicBullet` (capturing the nested `v:imagedata @r:id` /
`a:blip @r:embed`) and `w:lvlPicBulletId`.
- Import: `import_pic_bullets::resolve` looks each bullet's relationship up in
the *separate* `word/numbering.xml.rels`, resolves the image part, and bakes
a `data:` URI onto the numbering model (gated by `embed_images`).
- Mapper: a level referencing a resolved picture bullet maps to
`BulletChar::Image { src }`; an unresolved reference falls back to the
`w:numFmt="bullet"` text char.
Held the ceilings by extracting `reader/numbering.rs`'s inline tests to
`reader/numbering_tests.rs` and the resolver to `docx/import_pic_bullets.rs`.
Tested: `parses_num_pic_bullet_and_lvl_reference` (reader),
`picture_bullet_level_maps_to_image_bullet` +
`picture_bullet_without_resolved_src_falls_back_to_text_bullet` (mapper).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Map `text:list-level-style-image` to `BulletChar::Image`.
- Model: `OdfListLevelKind::Image { href, style_name }`.
- Reader: parse `<text:list-level-style-image>` (both the container and
self-closing forms) via new `styles_list::parse_image_level` /
`image_level_empty` helpers, capturing `xlink:href`.
- Mapper: `map_list_styles` now takes the package image map and resolves the
href to a `data:` URI (`image_data_uri`, mirroring the frame-image path),
producing `BulletChar::Image { src }`; a missing image falls back to `•`.
Held the `reader/styles.rs` ceiling by extracting the image-level parsing to
`styles_list.rs` (the arms are now one-liners).
Tested: `read_stylesheet_list_style_image_level` (reader),
`picture_bullet_resolves_to_image_data_uri` +
`picture_bullet_missing_image_falls_back_to_dot` (mapper).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
An `evenPage`/`oddPage` section break must start the section on the next even/odd page; previously these behaved like `nextPage`, so a section could begin on the wrong parity. - New `paginate_blanks` module: `needs_blank_before` decides (from the section start type and the running page count) whether a filler is required, and `blank_page` builds a content-free page carrying the section's geometry (no header/footer, no editing data). - `layout_paginated_full` consults it per group in Pass 1 (counting the filler so the section's own pages — and NUMPAGES — number correctly) and emits the filler ahead of the section's pages in Pass 2. The document's first section never gets a filler (its break type is immaterial). Tested: `paginate_blanks` unit tests (parity for even/odd/newpage/continuous, first-section guard, blank-page emptiness) + `odd_page_section_inserts_a_blank_ filler_when_parity_is_wrong` (end-to-end: an oddPage section after one page yields a 3-page layout with an empty middle page; the evenPage control stays 2). Plan 5.10 + fidelity-status Section Breaks row updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
EPUB export dropped `Inline::Math`; EPUB 3.3 renders MathML natively, so the model's namespaced `<math>` element is now written verbatim into the content XHTML (as markup, not escaped). - `render_inline` emits the MathML string and sets a new `RenderCtx.has_math` flag; `RenderedContent` carries it out. - `build_package_opf` adds `properties="mathml"` to the content document's manifest item when the content embeds math (EPUB 3.3 §5.4 conformance). Extracted `content.rs`'s inline tests to `content_tests.rs` to hold the 300-line ceiling. Tested: `math_is_emitted_as_mathml_and_flags_the_document` (body carries the verbatim MathML + the flag) and `content_item_declares_mathml_when_present` (the manifest property). Plan 5.10 + fidelity-status EPUB content row updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Per-column widths for multi-column sections (DOCX w:cols w:equalWidth="0" and ODF style:column) are now modelled and rendered at their true widths instead of collapsing to an equal split. - Model: SectionColumns.widths (Vec<Points>); empty = equal columns. - Loro bridge: widths round-trip as a ;-joined string (KEY_COL_WIDTHS), encoded via decode::encode_col_widths, split back on read. - Layout: column_layout_for resolves per-column widths (equal-split fallback); flow places each band at the cumulative width+gap offset (column_x_offset / break_column / emit_column_separators). Moved column_layout_for into flow_columns. - OOXML: reader captures w:col @w:w (twips); parse_sect_pr extracted to reader/sectpr.rs to hold the file ceiling. Mapper twips->points via new mapper/document_cols.rs. Writer emits w:equalWidth="0" + per-col <w:col w:w>. - ODF: OdfColumns.rel_widths from style:column @Style:rel-width; mapper distributes the page content area proportionally; writer re-emits rel-width shares (ratio-preserving). - page_form column editor preserves widths only when count is unchanged. Tests: DOCX + Loro-bridge exact-width round-trips, ODF ratio round-trip, and a layout-geometry test placing the second band past the equal split. Docs: fidelity-status (Section Breaks + Multi-column Sections) and the deferred-features plan updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The EPUB exporter previously dropped Inline::Field and Inline::Comment on the no-op arm. Both now render: - Fields resolve to static text (a reflowable EPUB has no live page/date context): the current_value import snapshot, else a metadata-backed value for Title/Author/Subject, wrapped in <span class="field">. Page/date/reference fields with no snapshot render nothing. A FieldEnv captured from DocumentMeta backs the metadata lookups. - A commented range emits an inline superscript ref marker at its start anchor (<sup class="comment-ref"><a epub:type="noteref">), and each referenced comment becomes an <aside epub:type="annotation"> (id, "[n] author — date" byline, body paragraphs) collected in a trailing <section epub:type="annotations">. The end anchor and unknown ids render nothing; markers are numbered in first-reference order. RenderCtx gains field_env, comments, and comment_seq (built in render_content from the document, mirroring the ODT Cx comment map). Tested: current_value snapshot, metadata-backed Title, unresolvable field renders nothing, and comment marker+aside with end-anchor de-duplication. Docs: fidelity-status EPUB content row updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The PDF exporter embedded the full font program for every used face. It now embeds a glyph subset (via the subsetter crate), typically shrinking the font streams by orders of magnitude. - subset_program reduces the face to its used glyphs; the subsetter renumbers glyphs to a contiguous range and returns the remapper. - Because the subsetter renumbers, the content stream (which addresses glyphs by their original id under Identity-H) is left untouched: a CIDToGIDMap stream remaps each original glyph id (used as the CID) to the subset's new gid. /W widths and ToUnicode stay keyed by CID, so no other code changes. - The BaseFont gains the mandatory six-letter subset tag (ABCDEF+…), derived deterministically from the used-glyph set. - Metrics are read from the original face (the subset strips cmap/OS/2). - A face the subsetter rejects (e.g. CFF2) falls back to the full program under an un-tagged name, so output is always valid and fully embedded. Tests: fonts_subset unit tests (tag shape/determinism, CIDToGIDMap remapping) + the integration test now asserts the subset tag and CIDToGIDMap are present. Docs: fidelity-status Font embedding row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
A standalone multi-column section that fits on one page (its own last page) now has its columns balanced to roughly equal heights instead of packing column 1 to the page bottom while column 2 sits empty — matching Word. - flow_balance.rs re-flows the section with the per-page content height capped to the smallest value that still fits every column on one page, found by a bounded binary search (feasibility is monotonic in the cap). Capping only the balancing re-flow leaves flow_para untouched. - Gated to single-page, footnote-free sections so footnote position and page-bottom content are never displaced. Multi-page, continuous-group, and footnote-bearing sections keep fill-first (TODO(column-balance- multipage)). - flow_section's paginated branch was extracted into flow_balance (a child module, so it reaches flow's private helpers), which also shrank flow.rs below its baseline. - Editing a multi-column section falls back to a full (balanced) relayout instead of the fill-first incremental resume (incremental.rs guard). To make room there, the note-detection cluster moved to incremental_notes.rs. Baseline ratcheted down for the files that shrank this session. Tested: short_multi_column_section_balances_across_columns; the full loki-layout suite (incl. the incremental==full property test) stays green. Docs: fidelity-status Multi-column Sections + deferred-features plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The PDF/X OutputIntent already embeds a CMYK ICC profile when one is supplied; the gap was ergonomics and an unclear conformance story. - Add OutputIntent::with_icc_profile builder for the integrator-supplied DestOutputProfile path. - Clarify in docs that strict PDF/X-3 / X-4 conformance requires the embedded profile, and that the default (no bundled CMYK profile) yields a valid X-1a-style intent referencing the registered condition only. - Record TODO(pdf-icc-default-profile): bundling a CMYK ICC profile is a licensing/asset decision; a build that bundles an approved ECI/FOGRA profile can default icc_profile to it. Tests: with_icc_profile builder; an integration test confirms a supplied profile is embedded and referenced as the DestOutputProfile, and that the default embeds none. Docs: fidelity-status CMYK/OutputIntent row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The PDF/X OutputIntent now embeds a CMYK DestOutputProfile out of the box, resolving TODO(pdf-icc-default-profile). The bundled profile is CGATS001Compat-v2-micro.icc from saucecontrol/Compact-ICC-Profiles, released under CC0 1.0 (public domain) — compatible with this crate's Apache-2.0 license. It characterises CGATS TR 001-1995 (U.S. SWOP coated web); data color space CMYK, with the desc/cprt/wtpt/A2B0 tags (A2B0 is the direction an output intent needs to characterise the DeviceCMYK content this crate emits). - assets/CGATS001Compat-v2-micro.icc (verbatim, MD5 profile id intact), assets/CC0-1.0.txt (license text), assets/README.md (provenance/scope). - OutputIntent::default() now embeds it via include_bytes! and sets the condition to CGATS TR 001; with_icc_profile overrides it for a certified press condition. Tests updated to the new default (default_intent_embeds_bundled_cmyk_profile asserts a valid CMYK 'acsp' profile; the export test now asserts the default carries a DestOutputProfile). Docs: fidelity-status + plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Widow/orphan control was modelled and round-tripped but ignored at layout time. The flow engine now avoids stranding a lone line when a paragraph splits across a page/column boundary, matching Word/LibreOffice (default 2 lines, on): - Orphan: a would-be lone first line at a page bottom defers the whole paragraph to the next page. - Widow: a would-be lone last line atop the next page pulls a line down so the tail keeps >= widow_min lines. ResolvedParaProps gains orphan_min/widow_min, resolved in para_props_map (OOXML's single w:widowControl governs both; ODF fo:orphans/fo:widows set each; explicit 0 disables). The decision is a pure resolver (flow_widow_orphan::resolve_split) applied in split_and_place_loop, guarded against the degenerate page-top case so pagination always terminates. Tested: 8 unit tests over the resolver (widow pull-down, orphan defer, page-top no-defer, widow-creates-orphan, disabled, continuation, empty) + an end-to-end orphan-defer test using exact line heights. Full loki-layout (217 lib + integration), loki-text, and loki-acid suites stay green. Docs: fidelity-status Widow/Orphan row + deferred-features 5.9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
A block-level w:sdt (structured document tag / content control) was skipped wholesale at import (skip_element), silently dropping every paragraph and table inside it — real data loss for the many Word documents whose cover pages, forms, and bound placeholders sit in content controls. The reader now unwraps the control: reader/document_sdt.rs descends into w:sdtContent and appends its children (paragraphs, tables, nested controls) to the surrounding body, so the content survives. The control's chrome (binding/placeholder/tag in w:sdtPr) is discarded — Loki has no content-control model. The now-dead DocxBodyChild::Sdt placeholder variant (and its two no-op mapper arms) is removed. Tested: block_sdt_content_is_unwrapped_into_the_body (reader, with a nested control + table) and the existing conformance test, updated from asserting the old drop-on-import to asserting the content is unwrapped (ooxml8_sdt_content_between_paragraphs_is_unwrapped). Full loki-ooxml suite green. Docs: fidelity-status Content Controls row + deferred-features 5.9. Tail (documented): w:sdt inside a table cell, inline (run-level) w:sdt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
CI's stable toolchain (1.97) flags the redundant `&` in
`format!("{} ", &footnote_mark(..))` under
`-D clippy::useless_borrows_in_formatting` (a lint absent from the local
1.94 toolchain, so the branch's first CI run surfaced this pre-existing
line). Remove the borrow — `format!` borrows its arguments already.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The branch's first CI run (stable 1.97, vs the older local 1.94) surfaced issues the local toolchain and my narrower local checks didn't catch. CI runs `cargo clippy --workspace --all-features -D warnings -D clippy::unwrap_used -D clippy::expect_used` and `cargo test --workspace --all-features`; reproduced both locally on 1.97. lint (clippy): - useless_borrows_in_formatting: redundant `&` in a footnote `format!` (loki-layout/flow.rs). - question_mark: manual Option unwrap in `parse_length` (loki-odf). - expect_used: infallible `.expect()` in `panel_data` reworked to `filter_map` (loki-text) — no panic in library code. build-and-test (test compile): - PageParagraphData literal in a loki-renderer test missed the `rotation` field added by the rotated-cell work. Verified with a `--keep-going` clippy sweep and a full `cargo test --all-features --no-run` on 1.97. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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 PR splits several large modules across the codebase to comply with the 300-line file ceiling convention. The changes are primarily structural refactoring—extracting cohesive functionality into dedicated submodules without altering behavior or APIs.
Key Changes
Layout Engine (
loki-layout)flow.rs: Extracted table and column logic into dedicated submodules:flow_balance.rs— column-height balancing for multi-column layoutsflow_table_cells.rs— per-cell block flowing and paddingflow_table_geom.rs— cell-height measurement and grid-column assignmentflow_table_paint.rs— row-height measurement and cell decoration emissionflow_list_marker.rs— list marker layoutflow_page_fields.rs— PAGE/NUMPAGES field detection and substitutionpara.rs: Extracted paragraph-level utilities:para_tabs.rs— tab-stop resolution and leader fillspara_query.rs— read-only geometry queries (hit-test, line-end offset)para_props_map.rs— model property mappingpara_underlays.rs— selection-geometry underlayspara_emit.rs— glyph run emission (expanded with rotation support)New modules:
options.rs,result_rotation.rs,flow_widow_orphan.rs,incremental_notes.rs,revision_style.rs,paginate_blanks.rs,table_shading.rsDocument Model (
loki-doc-model)document.rs: Extracteddocument_paper.rsfor paper-size utilitiesstyle/: Added focused modules:page_style.rs— named page style definitionstable_banding.rs— pure table-style conditional formatting resolverresolve_table.rs— table-specific style resolutionloro_mutation/: Extracted mutation operations into dedicated modules:page.rs— page-layout mutations (orientation)page_style.rs— per-page-style geometry editstable_ops.rs— structural table mutations (insert/delete rows/columns)para_mark.rs— paragraph-mark tracked deletionrevision.rs— tracked-change CRDT mutationsalign.rs— paragraph alignment mutationstext_containers.rs— text container mutationsblock_edit.rs— block-level editscontent/: Added:revision_ops.rs— accept/reject of tracked changestoc.rs— table-of-contents handlingpara_mark_merge.rs— paragraph-mark merge logicODF/ODT (
loki-odf)odt/reader/document.rs: Extracted frame and table parsing:document_frame.rs—draw:frameand frame-kind parsingdocument_table.rs— table parsingodt/mapper/: Extracted style and document mapping:styles_tests.rs— stylesheet mapper testsdocument/sections.rs— section mappingdocument/page.rs— page-style mapping (expanded)odt/write/: Added page-style and table-style writers:page_styles.rs— page-style serializationrevisions.rs— tracked-change serializationtable_style.rs— table-style serializationOOXML/DOCX (
loki-ooxml)docx/mapper/: Extracted property and document mapping:props_rpr.rs— run-property mappingdocument_cols.rs— column mappinghttps://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL