diff --git a/CLAUDE.md b/CLAUDE.md index fed5f1a8..b74b2b18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ lines). The full list and a proposed split strategy live in offenders are below. This is a dedicated split-pass backlog, not a per-change blocker — but do not *grow* these files or add new ones over the ceiling. -The split pass is **in progress** — **10 of 43 files done** (≈33 remain). Two +The split pass is **in progress** — **11 of 43 files done** (≈32 remain). Two techniques: 1. *Inline-test extraction* (safest, no production-code change): move a file's `#[cfg(test)] mod tests { … }` into a sibling `_tests.rs` referenced via @@ -141,9 +141,13 @@ techniques: `odt/mapper/lists.rs`, `layout/result.rs`, `renderer/render_layout.rs` — each was over the ceiling only because of a large inline test module. 2. *Directory split*: convert `foo.rs` → a `foo/` directory with section-cohesive - submodules (each starting `use super::*;`), re-export the public entry points - from `foo/mod.rs`, and move the tests via the same `#[path]` idiom. Done for - `odt/mapper/props.rs` → `odt/mapper/props/` (worked example). + submodules, re-export the public entry points from `foo/mod.rs`, and move the + tests via the same `#[path]` idiom. Give each submodule its **own explicit + `use` list** (importing siblings via `use super::sibling::fn`) — `use super::*` + trips `clippy::wildcard_imports`. Done for `odt/mapper/props.rs` → + `odt/mapper/props/` and `odt/mapper/document.rs` → + `odt/mapper/document/` (`mod`/`inlines`/`frames`/`blocks`/`page`/`meta`; worked + examples). (Test files are exempt from the production-line count.) @@ -156,10 +160,12 @@ techniques: | `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1241 | High | | `loki-ooxml/src/docx/write/document.rs` | 1169 | High | | `loki-ooxml/src/docx/reader/document.rs` | 1126 | High | -| `loki-odf/src/odt/mapper/document.rs` | 1094 | High | | `loki-text/src/routes/editor/editor_inner.rs` | 968 | High | | … 24 more (300–600 lines) — see the audit (10 files split 2026-06-21) | | | +(`odt/mapper/document.rs` (1094 lines) was split into the `odt/mapper/document/` +directory on 2026-06-26 — each module is now under the ceiling.) + (`read.rs` was split into `read.rs` + `props_read.rs`; both are now under 300 lines. `loro_bridge/inlines.rs` is now 219 lines, under the ceiling. `loki-text/src/components/document_source.rs` no longer exists.) diff --git a/Cargo.lock b/Cargo.lock index 47062b50..53063f55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,7 +842,10 @@ dependencies = [ "blitz-traits", "futures-util", "keyboard-types", + "kurbo 0.12.0", "log", + "parley 0.6.0", + "peniko", "rfd 0.17.2", "winit", ] @@ -3819,6 +3822,7 @@ dependencies = [ "loki-layout", "loki-odf", "loki-ooxml", + "loki-pdf", "loki-presentation-model", "loki-primitives", "loki-sheet-model", diff --git a/appthere-ui/src/components/ribbon/button.rs b/appthere-ui/src/components/ribbon/button.rs index f87f8f65..f330b5d4 100644 --- a/appthere-ui/src/components/ribbon/button.rs +++ b/appthere-ui/src/components/ribbon/button.rs @@ -80,6 +80,9 @@ pub fn AtRibbonIconButton( fg = icon_color, ), aria_label: aria_label.clone(), + // The hover tooltip overlay (blitz-shell) reads `title`; reuse the + // accessible name so every icon button is self-describing on hover. + title: aria_label.clone(), aria_pressed: if is_active { "true" } else { "false" }, disabled: is_disabled, onmouseenter: move |_| hovered.set(true), diff --git a/appthere-ui/src/safe_area.rs b/appthere-ui/src/safe_area.rs index 86d3694f..a14bd4cb 100644 --- a/appthere-ui/src/safe_area.rs +++ b/appthere-ui/src/safe_area.rs @@ -9,9 +9,11 @@ //! //! The insets are **not** fixed for the lifetime of the app: on Android they //! change with orientation (in landscape the navigation bar / cutout move to a -//! side). Call [`update_safe_area_insets`] from within the Dioxus runtime (e.g. -//! a resize handler) to push new values — readers of [`use_safe_area`] -//! re-render so the padding follows the current orientation. +//! side) and with the soft keyboard (the bottom inset grows by the on-screen +//! keyboard / IME height while it is visible, so content is pushed above it). +//! Call [`update_safe_area_insets`] from within the Dioxus runtime (e.g. a +//! resize handler, or the keyboard-driven re-sync) to push new values — readers +//! of [`use_safe_area`] re-render so the padding follows the current state. //! //! On desktop platforms nothing ever updates the value, so [`use_safe_area`] //! returns all-zero insets and the root padding is effectively a no-op. diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 1997cae7..d6342e72 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -9,7 +9,7 @@ This is the living source of truth documenting which document features, characte | Feature | Import Supported? | Layout / Render Supported? | Export Supported? | Notes | | :--- | :---: | :---: | :---: | :--- | | **Page Size & Margins** | Yes | Yes | Yes | Page sizes (A4, Letter) and margins resolved and written. | -| **Section Breaks** | Yes | Yes | Yes | Supports multiple sections with different page layouts. | +| **Section Breaks** | Yes | Yes | Yes | Supports multiple sections with different page layouts. The section break *type* (`w:type` continuous / nextPage / evenPage / oddPage) imports to `Section.start` (`SectionStart`); ODF has no equivalent and always yields `NewPage`. **Mixed page sizes/orientations render correctly:** the editor/print painter sizes each page's white background and drop shadow from that page's own `page.page_size` (via `page_chrome_size`), so an A4 or landscape-Letter section no longer leaves a gray streak. Tested by `page_chrome_uses_per_page_size`. **`continuous` breaks share a page.** Consecutive sections joined by `continuous` breaks are flowed as one **page-sharing group** (`flow::flow_section_group`): the group's first section starts the pages and owns the page geometry + headers/footers (a continuous break cannot change page size), and each continuous member continues on the **same page** below the previous content, switching column layout mid-page via a fresh column band (`FlowState::column_top_y`). So the ACID page-11 "Unequal multi-column text in a continuous section" label is now followed by its two-column content on the same page instead of stranding the heading. Tested by `continuous_section_shares_previous_page` and `continuous_multi_column_section_flows_into_two_columns_on_shared_page` (layout) + `section_type_maps_to_section_start` (DOCX mapper). **Limitations:** continuous columns are filled top-to-bottom (fill-first, no height *balancing* — Word balances continuous-section columns evenly); `evenPage`/`oddPage` start a new page like `nextPage` (the blank-page insertion to reach an even/odd page is not yet emitted); and unequal column widths (`w:cols w:equalWidth="0"`) render as equal columns (per-column widths are not modelled). | | **Document Metadata** | Yes | — | Yes | Core properties (title, subject, keywords, description, creator, last-modified-by, created/modified dates, language, revision) round-trip in both formats — DOCX via `docProps/core.xml` (OPC `CoreProperties`; the `serde` feature on `loki-opc` is enabled so the reader/writer are real, not stubs), ODT via `meta.xml`. **Extended Dublin Core** (publisher, contributors, rights, license, identifier, identifier-scheme, type, format, source, relation, coverage, issued, bibliographic-citation) round-trips too: `dc:identifier` uses the native core.xml element; the rest are carried as DOCX `docProps/custom.xml` custom properties / ODT `meta:user-defined` entries under reserved `dcmi:` names. The flattening is shared via `DublinCoreMeta::to_named_pairs`/`from_named_pairs` in `loki-doc-model`. Tested by `metadata_round_trip.rs` (DOCX) and `extended_dublin_core_round_trips` (ODT). Not yet written: custom user properties, `meta:editing-duration`, OOXML `docProps/app.xml`. | | **Multi-column Sections** | Yes | Yes | Yes | Column count, gap, and separator line round-trip in both formats — DOCX `w:cols` (`@w:num`/`@w:space`/`@w:sep`) and ODF `style:columns` (`fo:column-count`/`fo:column-gap` + `style:column-sep`). At **layout time** (paginated mode) the flow engine divides the content area into equal columns and fills each top-to-bottom before advancing to the next column and then the page (`flow_columns.rs`); the requested separator line is drawn between used columns. Continuous/reflow modes ignore columns by design. Tested by `export_columns.rs` / `multi_column_section_round_trips` (round-trip) and `text_flows_down_columns_before_paging` / `column_separator_line_is_drawn` (layout). Known limitations: columns are balanced only implicitly (fill-first, no height balancing), and footnotes still flow in the last active column. | | **Headers & Footers** | Yes | Yes | Yes | DOCX export writes all three variants — default, first-page (`w:titlePg`), and even-page (`word/settings.xml` with `w:evenAndOddHeaders`) — as `w:hdr`/`w:ftr` parts with full block content; ODT export writes them into the master page. Round-trip tested (`even_page_headers_footers_round_trip`). | @@ -21,7 +21,7 @@ This is the living source of truth documenting which document features, characte | **Widow/Orphan Control** | Yes | No | No | Ignored at layout time (Word defaults to "on"). | | **Bookmarks** | Yes | No | Yes | Bookmarks are written/parsed but do not affect layout. | | **Comments (annotations)** | Yes | Yes | Yes | Round-trip in both formats. The commented range is carried as `Inline::Comment` start/end anchors in the content flow; bodies (author, date, **multi-paragraph block content**) live in `Document.comments`. DOCX uses `w:commentRangeStart`/`w:commentRangeEnd` + a `CommentReference` run, with bodies in `word/comments.xml`; ODF uses inline `office:annotation` (body + `dc:creator`/`dc:date`) / `office:annotation-end`. **Rendered** in paginated mode as a **margin comment panel**: each anchored comment becomes a tinted card (author line + body) stacked in a gutter to the right of the page (`flow_comments.rs`; painted by `loki-vello`, and `loki-text` widens the canvas by `COMMENT_GUTTER_WIDTH` when comments are present). **Persisted through the Loro CRDT** as a JSON snapshot (`loro_bridge::comments`), so comment edits are durable and undoable. Tested by `comments_round_trip.rs` (DOCX), `comments_round_trip` (ODT), `loro_bridge::comments` (CRDT), and `comment_panel_renders_in_gutter` (layout). Limits: inline formatting inside a comment is flattened to plain text; the panel renders on full relayout (not incremental-reuse pages); cross-paragraph comment ranges anchor at the start paragraph. | -| **Math (equations)** | Yes | Partial | Yes | Mathematical equations round-trip through both formats. The format-neutral model stores math as a single MathML `` string in `Inline::Math` (the W3C interchange standard and ODF's native form). **DOCX** converts bidirectionally between OMML (`m:oMath` inline / `m:oMathPara` display) and MathML in `loki-ooxml`'s `docx/omml` module; the converter is mutually inverse over the common construct set — text runs (`m:r`/`m:t` ⇄ ``/``/``), fractions (`m:f`), super/subscripts (`m:sSup`/`m:sSub`/`m:sSubSup`), and radicals (`m:rad` ⇄ ``/``). **ODT** embeds the MathML as a formula sub-document (`draw:frame`/`draw:object` → `Object N/content.xml`, listed in the manifest with the `…opendocument.formula` media type) and reads it back, canonicalising on import (`loki-odf`'s `odt::math`); ODF does not distinguish display from inline math, so embedded formulas map to `MathType::InlineMath`. **Persisted through the Loro CRDT** losslessly: a block containing math is preserved as an opaque snapshot. **Rendered** by a first-pass math typesetter (`loki-layout`'s `math` module): the MathML is laid out into positioned glyph runs (tokens shaped via Parley) plus fraction-bar/radical rules, then placed inline via a Parley inline box (the same mechanism as tab stops). Covers identifiers/numbers/operators, rows, fractions (`mfrac`), scripts (`msup`/`msub`/`msubsup`), radicals (`msqrt`/`mroot`), and fenced expressions (`mfenced` / fence-wrapped rows) — reusing the standard `PositionedItem` glyph/rect types so `loki-vello` paints it with no renderer change. **The equation's baseline is aligned to the text baseline** (the inline box reserves the equation's ascent; Parley aligns box bottoms to the baseline, so the descent hangs below into the line like inline text, and the paragraph height grows to cover a deep denominator). **Radical signs and delimiters stretch to their content** via uniform glyph scaling. Tested by `omml_tests` + `math_round_trip.rs` (DOCX), `math_tests` + `math_round_trip.rs` (ODT), the `math::tests` typesetter unit tests (incl. stretch), and `inline_math_emits_typeset_items` / `inline_math_baseline_aligns_with_text` (layout integration). **Approximations / not yet done:** stretchy glyphs widen as they grow (uniform scaling, not true extensible glyphs); inter-atom spacing follows simple proportional gaps rather than the full TeX `mathspacing` table; matrices/n-ary operators/accents are not laid out. The OMML↔MathML converter likewise does not yet cover delimiters, n-ary operators, matrices, or accents (these pass through best-effort), so delimiter rendering currently applies to MathML that already contains fences (e.g. ODT-imported or hand-authored), not DOCX OMML round-trips. | +| **Math (equations)** | Yes | Partial | Yes | Mathematical equations round-trip through both formats. The format-neutral model stores math as a single MathML `` string in `Inline::Math` (the W3C interchange standard and ODF's native form). **DOCX** converts bidirectionally between OMML (`m:oMath` inline / `m:oMathPara` display) and MathML in `loki-ooxml`'s `docx/omml` module; the converter is mutually inverse over the common construct set — text runs (`m:r`/`m:t` ⇄ ``/``/``), fractions (`m:f`), super/subscripts (`m:sSup`/`m:sSub`/`m:sSubSup`), and radicals (`m:rad` ⇄ ``/``). **ODT** embeds the MathML as a formula sub-document (`draw:frame`/`draw:object` → `Object N/content.xml`, listed in the manifest with the `…opendocument.formula` media type) and reads it back, canonicalising on import (`loki-odf`'s `odt::math`); ODF does not distinguish display from inline math, so embedded formulas map to `MathType::InlineMath`. **Persisted through the Loro CRDT** losslessly: a block containing math is preserved as an opaque snapshot. **Rendered** by a first-pass math typesetter (`loki-layout`'s `math` module): the MathML is laid out into positioned glyph runs (tokens shaped via Parley) plus fraction-bar/radical rules, then placed inline via a Parley inline box (the same mechanism as tab stops). Covers identifiers/numbers/operators, rows, fractions (`mfrac`), scripts (`msup`/`msub`/`msubsup`), radicals (`msqrt`/`mroot`), and fenced expressions (`mfenced` / fence-wrapped rows) — reusing the standard `PositionedItem` glyph/rect types so `loki-vello` paints it with no renderer change. **The equation's baseline is aligned to the text baseline** (the inline box reserves the equation's ascent; Parley aligns box bottoms to the baseline, so the descent hangs below into the line like inline text, and the paragraph height grows to cover a deep denominator). **Radical signs and delimiters stretch to their content** via uniform glyph scaling. **Math is set in a serif/math face** (`FontFamily::Source("Cambria Math, STIX Two Math, Latin Modern Math, serif")` in the token shaper) rather than the sans-serif body default, matching Word's Cambria Math. **A paragraph that is solely an equation is centered** (display math): Word renders both `m:oMathPara` and a bare paragraph-level `m:oMath` as a centered display equation, so the DOCX mapper centers a paragraph whose only content (ignoring whitespace) is math when it has no explicit alignment. Tested by `omml_tests` + `math_round_trip.rs` (DOCX), `math_tests` + `math_round_trip.rs` (ODT), the `math::tests` typesetter unit tests (incl. stretch), and `inline_math_emits_typeset_items` / `inline_math_baseline_aligns_with_text` (layout integration). **Approximations / not yet done:** stretchy glyphs widen as they grow (uniform scaling, not true extensible glyphs); inter-atom spacing follows simple proportional gaps rather than the full TeX `mathspacing` table; matrices/n-ary operators/accents are not laid out. The OMML↔MathML converter likewise does not yet cover delimiters, n-ary operators, matrices, or accents (these pass through best-effort), so delimiter rendering currently applies to MathML that already contains fences (e.g. ODT-imported or hand-authored), not DOCX OMML round-trips. | | **Templates (DOTX / OTT)** | Yes | — | Partial | Office `.dotx`/`.dotm` and LibreOffice `.ott` open as new untitled documents (the importers key off the `officeDocument` relationship / accepted template mimetype). Export: **Save as Template** writes `.dotx` (template content type) via `DocxTemplateExport`. Five templates (Markdown, APA, MLA, Screenplay, Resume) ship as bundled `.dotx` assets (`loki-templates`) and open from the home gallery. | | **ODT export** | Yes | — | Yes | `loki-odf`'s `OdtExport` writes a full ODT package (`content.xml` / `styles.xml` / `meta.xml` + `Pictures/`). **Lossless** for: the complete character-property set (fonts incl. complex/East-Asian, size, weight, italic, underline, strike, caps, outline, shadow, super/sub, colour, letter/word spacing, kerning, scale, languages), the complete paragraph-property set (alignment, indents, spacing, line height, keep/widow/orphan/break flags, borders, padding, tab stops, bidi, background), the named style catalog, **multi-section page geometry** (each section gets its own `style:page-layout` + `style:master-page`; section breaks are emitted as `style:master-page-name` on the first paragraph of each section, the form the importer reads back), **headers/footers** (default/first/even, written per-section into the master page with their own automatic styles + images), headings, styled paragraphs, lists, tables, footnotes/endnotes, links, **bookmarks, fields, and embedded images** (decoded from data URIs and written as `Pictures/` parts), and core Dublin Core metadata. A property-level round-trip test asserts each survives. Editing an opened `.odt` and saving round-trips to ODT. **Multi-column sections** (`style:columns` with count/gap/separator), **extended Dublin Core** (publisher, contributors, rights, license, identifier, type, format, source, relation, coverage, issued, citation — carried as `meta:user-defined` entries under reserved `dcmi:` names), and **comments** (`office:annotation` / `office:annotation-end`) also round-trip. **Math** is emitted as embedded formula objects (`draw:object` → `Object N/content.xml` MathML, listed in the manifest). Still not emitted: the OTT template content type. | | **Reflow (non-paginated) view** | — | Yes | — | `LayoutMode::Reflow` + `RenderMode::Reflow` render a continuous web-style flow through the same layout/Vello pipeline as paginated view (full font/size/alignment fidelity), sliced into zero-gap GPU band tiles (768pt ⇒ exact 1024 CSS px, so tiles stack seamlessly). Relayouts to the window width on resize (shell re-emits `onscroll` for scroll containers). Content wider than the viewport (e.g. a fixed-width table) widens the tiles so it is reachable by horizontal scrolling rather than clipped. No headers/footers/page chrome by design; the status-bar page indicator is hidden in reflow. **Editing:** `ContinuousLayout` carries per-paragraph editing data, so click-to-cursor, caret placement/painting, range-selection highlighting (mouse drag-select + Shift+Arrow), and reflow-native arrow / Home / End navigation all work, plus typing/undo/formatting. Still missing: typing/Backspace over a selection does not yet delete the selected range first (it inserts at the focus), and touch long-press selection is not wired for reflow. Android CPU builds (no `android_gpu`) fall back to a low-fidelity HTML flow (`reflow_view.rs`) with no caret. | @@ -37,14 +37,15 @@ This is the living source of truth documenting which document features, characte | **Underline** | Yes | Yes | Yes | Style varieties (single, double, dotted, dash, wave, thick) mapped. | | **Strikethrough** | Yes | Yes | Yes | Single and double strikethrough variants mapped. | | **Font Family / Size** | Yes | Yes | Yes | Resolves against style catalog and font resources. | -| **Vertical Alignment** | Yes | Yes | Yes | Superscript and subscript support. | +| **Vertical Alignment** | Yes | Yes | Yes | Superscript and subscript (`w:vertAlign` / `style:text-position`) — font reduced to 58 % and the run shifted above/below the baseline. **Manual baseline shift / text rise** (`w:position`, in half-points; `style:text-position` vertical component) is also supported (`CharProps.baseline_shift` → `StyleSpan.baseline_shift`): the glyphs are raised/lowered *without* shrinking, applied **per glyph** at emit time. This per-glyph application (and the same for horizontal scale, below) makes both robust to Parley coalescing adjacent same-font runs into one glyph run — a run carrying only a different rise/scale (attributes Parley does not track) would otherwise be missed by a per-run lookup, so the ACID page-14 `raised`/`lowered`/`scaled150` runs previously rendered flat/unscaled. Tested by `coalesced_scale_and_baseline_shift_apply_per_glyph` (layout) and `position_maps_to_baseline_shift_in_points` (DOCX mapper). Not re-exported. | | **Color** | Yes | Yes | Yes | Linear sRGB, transparent, and fallback mappings. | -| **Highlight Color** | Yes | Yes | Yes | Mapped to 16 standard palette colors. | +| **Highlight Color** | Yes | Yes | Yes | Run highlight (`w:highlight` → 16-colour palette) and run shading (`w:shd @fill` / `fo:background-color`, folded into the same `StyleSpan.highlight_color`) render as a filled underlay behind the text. **Robust to Parley run coalescing:** the underlay is emitted from a Parley **selection-geometry pass** over each highlighted span's byte range (`layout_paragraph`), not from the per-glyph-run lookup — so a highlighted run that Parley shaped into one glyph run together with an adjacent same-font/colour run (highlight is not a Parley style) still paints. (The earlier per-run lookup required a single span to fully contain the shaped run and silently dropped the highlight whenever runs coalesced — e.g. the ACID `highlight=yellow` + `shd run-fill` paragraph showed nothing.) The banded drop-cap/float path keeps the per-run underlay. Tested by `highlight_color_produces_filled_rect_before_glyph_run` and `highlight_emits_even_when_runs_coalesce`. | | **Letter Spacing** | Yes | Yes | Yes | Mapped to Parley letter spacing. | | **Word Spacing** | Yes | Yes | Yes | Mapped to Parley word spacing. | -| **Small Caps / All Caps** | Yes | Yes | Yes | Uppercases characters if all-caps enabled; maps to Parley. | +| **Small Caps / All Caps** | Yes | Yes | Yes | Both are **synthesized** during `flatten_paragraph` (resolve.rs), since Parley exposes no `FontVariantCaps`. **All caps:** the run text is uppercased. **Small caps:** the text is uppercased *and* the letters that were lowercase in the source are split into their own spans at a reduced size (`SMALL_CAPS_RATIO` = 0.8 of the cap size), so capitals stay full height while former-lowercase letters render as small capitals — the real small-caps look. (Previously `small_caps` only set an unused `StyleSpan.font_variant` flag, so small-caps text rendered at full size, indistinguishable from normal.) Tested by `flatten_all_caps_uppercases_text` and `flatten_small_caps_uppercases_and_shrinks_lowercase`. | | **Shadow Text** | Yes | Yes | Yes | Mapped to StyleSpan properties. | -| **Scale / Kerning** | Yes | No | No | Dropped at layout time. | +| **Horizontal Scale (`w:w`)** | Yes | Yes | Yes | `CharProps.scale` 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 (now returned from `emit_glyph_run`) so they do not overlap. **Applied per glyph** via each glyph's cluster→span mapping, so a scaled run that Parley shaped together with an un-scaled neighbour (same font/colour) still stretches only its own glyphs — see the baseline-shift row for the coalescing rationale. COMPAT(parley): 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 (gap #14). Tested by `horizontal_scale_widens_glyph_advances` and `coalesced_scale_and_baseline_shift_apply_per_glyph`. | +| **Kerning** | Yes | No | No | Kerning flag dropped at layout time (gap #23). | | **Language Tags** | Yes | No | No | No locale-sensitive shaping or hyphenation. | --- @@ -56,13 +57,14 @@ This is the living source of truth documenting which document features, characte | **Alignment** | Yes | Yes | Yes | Left, center, right, and justified alignments supported. | | **Indentation** | Yes | Yes | Yes | Left, right, first line, and hanging indents mapped. | | **Spacing (Before/After)** | Yes | Yes | Yes | Margins and paragraph offsets respected. | -| **Line Height** | Yes | Yes | Yes | Supports exact, relative (multipliers), and at-least rules. | +| **Line Height** | Yes | Yes | Yes | Supports exact, relative (multipliers), and at-least rules. **`exact` clips like Word**: each line's items are wrapped in a `PositionedItem::ClippedGroup` sized to the fixed line box (`pts` tall). The box is **bottom-anchored** — its bottom sits at `baseline + descent` and its top is `pts` above (`top = baseline + descent − pts`) — so when the font is taller than `pts` the ascenders (and any raised superscript) are clipped while descenders are preserved, matching Word's "tops cut off" behaviour for small exact spacing. (The box was previously *centered* on the glyph box, which clipped descenders too — visibly unlike Word on the ACID `EXACT 12pt` case.) Consecutive boxes still tile exactly because Parley advances the baseline by `pts`. `atLeast`/`auto` lines grow naturally and are not clipped. Tested by `exact_line_height_clips_each_line` (asserts the box bottom sits below the baseline). | | **Borders** | Yes | Yes | Yes | Top, bottom, left, right borders supported. | -| **Tab Stops** | Yes | Yes | Yes | Position-sorted tab stops supported. | -| **Background Color** | Yes | Yes | Yes | Paragraph background shading supported. | +| **Tab Stops** | Yes | Yes | Yes | Position-sorted tab stops with **alignment and leaders**. The two-pass tab expansion (`para.rs`) measures the content following each tab via zero-width probe inline boxes (plus a decimal-marker box per tab and an end-of-text sentinel), then sizes the final inline box so the content lands per the stop's alignment: **Left** advances to the stop, **Right** ends the content at the stop, **Center** centres it, **Decimal** places the first `.` at the stop. **Leaders** (dot/dash/underscore/heavy/middle-dot) are drawn across the gap as renderer-agnostic fills. Tabs are processed left-to-right, accumulating the shift so later stops resolve against the shifted pen. Tested by `tab_stops_tests.rs` (right/center/decimal geometry + dot leader). Limits: content that wraps mid-tab-sequence falls back to left-align for the wrapped tab; `bar` tabs (vertical rule) map to left; leader dots are square fills, not period glyphs. | +| **Background Color / Shading** | Yes | Yes | Yes | Paragraph, run, and cell background shading supported. **Shading patterns** (`w:shd @w:val`) are honoured by a shared `resolve_shading` helper: `clear` uses the fill, `solid` uses the pattern colour, and `pctN` blends `N`% of `@w:color` over `@w:fill` (so e.g. `pct25 fill=FFFFFF color=1C7293` renders as a light teal instead of white); texture patterns fall back to the fill colour. Tested by `resolve_shading` (clear/solid/pct25/nil/texture). | | **Named / Custom Paragraph Styles** | Yes | Yes | Yes | The full style catalog round-trips through DOCX `styles.xml`: custom styles created in the style editor, plus edits to built-in `Normal`/`Heading1`–`6`, persist across save/reload. Font family, weight (→ bold), size, alignment, indentation (incl. first-line), spacing, line height, `basedOn`, `next`-style, outline level, and the custom flag are all written and read back. In-session, the catalog round-trips through the Loro CRDT (`loro_bridge::styles`, a JSON snapshot like metadata), so style-editor edits are durable across rebuilds and **undoable** with Ctrl+Z/Ctrl+Y. | | **border_between** | Yes | No | No | Rules between adjacent same-styled paragraphs ignored. | | **bidi / RTL** | Yes | No | No | RTL paragraphs ignored due to Parley API limitations. | +| **Drop Cap** | Yes | Yes | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Rendered** 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 (`para_drop_cap.rs` + `layout_paragraph`). Word encodes a drop cap as a `w:framePr` frame paragraph holding just the initial followed by the body paragraph; a DOCX import pass (`drop_cap_merge.rs`) folds the frame into the body so the single-paragraph (ODF-style) renderer applies. The cap is sized so its ascent spans the line band, coloured/weighted from the initial's run, positioned with its top at the first line and baseline near the last spanned line; `margin` mode hangs it in the left margin. **Per-line precision (read-only):** body lines *beside* the cap are narrowed while lines *below* it reclaim the full column width — Parley exposes no public per-line max-advance, so the body is laid out in two passes (`para_band`: narrow band lines + full-width reflow of the tail, stacked). **Live editor (`preserve_for_editing`):** the enlarged initial is now rendered in the editor too, with the body laid out as a single hit-testable Parley layout (the cap bytes are trimmed and the orig↔clean maps rebased past them, and `ParagraphLayout::drop_lines`/`drop_shift` keep the caret, hit-test, and selection geometry aligned with the shifted lines). **Limitations:** the editor body uses the uniform-narrow fallback (lines below the cap are slightly narrow), and cursor granularity *around* the cap is coarse — the cap collapses to the body-start caret position, so it is deletable (Backspace from the body start) but not independently selectable. Not re-exported, not round-tripped through Loro. Tested: `frame_pr_*` + `drop_cap_merge` (DOCX), `read_stylesheet_drop_cap`/`drop_cap_*` (ODT), `cap_byte_len_*` + `drop_cap_enlarges_initial_and_shifts_first_lines` / `drop_cap_enlarged_and_hit_testable_in_editor` (layout). ACID TC-DOCX-015 (Word ref) and TC-ODT-013 both visually verified — the ODT path (`acid_odt.odt`, the `T` initial of the case label) renders identically via the shared `para_band` renderer: enlarged initial over three lines, body wrapping to its right, the tail lines reclaiming full width. | --- @@ -70,13 +72,16 @@ This is the living source of truth documenting which document features, characte | Feature | Import | Layout/Render | Export | Notes | | :--- | :---: | :---: | :---: | :--- | -| **Column Widths** | Yes | Yes | Yes | Mapped using `ColWidth` and `TableWidth` specs. | +| **Column Widths** | Yes | Yes | Yes | Mapped using `ColWidth` and `TableWidth` specs. **Cell content that does not fit a column wraps within it** — an over-long unbreakable word breaks to the column width (CSS `overflow-wrap: anywhere`, set on cell paragraphs via `FlowState::break_long_words`), so the row grows tall like Word instead of the word overflowing horizontally into the next cell (TC-DOCX-006). Tested by `long_word_wraps_within_narrow_cell`. **Fixed vs autofit layout is honoured:** `w:tblLayout w:type="fixed"` is parsed (`DocxTblPr::layout`) and carried into the model as the `table-fixed-layout` class (`TABLE_FIXED_LAYOUT_CLASS`); for such tables `resolve_column_widths` (`flow.rs`) honours the grid (`gridCol`) widths exactly — the table over/underflows rather than rescaling — matching Word (TC-DOCX-006). Autofit tables (the default) still rescale fixed widths proportionally to the table width. Tested by `fixed_columns_should_be_honored_like_word` (layout) and `tbl_layout_fixed_marks_table_class` / `tbl_layout_autofit_has_no_fixed_class` (DOCX mapper); the prior rescale behaviour for autofit is locked by `fixed_columns_*_current_behavior`. **Cell content is hard-clipped to the cell box:** each cell's flowed content is wrapped in a `PositionedItem::ClippedGroup` whose rect is the cell box (`flow.rs`), so over-wide content (a wide image, an unbreakable token exceeding a fixed column) is masked at the cell boundary rather than bleeding into neighbours — matching Word. Both renderers honour the clip: `loki-vello` pushes a Vello clip layer; `loki-pdf` emits `re W n` inside `q`/`Q`. Cell backgrounds/borders stay outside the clip so they paint fully. Tested by `cell_content_is_clipped_to_cell_box` (layout) and `clipped_group_emits_clip_operators` (PDF). Known gap: a cell whose content spills onto a later page is not clipped on the continuation page (single-page cells only — per-page rects deferred). | | **Row Heights** | Yes | Yes | Yes | Evaluated dynamically based on cell content. | -| **Column Spanning** | Yes | Yes | Yes | Supports horizontal cell merging. | -| **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). | +| **Column Spanning** | Yes | Yes | Yes | Supports horizontal cell merging (`w:gridSpan`). | +| **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). **Combined vMerge + gridSpan (L-merges) place correctly:** the layout assigns each cell its grid columns via a shared coverage grid (`assign_cell_columns`), so a cell in a row whose leading column is occupied by a vertical merge above is shifted into the next free column instead of overlapping the merged cell (TC-DOCX-005). Tested by `vmerge_gridspan_l_merge_places_cells_correctly` and `test_table_row_span_distribution`. | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | +| **Floating Images / Wrap Mode** | Yes | Yes | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Cross-paragraph wrap:** a float taller than its anchoring paragraph keeps wrapping the *following* paragraphs (tracked as `flow::float_impl::ActiveFloat`) until its bottom is cleared; bounded to a single page and to consecutive plain paragraphs (a table/list/rule or page break reserves the float's remaining height instead). **Limitations (gap #12):** the live-editor path keeps the uniform-narrow fallback; the tight wrap **contour** is approximated by the bounding box; true `behindDoc` overlap and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported to DOCX/ODT. **EPUB export** now maps a side-wrapping float to a CSS `float:left/right` on the `` so the reflowable book flows text around it (the reflow-target equivalent of the paginated wrap band; `behind`/`TopAndBottom` stay block-level). **ODT parity:** a non-`as-char` `draw:frame` image carrying a `style:wrap` graphic style is now mapped to a floating inline image (wrap + `FLOATING_CLASS`) in its anchoring paragraph — the same representation as the DOCX path — and the frame's `svg:width`/`svg:height` are carried as `cx_emu`/`cy_emu` (without which the layout treated every ODT image as zero-sized and skipped it). Tested: `flow_float` unit tests + `tall_float_wraps_following_paragraph` + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` + `floating_image_frame_becomes_inline_float_with_size` (ODT). ACID TC-DOCX-023 and TC-ODT-007 both visually verified (image at the left margin, text wrapping to its right and reclaiming full width below the frame). | | **External Images** | Yes | No | No | Renders as gray placeholder rectangles. | +| **Nested Tables** | Yes | Yes | No | A table nested inside a cell is imported in **both formats**. **DOCX:** a `w:tbl` inside a `w:tc` — the DOCX cell model holds ordered block content (`Vec`), the reader recurses into the inner table, and the mapper produces a `Block::Table` inside the cell's blocks (interleaved with paragraphs in document order). **ODT:** a `table:table` inside a `table:table-cell` — the ODF cell model now holds ordered block content (`OdfTableCell.content: Vec`, replacing the paragraph-only field), the reader recurses through `read_table`, and the cell mapper flows each child through `map_body_children`, which dispatches a nested table back through `map_table` (lists are likewise now preserved as `Block::BulletList`/`OrderedList` rather than flattened to 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. The ODF document mapper (`odt/mapper/document.rs`, formerly 1378 lines) was split into a `document/` directory (`mod`/`inlines`/`frames`/`blocks`/`page`/`meta` + moved tests) so the cell mapper could grow under the file-size ceiling. Tested by `nested_table_in_cell_maps_to_table_block` (DOCX), `nested_table_in_cell_is_parsed_into_cell_content` (ODT reader), and `nested_table_in_cell_maps_to_inner_table_block` (ODT mapper). Not re-exported. | +| **Page Number Format** | Yes | Yes | No | The page-number numbering scheme is imported in **both formats** into `PageLayout.page_number_format` and applied through `FieldContext`: the PAGE field is formatted via the shared list-marker converter (`lowerRoman`→i, ii, iii; `upperRoman`; `lowerLetter`/`upperLetter`); NUMPAGES stays decimal. **DOCX:** `w:pgNumType @w:fmt`/`@w:start` — the section restart (`page_number_start`) also offsets the displayed number from the section's first physical page. **ODT:** `style:num-format` on `style:page-layout-properties` (the active master page's layout) is read into `OdfPageLayout.num_format` and mapped through the same `map_numbering_scheme` helper shared with list numbering; decimal (`"1"`/absent) is left unset (the renderer default). Tested by `substitution_formats_page_number_as_lower_roman` (DOCX), `read_stylesheet_page_layout_num_format` (ODT reader), and `page_num_format_lower_roman_maps`/`page_num_format_upper_alpha_maps`/`page_num_format_decimal_and_absent_stay_none` (ODT mapper). ODT page-number *restart* (the per-paragraph `style:page-number`) remains a follow-up. Not re-exported. | --- @@ -111,6 +116,45 @@ When a document requests a font that is unavailable locally, Loki automatically - **Calibri** → **Carlito** - **Cambria** → **Caladea** +The substitute faces are **embedded in the layout engine and registered lazily** (`loki-layout` `FontResources::resolve_font_name`) the first time a substitute is requested but not already in the font collection. This guarantees the substitution works in headless contexts — **PDF/EPUB export, CI, the ACID harness, and fresh installs before the desktop installer places the fonts system-wide** — not just on a fully-installed desktop. Previously the bundled faces were registered only on Android, so on desktop/headless a requested "Calibri" resolved to itself, was not found, and Parley fell back to a wider font lacking some glyphs (digits rendered as `.notdef`, and list markers / Calibri text reflowed). Tested by `substituted_family_is_actually_available` and `fallback_font_blobs_embedded_on_all_targets` (`loki-layout` `font::tests`). + +**Default-style inheritance:** a paragraph with no explicit style (`w:pStyle`) now resolves through the document's **default paragraph style** (`StyleCatalog::default_paragraph_style`, set by the DOCX mapper to the `w:default="1"` paragraph style — typically `Normal`, rooted at `w:docDefaults`). Both the paragraph and character resolution paths (`loki-layout` `resolve_para_props` / `flatten_paragraph`) honour it via `effective_paragraph_style`. This means default-font body text inherits the document base font (e.g. Calibri → Carlito) instead of importing with `font_name = None` and rendering in the engine's default face. Tested by `default_paragraph_style_resolves_doc_default_font` / `explicit_default_paragraph_style_is_preferred` (`loki-ooxml`) and `effective_paragraph_style_falls_back_to_default` (`loki-doc-model`). (ODT's `style:default-style` is not yet wired into this field — a separate follow-up.) + +### UI / renderer font registration (synchronous, all platforms) + +The bundled fonts are registered into the **UI renderer's** font collection +(Blitz/parley `FontContext`) **synchronously at launch**, separately from the +document layout engine above. The apps pass `loki_fonts::ui_font_blobs()` — the +*Atkinson Hyperlegible Next* UI typeface plus the five metric-compatible +families — through `dioxus::native::Config::with_fonts(..)`, which threads them +into `blitz-dom`'s `DocumentConfig.extra_fonts`; `BaseDocument::new` registers +each blob on top of the system fonts and the default bullet font (see +[patches.md](patches.md), blitz-dom item 7 / dioxus-native). + +This replaced the previous mechanism — a `document::Style` injecting an +`@font-face` `data:` URI — which depended on the renderer's **asynchronous** +network-provider resource fetch. That async path did not load the UI typeface on +Android (the chrome fell back to a wide system font, so digits in the status +bar / ruler rendered noticeably wide). Because the font bytes are known at +compile time, synchronous registration is the correct layer and works uniformly +on desktop, Android, and iOS. The blobs are validated non-empty by +`ui_font_blobs_includes_ui_face_plus_fallbacks` (`loki-fonts`). + +### Known gap — symbol-glyph fallback on macOS + +List bullet markers are inserted as plain text in the **body font** (the +`w:rFonts="Symbol"`/`Wingdings` override stored on the list level is currently +ignored), and most ACID bullets use real Unicode glyphs (`●` U+25CF, `○` U+25CB, +`■` U+25A0, `♦` U+2666). On Linux/Windows these resolve through the bundled/system +fallback chain, but **on macOS some symbol glyphs (the `♦` diamond bullets on +page 7, the icon next to the bookmark target on page 16) are dropped** — the +embedded metric-compatible faces (Arimo/Carlito/…) carry no dingbat coverage, and +macOS's CoreText fallback does not supply these code points the way fontconfig +does on Linux. The robust fix is to register a symbol-capable fallback face (e.g. +an OpenSymbol/DejaVu blob) in `loki-fonts` so the fallback chain always finds +these glyphs; it needs verification on macOS hardware (not reproducible in the +Linux CI/headless environment), so it is tracked here rather than fixed blind. + ### Font Substitution Alerts When any font substitutions or missing fonts (such as **Aptos**, which has no open-source metric-compatible fallback) are detected in a document, Loki: 1. Flags the substitution/missing font status in the document's layout context. @@ -170,6 +214,7 @@ OCF (ZIP) container. | **CMYK colour + OutputIntent** | `loki-pdf` | Yes | All text/graphics emitted in DeviceCMYK with a PDF/X `OutputIntent`. Default printing condition is FOGRA39. An ICC `DestOutputProfile` is embedded only when supplied via `OutputIntent::icc_profile`; otherwise the registered condition identifier is referenced (supply a licensed profile for full certification). | | **XMP + Info + trailer ID + Trapped** | `loki-pdf` | Yes | XMP metadata packet, Document Info dictionary, trailer `/ID`, and `Trapped` flag all written (PDF/X requirements). | | **Text decorations / rules / borders / fills** | `loki-pdf` | Yes | Underline/strikethrough/overline, horizontal rules, table borders and cell fills emitted as CMYK fills. | +| **`.notdef` glyph filtering** | `loki-pdf` | Yes | Glyph id 0 (`.notdef`) is skipped on export, matching the on-screen `loki-vello` renderer (`glyph.rs`). Without this the PDF showed tofu boxes where the layout emits `.notdef` — notably the glyph Parley shapes for tab characters (`\t`), whose advance is supplied separately by the tab-stop inline box. Tested by `notdef_only_run_emits_nothing` / `notdef_is_filtered_from_real_run`. Note: this only removes spurious *ink* — it does not fix list-marker numbers shaped in a digit-less font, nor tab *alignment* types (right/center/decimal) and leaders, which remain separate gaps. | | **Images in PDF** | `loki-pdf` | Yes | `data:` URI images are decoded, converted to **DeviceCMYK**, Flate-compressed, and embedded as image XObjects; transparency is preserved via a DeviceGray soft mask. CMYK conversion is the naive transform (no ICC); subsetting/recompression of already-CMYK sources is not yet optimised. | | **Clipping / rotation in PDF** | `loki-pdf` | Partial | `ClippedGroup` renders children without the clip mask; `RotatedGroup` renders at the group origin without rotation (over-paint preferred to omission). | | **EPUB 3.3 container** | `loki-epub` | Yes | OCF ZIP with `mimetype` stored first, `META-INF/container.xml`, package document, navigation document, one XHTML content document, a stylesheet, and packaged image resources. | diff --git a/docs/patches.md b/docs/patches.md index db9d8040..07fc8ed0 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -122,6 +122,37 @@ focusable `
`, so tapping it raises the keyboard while tapping a ribbon `