test(loki-layout): pin list hanging-indent & table fixed-width geometry#23
Merged
Merged
Conversation
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
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
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
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.
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