Skip to content

test(loki-layout): pin list hanging-indent & table fixed-width geometry#23

Merged
kevincarlson merged 35 commits into
mainfrom
claude/docx-odt-rendering-tests-m4lkag
Jun 27, 2026
Merged

test(loki-layout): pin list hanging-indent & table fixed-width geometry#23
kevincarlson merged 35 commits into
mainfrom
claude/docx-odt-rendering-tests-m4lkag

Conversation

@kevincarlson

Copy link
Copy Markdown
Member

Add GPU-free layout-geometry assertions for two DOCX/ODT fidelity areas
that the page-count + glyph-coverage canaries cannot see:

  • hanging_indent_tests.rs: assert wrapped list continuation lines align
    under the text start (one hanging indent past the marker), for both
    bullet and numbered lists. Locks the Word/LibreOffice contract against
    regression. Confirms the flow engine's continuation-line alignment is
    correct in the catalog-driven (imported) path.

  • table_tests.rs: characterize column-width resolution when explicit
    fixed widths over/underflow the table width (Loki rescales; documents
    the divergence from Word's fixed-layout honour-and-overflow). Adds an
    ignored target-spec test (fixed_columns_should_be_honored_like_word)
    that encodes the Word behaviour, unblocked once w:tblLayout is plumbed.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7

claude added 30 commits June 24, 2026 05:25
Add GPU-free layout-geometry assertions for two DOCX/ODT fidelity areas
that the page-count + glyph-coverage canaries cannot see:

- hanging_indent_tests.rs: assert wrapped list continuation lines align
  under the text start (one hanging indent past the marker), for both
  bullet and numbered lists. Locks the Word/LibreOffice contract against
  regression. Confirms the flow engine's continuation-line alignment is
  correct in the catalog-driven (imported) path.

- table_tests.rs: characterize column-width resolution when explicit
  fixed widths over/underflow the table width (Loki rescales; documents
  the divergence from Word's fixed-layout honour-and-overflow). Adds an
  ignored target-spec test (fixed_columns_should_be_honored_like_word)
  that encodes the Word behaviour, unblocked once w:tblLayout is plumbed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Capture two previously-dropped constructs at import into typed model
fields — the honest first layer before any layout/rendering work.

Model (loki-doc-model):
- DropCap / DropCapLength (style/props/drop_cap.rs) + ParaProps.drop_cap.
- FloatWrap / TextWrap / WrapSide (content/float.rs) with a typed
  NodeAttr (de)serialization API (store/read) over reserved kv keys,
  consistent with the existing `floating` class convention.

DOCX (loki-ooxml):
- Parse w:framePr (dropCap/lines/hSpace) → DropCap; "drop" vs "margin".
- Parse wp:wrapSquare/Tight/Through/TopAndBottom/None (+ wrapText side,
  anchor behindDoc) on drawings → FloatWrap on the image NodeAttr.

ODT (loki-odf):
- Parse style:drop-cap (lines/length/distance) in paragraph-properties
  → DropCap (length supports "word" or a char count).
- Parse style:graphic-properties style:wrap/style:run-through on the
  frame's graphic style → FloatWrap on the floating figure.

Neither is rendered yet: the initial is still laid out inline and the
flow engine has no exclusion zones for floats (gap #12). Documented in
docs/fidelity-status.md (new Drop Cap and Floating Images rows).

Tests: model round-trips (float/drop_cap), DOCX mapper (frame_pr_*,
anchor_drawing_carries_wrap_mode), ODT reader+mapper
(read_stylesheet_drop_cap/graphic_wrap, drop_cap_*). All green;
clippy --workspace -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Renders a DOCX fixture through Loki's own pipeline (import → loki-layout
paginate → loki-pdf export) to a PDF, so its output can be diffed against
a canonical Word/LibreOffice PDF render. GPU-free; reuses the same layout
engine as the editor. Used to evaluate acid_docx fidelity against a
Word-for-Android reference render.

  cargo run -p loki-acid --example render_acid_pdf -- <in.docx> <out.pdf>

Adds loki-pdf as a dev-dependency of loki-acid (examples only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
The PDF exporter drew every glyph including id 0 (.notdef), so any glyph
the layout shapes as .notdef rendered as a visible tofu box — most
conspicuously the glyph Parley shapes for tab characters (\t), which
appeared in tab stops, tab leaders, and list markers (whose synthesized
text is "<marker>\t"). The on-screen loki-vello renderer already filters
id 0 (glyph.rs); loki-pdf did not, so the two renderers disagreed.

render_run now filters id 0 before registering the face and drawing,
mirroring loki-vello exactly. The tab's advance is unaffected (it comes
from the layout's tab-stop inline box, not the .notdef glyph), so only
the spurious ink is removed.

Verified against the Word-for-Android acid_docx reference: tofu boxes are
gone from the tab-stop and list pages. This unmasks two pre-existing,
separate bugs (documented, not fixed here): list-marker numbers are
shaped in a font without digit coverage (so they were tofu, now absent),
and tab alignment types (right/center/decimal) + leaders are unimplemented.

Tests: notdef_only_run_emits_nothing, notdef_is_filtered_from_real_run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
…atforms

List-item markers (and any Calibri/Arial/Times/Cambria text) rendered with
missing glyphs — numbers came out as .notdef — because the metric-compatible
fallback faces (Carlito/Caladea/Arimo/Cousine/Tinos) were registered only on
`target_os = "android"`. On desktop the engine relied on the installer placing
them under `assets/fonts/`, which is absent in headless/CI/PDF-export. So
`resolve_font_name("Calibri")` found no Carlito, returned "Calibri", and Parley
fell back to a wider font that lacked digit glyphs.

`FontResources` now registers the embedded fallback faces *lazily* — only when a
substitute family is requested but not already present in the collection — so a
fully-installed desktop never pays for them, while headless export, CI, the ACID
harness, and Android resolve substitutes correctly. `loki-fonts` exposes the raw
face bytes (`fallback_font_blobs`) on all targets; the `@font-face` CSS builder
stays Android-CPU-only.

Verified against the Word acid_docx reference: list markers now render
1./1.1/2.0.1 exactly like Word. (The Wingdings bullet glyphs remain blank —
no open-source Symbol/Wingdings substitute exists.)

Note: default-font *body* text still imports with font_name=None because
`loki-ooxml` does not yet apply `w:docDefaults`; that residual page-drift
(TC-DOCX-027) is documented and tracked separately.

Tests: substituted_family_is_actually_available,
fallback_font_blobs_embedded_on_all_targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
A paragraph with no explicit style (`w:pStyle`) resolved to `CharProps`/
`ParaProps::default()`, bypassing the `__DocDefault` → `Normal` chain that
carries the document base font (`w:docDefaults`). So default-font body text
imported with `font_name = None` and rendered in the engine's default face —
a wide fallback rather than the document's Calibri (→ Carlito) — diverging
from Word and inflating pagination.

- `StyleCatalog` gains `default_paragraph_style` + `effective_paragraph_style`:
  the style a bare paragraph inherits from. The DOCX mapper sets it to the
  `w:default="1"` paragraph style (else the canonical/synthesized `Normal`),
  which roots at `__DocDefault`.
- `loki-layout` `resolve_para_props` / `flatten_paragraph` use
  `effective_paragraph_style` so a `style_id = None` paragraph resolves the
  document default for both paragraph and character properties.

Verified against the Word acid_docx render: body text now lays out in Carlito
(metric-compatible Calibri) instead of the wide DejaVu fallback. (ODT's
`style:default-style` is not yet wired into this field — separate follow-up.)

Tests: default_paragraph_style_resolves_doc_default_font /
explicit_default_paragraph_style_is_preferred (loki-ooxml),
effective_paragraph_style_falls_back_to_default (loki-doc-model).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Tab stops only ever advanced content to the next stop (left-align), and
`ResolvedTabStop` dropped the stop's alignment and leader during resolution.
So right-aligned tabs pushed content off the line (it wrapped), decimal tabs
left numbers un-aligned, and dot/dash leaders were never drawn — TOC lines and
number columns diverged badly from Word.

- `ResolvedTabStop` now carries `alignment` + `leader` (populated in resolve.rs).
- The two-pass tab expansion measures the content following each tab using
  zero-width probe inline boxes — plus a decimal-marker box per tab and an
  end-of-text sentinel — then sizes the final box so the content lands per the
  stop's alignment: Left advances to the stop, Right ends at it, Center centres
  on it, Decimal puts the first `.` at it. Tabs are processed left-to-right,
  accumulating the shift so later stops resolve against the shifted pen.
- Leaders (dot/dash/underscore/heavy/middle-dot) are drawn across the gap as
  renderer-agnostic FilledRects.

Verified against the Word-for-Windows acid_docx reference: the TC-008/009 page
now matches — "Chapter One ....... 12" right-aligned with a dot leader, and the
1234.5 / 7.89 / 42.0 column aligned on the decimal point.

Tests: tab_stops_tests.rs (right/center/decimal geometry, dot-leader fill, and a
left-tab regression guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
A long unbreakable word in a narrow fixed-width cell overflowed horizontally,
spilling its text across into the neighbouring cell, because cell paragraphs
used the default no-break wrapping. Word breaks such a word to the column width
(the row grows tall instead).

Cell content now lays out with CSS `overflow-wrap: anywhere`: `ResolvedParaProps`
gains `break_long_words`, threaded from a new `FlowState::break_long_words` that
the flow engine sets while flowing cell blocks (and the cell-height measuring /
rotated-cell paths). `push_para_styles` pushes `OverflowWrap::Anywhere` only when
the flag is set, so normal body paragraphs are unaffected. The cache key already
folds in `ResolvedParaProps`, so cell and body layouts stay distinct.

Verified against the Word acid_docx reference: the TC-006 narrow column now
character-wraps the long word exactly like Word (Narrowc/olumnsh/ouldnot/…), the
row grows tall, and nothing spills into the wide cell.

Tests: long_word_wraps_within_narrow_cell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Combined vertical-merge + gridSpan tables (L-merges, TC-DOCX-005) rendered
wrong: a cell in a row whose leading column was occupied by a `row_span`
(vMerge) cell from above was placed too far left, overlapping the merged cell,
because every table pass recomputed each cell's grid column independently from
column 0 — ignoring coverage from rows above.

`flow_table` now computes the cell→grid-column assignment once via
`assign_cell_columns`, which walks the grid top-to-bottom marking the columns a
`row_span > 1` cell covers in the rows it spans; later cells skip covered
columns. All four passes (height measure, span distribution, content flow,
background/border) use this shared `(col_start, col_end)` instead of a local
per-row counter.

Verified against the Word acid_docx reference: the TC-003/004/005 table now
matches — "A merged" spans rows 2-3 and the "B3+C3 gridSpan=2" cell sits under
columns B-C (not at the left edge).

Tests: vmerge_gridspan_l_merge_places_cells_correctly; updated
test_table_row_span_distribution to assert the spanned-column placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Word's fixed table layout keeps grid (gridCol) column widths exactly,
letting the table over/underflow the declared width, whereas autofit
rescales them to fit. Loki previously always rescaled.

- Parse `w:tblLayout @w:type` into `DocxTblPr::layout` (reader).
- Carry "fixed" into the model as the `table-fixed-layout` class
  (`TABLE_FIXED_LAYOUT_CLASS`) on the table's `NodeAttr` (mapper).
- `resolve_column_widths` skips the autofit rescale when the class is
  present, honouring fixed grid widths exactly (layout).

Verified end-to-end on ACID TC-DOCX-006: the narrow fixed column stays
narrow (its long unbreakable word char-wraps within it) instead of
growing to fit. Tests: fixed_columns_should_be_honored_like_word,
tbl_layout_fixed_marks_table_class, tbl_layout_autofit_has_no_fixed_class.

Also fixes a pre-existing clippy field_reassign_with_default in
loki-doc-model section.rs test code that blocked the --all-targets gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Word clips a table cell's content to the cell boundary; over-wide
content (a wide image, or an unbreakable token that exceeds a fixed
column) is masked rather than bleeding into the neighbouring cell.
Loki previously over-painted — char-wrapping kept ordinary text in
bounds, but nothing constrained the rest.

- Layout: each single-page cell's flowed content is wrapped in a
  `PositionedItem::ClippedGroup` whose rect is the cell box (`flow.rs`).
  Backgrounds/borders are emitted outside the clip so they paint fully.
- PDF: `ClippedGroup` now emits a real clip path (`re W n` inside
  `q`/`Q`) instead of the previous over-paint TODO (`page.rs`).
  `loki-vello` already pushed a Vello clip layer for these groups.

Continuation pages of a cell that spills across a page boundary are not
yet clipped (single-page cells only); documented as a known gap.

Tests: cell_content_is_clipped_to_cell_box (layout),
clipped_group_emits_clip_operators (PDF). Existing table/flow tests that
scanned top-level glyph runs now recurse into clip/rotation groups via
shared `flatten`/`any_glyph_run` helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Drop caps were imported but rendered inline at body size. They now
render on the read-only/paint path (PDF/print/canvas): the leading
initial is enlarged to span `lines` rows and the body text wraps to its
right, matching Word (ACID TC-DOCX-015).

- `para_drop_cap.rs` (new): extract the initial, shape it enlarged so its
  ascent spans the line band (coloured/weighted from the initial's run),
  and report the body inset + cap glyph items.
- `layout_paragraph` (para.rs): when a paragraph carries a drop cap and
  qualifies (read-only, no tabs/math), trim the initial from the body,
  re-break the body at the narrowed width, shift the first `n_lines`
  lines into the cap band, and emit the enlarged initial.
- `drop_cap_merge.rs` (new, DOCX import): Word encodes a drop cap as a
  `w:framePr` frame paragraph (just the initial) followed by the body
  paragraph. This pass folds the frame into the body so the
  single-paragraph (ODF-style) renderer applies to both formats.

Limitations (documented in fidelity-status):
- Editor path (`preserve_for_editing`) keeps the initial inline so
  hit-test indices stay aligned — follow-up.
- APPROX(drop-cap-width): Parley exposes no public per-line max-advance
  (the setter is on its private breaker state), so body lines below the
  cap wrap at the narrowed width rather than reclaiming full measure.

Tests: cap_byte_len_*, trim/merge unit tests, and layout integration
tests (drop_cap_enlarges_initial_and_shifts_first_lines,
drop_cap_inline_when_preserving_for_editing). Verified visually against
the Word reference on ACID page 9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Floating (anchored) drawings were imported with their wrap mode but
never laid out — all images stacked as full-width blocks above the
text, and floats weren't placed beside text. They now wrap: text flows
beside the float and the image is painted in the reserved band,
matching the Word reference on ACID TC-DOCX-023.

- `flow_float.rs` (new): plan a side band for a Square/Tight/Through (and
  non-behind None) float — float on the left when text occupies the
  right and vice-versa; Both/Largest default to a left float. Reserves
  the band via paragraph start/end indent and emits the image item.
- `flow_para.rs`: plan the float before paragraph layout, widen the
  indent so the text wraps, remove it from the block-stacked set, then
  paint it and reserve its height so the next paragraph clears it.
- `CollectedImage` now carries the `FloatWrap` read from the image's
  NodeAttr (`resolve.rs`).

A margin-anchored `wrapNone` image that is not behind the text is
treated as a side wrap because Word reserves its space (the ACID
reference flows text beside, not under, it).

Limitations (documented in fidelity-status, gap #12): v1 wraps only the
float's anchoring paragraph and narrows every line of it uniformly (same
Parley per-line-width limit as drop caps); cross-paragraph wrap, the
tight contour, behindDoc overlap, and absolute anchor positioning are
not yet done. TopAndBottom / behind-text floats still stack as blocks.

Tests: flow_float unit tests (side selection, oversized skip, behind/
top-bottom skip, wrapNone-front wrap). Verified visually on ACID page 10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Pull the per-run glyph emission (highlight underlay, hard-shadow copy,
main glyph run, underline/strikethrough) out of layout_paragraph's main
loop into `para_emit::emit_glyph_run`, and make the span lookup helpers
pub(crate). No behaviour change — the main loop now calls the helper.

This prepares the banded (drop-cap / float) layout path to reuse the
exact same emission for its two sub-layouts (per-line precision), instead
of duplicating it. para.rs shrinks; the helper is 171 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Both features previously narrowed *every* line of the affected paragraph
because Parley exposes no public per-line max-advance (the setter is on
its private breaker state). Lines below the cap/float stayed narrowed
instead of reclaiming the full column as Word does.

New `para_band` lays the body out in two passes: the lines overlapping
the band (cap / float height) are taken from a narrow layout (shifted
right for a left-side object), and the remaining text is re-flowed at
full width and stacked below. Glyph emission is shared with the main
loop via `para_emit::emit_glyph_run`.

- `ResolvedParaProps::wrap_band` carries a float band from the flow
  engine (`flow_para` sets it instead of adjusting the indent).
- `layout_paragraph` builds a unified band from the drop cap or the
  float and routes plain-text read-only paragraphs through the split;
  the editor / tab / math paths keep the uniform-narrow fallback (and
  their Parley layout for hit-testing — the split never runs there).
- `prepend_para_box` factors the shared border/background emission.

Verified against the Word reference: ACID page 9 (drop cap) — the 4th
line below the 3-line cap reclaims full width; ACID page 10 (float) —
text below the image returns to the left margin at full width.

Tests: para_band unit tests (left/right band, short body), strengthened
drop_cap_enlarges_initial_and_shifts_first_lines to assert the tail
reclaims x ≈ 0. All layout/ooxml/pdf/vello suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
A float taller than its anchoring paragraph now keeps wrapping the
*following* paragraphs beside its remaining vertical extent, instead of
those paragraphs starting below the float. This matches Word, where a
tall image flows the next paragraphs around it until its bottom edge.

- `flow::float_impl::ActiveFloat` records a float's page-relative bottom,
  band inset, and side on `FlowState::active_float` when the float
  overhangs its anchor.
- `flow_paragraph` applies the remaining band as a `WrapBand` on each
  following paragraph that starts above the float bottom (`para_band`
  then narrows just the overlapping lines), and clears the float once a
  paragraph reaches its bottom. The old `max(height, float_height)`
  reservation is dropped so the next paragraph can start within the band.
- `reserve_active_float` reserves any unused float tail (advances the
  cursor to the float bottom) when the wrap can't continue: a
  non-paragraph block (table/list/rule), the end of the block list, or a
  page boundary. Wrap is bounded to a single page.

Verified: new flow test `tall_float_wraps_following_paragraph` (short
anchor + 1-inch float + long follower) asserts the follower's lines
beside the float are shifted and the lines below reclaim full width;
ACID page 10 (float taller-than-text anchor) is byte-for-byte unchanged.
All layout/ooxml/pdf suites green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Previously the dropped initial only rendered on the read-only paint path;
in the editor (`preserve_for_editing`) it stayed inline at body size,
because trimming the cap from the body text would desync hit-test
indices. The editor now shows the same enlarged initial as print.

The read-only path keeps the precise two-pass band split (`para_band`,
no single hit-test layout). The editor instead lays the body out as ONE
uniform-narrow Parley layout it can hit-test against:

- The cap bytes are trimmed from `clean_text` and the orig↔clean maps
  are rebased past them, so body-layout offsets map back to the correct
  original bytes for cursor/click/selection.
- `ParagraphLayout` gains `drop_lines` / `drop_shift`; `line_indent`
  adds the shift to the leading lines beside the cap so the caret,
  hit-test, and selection geometry line up with the rendered glyphs.
- The fallback path emits the enlarged initial and shifts only the first
  `n_lines` (replacing the prior uniform float-band shift, which now also
  shifts just the lines beside the band — more correct for both).

Limitations (documented): the editor body uses the uniform-narrow
fallback (lines below the cap are slightly narrow vs. the read-only
per-line split), and cursor granularity around the cap is coarse — the
cap collapses to the body-start caret, so it is deletable (Backspace from
body start) but not independently selectable.

Tests: `drop_cap_enlarged_and_hit_testable_in_editor` replaces the old
inline-only test (asserts enlarged glyph + retained layout + first-line
caret shifted + body click maps past the cap). Read-only ACID page 9 is
byte-for-byte unchanged; all layout/ooxml/odf/pdf/vello suites green;
workspace clippy gate clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Extend the render_acid_pdf example to dispatch on file extension so it
can render .odt fixtures (via OdtImport) as well as .docx, enabling
visual spot-checks of the ODT layout path.

Spot-check findings (docs/fidelity-status.md updated):
- TC-ODT-013 (drop cap): renders identically to the DOCX path through
  the shared para_band renderer — the enlarged initial spans three
  lines, the body wraps to its right, and the tail lines reclaim the
  full column width (item-#1 per-line precision confirmed on ODT).
- TC-ODT-007 (frames + wrap): the acid_odt.odt fixture contains no
  draw:frame/draw:image/style:wrap content, so the ODT import→float
  wiring is unit-tested but cannot be visually verified through ACID;
  the float layout itself is renderer-shared with the verified DOCX
  path (TC-DOCX-023).

No production code change — example tooling + docs only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
The ODT float path had two gaps that meant a `draw:frame` image never
wrapped text and, in fact, never rendered:

1. The frame's `svg:width`/`svg:height` were parsed but never carried to
   the image, so the layout engine saw a zero-sized image and skipped it
   (and `plan_float` could not reserve a wrap band). `map_frame` now sets
   `cx_emu`/`cy_emu` on the image attr (1 pt = 12700 EMU) for every image
   frame (as-char and floating alike).
2. A floating image frame was emitted as a separate `Block::Figure` that
   the flow engine block-stacks, so the paragraph-level `plan_float`
   (which reads paragraph inlines, like the DOCX path) never saw it.
   A non-`as-char` image frame with a `style:wrap` graphic style is now
   emitted as a floating inline image (wrap + `FLOATING_CLASS`) in its
   anchoring paragraph — the same representation as DOCX — so text wraps
   beside it via the shared `para_band` renderer. Wrap-less floating
   frames and text boxes/objects keep the block-figure path.

Fixture: `acid_odt.odt` gains the TC-ODT-007 case — a 1in float
(`Pictures/float.png`) with a `style:wrap="right"` graphic style and
wrapping body text, plus the `svg`/`xlink` namespace declarations and a
manifest entry. The example's ODT support (added earlier) renders it.

Verified: image floats at the left margin, text wraps to its right for
the lines beside it and reclaims full width below the frame — matching
the DOCX TC-DOCX-023 reference. New mapper test
`floating_image_frame_becomes_inline_float_with_size`; all
loki-odf/loki-acid/loki-layout suites green; clippy gate clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
`tests/helpers.rs` is included via `mod helpers;` by six separate
integration-test binaries; each flags the helpers it doesn't personally
call as dead even though another binary uses them. Add a module-level
`#![allow(dead_code)]` (with an explanatory comment) — the canonical fix
for shared integration-test modules. Every helper is confirmed used by
at least one binary, so nothing is actually removed.

Silencing that unmasked a handful of pre-existing pedantic lints in
test-only code (the lib's cast error had previously made clippy bail
before reaching the test targets). All mechanical, no behaviour change:
- round_trip.rs: drop an unused `NodeAttr` import.
- document.rs test helper: elide redundant `'a` lifetimes.
- props/tests.rs: method-reference closures (`LanguageTag::as_str`),
  hoist a `use` above statements, `unwrap_or_else(|| panic!)` instead of
  `expect(&format!(...))`.
- styles.rs test: hoist a `use` above statements.
- math_tests.rs: backtick `MathML` in a doc comment.
- reader/document.rs test: terminating semicolons + inlined format args.

`cargo clippy -p loki-odf --all-targets -- -D warnings` is now clean;
all 15 loki-odf test groups pass; the workspace gate is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
EPUB is reflowable, so it can't reproduce the paginated wrap band — but
it can flow text around a floating image with CSS `float`, the natural
reflow-target equivalent. A floating `Inline::Image` (carrying a
`FloatWrap` on its attr, as both the DOCX and ODT importers now emit)
gets `style="float:left|right; margin; max-width"`; `WrapSide` picks the
side the float sits on (opposite the text), mirroring `loki-layout`'s
`plan_float`. Behind-text and `TopAndBottom` floats stay block-level.

Also fixes three pre-existing `field_reassign_with_default` lints in
loki-epub test code surfaced by `--all-targets`.

Tested: `floating_image_emits_css_float` / `behind_text_float_is_not_floated`.
`cargo clippy -p loki-epub --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
In reflow (non-paginated) mode a tap never placed the cursor: the touch
tap and long-press handlers were paginated-only — they read
`state.paginated_layout` (which is `None` in reflow) and called
`hit_test_document`, with no reflow branch. Because no cursor state was
ever set, the reflow caret (whose paint path is already wired in
`render_layout::paint_tile`) had nothing to draw — so the editor looked
unresponsive to touch and the caret was invisible, even though the
mouse-per-tile reflow path worked.

Add a reflow branch to both touch handlers that mirrors the proven mouse
path: build the continuous layout via `ensure_reflow_layout` at the same
width the renderer paints (`scroll_metrics().client_width`), map the
window point through the new `reflow_hit_test_window`, and set the cursor
(`page_index = 0`, paragraph, byte) exactly like `on_reflow_click`.
Long-press word-select is unified to resolve the position per view mode
then select the word. The change is additive — the paginated path is
untouched and reflow touch was previously a no-op — so it cannot regress
either. The soft keyboard already opens via the canvas `inputmode="text"`
+ focus-on-tap; it only seemed broken because the tap did nothing.

`reflow_hit_test_window` reuses the Strategy-C transform: the vertical
origin (toolbar top + scroll) is exact, so the tap always lands on the
correct line; the horizontal origin is approximate (Blitz exposes no
element rect) and only affects which character, which `hit_test` clamps.

Tests: `reflow_tap_resolves_to_second_paragraph`,
`reflow_tap_in_first_paragraph_resolves_to_block_0`,
`reflow_tap_above_canvas_top_is_none` (coordinate transform);
`ContinuousLayout::hit_test`/`cursor_rect_canvas` were already covered.
Live touch/GPU behaviour needs on-device verification (not runnable in CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
The Android soft keyboard appeared on the first tap into the editor but
could not be brought back: when the user dismisses it (back / swipe-down)
the OS sends no notification, so the patch's `ime_active` flag stayed
`true`, and `update_ime_for_focus`'s `wants_ime != ime_active` guard then
suppressed every later `set_ime_allowed(true)` — a second tap to move the
caret in the already-focused canvas did nothing.

`update_ime_for_focus` now takes a `force_show` flag. A focus change still
toggles IME on/off (unchanged), but a fresh pointer release (mouse-up /
touch tap) on a text surface re-asserts `set_ime_allowed(true)` even when
IME is already active, re-summoning a dismissed keyboard. Tab focus moves
pass `force_show = false`.

Vendored-patch change (blitz-shell); documented in docs/patches.md. Builds
across the workspace; live keyboard behaviour needs on-device verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Blitz/Stylo lack `position: absolute`/`fixed`, so tooltips can't be DOM
nodes (they were deferred). Instead the vendored blitz-shell now paints a
tooltip directly into the Vello scene after the DOM — outside Blitz
entirely.

New `tooltip.rs`: a self-contained parley `FontContext` shapes the label
(generic sans-serif, `()` brush) and `TooltipShaper::paint` draws a soft
shadow + rounded-rect background + glyphs via `PaintScene::{draw_box_shadow,
fill, draw_glyphs}`, mirroring blitz-paint's glyph bridge (so `run.font()`
is the `peniko::FontData` `draw_glyphs` expects — hence pinned parley 0.6 /
peniko 0.5 / kurbo 0.12 to match the blitz stack).

window.rs wiring:
- `CursorMoved` hit-tests `doc.hit(x,y)` and walks ancestors for a `title`
  attribute; a newly-hovered titled element arms a 500 ms delayed show.
- The loop is `ControlFlow::Wait`, so a one-shot thread sends `Poll` at the
  deadline to wake it; `poll()` flips the tooltip visible + requests redraw.
- `render_scene` composites the tooltip on top of `paint_scene`.
- Click / scroll / keypress / touch dismiss it.

App side: the ribbon icon button reuses its `aria_label` as the `title`, so
every icon is self-describing on hover with zero per-button work.

Documented in docs/patches.md. Workspace builds + clippy gate clean. The
overlay is GPU-rendered — visual behaviour (appearance, position, timing)
needs on-device verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
On a stock NativeActivity with no windowSoftInputMode, the GL surface is
not resized when the soft keyboard appears, so the keyboard overlays the
bottom of the app — the ribbon and bottom-of-document content become
unreachable. winit / Blitz / Dioxus surface no IME-visibility or height
events, so nothing drove a safe-area update.

Fix the cause at three layers, reusing the existing safe-area pipeline:

- loki-file-access: fold WindowInsets.Type.ime() into the inset mask
  (getInsets(systemBars | displayCutout | ime)). getInsets returns the
  per-side union and ime() is bottom-only, so top/left/right are
  unchanged while the keyboard is hidden and bottom grows to the keyboard
  height while it is visible. ime() is API 30+, same as getInsets(int),
  so the existing None fallback already covers older API levels.

- blitz-shell: we already drive the keyboard via set_ime_allowed, so a
  visibility change is the cue to re-reserve the bottom inset. update_ime_for_focus
  now calls arm_ime_settle on show/hide/force-show, opening a bounded
  settle window (IME_INSET_SETTLE, 400ms) and waking the idle loop at
  60/160/280/400ms via Poll. While settling, poll() re-runs
  resync_scroll_geometry so the app re-queries insets as the platform
  reports and animates the IME inset. Android-only; None elsewhere.

- loki-text / appthere-ui: no logic change — the existing
  SafeAreaResizeSensor catches the resync onscroll tick and pushes the
  keyboard-aware insets through update_safe_area_insets, so the root
  bottom padding tracks the keyboard. Comments/docs updated.

Limitation: a system-back / swipe-down dismissal is not reported by the
OS, so the expanded bottom padding (harmless chrome, no content overlap)
persists until the next focus change or tap re-syncs — the same OS gap
the soft-keyboard re-trigger documents.

Verified: cargo check/clippy clean on host and aarch64-linux-android;
fmt clean; appthere-ui tests pass. The on-device keyboard behaviour
needs verification on a physical Android device (cannot run a GPU/touch
windowed app in this environment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
The blitz-shell IME-settle re-sync and the IME-aware inset query
(loki-file-access) are shared patch crates, so they already apply to all
three apps. What was missing in loki-spreadsheet and loki-presentation
was the app-side wiring that loki-text already had: both apps only seeded
the safe-area insets once at startup (orientation-independent system
bars) and never re-queried, so neither the orientation change nor the
soft keyboard could move the padding.

Both apps now carry the same current_safe_area() + SafeAreaResizeSensor
pair as loki-text: the blitz shell re-emits onscroll on resize and across
the IME show/hide animation, the hidden sensor catches that tick and
re-queries query_window_insets_dp (whose mask now includes the IME
inset), so the bottom padding tracks the keyboard and lifts the toolbar /
bottom-of-document content above it. Desktop renders nothing.

Tooltips: the overlay rendering is shared (blitz-shell) and fires on any
element with a `title`. loki-spreadsheet's ribbon uses the shared
AtRibbonIconButton, which already sets title = aria_label, so its icon
buttons get tooltips for free. The only icon-only control without a
visible label in either app is the save-banner dismiss (×) button — give
it a `title` so it is self-describing on hover. The remaining toolbar
buttons in loki-presentation carry visible text labels, so no redundant
tooltip is added there.

Verified: cargo check + clippy -D warnings clean on host for both apps;
fmt clean. The Android-only paths are a verbatim copy of loki-text's
shipped app.rs (same crate calls), so they are validated by parity plus
the host build; a full Android compile of the app crates needs the NDK
(ring's C build) which is unavailable here, and on-device keyboard
behaviour still needs verification on a physical Android device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Four fidelity fixes surfaced while reviewing the ACID document, plus a
suite-consistency pass on the two newer apps.

1. Per-page drop shadow / gray streak (loki-vello). paint_single_page and
   paint_paginated sized each page's white background and L-shaped drop
   shadow from the document-level `layout.page_size`, but the render tiles
   are textured at the per-page size. A section with a different size or
   orientation (A4, or landscape US Letter in a portrait document) got a
   mis-sized white rect, leaving the canvas showing through as a gray
   streak. Both paths now read each page's own `page.page_size` via a new
   `page_chrome_size` helper (regression-tested).

2. w:shd pattern shading (loki-ooxml). The DOCX reader captured only
   `w:shd @w:fill`, so a pattern shade like
   `<w:shd val="pct25" fill="FFFFFF" color="1C7293"/>` rendered as its
   white fill — i.e. looked unshaded. The reader now also captures
   `@w:val` and `@w:color`, and a shared `resolve_shading` helper blends
   `pctN` patterns (N% of color over fill), maps `solid`/`clear`/`nil`,
   and falls back to the fill colour for texture patterns. Applied to
   paragraph, run, and cell shading alike.

3. Scaled-width text / w:w (loki-layout). CharProps.scale was imported
   but dropped at resolve time. It now reaches StyleSpan.scale and is
   applied geometrically to glyph advances/positions and highlight &
   decoration widths in emit_glyph_run, anchored at the run's left edge.
   Later runs on the line are shifted by the added width so they do not
   overlap. COMPAT(parley-0.6): Parley has no geometric horizontal scale,
   so line-breaking still measures the unscaled run — a scaled run can
   extend past the right margin where Word would have wrapped earlier.

4. Suite consistency (loki-spreadsheet, loki-presentation). Both apps'
   root container now fills the safe-area padding with COLOR_SURFACE_CHROME
   and rounds insets to integer px, matching loki-text (avoids a white
   system-bar gap and high-density hairline seams).

Tests: loki-vello page_chrome_size guard; loki-ooxml resolve_shading
(clear/solid/pct25/nil/texture); loki-layout horizontal_scale advance
check. Full loki-layout suite (168) green; both apps + loki-vello +
loki-ooxml compile clean on host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Two more ACID fidelity gaps.

Page-number formats. `w:pgNumType @w:fmt`/`@w:start` were parsed into a
DocxSectPr but never applied: every PAGE field rendered decimal. The
section's format and restart value now flow DocxSectPr → PageLayout
(page_number_format / page_number_start) and into FieldContext, where
substitute_page_fields formats the PAGE field through the existing
list-marker converter (lowerRoman → i, ii, iii; upper roman; lower/upper
letter). NUMPAGES stays decimal. The restart offsets the displayed number
from the section's first physical page. ODT page-number formats
(style:num-format on the master page) remain a follow-up.

Exact line-height clipping ("clipping lines"). With lineRule="exact" the
line box is a fixed height and taller content is clipped, unlike
"atLeast" which grows. Layout set Parley's absolute line height (correct
baseline advance) but never clipped, so over-tall glyphs/inline objects
bled into adjacent lines. Each line's emitted items are now wrapped in a
ClippedGroup sized to the fixed box — `pts` tall, positioned by the same
half-leading Parley uses (box top = baseline - (pts + ascent - descent)/2,
verified against Parley's reported metrics for pts both below and above
the natural line height). The clip is wide horizontally so only the
vertical extent is bounded, matching Word. No clip layer is emitted for
auto/atLeast lines.

Tests: loki-layout substitution_formats_page_number_as_lower_roman (7 →
"vii"); exact_line_height_clips_each_line (8pt box height + glyph-run
present, and no clip for default line height). Full loki-layout suite
(170) green; loki-ooxml + loki-doc-model compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
A w:tbl nested inside a w:tc was silently dropped: the cell model only
held Vec<DocxParagraph> and both the reader and mapper ignored nested
tables, so the inner table vanished from the rendered document.

The cell now holds ordered block content (Vec<DocxBodyChild>, the same
enum used for the body) so paragraphs and nested tables keep their
document order. The reader recurses into w:tbl inside w:tc via the
existing parse_table; the mapper maps each child, recursing through
map_table for nested tables to produce a Block::Table inside the cell's
blocks. The layout already flows a cell's blocks through flow_block,
which dispatches Block::Table to flow_table, so nested tables lay out
(and clip to the cell box) with no layout change.

ODT nested-table import is deferred: the ACID ODT fixture contains no
nested table, and the ODF cell mapper lives in the 1094-line
odt/mapper/document.rs which is over the file-size ceiling and must be
split before growing. Tracked in docs/fidelity-status.md.

Tests: loki-ooxml nested_table_in_cell_maps_to_table_block (paragraph +
nested table preserved in order, inner Block::Table present). Full
loki-ooxml suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
…nested tables

Documentation sync (per CLAUDE.md) for the rendering/import changes in
this branch, plus a clippy fix (move the cell-content `use` above the
first statement in map_cell to satisfy items_after_statements).

Status registry updates: horizontal scale (w:w) now layout-supported with
the Parley line-break caveat; w:shd pattern shading (pctN blend); exact
line-height clipping; mixed page-size chrome (per-page background/shadow);
roman/alpha page-number formats (DOCX, ODT deferred); nested tables
(DOCX, ODT deferred).

Full workspace `cargo clippy -- -D warnings` and `cargo fmt --check`
pass; loki-acid catalog tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
claude added 5 commits June 26, 2026 08:01
Closes the two ODT-deferred gaps tracked in docs/fidelity-status.md, both
of which were blocked on the over-ceiling ODF document mapper.

Split odt/mapper/document.rs (1378 lines) into a document/ directory. The
1378-line mapper was over the 300-line file ceiling, so the nested-table
work could not grow it. It is now a directory of section-cohesive modules —
mod (entry + body dispatch + context), inlines (paragraphs/runs/fields),
frames (images/objects), blocks (lists/tables/toc/sections), page (page
layout), meta (document metadata) — each with its own explicit `use` list
(not `use super::*`, which trips clippy::wildcard_imports), plus the test
module moved out via #[path]. Every production module is now under the
ceiling. No behavior change from the split itself.

Nested tables in ODT cells. A table:table nested inside a table:table-cell
was silently dropped: the ODF cell model held only Vec<OdfParagraph> and
both the reader and the cell mapper ignored nested tables. The cell now
holds ordered block content (OdfTableCell.content: Vec<OdfBodyChild>, the
same enum used for the body), so paragraphs, lists, and nested tables keep
their document order. The reader recurses into table:table inside a cell via
the existing read_table; the mapper flows each child through
map_body_children, which dispatches a nested table back through map_table to
produce a Block::Table inside the cell. Lists in cells are now preserved as
Block::BulletList/OrderedList instead of being flattened to bare
paragraphs. The layout already flows a cell's blocks through
flow_block/flow_table, so the nested table lays out and clips to the cell
box with no layout change — mirroring the DOCX path.

ODT page-number format. style:num-format on style:page-layout-properties
(the active master page's layout) was never read, so every ODT PAGE field
rendered decimal. It now parses into OdfPageLayout.num_format and maps
through map_numbering_scheme (the same ODF num-format -> NumberingScheme
helper shared with list numbering) into PageLayout.page_number_format, which
the existing FieldContext substitution formats (i/ii/iii, I/II/III, a/b/c,
A/B/C). Decimal ("1"/absent) is left unset (the renderer default). The
per-paragraph page-number *restart* (style:page-number) remains a follow-up.

Tests: nested_table_in_cell_is_parsed_into_cell_content (ODT reader),
nested_table_in_cell_maps_to_inner_table_block (ODT mapper),
read_stylesheet_page_layout_num_format (ODT styles reader),
page_num_format_{lower_roman,upper_alpha,decimal_and_absent_stay_none} (ODT
mapper). Full loki-odf suite (143 lib + integration) green; cargo check
--workspace + clippy --workspace -D warnings clean; loki-acid and
loki-layout (170) suites unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Three rendering-fidelity bugs the ACID document surfaced. All three features
were imported correctly but mis-rendered (or not rendered) by the layout.

Highlights / run shading. A run's highlight (`w:highlight`) and run shading
(`w:shd`/`fo:background-color`) imported fine but never painted. The
underlay was emitted from a per-glyph-run lookup that required a single
StyleSpan to FULLY contain the shaped run — but Parley coalesces adjacent
runs that share font/colour (highlight is not a Parley style), so a
highlighted run shaped together with its neighbour matched no single span
and the highlight was silently dropped. The ACID `highlight=yellow` +
`shd run-fill` paragraph rendered nothing. The main paragraph path now emits
the underlay from a Parley selection-geometry pass over each highlighted
span's byte range (immune to coalescing); the banded drop-cap/float path
keeps the per-run underlay (gated behind a new `emit_highlight` flag).

Small caps. `small_caps` only set an unused `StyleSpan.font_variant` flag —
nothing ever read it, so small-caps text rendered at full size,
indistinguishable from normal. Parley has no `FontVariantCaps`, so it is now
synthesized in `flatten_paragraph`: the text is uppercased and the letters
that were lowercase in the source are split into their own spans at a reduced
size (0.8× the cap size), giving the real small-caps look (capitals stay full
height). All caps was already synthesized by uppercasing and is unchanged.

Exact line-height clipping. `lineRule="exact"` wrapped each line in a clip
box, but the box was centered on the glyph box, so it clipped descenders as
well as ascenders — unlike Word. Word bottom-anchors the box (bottom at
baseline + descent), clipping only the tops when the font is taller than the
exact height (the well-known "tops cut off" behaviour). The clip top is now
`baseline + descent − pts`; boxes still tile exactly since Parley advances the
baseline by `pts`.

Tests: highlight_emits_even_when_runs_coalesce (layout),
flatten_small_caps_uppercases_and_shrinks_lowercase (resolve),
exact_line_height_clips_each_line now asserts the box bottom sits below the
baseline. Full loki-layout suite (172) green; loki-acid (incl. glyph-coverage
canaries) green; cargo check --workspace + clippy -D warnings clean. Verified
end-to-end against acid_docx: the yellow highlight and green run-shading rects
now emit at the correct positions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
Addresses several ACID fidelity gaps reported on pages 11 and 14.

Baseline shift (w:position) + per-glyph scale (page 14). The "raised" and
"lowered" runs use w:position (a manual baseline rise in half-points,
positive up) — which was not imported at all, so they rendered on the
baseline. And the adjacent "scaled150" (w:w=150) run lost its scaling: all
three runs share one font/colour, so Parley shapes them into a SINGLE glyph
run, and the per-run scale lookup (keyed on the whole run's text range)
matched no single span and dropped the scale. Now:
  - w:position imports to CharProps.baseline_shift (Points) →
    StyleSpan.baseline_shift, and
  - emit_glyph_run resolves each glyph's span via its cluster's source byte
    offset and applies scale + rise PER GLYPH (anchored at the run's left
    edge), so a coalesced run with a scaled and/or raised/lowered sub-region
    renders each glyph correctly. The fast (uniform) path is unchanged, so
    normal text is unaffected. emit_glyph_run now returns the extra width it
    added so the caller shifts later runs on the line correctly.

Display math: serif font + centering (page 11). The math typesetter shaped
tokens in the sans-serif body default; it now requests a serif/math stack
("Cambria Math, STIX Two Math, Latin Modern Math, serif") to match Word's
Cambria Math. And a paragraph whose sole content is an equation is now
centered: Word renders both m:oMathPara and a bare paragraph-level m:oMath as
a centered display equation (the ACID fixture uses bare m:oMath, imported as
InlineMath since context is unknown at read time), so the DOCX mapper centers
a math-only paragraph that has no explicit alignment.

Documented as known gaps (not fixed here): (1) `continuous` section breaks —
the break type is not imported and every section starts a new page, so a
continuous multi-column section lands on a new page instead of continuing on
the same one (ACID page 11; the content imports fine, only same-page
continuation is missing); (2) symbol-glyph fallback on macOS — bullet/icon
glyphs (♦ etc.) drop because the fallback chain lacks dingbat coverage there.
Both need larger work / macOS verification; see docs/fidelity-status.md.

Tests: coalesced_scale_and_baseline_shift_apply_per_glyph (layout),
position_maps_to_baseline_shift_in_points (DOCX mapper). Full loki-layout
(173) + loki-ooxml (163) + loki-doc-model suites green; workspace clippy
-D warnings + fmt clean. Verified end-to-end against acid_docx: the coalesced
run now carries y=-6/+6 raised/lowered glyphs, and both math-only paragraphs
import centered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
…age)

Page 11: a `continuous` section break should pack the next section onto the
SAME page as the previous one, only changing the column layout. Previously
the section break type was not imported and every section started on a fresh
page, so the ACID "Unequal multi-column text in a continuous section" label
was stranded with its two-column content pushed onto a new page.

Model + import. Add `SectionStart` (Continuous / NewPage / EvenPage /
OddPage) and `Section.start`. The DOCX reader reads `w:sectPr/w:type @w:val`
into `DocxSectPr.section_type`; the mapper maps it to `Section.start`. ODF has
no equivalent (master-page transitions are always page breaks) so it stays
`NewPage`.

Layout — page-sharing groups. `layout_paginated_full` now partitions sections
into groups: a new group begins at the first section and at every
non-`continuous` section. Each group is flowed by the new
`flow::flow_section_group` in ONE `FlowState`, so continuous members continue
on the same page below the previous content. The group's first section owns
the page geometry and headers/footers — a continuous break cannot change the
page size — and each continuous member only switches the column layout, via
`begin_continuous_section`, which opens a fresh column band at the current
`cursor_y` (not the page top). A new `FlowState::column_top_y` records each
band's top so `break_column` advances columns from there and column
separators span the band; `run_paginated_loop` gained a block-index base so
editing indices stay group-global.

Limitations (documented): continuous columns are filled top-to-bottom
(fill-first, no height balancing — Word balances them); evenPage/oddPage
behave like nextPage (no blank-page insertion yet); unequal column widths
render as equal columns (per-column widths are not modelled).

Tests: continuous_section_shares_previous_page,
continuous_multi_column_section_flows_into_two_columns_on_shared_page
(layout), section_type_maps_to_section_start (DOCX mapper). Full loki-layout
(176) + loki-ooxml + loki-doc-model + loki-acid suites green; workspace clippy
-D warnings + fmt clean. Verified end-to-end against acid_docx: section 1
imports as start=Continuous, and its two-column content now flows mid-page
(page 10) below the section-0 tail instead of on its own page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
The UI typeface (Atkinson Hyperlegible Next) and the metric-compatible
fallback families were registered into the renderer's font collection via
a `document::Style` injecting an `@font-face` `data:` URI. That path relies
on the renderer's asynchronous network-provider resource fetch and did not
load the UI typeface on Android — the chrome fell back to a wide system
font (status-bar / ruler digits rendered noticeably wide).

The font bytes are known at compile time, so the correct layer is to
register them synchronously at startup rather than fetch them at runtime:

- blitz-dom: `DocumentConfig` gains `extra_fonts: Vec<Vec<u8>>`;
  `BaseDocument::new` registers each blob into the parley `FontContext`
  on top of the system fonts and the default bullet font.
- dioxus-native: `Config` gains `with_fonts(..)`; `launch_cfg_with_props`
  threads the blobs into `DocumentConfig.extra_fonts`.
- loki-fonts: new `ui_font_blobs()` returns the Atkinson UI face plus the
  five fallback faces; the obsolete `@font-face`/`data:` URI generators
  (`ui_face_css`, `face_css`, base64 encoder) are removed.
- loki-text / loki-spreadsheet / loki-presentation: launch via
  `dioxus::native::launch_cfg` with `Config::with_fonts(ui_font_blobs())`
  on both desktop and Android entry points; drop the redundant
  `document::Style` font injections.

Docs: patches.md (blitz-dom item 7, dioxus-native) and fidelity-status.md
updated. Verified `cargo clippy --workspace -- -D warnings`, `cargo fmt`,
and loki-fonts / loki-layout font tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7
@kevincarlson kevincarlson merged commit 91762ec into main Jun 27, 2026
1 of 2 checks passed
kevincarlson pushed a commit that referenced this pull request Jul 5, 2026
…(Spec 02 M4+M5)

Golden generation (B-2/M4): committed first golden set —
goldens/odt/{para-carlito,styles-tinos,para-gelasio}/page-1.png,
generated by scripts/generate-odf-goldens.sh (LibreOffice 24.2
headless -> PDF -> the pinned rasterizer at CONFORMANCE_DPI), each
with a GENERATION.txt (reference app, rasterizer version, date,
operator). Fixtures are produced by a checked-in generator
(loki-odf --example gen_conformance_fixtures) referencing
metric-compatible font names directly (D4), and the same loki-fonts
TTF bytes are installed for fontconfig so both engines shape
identical faces.

Calibration (B-3/D5): goldens/CALIBRATION.md records the measured
cross-renderer noise floor (1500 regions over 3 fixtures) and derives
Tolerance::calibrated() = {min_ssim 0.60, max_delta_e 10.0} from the
agreeing fixtures — replacing the folklore 0.98 with data. The pass
independently FOUND a real fidelity gap: para-carlito's heatmap shows
cumulative advance drift per line = LibreOffice applies Carlito kern
pairs, loki-layout does not yet (known fidelity gap #23), quantified
at region SSIM 0.23 / dE 19.8.

Visual axis (M5 acceptance): loki-render-cpu/tests/visual_golden.rs
compares candidate renders against the committed goldens at the
calibrated tolerance — styles-tinos and para-gelasio PASS; the
para-carlito divergence is pinned as an expected-failure canary that
flips when kerning lands (gate stays advisory until then, per the
record). calibrate_odf example re-runs the measurement.

Phase 3.6 of docs/deferred-features-plan-2026-07-04.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
kevincarlson pushed a commit that referenced this pull request Jul 5, 2026
…t (fidelity gap #23)

The Spec 02 calibration pass flagged para-carlito diverging from its
LibreOffice golden (region SSIM 0.23, dE 19.8) and initially blamed
missing kerning. Root-causing inverted that: Parley 0.10's harfrust
shaper already applies GPOS kern pairs (verified against Carlito's
tables — the historical gap had been silently closed by the Parley
0.8->0.10 upgrade), and per-line ink measurement showed the GOLDEN
consistently wider by exactly the kern amount. LibreOffice was not
kerning the document at all: the fixture ODT carries no
style:letter-kerning, and both LO-on-import and Word (w:kern
threshold 0) default pair kerning OFF — while loki kerned
unconditionally, flipping borderline line wraps into whole-line diffs.

The real gap #23 was therefore the missing kerning *toggle*:

- StyleSpan gains ; resolve.rs forwards
  CharProps.kerning (already mapped by both readers: OOXML w:kern>0,
  ODF style:letter-kerning). Unless a run explicitly enables kerning,
  layout pushes a '"kern" 0' font-feature so the shaper matches the
  reference apps' default-off behaviour.
- Regression locks in loki-layout/tests/kerning_applied.rs: kern-on
  advance matches the font's (A,V) pair to 0.05pt, default keeps
  natural advances, pairs stay contextual, ligatures unaffected.
- Recalibration: para-carlito region floor improved SSIM 0.23->0.69,
  dE 19.8->7.5 — all three fixtures now in one noise band and GREEN at
  the calibrated tolerance; the expected-failure canary is promoted to
  a passing golden check. CALIBRATION.md records the re-measured
  distributions and keeps the full mis-attribution/root-cause trail as
  a worked example. para-carlito's fixture text also de-knife-edged
  (varied sentences instead of a repeated pangram).
- To stay under para.rs's ratcheted ceiling, the self-contained
  list-marker synthesis block moved to loki-layout/src/list_marker.rs
  (para.rs 1979 -> 1856, baseline ratcheted down; existing
  para::format_counter / format_list_marker paths re-exported).
- fidelity-status.md Kerning row -> resolved; audit/plan rows updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
kevincarlson pushed a commit that referenced this pull request Jul 5, 2026
…t (fidelity gap #23)

The Spec 02 calibration pass flagged para-carlito diverging from its
LibreOffice golden (region SSIM 0.23, dE 19.8) and initially blamed
missing kerning. Root-causing inverted that: Parley 0.10's harfrust
shaper already applies GPOS kern pairs (verified against Carlito's
tables — the historical gap had been silently closed by the Parley
0.8->0.10 upgrade), and per-line ink measurement showed the GOLDEN
consistently wider by exactly the kern amount. LibreOffice was not
kerning the document at all: the fixture ODT carries no
style:letter-kerning, and both LO-on-import and Word (w:kern
threshold 0) default pair kerning OFF — while loki kerned
unconditionally, flipping borderline line wraps into whole-line diffs.

The real gap #23 was therefore the missing kerning toggle:

- StyleSpan gains a `kerning: Option<bool>` field; resolve.rs forwards
  CharProps.kerning (already mapped by both readers: OOXML w:kern>0,
  ODF style:letter-kerning). Unless a run explicitly enables kerning,
  layout pushes a `"kern" 0` font-feature so the shaper matches the
  reference apps' default-off behaviour.
- Regression locks in loki-layout/tests/kerning_applied.rs: kern-on
  advance matches the font's (A,V) pair to 0.05pt, default keeps
  natural advances, pairs stay contextual, ligatures unaffected.
- Recalibration: para-carlito region floor improved SSIM 0.23->0.69,
  dE 19.8->7.5 — all three fixtures now in one noise band and GREEN at
  the calibrated tolerance; the expected-failure canary is promoted to
  a passing golden check. CALIBRATION.md records the re-measured
  distributions and keeps the full mis-attribution/root-cause trail as
  a worked example. para-carlito's fixture text also de-knife-edged
  (varied sentences instead of a repeated pangram).
- To stay under para.rs's ratcheted ceiling, the self-contained
  list-marker synthesis block moved to loki-layout/src/list_marker.rs
  (para.rs 1979 -> 1856, baseline ratcheted down; existing
  para::format_counter / format_list_marker paths re-exported).
- fidelity-status.md Kerning row -> resolved; audit/plan rows updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants