diff --git a/Cargo.lock b/Cargo.lock index c43260cb..db0f5afa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4411,6 +4411,7 @@ dependencies = [ "loki-layout", "loki-odf", "loki-ooxml", + "loki-opc", "loki-pdf", "loki-presentation-model", "loki-primitives", @@ -4653,6 +4654,7 @@ dependencies = [ "flate2", "image", "loki-doc-model", + "loki-fonts", "loki-layout", "pdf-writer", "subsetter", @@ -4730,6 +4732,7 @@ dependencies = [ "loki-fonts", "loki-layout", "loki-odf", + "loki-ooxml", "thiserror 2.0.18", "vello_cpu 0.0.9", ] diff --git a/appthere-conformance/examples/rasterize_pdf.rs b/appthere-conformance/examples/rasterize_pdf.rs index e4ec3ef8..9daa7bbd 100644 --- a/appthere-conformance/examples/rasterize_pdf.rs +++ b/appthere-conformance/examples/rasterize_pdf.rs @@ -2,8 +2,10 @@ // Copyright 2026 AppThere Loki contributors //! CLI wrapper over the pinned PDF→PNG rasterizer (Spec 02 D3), used by -//! `scripts/generate-odf-goldens.sh` so golden generation goes through the -//! exact same stage as every other rasterization. +//! `scripts/generate-odf-goldens.sh` (scripted LibreOffice path) and +//! `scripts/generate-office-goldens.sh` (ingest a PDF printed from +//! Word/Excel/LibreOffice) so golden generation goes through the exact same +//! stage as every other rasterization. //! //! Usage: `cargo run -p appthere-conformance --example rasterize_pdf -- ` diff --git a/appthere-conformance/fixtures/docx/acid-docx.docx b/appthere-conformance/fixtures/docx/acid-docx.docx new file mode 100644 index 00000000..3e932ce9 Binary files /dev/null and b/appthere-conformance/fixtures/docx/acid-docx.docx differ diff --git a/appthere-conformance/fixtures/docx/acid2-docx.docx b/appthere-conformance/fixtures/docx/acid2-docx.docx new file mode 100644 index 00000000..d89ae9cb Binary files /dev/null and b/appthere-conformance/fixtures/docx/acid2-docx.docx differ diff --git a/appthere-conformance/fixtures/docx/iris-blueprint.docx b/appthere-conformance/fixtures/docx/iris-blueprint.docx new file mode 100644 index 00000000..7ab005dd Binary files /dev/null and b/appthere-conformance/fixtures/docx/iris-blueprint.docx differ diff --git a/appthere-conformance/goldens/docx/acid-docx/PENDING.txt b/appthere-conformance/goldens/docx/acid-docx/PENDING.txt new file mode 100644 index 00000000..0536d2f9 --- /dev/null +++ b/appthere-conformance/goldens/docx/acid-docx/PENDING.txt @@ -0,0 +1,27 @@ +Golden capture PENDING — no reference render committed yet. + +The visual golden for this DOCX fixture is Microsoft Word's own render, +which cannot be produced headlessly on Linux/CI. To capture it, on a +machine with Microsoft Office: + + 1. Open appthere-conformance/fixtures/docx/acid-docx.docx in Word. + 2. Export/Print to PDF at default ("Standard") settings. + 3. Run, from a checkout with poppler-utils installed: + + scripts/generate-office-goldens.sh \ + --format docx --stem acid-docx \ + --pdf .pdf \ + --reference "Microsoft 365 Word ()" \ + --fixture appthere-conformance/fixtures/docx/acid-docx.docx + +This writes page-N.png + GENERATION.txt here, replacing this file. The +DOCX visual-golden test (loki-render-cpu/tests/visual_golden_docx.rs) +then compares Loki's render against them; until then it is a no-op. + +NOTE (acid-docx): this stress fixture contains Japanese text and Loki +currently lacks the ICU4X `ja` line-segmentation model ("No segmentation +model for language: ja" at layout time). Until that is bundled, the CJK +line-breaking is not stable across builds, so a committed golden for this +fixture may compare unequal even when the layout is otherwise correct. +Bundle the `ja` segmenter before capturing this golden. The CJK-free +`iris-blueprint` fixture has no such caveat. diff --git a/appthere-conformance/goldens/docx/acid2-docx/PENDING.txt b/appthere-conformance/goldens/docx/acid2-docx/PENDING.txt new file mode 100644 index 00000000..ef68b1c1 --- /dev/null +++ b/appthere-conformance/goldens/docx/acid2-docx/PENDING.txt @@ -0,0 +1,37 @@ +Golden capture PENDING — no reference render committed yet. + +The visual golden for this DOCX fixture is Microsoft Word's own render, +which cannot be produced headlessly on Linux/CI. To capture it, on a +machine with Microsoft Office: + + 1. Open appthere-conformance/fixtures/docx/acid2-docx.docx in Word. + 2. Export/Print to PDF at default ("Standard") settings. + 3. Run, from a checkout with poppler-utils installed: + + scripts/generate-office-goldens.sh \ + --format docx --stem acid2-docx \ + --pdf .pdf \ + --reference "Microsoft 365 Word ()" \ + --fixture appthere-conformance/fixtures/docx/acid2-docx.docx + +This writes page-N.png + GENERATION.txt here, replacing this file. The +DOCX visual-golden test (loki-render-cpu/tests/visual_golden_docx.rs) +then compares Loki's render against them; until then it is a no-op. + +NOTE (acid2-docx): this fixture is *hand-authored OOXML*, not a +Word-authored document — it is packaged from reviewable XML parts by +`cargo run -p loki-acid --example gen_acid2_docx`. Regenerate the fixture +(and this golden's provenance) from that generator if the parts change. +Unlike acid-docx it is deliberately CJK-free, so it has no `ja` +line-segmentation caveat. It combines features into four realistic +archetypes (business report, résumé, newsletter, invoice/contract) plus a +feature-matrix appendix, so a failing page maps to an archetype rather +than a single isolated construct. + +Known Loki gaps this fixture currently surfaces (candidate golden-diff +regions once the reference lands): table-style (`w:tblStyle`) borders, +decimal tab stops *inside* table cells, floating text boxes (`wps` +shapes), page borders (`w:pgBorders`), line numbering (`w:lnNumType`), +pattern/gradient cell shading, run text effects (emboss/imprint/shadow), +character borders (`w:bdr`), and `w:noBreakHyphen`/`w:softHyphen` glyphs. +See docs/fidelity-status.md. diff --git a/appthere-conformance/goldens/docx/iris-blueprint/PENDING.txt b/appthere-conformance/goldens/docx/iris-blueprint/PENDING.txt new file mode 100644 index 00000000..b5ee6bdd --- /dev/null +++ b/appthere-conformance/goldens/docx/iris-blueprint/PENDING.txt @@ -0,0 +1,19 @@ +Golden capture PENDING — no reference render committed yet. + +The visual golden for this DOCX fixture is Microsoft Word's own render, +which cannot be produced headlessly on Linux/CI. To capture it, on a +machine with Microsoft Office: + + 1. Open appthere-conformance/fixtures/docx/iris-blueprint.docx in Word. + 2. Export/Print to PDF at default ("Standard") settings. + 3. Run, from a checkout with poppler-utils installed: + + scripts/generate-office-goldens.sh \ + --format docx --stem iris-blueprint \ + --pdf .pdf \ + --reference "Microsoft 365 Word ()" \ + --fixture appthere-conformance/fixtures/docx/iris-blueprint.docx + +This writes page-N.png + GENERATION.txt here, replacing this file. The +DOCX visual-golden test (loki-render-cpu/tests/visual_golden_docx.rs) +then compares Loki's render against them; until then it is a no-op. diff --git a/appthere-conformance/src/corpus/manifest.rs b/appthere-conformance/src/corpus/manifest.rs index cfeb74a0..07cfe48f 100644 --- a/appthere-conformance/src/corpus/manifest.rs +++ b/appthere-conformance/src/corpus/manifest.rs @@ -22,9 +22,23 @@ pub const LIBREOFFICE_24_2: Reference = Reference { version: "24.2", }; +/// The Microsoft Word reference for the OOXML/DOCX visual goldens. The exact +/// build is recorded per golden in its `GENERATION.txt` (Word can't be +/// automated headlessly, so its goldens are captured manually via +/// `scripts/generate-office-goldens.sh`); this names the authority. +pub const MICROSOFT_365: Reference = Reference { + app: "Microsoft 365 Word", + version: "manual capture (see GENERATION.txt)", +}; + /// All three axes apply (the common case for the visual baseline fixtures). const ALL_AXES: &[Axis] = &[Axis::Schema, Axis::RoundTrip, Axis::Visual]; +/// The visual axis only — for fixtures whose reference is Microsoft Word (the +/// visual authority for OOXML) and whose round-trip/schema axes have not yet +/// been vetted against these specific documents. +const VISUAL_ONLY: &[Axis] = &[Axis::Visual]; + /// The committed on-disk corpus. Every entry's document must exist under /// [`fixtures_root`]; entries carrying [`Axis::Visual`] also have a golden /// directory under [`goldens_root`]. @@ -58,6 +72,42 @@ pub const MANIFEST: &[FixtureMeta] = &[ reference: Some(LIBREOFFICE_24_2), tolerance_override: None, }, + // OOXML/DOCX visual fixtures. References come from Microsoft Word (the + // OOXML authority) and are captured manually — the golden dirs currently + // hold a PENDING.txt (see it for the capture steps), so the DOCX + // visual-golden test is a documented no-op until the page PNGs land. + FixtureMeta { + id: "acid-docx", + format: Format::Docx, + feature: "ACID rendering stress suite (line spacing, tables, lists, tabs, fields)", + severity: Severity::P0, + axes: VISUAL_ONLY, + reference: Some(MICROSOFT_365), + tolerance_override: None, + }, + FixtureMeta { + id: "iris-blueprint", + format: Format::Docx, + feature: "Real-world structured document (headings, bullet lists, shaded/bordered callout tables)", + severity: Severity::P1, + axes: VISUAL_ONLY, + reference: Some(MICROSOFT_365), + tolerance_override: None, + }, + // ACID 2 — realistic archetypes (report, résumé, newsletter, invoice) that + // combine features densely, plus an isolated feature-matrix appendix. Unlike + // acid-docx it is hand-authored OOXML (see `loki-acid`'s `gen_acid2_docx`), + // so it can exercise constructs Loki cannot yet emit. Golden is Word's render + // of this same file; visual test is a no-op until the page PNGs land. + FixtureMeta { + id: "acid2-docx", + format: Format::Docx, + feature: "ACID 2 combined-feature archetypes (report/résumé/newsletter/invoice) + feature-matrix appendix", + severity: Severity::P0, + axes: VISUAL_ONLY, + reference: Some(MICROSOFT_365), + tolerance_override: None, + }, ]; /// The corpus root: `appthere-conformance/fixtures/`. diff --git a/docs/audit-2026-06.md b/docs/audit-2026-06.md index ed92afd8..170dea97 100644 --- a/docs/audit-2026-06.md +++ b/docs/audit-2026-06.md @@ -108,6 +108,18 @@ plus a few robustness papercuts. calling `parse_table`, and a nested-table field on `DocxTableCell`). **If** that recursion is added, it reintroduces the depth concern from S-1, so pair it with a `MAX_NESTING_DEPTH` guard at that point. +- **Resolution (2026-07-20):** Nested tables are parsed (the `tbl` arm calls + `parse_table` recursively) and guarded by `MAX_NESTING_DEPTH` in + `docx/reader/document_table.rs`. The guard rejects on depth *count*, but reaching + the rejection still recurses that many table levels first, and one table level + costs three stack frames (`parse_table` → `parse_table_row` → `parse_table_cell`). + At the original limit of 100 that is ~300 frames — enough to overflow a 2-MiB + worker-thread stack *before* the guard fires (the + `excessive_table_nesting_is_rejected_not_stack_overflow` regression test + reproduced this on the default 2-MiB test stack). The limit was lowered to **50** + so the recursion stays comfortably within a 2-MiB stack while remaining ~10× the + deepest real documents. The `w:sdt` content-control parser (1 frame/level) shares + the same budget. ### S-2 — Unsanitized dimension values from file → unit conversion *(Verified · Low · P2)* diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index b5dc732f..c0dc7924 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -86,7 +86,7 @@ BM-3** (render-cost proxy) and the ACID headless-raster registry item. | 3.2 | B-6 | ✅ **Done 2026-07-05** — ISO 29500-4:2016 Transitional + mce, ECMA-376 Part-2 OPC, OASIS ODF 1.3 RNG + MathML3 vendored with PROVENANCE (sha256); real DOCX/ODT exports schema-validated incl. malformed-part negative tests. Tails: Strict XSDs, Dublin Core imports for core.xml (documented in `schemas/README.md`). | M | | 3.3 | B-1 | ✅ **Done 2026-07-05** — new `loki-render-cpu` crate: `vello_cpu` (=0.0.9) rasterizes the same `PositionedItem` stream the GPU path paints, headless, byte-deterministic (M5 acceptance smoke tests). TODO(conformance-render): decode image items (grey placeholder today). | M | | 3.4 | B-5 | ✅ **Done 2026-07-05** — `appthere_conformance::raster::PdfRasterizer` (pdftoppm pinned flags @ `CONFORMANCE_DPI` 144, version captured, byte-determinism tested). | S | -| 3.5 | B-2, B-3, B-4 | ✅ **Done 2026-07-05** — SSIM+CIEDE2000 worst-region differ with heatmaps (`golden/diff.rs`, Sharma reference pairs verified); 3 ODF goldens committed with GENERATION metadata (`scripts/generate-odf-goldens.sh`); calibration record `goldens/CALIBRATION.md` → `Tolerance::calibrated()` {0.60, 10.0}. **The calibration pass found and quantified fidelity gap #23 (kerning)** — pinned as the `para-carlito` expected-failure canary. OOXML goldens remain the manual Windows/Word COM procedure (§7.2). | M | +| 3.5 | B-2, B-3, B-4 | ✅ **Done 2026-07-05** — SSIM+CIEDE2000 worst-region differ with heatmaps (`golden/diff.rs`, Sharma reference pairs verified); 3 ODF goldens committed with GENERATION metadata (`scripts/generate-odf-goldens.sh`); calibration record `goldens/CALIBRATION.md` → `Tolerance::calibrated()` {0.60, 10.0}. **The calibration pass found and quantified fidelity gap #23 (kerning)** — pinned as the `para-carlito` expected-failure canary. OOXML goldens follow the manual print-from-Office procedure (§7.2), now scripted by `scripts/generate-office-goldens.sh` (ingest a Word/Excel-printed PDF → same pinned rasterizer → `goldens///`); it also serves LibreOffice manual prints for ODF. | M | | 3.6 | B-11 | ✅ **Done 2026-07-05** — all three axes run as cargo tests in the existing `build-and-test` job (xmllint + pdftoppm installed); schema + round-trip are hard gates, visual is advisory-by-construction (known divergence pinned; hardens when kerning lands + recalibration). | S | | 3.7 | B-8, B-9 | **Done ✅ 2026-07-12** — the corpus layer is promoted into `appthere-conformance::corpus` (B-8): the `TC-*` catalog (the four per-format tables moved wholesale; grouping helpers add the *feature* dimension — `by_feature`/`cases_with_severity`), the `Format`/`Severity` vocabulary, and the new `Fixture`/`Consumer` traits (a consumer supplies bytes+metadata and an import/export pair; the crate stays free of Text-specific assumptions). Golden/candidate discovery generalised into `golden::discovery` (root-parameterized). `loki-acid` is the **first consumer**: its `catalog`/`severity` modules are re-export shims, the acid `Fixture` enum implements the shared trait (axes derived from importer availability), `AcidConsumer` wraps the import dispatch (export honestly reports the corpus is import-only), and its golden discovery delegates. B-9: the on-disk corpus is `fixtures//.` + `goldens///` with a code-as-manifest (`corpus::manifest::MANIFEST`) recording per fixture the feature, applicable axes, reference app+version (LibreOffice 24.2 per `CALIBRATION.md`), and justified tolerance overrides (none needed post-kerning-fix); manifest tests assert on-disk existence + visual entries carry references. *Corrected en route:* the spec's "141 TC cases" headline miscounts — the per-format tables (the machine-readable truth) sum to **139**, now asserted. ODP/ODG/PPTX importers stay gated on the unbuilt ACID PPTX generator (§5.1, ratified). | M | diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 1e892260..c87a1ee3 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -15,19 +15,19 @@ This is the living source of truth documenting which document features, characte | **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 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). **Unequal column widths** (5.10, 2026-07-10): per-column widths are modelled (`SectionColumns.widths`) and honoured end-to-end. DOCX `w:cols w:equalWidth="0"` with one `` per column (twips) is read (`reader/sectpr.rs`), mapped (`mapper/document_cols.rs`), and written back; ODF `style:column @style:rel-width` relative shares are read and, on export, re-emitted (the page content area is distributed proportionally on import, so ODF preserves the *ratio* while DOCX preserves exact points). The Loro bridge round-trips the widths as a `;`-joined string (`KEY_COL_WIDTHS`). The flow engine places each column at the running sum of preceding widths + gaps (`flow_columns.rs::column_layout_for`/`column_x_offset`), so a 2:1 split lands the second band past the equal-split boundary. Tested by `unequal_column_widths_round_trip` (DOCX + Loro bridge), `unequal_column_widths_round_trip` (ODF ratio), and `unequal_columns_place_second_band_at_first_width` (layout). **Column height balancing** (5.10, 2026-07-10): a standalone multi-column section that fits on a single page (its own last page) now has its columns **balanced** — filled to roughly equal heights instead of packing column 1 to the page bottom while column 2 sits empty — matching Word. The full flow re-flows the section with the per-page content height capped to the smallest value that still fits every column on one page (a bounded binary search, `flow_balance.rs`); footnote position and page-bottom content are untouched because balancing only runs for single-page, footnote-free sections. Editing a multi-column section falls back to a full (balanced) relayout rather than the fill-first incremental resume (`incremental.rs`). Tested by `short_multi_column_section_balances_across_columns`. **Multi-page last-page balancing** (5.10, 2026-07-12): a multi-page multi-column section now balances its **last page** too — the flow records a verified *tail candidate* (the block that started the newest page plus a resume snapshot; clean-top checkpoints almost never exist for later pages because page breaks happen mid-block), replays the tail uncapped to confirm it reproduces the natural last page, then re-flows it with the balanced cap and splices it in; a last page starting mid-paragraph fails verification and keeps fill-first. The same pass fixed the **routing** so production documents actually balance: `layout_paginated_full` flows every group through `flow_section_group`, which previously never reached the balanced path — single-section groups now delegate to `flow_section` (`flow_group.rs`). Tested by `multi_page_two_column_section_balances_only_the_last_page`, `single_section_group_routes_through_column_balancing`, and `last_page_starting_mid_paragraph_keeps_fill_first`. **Remaining limitations:** `continuous` (multi-section) group tails and sections carrying footnotes keep fill-first; footnotes still flow in the last active column. **Columns are user-editable** (2026-07-06): the Layout ribbon tab's Columns group (one/two/three) applies `set_document_columns` and relayouts. | | **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`). | | **Table of Contents** | Yes | Yes | Yes (ODT) | A `Block::TableOfContents` carries a cached snapshot of level-indented entry paragraphs. **Generated in-editor** (2026-07-07, References ribbon tab): `content::toc::build_toc` walks the document's `Block::Heading`s (down to a depth, default 3) into entry paragraphs; `insert_table_of_contents` inserts one at the caret and `refresh_table_of_contents` rebuilds the snapshot ("update field"). Page numbers are **not** included in the snapshot (they need the paginated layout — like a freshly-inserted, not-yet-updated Word TOC field). **Layout / render:** `flow_block` flows the cached body (previously the `_ => {}` catch-all silently dropped `TableOfContents`/`Index` blocks, so imported or inserted TOCs were invisible — fixed with `flow_blocks`; regression-tested). **Export:** ODT writes the body via `text:*` paragraphs; DOCX TOC-field export is not yet written. | -| **Track Changes (revisions)** | Partial | Partial | Partial | **Model + render** (2026-07-07, Review tab 4a.2). A live tracked-change mark — `CharProps::revision` (`RevisionMark { kind: Insertion/Deletion, author, date, id }`) — rides a run as a CRDT mark (`MARK_REVISION`) and survives an edit cycle (round-trip tested); pure `accept_revisions`/`reject_revisions` transforms and a `DocumentSettings.track_changes` flag exist. **Rendered** (`revision_style.rs`): a tracked run is coloured by its author (deterministic palette) and decorated by kind — insertion **underlined**, deletion **struck through** — derived at layout time (so accept/reject reverts appearance with nothing stored); the existing decoration paint path needs no change. **Editor records insertions:** with track-changes on (`DocumentSettings.track_changes`), typing routes through `insert_text_tracked_at` (mark configured `expand: None` so it doesn't bleed onto adjacent typing), stamping typed text as an insertion by `meta.creator`. **Toggle:** the **Review** ribbon tab's Track Changes button flips the flag via `set_track_changes`; `DocumentSettings` now round-trips through the CRDT (JSON snapshot under `KEY_SETTINGS`), so the flag survives relayout, undo/redo, and save. **Records deletions too:** with tracking on, Backspace/Delete strike text through instead of removing it (`tracked_grapheme_delete` + `delete_action` — the author's own insertion is un-typed, an already-struck grapheme is stepped over, normal text is marked struck). **Accept/Reject:** the Review tab's Accept-all / Reject-all buttons apply `accept_reject_all_revisions` surgically to the live Loro text (clear the mark for kept runs, delete the text for removed runs — one undoable step); they disable once the document is clean, and the sweep reaches revisions **nested in table cells and note bodies** (`collect_all_text_containers` walks the whole block tree — 2026-07-08). **Selection deletions record too** (2026-07-07): deleting a selection (Backspace/Delete/Enter/replace-typing) under tracking strikes it through instead of removing it — `tracked_delete_selection_at` applies `delete_action` per run across the range (own insertions un-typed, already-struck text skipped) and **preserves the paragraph marks between selected blocks** (no merge). **Per-change accept/reject** (2026-07-08): the Review tab's Changes group also has Accept / Reject buttons that resolve the single change **at the caret** (`accept_reject_revision_at` resolves the contiguous `MARK_REVISION` span, then repositions the caret); they enable only when the caret sits on a change (`revision_at`) — and, being path-aware, work inside table cells / note bodies too. **Paragraph-mark deletion** (2026-07-08): Backspace at the start of a top-level paragraph under tracking records a tracked deletion of the *previous* paragraph's mark (¶) — stored as a `Deletion` on that paragraph's `direct_char_props.revision`, the OOXML `w:pPr/w:rPr/w:del` slot, round-tripped through the CRDT — instead of merging; **accept-all** then removes the ¶ (the paragraphs merge) and **reject-all** clears the mark (`set_para_mark_deletion` records; the `para_mark` sweep resolves, recursing into cells/notes). No ¶ glyph is painted, so a recorded para-mark deletion is invisible until reviewed. **It round-trips through DOCX** (2026-07-08): the paragraph mark's `w:pPr/w:rPr` carries a `w:del`/`w:ins` (`DocxMarkRevision`), read back into `direct_char_props.revision` (`paragraph_mark_deletion_round_trips`). **DOCX import/export** (2026-07-07): tracked runs round-trip through OOXML — `w:ins`/`w:del` wrappers carry `w:id`/`w:author`/`w:date`, deleted text is written as `w:delText`, and both map to/from `CharProps::revision` (`revision_round_trip.rs`). **ODT import/export** (2026-07-07): tracked runs round-trip through ODF's change model — a leading `text:tracked-changes` table holds one `text:changed-region` per change (`text:insertion`/`text:deletion` + `office:change-info` with `dc:creator`/`dc:date`; a deletion stows its removed `text:p` text), and the body brackets an insertion with `text:change-start`/`text:change-end` and marks a deletion with a `text:change` point, all keyed by `text:change-id` (`loki-odf`'s `revision_round_trip.rs`). Recording a para-mark deletion works inside table cells / note bodies too (2026-07-08): Backspace-at-start there records via the path-aware `set_para_mark_deletion_at`, and the accept/reject sweep already recurses into those containers. The **per-change** Accept/Reject buttons resolve a para-mark deletion too (2026-07-08): when the caret sits on a paragraph whose ¶ is struck (and no text-run change is at the caret), they fall back to `accept_reject_para_mark_at` / `para_mark_at`. **Struck ¶ rendered (2026-07-11):** a tracked paragraph-mark deletion now paints a struck, author-coloured end-of-paragraph marker — two short stems + a strikethrough segment after the last line's text, emitted as paint-only items (`para_underlays::emit_para_mark_deletion` off `ResolvedParaProps::para_mark_deleted_color`), so caret placement, hit-testing, and wrapping are untouched (the constraint that had deferred it). Root-cause fix included: `flatten_paragraph` no longer bleeds the ¶'s revision onto the paragraph's runs (the whole text used to render struck); regression-locked by `tracked_para_mark_deletion_renders_struck_marker_without_striking_text`. **ODF ¶-deletion round-trips too (2026-07-11):** export emits an end-of-paragraph `text:change` point whose deletion region stows only the paragraph break (an empty `text:p` — LibreOffice's shape), and import maps that shape back onto the paragraph's `direct_char_props.revision` rather than materialising a struck empty run (`tracked_paragraph_mark_deletion_round_trips`). Track changes is now feature-complete for the editor path **and** both format round-trips. (The older opaque `TrackedChange` type is for format round-trip only and stays unwired.) | +| **Track Changes (revisions)** | Partial | Partial | Partial | **Model + render** (2026-07-07, Review tab 4a.2). A live tracked-change mark — `CharProps::revision` (`RevisionMark { kind: Insertion/Deletion, author, date, id }`) — rides a run as a CRDT mark (`MARK_REVISION`) and survives an edit cycle (round-trip tested); pure `accept_revisions`/`reject_revisions` transforms and a `DocumentSettings.track_changes` flag exist. **Rendered** (`revision_style.rs`): a tracked run is coloured by its author (deterministic palette) and decorated by kind — insertion **underlined**, deletion **struck through** — derived at layout time (so accept/reject reverts appearance with nothing stored); the existing decoration paint path needs no change. **Editor records insertions:** with track-changes on (`DocumentSettings.track_changes`), typing routes through `insert_text_tracked_at` (mark configured `expand: None` so it doesn't bleed onto adjacent typing), stamping typed text as an insertion by `meta.creator`. **Toggle:** the **Review** ribbon tab's Track Changes button flips the flag via `set_track_changes`; `DocumentSettings` now round-trips through the CRDT (JSON snapshot under `KEY_SETTINGS`), so the flag survives relayout, undo/redo, and save. **Records deletions too:** with tracking on, Backspace/Delete strike text through instead of removing it (`tracked_grapheme_delete` + `delete_action` — the author's own insertion is un-typed, an already-struck grapheme is stepped over, normal text is marked struck). **Accept/Reject:** the Review tab's Accept-all / Reject-all buttons apply `accept_reject_all_revisions` surgically to the live Loro text (clear the mark for kept runs, delete the text for removed runs — one undoable step); they disable once the document is clean, and the sweep reaches revisions **nested in table cells and note bodies** (`collect_all_text_containers` walks the whole block tree — 2026-07-08). **Selection deletions record too** (2026-07-07): deleting a selection (Backspace/Delete/Enter/replace-typing) under tracking strikes it through instead of removing it — `tracked_delete_selection_at` applies `delete_action` per run across the range (own insertions un-typed, already-struck text skipped) and **preserves the paragraph marks between selected blocks** (no merge). **Per-change accept/reject** (2026-07-08): the Review tab's Changes group also has Accept / Reject buttons that resolve the single change **at the caret** (`accept_reject_revision_at` resolves the contiguous `MARK_REVISION` span, then repositions the caret); they enable only when the caret sits on a change (`revision_at`) — and, being path-aware, work inside table cells / note bodies too. **Paragraph-mark deletion** (2026-07-08): Backspace at the start of a top-level paragraph under tracking records a tracked deletion of the *previous* paragraph's mark (¶) — stored as a `Deletion` on that paragraph's `direct_char_props.revision`, the OOXML `w:pPr/w:rPr/w:del` slot, round-tripped through the CRDT — instead of merging; **accept-all** then removes the ¶ (the paragraphs merge) and **reject-all** clears the mark (`set_para_mark_deletion` records; the `para_mark` sweep resolves, recursing into cells/notes). No ¶ glyph is painted, so a recorded para-mark deletion is invisible until reviewed. **It round-trips through DOCX** (2026-07-08): the paragraph mark's `w:pPr/w:rPr` carries a `w:del`/`w:ins` (`DocxMarkRevision`), read back into `direct_char_props.revision` (`paragraph_mark_deletion_round_trips`). **DOCX import/export** (2026-07-07): tracked runs round-trip through OOXML — `w:ins`/`w:del` wrappers carry `w:id`/`w:author`/`w:date`, deleted text is written as `w:delText`, and both map to/from `CharProps::revision` (`revision_round_trip.rs`). **ODT import/export** (2026-07-07): tracked runs round-trip through ODF's change model — a leading `text:tracked-changes` table holds one `text:changed-region` per change (`text:insertion`/`text:deletion` + `office:change-info` with `dc:creator`/`dc:date`; a deletion stows its removed `text:p` text), and the body brackets an insertion with `text:change-start`/`text:change-end` and marks a deletion with a `text:change` point, all keyed by `text:change-id` (`loki-odf`'s `revision_round_trip.rs`). Recording a para-mark deletion works inside table cells / note bodies too (2026-07-08): Backspace-at-start there records via the path-aware `set_para_mark_deletion_at`, and the accept/reject sweep already recurses into those containers. The **per-change** Accept/Reject buttons resolve a para-mark deletion too (2026-07-08): when the caret sits on a paragraph whose ¶ is struck (and no text-run change is at the caret), they fall back to `accept_reject_para_mark_at` / `para_mark_at`. **Struck ¶ rendered (2026-07-11):** a tracked paragraph-mark deletion now paints a struck, author-coloured end-of-paragraph marker — two short stems + a strikethrough segment after the last line's text, emitted as paint-only items (`para_underlays::emit_para_mark_deletion` off `ResolvedParaProps::para_mark_deleted_color`), so caret placement, hit-testing, and wrapping are untouched (the constraint that had deferred it). Root-cause fix included: `flatten_paragraph` no longer bleeds the ¶'s revision onto the paragraph's runs (the whole text used to render struck); regression-locked by `tracked_para_mark_deletion_renders_struck_marker_without_striking_text`. **ODF ¶-deletion round-trips too (2026-07-11):** export emits an end-of-paragraph `text:change` point whose deletion region stows only the paragraph break (an empty `text:p` — LibreOffice's shape), and import maps that shape back onto the paragraph's `direct_char_props.revision` rather than materialising a struck empty run (`tracked_paragraph_mark_deletion_round_trips`). **Display mode (Show Markup, 2026-07-20):** rendering is no longer hardwired to "All Markup" — `LayoutOptions::revision_display` (`RevisionDisplay::AllMarkup`/`Final`/`Original`) selects a **non-destructive** view over the same document, mirroring Word's Review-tab "Show Markup" dropdown. `Final` (No Markup) renders the accepted document — deletions hidden, insertions as normal un-coloured text; `Original` renders the rejected document — insertions hidden, deletions as normal text; the revision marks are never touched (unlike accept/reject). Implemented as a pre-flatten inline filter (`revision_filter::display_inlines`: drops the hidden runs, strips the revision mark off the shown ones so `revision_style` adds no colour/decoration — borrowed unchanged on the All-Markup / no-revision common path) plus ¶-marker suppression in the non-markup views. The mode threads from an ambient `loki_renderer::revision` (the same pattern as the spell state) into the paint path; the editor's own hit-test layout stays All-Markup so caret↔document offset mapping remains exact (switching the view is read-only). Verified end-to-end by rendering the ACID2 fixture's tracked "2.0%→1.5%" edit in all three modes (struck+underlined / "1.5%" / "2.0%"); tested by `revision_display_modes_change_flattened_text_and_decoration` + the `revision_filter` unit tests. **Still pending:** the Review-tab **Show Markup dropdown** UI (the capability + ambient are wired; the ribbon control that calls `loki_renderer::revision::set_display` and forces a relayout is not yet built); and paragraph *merge* in the Final view (a struck ¶ is simply not marked, not merged). Track changes is otherwise feature-complete for the editor path **and** both format round-trips. (The older opaque `TrackedChange` type is for format round-trip only and stays unwired.) | | **Footnotes & Endnotes** | Yes | Yes | Yes | Rendered at end of section with separator rules. **DOCX export** writes the reference marker as a `` to the always-emitted `FootnoteReference`/`EndnoteReference` character style (which carries the superscript), matching Word — *not* as an explicit `` on the marker run, which would add a direct run property the source model never had and break round-trip stability. Verified by the Spec 02 import-export-import guard `docx_reference_round_trip_is_stable`. | | **Dynamic Fields** | Yes | Partial | Yes | Import reads **both** field encodings: complex fields (`w:fldChar`/`w:instrText` state machine) and the compact `w:fldSimple` form (`@w:instr` + cached-result runs, including the self-closing variant) — both map to an identical `Inline::Field` (`fld_simple.rs`). Export writes body-text fields as complex fields (with `separate`+result when a snapshot is cached) — PAGE, NUMPAGES, DATE/TIME (incl. `\@` format switch), TITLE, AUTHOR, SUBJECT, FILENAME, NUMWORDS, REF/PAGEREF cross-references, and `Raw` instructions round-trip (`export_fields.rs`). PAGE / NUMPAGES in headers & footers render real per-page values (per-page re-layout in `assign_headers_footers`); body-text fields still *render* snapshots (post-layout computation, ADR-0008, is proposed). | | **Line-boundary Splitting** | — | Yes | — | Paragraphs split cleanly across pages using `ClippedGroup` masks. | | **Content Controls (`w:sdt`)** | Yes (unwrapped) | Yes | — | A block-level structured document tag (content control — cover pages, forms, bound title/date placeholders) is **unwrapped at import** (5.9, 2026-07-10): its `w:sdtContent` children (paragraphs, tables, nested controls) become normal body blocks so the content survives, instead of the whole `w:sdt` being skipped (the previous behaviour, which silently dropped it — data loss). The control's chrome (binding, placeholder, tag in `w:sdtPr`) is discarded — Loki has no content-control model. Reader lives in `reader/document_sdt.rs`. Tested by `block_sdt_content_is_unwrapped_into_the_body` (reader, incl. a nested control + table) and `ooxml8_sdt_content_between_paragraphs_is_unwrapped` (end-to-end import). **Not yet:** `w:sdt` inside a table cell, inline (run-level) `w:sdt`, and re-export as a control (unwrapped content exports as plain blocks). | | **keep-together** | Yes | Yes | Yes | Prevents paragraph line breaks across pages when enabled. | -| **keep-with-next** | Yes | Yes | Yes | Scans forward up to 5 blocks to place headings and body together. | +| **keep-with-next** | Yes | Yes | Yes | Scans forward up to 5 blocks to place headings and body together (`flow_para_chain.rs`). **Non-paragraph blocks no longer dropped (2026-07-14):** the chain now only extends into blocks it can actually lay out (paragraph-like); a `keepNext` paragraph immediately followed by a **table** (the ubiquitous "Table N" caption pattern) previously absorbed the table as a zero-height empty paragraph and silently discarded every cell — the table now flows through its normal dispatch. **Inline images in a `keepNext` paragraph preserved (2026-07-14):** the chain's speculative layout formerly discarded collected images, so a captioned figure whose image paragraph is kept with its caption vanished; the chain now runs the same block-image stacking as the normal path (shared `flow_para::stack_block_images`). Both surfaced by the ACID 2 fixture; tested by `keep_next_caption_does_not_drop_the_following_table` and `keep_with_next_paragraph_keeps_its_inline_image`. **Known limit (`TODO(kwn-table)`):** a caption is not yet kept *visually* with its table across a page break (needs real table measurement inside the chain). | | **Widow/Orphan Control** | Yes | Yes | Yes | **Honoured at layout time** (5.9, 2026-07-10). When a paragraph splits across a page/column boundary the flow engine avoids stranding a lone line: an **orphan** (the paragraph's first line alone at a page bottom) defers the whole paragraph to the next page; a **widow** (its last line alone atop the next page) pulls a line down so ≥ `widow_min` lines carry over. Both default to **2 lines, matching Word/LibreOffice's default-on** (`ResolvedParaProps::{orphan_min,widow_min}`, resolved in `para_props_map`: OOXML's single `w:widowControl` toggle governs both, ODF's `fo:orphans`/`fo:widows` set each; an explicit `0` disables). The decision is a pure resolver (`flow_widow_orphan::resolve_split`) applied in `split_and_place_loop`, guarded against the degenerate page-top case so pagination always terminates. Tested by `flow_widow_orphan` unit tests (8) + `orphan_control_defers_a_would_be_orphan_paragraph` (end-to-end). | | **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`), radicals (`m:rad` ⇄ ``/``), and, since 5.8 (2026-07-12), **delimiters** (`m:d` ⇄ ``, OMML defaults resolved on read so the fence characters always travel explicitly), **n-ary operators** (`m:nary` ⇄ a ``/``-family script with the n-ary `` as base — `m:limLoc`, `m:subHide`/`m:supHide`, and the `∫` default all honoured; the `m:e` operand travels as the following sibling and is re-consumed by a write-side lookahead), **matrices** (`m:m`/`m:mr` ⇄ ``/``/``), **accents** (`m:acc` ⇄ ``, default U+0302), and **limits** (`m:limLow`/`m:limUpp` ⇄ ``/``; a generic `` nests them best-effort). The converter's `XmlNode` now carries attributes (`m:val`, `open`, `accent`, …), which the old tree silently dropped. **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`), fenced expressions (`mfenced` / fence-wrapped rows — since 5.8 honouring the `open`/`close`/`separators` attributes, which the parser now retains), and, since 5.8, **under/over stacks** (`munder`/`mover`/`munderover` — n-ary limits centred above/below the operator; `mover accent="true"` hugs the base as an accent) and **matrices** (`mtable` — a grid with per-column widths and per-row baselines, centred on the math axis; `math/stacks.rs`) — 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; n-ary operators render at text size (no display-style big-operator enlargement); `m:eqArr`, `m:box`, `m:bar`, `m:groupChr`, and `m:func` still pass through best-effort; a matrix's per-column alignment (`m:mcs`) is ignored (cells centre). | | **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 distinct **named page style** gets its own `style:page-layout` + `style:master-page`, named after the stored `section.page_style` id — sanitised to an XML `NCName` — so a renamed page style round-trips under its real name (a distinct human name rides along as `style:display-name`); sections sharing a page style share one master page, and a section with no stored ref keeps a positional name; section breaks are emitted as `style:master-page-name` on the first paragraph of each section, the form the importer reads back — and import registers those master-page names (with their `style:display-name`) as first-class `page_styles` catalog entries), **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), **comments** (`office:annotation` / `office:annotation-end`), and **tracked changes** (`text:tracked-changes` regions + `text:change-*` milestones) also round-trip. **Math** is emitted as embedded formula objects (`draw:object` → `Object N/content.xml` MathML, listed in the manifest). **Document defaults** are emitted as `` (paragraph + text families, 5.10 2026-07-12 — see **Default-style inheritance** below). Still not emitted: the OTT template content type. | +| **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, emboss/imprint via `style:font-relief`, character border via `fo:border`+`fo:padding` on `style:text-properties`, 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 distinct **named page style** gets its own `style:page-layout` + `style:master-page`, named after the stored `section.page_style` id — sanitised to an XML `NCName` — so a renamed page style round-trips under its real name (a distinct human name rides along as `style:display-name`); sections sharing a page style share one master page, and a section with no stored ref keeps a positional name; section breaks are emitted as `style:master-page-name` on the first paragraph of each section, the form the importer reads back — and import registers those master-page names (with their `style:display-name`) as first-class `page_styles` catalog entries), **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), **comments** (`office:annotation` / `office:annotation-end`), and **tracked changes** (`text:tracked-changes` regions + `text:change-*` milestones), and **floating text boxes** (`draw:text-box` frames with a graphic style carrying wrap + fill + border — the DOCX `wps` shape's ODF analogue) also round-trip. **Math** is emitted as embedded formula objects (`draw:object` → `Object N/content.xml` MathML, listed in the manifest). **Document defaults** are emitted as `` (paragraph + text families, 5.10 2026-07-12 — see **Default-style inheritance** below). 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). The automatic paginated↔reflow switch (Spec 03 M2) is **zoom-aware** (2026-07-06): the status-bar zoom feeds the shared responsive `Viewport::zoom`, so zooming a page past the point a full column fits the viewport flips the editor into reflow instead of forcing horizontal scroll, and zooming back out restores pagination (hysteretic; frozen once the user picks a mode). **Bounded reading measure (Spec 03 M4):** the reflow tile is capped at `MAX_REFLOW_TILE_PX` (820 CSS px) and centred (`margin: auto`) on wide windows, so the line length stays comfortable instead of running edge-to-edge; narrow screens still use their full width. A single `render_layout::{reflow_tile_width_px, reflow_content_width_pt}` feeds paint, hit-test, and keyboard nav so they stay aligned (the HTML fallback references the same constant). 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. Selection editing is view-independent and works here too: typing replaces the active selection and Backspace/Delete remove it, incl. multi-block ranges (`delete_selection_at` + `editor_keydown_text.rs`, 2026-07-05). Still missing: 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. | --- @@ -36,7 +36,7 @@ This is the living source of truth documenting which document features, characte | Property | Import | Layout/Render | Export | Notes | | :--- | :---: | :---: | :---: | :--- | -| **Bold / Italic** | Yes | Yes | Yes | Fully supported. | +| **Bold / Italic** | Yes | Yes | Yes | Fully supported. **Variable-font bold fixed (2026-07-13):** the bundled Arimo (Arial substitute) is a `wght` variable font, so a bold Arial run is shaped by Parley at `wght=700`. The painters previously rendered every variable font at its **default master** (regular weight) — `PositionedGlyphRun` didn't carry the run's variation coordinates — so bold Arial/Helvetica text came out regular-weight with bold advances ("wide but not bold"). `PositionedGlyphRun.normalized_coords` now carries Parley's per-run normalized coordinates (`run.normalized_coords()`), and both painters apply them (`loki-render-cpu` via glifo's `.normalized_coords`, `loki-vello` via `draw_glyphs(...).normalized_coords`) instead of the default all-zero master. Static faces (Carlito/Tinos/… bold) carry empty coords and are unaffected. Regression-locked by `variable_font_weight.rs` (bold run carries a non-zero `wght` coord; bold ≠ regular instance). **PDF export instances too (2026-07-13):** `loki-pdf` keys each embedded face by `(data, index, coords)` and, for a non-default instance, **instances the outlines and metrics** — `subsetter::subset_with_variations` (skrifa) for the `FontFile2` program and `ttf_parser::set_variation` for the widths/bbox — after converting the normalized coords back to user-space `(fvar tag, value)` via the font's `fvar` (exact at axis endpoints, where bold `wght`=max sits). So a bold-Arial run exports a genuinely bold Arimo subset, not the default master. Verified end-to-end (the exported Iris Blueprint embeds a distinct bold Arimo instance ~56% heavier in average glyph ink than the regular one); regression-locked by `variable_font_instancing_changes_glyph_outlines` (instancing at `wght=700` moves the 'B' outline). | | **Numeric Font Weight** | Partial | Yes | Partial | `CharProps.font_weight` (OpenType 1–1000) renders via Parley `FontWeight`, superseding boolean `bold` when set. Editable per-style in the style editor's weight selector (Thin…Black). Import: ODF `fo:font-weight` numeric; OOXML `w:b` is boolean only. Export: DOCX collapses to bold/not-bold (≥ 600 ⇒ bold). | | **Underline** | Yes | Yes | Yes | Style varieties (single, double, dotted, dash, wave, thick) mapped **and rendered per-variant (5.2, 2026-07-08)** — `para_emit` carries the `w:u` variant onto the `PositionedDecoration` and `loki-vello` strokes each (double = two lines, dotted/dashed = kurbo dash patterns, wave = squiggle, thick = 2× width). | | **Strikethrough** | Yes | Yes | Yes | Single and double strikethrough variants mapped **and rendered** — `w:dstrike` double draws as two parallel lines (5.2, 2026-07-08). | @@ -62,9 +62,9 @@ This is the living source of truth documenting which document features, characte | **Indentation** | Yes | Yes | Yes | Left, right, first line, and hanging indents mapped. | | **Picture Bullets** | Yes | Yes | No | A list level whose marker is an **image** (5.4). Model: `BulletChar::Image { src }`. **Layout:** Parley cannot inline an image, so the marker is placed **out-of-band** (`flow_list_marker::picture_bullet_item`) — a square sized to line 0, left-aligned in the hanging label box; the text label is empty. **DOCX import:** `w:numPicBullet` / `w:lvlPicBulletId`, with the bullet image resolved through the *separate* `word/numbering.xml.rels` to a `data:` URI (`import_pic_bullets`); an unresolved reference falls back to the text bullet char. **ODT import:** `text:list-level-style-image` (`xlink:href` → `data:` URI). Tested end-to-end at the layout, OOXML (reader+mapper), and ODF (reader+mapper) layers. Not re-exported; non-left label alignment and exact OOXML/ODF bullet sizing are refinements. | | **Spacing (Before/After)** | Yes | Yes | Yes | Margins and paragraph offsets respected. | -| **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 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. When a paragraph runs out of explicit stops, tabs fall back to a **default grid** honouring the document's `DocumentSettings::default_tab_stop_pt` (Word `w:defaultTabStop` / ODF `style:tab-stop-distance`) — threaded via `LayoutOptions::default_tab_stop_pt`, folded in from `doc.settings` by `layout_document`, and defaulting to 36 pt (½ inch). Tested by `tab_stops_tests.rs` (right/center/decimal geometry + dot leader + default-grid fallback, custom grid, and document-settings folding). 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. | +| **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`. **Body-anchored descent (2026-07-14):** the box is anchored on the **body (first) run's** descent, not the line's *aggregate* descent — otherwise a raised superscript (or over-tall inline element) inflates the aggregate descent and pushes the box down over the body text's tops. On the ACID `EXACT 12pt` case this had clipped the *small* body text's tops while Word shows the full body text and clips only the raised superscript; now Loki matches. `atLeast`/`auto` lines grow naturally and are not clipped. Tested by `exact_line_height_clips_each_line` (box bottom below the baseline) and `exact_line_clip_is_anchored_by_body_text_not_a_raised_run` (a raised run doesn't move the box). | +| **Borders** | Yes | Yes | Yes | Top, bottom, left, right borders supported. **Empty-paragraph rules + height (2026-07-13):** an empty paragraph is now laid out with one line of height and its border/background box, instead of collapsing to zero. Previously the render/export path returned a zero-height, item-less layout for empty text, so (a) an empty paragraph carrying a bottom border — Word's horizontal-rule idiom, used here above section headings and on the title page — drew **no rule**, and (b) blank spacer paragraphs took **no vertical space**, packing content tighter than Word. Both are fixed in one place (`para::layout_paragraph_uncached`'s empty-text branch): a phantom single space supplies the line metrics, the border/rule spans the content column, and the caret layout is still kept for the editor. Verified on the Iris Blueprint (the title-page rule and the rule above "2. Core Architecture" now render, and paragraph spacing matches Word — pagination grew 13→14 pages). Regression-locked by `empty_paragraph_occupies_one_line` and `empty_paragraph_with_bottom_border_emits_a_rule`. **Borders/shading span the content column (2026-07-14):** `prepend_para_box` previously sized the border/background box to the text ink width (`layout.width()`) at `x=0`, so a short bordered or shaded paragraph got a box only as wide as its text. It now spans the content column — from the start indent to the end indent — matching Word (a 400pt-wide column with a short line now fills the full 400pt, was ~50pt). Regression-locked by `paragraph_border_spans_the_content_column`. | +| **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. When a paragraph runs out of explicit stops, tabs fall back to a **default grid** honouring the document's `DocumentSettings::default_tab_stop_pt` (Word `w:defaultTabStop` / ODF `style:tab-stop-distance`) — threaded via `LayoutOptions::default_tab_stop_pt`, folded in from `doc.settings` by `layout_document`, and defaulting to 36 pt (½ inch). Tested by `tab_stops_tests.rs` (right/center/decimal geometry + dot leader + default-grid fallback, custom grid, and document-settings folding). 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. **No-overshoot fix (2026-07-13):** `\t` is excluded from the shaped text (a tab is realised purely by its inline box). Left in, it shaped to a glyph — a `.notdef` in fonts without a tab glyph (e.g. Arimo/Arial) — whose ~8pt advance stacked on top of the box, pushing the following content past the stop; a bullet list's text landed at ~0.61in instead of the 0.5in text indent. Its byte still maps to the following clean position via `orig_to_clean`, so tab positions, decimal detection, and hit-testing are unaffected. Regression-locked by `tab_no_overshoot.rs` (list marker `●\t` lands text on the 0.5in indent). **Empty-span panic fixed (2026-07-14):** because a tab is excluded from the shaped text, a run that is *only* a tab yet carries its own char props (e.g. an underlined signature-line tab) remaps to a zero-length style span; Parley asserts `start < end` on every span, so `para_build` now drops empty spans (matching the existing guard in `para_underlays`) instead of panicking. Surfaced by ACID 2; tested by `tab_only_styled_run_does_not_panic`. | | **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 | Yes | Yes | Word semantics (ECMA-376 §17.3.1.4): consecutive paragraphs with identical border settings form one bordered group — the second member's top edge is suppressed and each internal boundary draws the `w:between` rule once (as the upper member's bottom edge). Group membership is decided by a cheap child-wins 5-edge border-signature probe against the slice neighbours (`flow_para_between.rs`), staged by the block loops onto `FlowState` and applied to the resolved edges *before* layout so it participates in the paragraph cache key. A between-only group (no outer borders) draws just the boundary rule. Scope: `StyledPara` runs within one block slice; keep-with-next chains and synthesized paragraphs break a group. Tested by `same_border_group_draws_between_rule_once`, `different_borders_do_not_group`, `between_only_group_draws_just_the_boundary_rule`. | @@ -77,16 +77,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. **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). | +| **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) now honour Word's minimum-content-width guarantee** (2026-07-13): the preferred `gridCol` widths are first scaled proportionally to the table width, then every column is guaranteed at least its *minimum content width* — the widest unbreakable word in any of its cells — with the surplus redistributed by the preferred widths (`flow_table_autofit.rs`: `measure_cell_min_width` flows each cell at ~0 width without long-word breaking; `distribute_with_mins` pins under-min columns and shares the remainder; if the minimums alone exceed the table width the columns keep their minimums and the table overflows). This fixes the over-tall callout boxes a too-narrow preferred label column used to produce (e.g. a 400-twip label holding "INSIGHT" no longer stacks one character per line). A well-proportioned table where no column violates its minimum is returned unchanged, so existing layouts are unaffected. Tested by `fixed_columns_should_be_honored_like_word` and `long_word_wraps_within_narrow_cell` (fixed-layout wrap) / `long_word_grows_autofit_column` (autofit grows) (layout), the `flow_table_autofit` distribution unit tests, and `tbl_layout_fixed_marks_table_class` / `tbl_layout_autofit_has_no_fixed_class` (DOCX mapper). **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 (`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 (`w:textDirection` `tbRl`/`tbLr`/`btLr` → a 90°/270° `RotatedGroup`). **Rotated-cell editing (4b.5, 2026-07-08):** rotated cells now emit editing data — `flow_cell_blocks` returns the per-paragraph editing entries alongside items, and the flow engine tags them with a `CellRotation` (`loki-layout/src/result_rotation.rs`) mirroring the paint-time affine. `PageParagraphData::hit_local` / `local_to_page` invert it, so **clicking a rotated cell places the caret on the correct character** (was read-only). Tested by `rotated_cell_emits_editing_data_with_rotation` + `result_tests` transform round-trips. **Caret + navigation rotation-aware too (2026-07-11):** the caret line, selection fills, and handles paint through a rotation-composed affine (`loki-vello` `scene_cursor::cursor_paint_transform`, matching `CellRotation::local_to_page` — unit-tested for equality), so the caret **tilts with the text**; up/down arrow navigation maps the caret via `local_to_page` and aims at neighbours via `PageParagraphData::visual_y_span` (post-rotation bounding box). Rotated-cell editing is feature-complete. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | -| **Floating Images / Wrap Mode** | Yes | Yes | Partial | 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. **DOCX re-export (2026-07-12):** a floating image (wrap keys or the `floating` class on its `NodeAttr`) is now written as `w:drawing`/`wp:anchor` carrying the wrap element (`wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` + `wrapText` side + `behindDoc`), so the wrap mode/side/behind-text flag round-trips (`write_anchor_drawing` in `docx/write/document_drawing.rs`; tested by `tc_docx_023_floating_image_wrap_modes_round_trip`). Anchor **position** is not modelled — the reader recovers only wrap + `behindDoc`, so `wp:positionH`/`positionV` are written at a zero column/paragraph offset. **Still pending:** ODT re-export of the float (the ODT writer emits images `as-char`); the tight/through **wrap polygon** is a bounding-box rectangle, not the true contour. **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). **Class-only fallback (5.6):** an image tagged `FLOATING_CLASS` but carrying **no** explicit wrap keys — e.g. an anchored DOCX `wp:anchor` whose wrap child was absent/unrecognised (the mapper still adds the class) — is now treated as floating (`FloatWrap::read_or_class_default` → square/both-sides) instead of collapsing to inline. 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). | +| **Floating Images / Wrap Mode** | Yes | Yes | Partial | 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` 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. **`wrapNone` overlaps instead of wrapping (2026-07-14):** Word reserves *no* space for a `wrapNone` object, so Loki now flows the text at full column width and draws the image as a side-anchored **overlay** — over the text (`behindDoc="0"`) or under it (`behindDoc="1"`), taking no vertical space and shifting no lines (`flow_para.rs`). Verified against the Word reference on ACID TC-DOCX-023/024 (margin-anchored `wrapNone` image overlapping the full-width paragraph, matching Word) — the earlier behaviour wrongly reserved a wrap band beside 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; absolute anchor positioning (`wp:positionH`/`positionV` offsets) is not yet modelled, so an overlay is anchored to its wrap side rather than its exact offset. `TopAndBottom` and behind-text *side-wrap* floats still stack as a block (only `wrapNone` honours the behind-text z-order today). **DOCX re-export (2026-07-12):** a floating image (wrap keys or the `floating` class on its `NodeAttr`) is now written as `w:drawing`/`wp:anchor` carrying the wrap element (`wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` + `wrapText` side + `behindDoc`), so the wrap mode/side/behind-text flag round-trips (`write_anchor_drawing` in `docx/write/document_drawing.rs`; tested by `tc_docx_023_floating_image_wrap_modes_round_trip`). Anchor **position** is not modelled — the reader recovers only wrap + `behindDoc`, so `wp:positionH`/`positionV` are written at a zero column/paragraph offset. **Still pending:** ODT re-export of the float (the ODT writer emits images `as-char`); the tight/through **wrap polygon** is a bounding-box rectangle, not the true contour. **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). **Class-only fallback (5.6):** an image tagged `FLOATING_CLASS` but carrying **no** explicit wrap keys — e.g. an anchored DOCX `wp:anchor` whose wrap child was absent/unrecognised (the mapper still adds the class) — is now treated as floating (`FloatWrap::read_or_class_default` → square/both-sides) instead of collapsing to inline. Tested: `flow_float` unit tests (incl. `wrap_none_is_not_side_wrapped`) + `tall_float_wraps_following_paragraph` + `wrap_none_float_overlaps_text_without_reserving_space` + `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. | -| **Table Styles** | Partial | Partial | Partial | **Style reference** (2026-07-08, Spec 05 4a.3 foundation): a table now remembers its **named style** (OOXML `w:tblStyle` / ODF `table:style-name`). It is stored in the table's `NodeAttr` `"style"` key — the same convention a heading uses — so it round-trips through the Loro bridge (part of the serde table skeleton, no bridge change) and through **DOCX** export/import (the reader already parsed `w:tblStyle`; the mapper now carries it and the writer emits it before `w:tblW`). Read via `Table::style_name()`. Tested by `table_style_round_trip.rs` (DOCX) + `table_style_reference_round_trips_through_the_bridge` (CRDT). **Banding/conditional model + resolver** (2026-07-08): `TableStyle` now carries a `conditional: IndexMap` map (the twelve OOXML `w:tblStylePr` regions — first/last row/col, the four corners, the horizontal/vertical bands, plus `WholeTable`) and `TableProps` gained `row_band_size`/`col_band_size`; a `TableLook` struct models the per-table `w:tblLook` flags (default `04A0`: header row + first column + row banding). A pure `resolve_cell_shading(style, look, row, col, rows, cols)` (`style/table_banding.rs`) returns the shading a style contributes to a cell, honouring OOXML precedence (corners > first/last row > first/last col > horizontal bands > vertical bands > whole-table) and resolving *per-property* (a higher region with no shading falls through), with the base table shading as the final fallback. 12 unit tests (banding parity, band size, header/first-col exclusion, corner-needs-both-flags, precedence ordering, out-of-range). **Rendered** (2026-07-08): the flow engine now **consults the table style** when painting cell backgrounds — a new `loki-layout` `table_shading` module (`resolve_table_style` looks the style up in the catalog by the table's `"style"` attr; `cell_style_shading` calls the resolver) layers style/banding shading **under** any direct `CellProps` shading at the Pass-3b cell-paint seam (`flow.rs`), so a table that references a banded style paints its header/banded cells with no direct cell shading needed. The active `w:tblLook` is not yet imported, so Word's default (`04A0`) is assumed for now (`TODO(table-tbllook-import)`). Tested by `table_style_banding_shades_the_header_row` (flow) + `table_shading` unit tests. **DOCX conditional-formatting import** (2026-07-08): the DOCX styles reader now parses a `w:type="table"` style's band sizes (`w:tblStyleRowBandSize`/`w:tblStyleColBandSize`), base whole-table cell shading (`w:tcPr/w:shd`), and each `w:tblStylePr` region's cell shading (`w:type` + `w:tcPr/w:shd`) into a new `DocxTableStyleProps`/`DocxTblStylePr` model; the styles mapper translates them into `TableStyle.table_props` (band sizes + base `background_color`) and the `conditional` region map (`map_table_region` maps the twelve OOXML region names; unknown or unshaded regions are skipped). So a real Word document that uses a built-in banded style (e.g. *List Table*/*Grid Table Accent*) now imports its conditional shading and — via the layout wiring above under the default `w:tblLook` — **paints banded rows/header end-to-end**. Tested by `parses_table_style_banding` (reader) + `table_style_conditional_formatting_maps` (mapper). **Per-table `w:tblLook` import** (2026-07-08): the DOCX reader now parses each table instance's `w:tblLook` (both the explicit `w:firstRow`/`w:noHBand`/… attributes and the legacy `w:val` hex bitmask, into a `DocxTblLook`); the mapper encodes it (via the doc-model `TableLook::encode_attr`, format-neutral) into the table's `NodeAttr` `"tbllook"` key (round-trips through the Loro bridge like `"style"`), and the flow engine reads it (`table_shading::table_look`) so a table that turns off banding or enables the last-row/last-column region renders with **its** active regions instead of the assumed default. Tested by `parse_tbl_look_*` (reader), `map_tbl_look` (mapper), `table_look_attr_round_trips` (model codec), and `table_look_disabling_first_row_suppresses_style_shading` (flow, end-to-end). **DOCX banding export** (2026-07-08): the export half now writes table-style banding, so it survives a full DOCX round-trip. A new `write/table_style.rs` emits each catalog `TableStyle` as a `w:style w:type="table"` (band sizes → `w:tblPr`, base shading → `w:tcPr/w:shd`, each conditional region → `w:tblStylePr w:type="…"` with its `w:tcPr/w:shd`), and writes the table instance's `w:tblLook` (both explicit boolean attributes and the legacy `w:val` bitmask) into its `w:tblPr` from the `"tbllook"` attr. Tested by 4 writer unit tests + `table_style_banding_and_tbllook_round_trip` (a banded style + non-default look survive export→import intact). **ODT cell-shading export** (2026-07-08): the ODF-native representation of table shading — ODF bakes region shading into **per-cell** styles rather than conditional regions (LibreOffice's model), and the ODT writer previously dropped all cell formatting. `AutoStyles::cell_style` now emits a deduplicated automatic `` carrying `fo:background-color` for each shaded cell (`TC{n}` names), referenced by `table:style-name` on the ``; ODT import of `fo:background-color` already existed (`map_cell_props`), so a shaded cell now round-trips through ODT. Tested by `cell_style_emits_background_and_dedupes` + `a_cell_without_shading_gets_no_style` (writer) and `cell_background_round_trips_via_table_cell_style` (end-to-end ODT round-trip). **ODT banding resolution on export** (2026-07-08): the ODT table writer now **resolves a referenced table style's banding into per-cell backgrounds** — so a table that carries only a `"style"` reference + `"tbllook"` (e.g. a DOCX-imported banded table) exports its bands to ODT as concrete per-cell shading. The writer flattens the rows, assigns each cell its grid column (a coverage-grid mirror of the layout's `assign_cell_columns`, honouring `col_span`/`row_span`), then computes each cell's effective background = its direct shading **else** `resolve_cell_shading(style, look, row, col, rows, cols)` (the same doc-model resolver the layout uses), baking the result into the per-cell `table-cell` style. The style catalog is threaded to the writer via a `Cx.table_styles` clone. Tested by `table_style_banding_resolves_into_per_cell_shading_on_odt_export` (a firstRow-banded style with no direct shading → header cells come back shaded, body cells not). **ODT table-level `table:style-name`** (2026-07-08): the ODT writer now emits a named `` for each catalog table style (`write/table_style.rs`, into `styles.xml`'s ``), carrying table-level geometry — `style:width`/`style:rel-width` (from `TableProps.width`), `table:align`, and `fo:background-color` — and references it via `table:style-name` on the ``. Import restores the **reference** (`OdfTable.style_name` → `Table::set_style_name` in `map_table`, one line — the reader already parsed it). So a table's named style survives an ODT round-trip. Tested by 4 writer unit tests (`emits_width_alignment_and_background`, `percent_width_uses_rel_width`, `a_style_with_no_geometry_omits_table_properties`, `synthetic_styles_are_skipped`) + `table_style_name_reference_round_trips` (end-to-end); schema validation green. **Not yet:** ODT import of the table-style *definition* back into the catalog (the reference survives but width/align/bg aren't re-read into a `TableStyle` yet), cell **borders**/padding export, `w:cnfStyle`, conditional character formatting, and the editing UI. | +| **Table Styles** | Partial | Partial | Partial | **Style reference** (2026-07-08, Spec 05 4a.3 foundation): a table now remembers its **named style** (OOXML `w:tblStyle` / ODF `table:style-name`). It is stored in the table's `NodeAttr` `"style"` key — the same convention a heading uses — so it round-trips through the Loro bridge (part of the serde table skeleton, no bridge change) and through **DOCX** export/import (the reader already parsed `w:tblStyle`; the mapper now carries it and the writer emits it before `w:tblW`). Read via `Table::style_name()`. Tested by `table_style_round_trip.rs` (DOCX) + `table_style_reference_round_trips_through_the_bridge` (CRDT). **Banding/conditional model + resolver** (2026-07-08): `TableStyle` now carries a `conditional: IndexMap` map (the twelve OOXML `w:tblStylePr` regions — first/last row/col, the four corners, the horizontal/vertical bands, plus `WholeTable`) and `TableProps` gained `row_band_size`/`col_band_size`; a `TableLook` struct models the per-table `w:tblLook` flags (default `04A0`: header row + first column + row banding). A pure `resolve_cell_shading(style, look, row, col, rows, cols)` (`style/table_banding.rs`) returns the shading a style contributes to a cell, honouring OOXML precedence (corners > first/last row > first/last col > horizontal bands > vertical bands > whole-table) and resolving *per-property* (a higher region with no shading falls through), with the base table shading as the final fallback. 12 unit tests (banding parity, band size, header/first-col exclusion, corner-needs-both-flags, precedence ordering, out-of-range). **Rendered** (2026-07-08): the flow engine now **consults the table style** when painting cell backgrounds — a new `loki-layout` `table_shading` module (`resolve_table_style` looks the style up in the catalog by the table's `"style"` attr; `cell_style_shading` calls the resolver) layers style/banding shading **under** any direct `CellProps` shading at the Pass-3b cell-paint seam (`flow.rs`), so a table that references a banded style paints its header/banded cells with no direct cell shading needed. The active `w:tblLook` is not yet imported, so Word's default (`04A0`) is assumed for now (`TODO(table-tbllook-import)`). Tested by `table_style_banding_shades_the_header_row` (flow) + `table_shading` unit tests. **DOCX conditional-formatting import** (2026-07-08): the DOCX styles reader now parses a `w:type="table"` style's band sizes (`w:tblStyleRowBandSize`/`w:tblStyleColBandSize`), base whole-table cell shading (`w:tcPr/w:shd`), and each `w:tblStylePr` region's cell shading (`w:type` + `w:tcPr/w:shd`) into a new `DocxTableStyleProps`/`DocxTblStylePr` model; the styles mapper translates them into `TableStyle.table_props` (band sizes + base `background_color`) and the `conditional` region map (`map_table_region` maps the twelve OOXML region names; unknown or unshaded regions are skipped). So a real Word document that uses a built-in banded style (e.g. *List Table*/*Grid Table Accent*) now imports its conditional shading and — via the layout wiring above under the default `w:tblLook` — **paints banded rows/header end-to-end**. Tested by `parses_table_style_banding` (reader) + `table_style_conditional_formatting_maps` (mapper). **Per-table `w:tblLook` import** (2026-07-08): the DOCX reader now parses each table instance's `w:tblLook` (both the explicit `w:firstRow`/`w:noHBand`/… attributes and the legacy `w:val` hex bitmask, into a `DocxTblLook`); the mapper encodes it (via the doc-model `TableLook::encode_attr`, format-neutral) into the table's `NodeAttr` `"tbllook"` key (round-trips through the Loro bridge like `"style"`), and the flow engine reads it (`table_shading::table_look`) so a table that turns off banding or enables the last-row/last-column region renders with **its** active regions instead of the assumed default. Tested by `parse_tbl_look_*` (reader), `map_tbl_look` (mapper), `table_look_attr_round_trips` (model codec), and `table_look_disabling_first_row_suppresses_style_shading` (flow, end-to-end). **DOCX banding export** (2026-07-08): the export half now writes table-style banding, so it survives a full DOCX round-trip. A new `write/table_style.rs` emits each catalog `TableStyle` as a `w:style w:type="table"` (band sizes → `w:tblPr`, base shading → `w:tcPr/w:shd`, each conditional region → `w:tblStylePr w:type="…"` with its `w:tcPr/w:shd`), and writes the table instance's `w:tblLook` (both explicit boolean attributes and the legacy `w:val` bitmask) into its `w:tblPr` from the `"tbllook"` attr. Tested by 4 writer unit tests + `table_style_banding_and_tbllook_round_trip` (a banded style + non-default look survive export→import intact). **ODT cell-shading export** (2026-07-08): the ODF-native representation of table shading — ODF bakes region shading into **per-cell** styles rather than conditional regions (LibreOffice's model), and the ODT writer previously dropped all cell formatting. `AutoStyles::cell_style` now emits a deduplicated automatic `` carrying `fo:background-color` for each shaded cell (`TC{n}` names), referenced by `table:style-name` on the ``; ODT import of `fo:background-color` already existed (`map_cell_props`), so a shaded cell now round-trips through ODT. Tested by `cell_style_emits_background_and_dedupes` + `a_cell_without_shading_gets_no_style` (writer) and `cell_background_round_trips_via_table_cell_style` (end-to-end ODT round-trip). **ODT banding resolution on export** (2026-07-08): the ODT table writer now **resolves a referenced table style's banding into per-cell backgrounds** — so a table that carries only a `"style"` reference + `"tbllook"` (e.g. a DOCX-imported banded table) exports its bands to ODT as concrete per-cell shading. The writer flattens the rows, assigns each cell its grid column (a coverage-grid mirror of the layout's `assign_cell_columns`, honouring `col_span`/`row_span`), then computes each cell's effective background = its direct shading **else** `resolve_cell_shading(style, look, row, col, rows, cols)` (the same doc-model resolver the layout uses), baking the result into the per-cell `table-cell` style. The style catalog is threaded to the writer via a `Cx.table_styles` clone. Tested by `table_style_banding_resolves_into_per_cell_shading_on_odt_export` (a firstRow-banded style with no direct shading → header cells come back shaded, body cells not). **ODT table-level `table:style-name`** (2026-07-08): the ODT writer now emits a named `` for each catalog table style (`write/table_style.rs`, into `styles.xml`'s ``), carrying table-level geometry — `style:width`/`style:rel-width` (from `TableProps.width`), `table:align`, and `fo:background-color` — and references it via `table:style-name` on the ``. Import restores the **reference** (`OdfTable.style_name` → `Table::set_style_name` in `map_table`, one line — the reader already parsed it). So a table's named style survives an ODT round-trip. Tested by 4 writer unit tests (`emits_width_alignment_and_background`, `percent_width_uses_rel_width`, `a_style_with_no_geometry_omits_table_properties`, `synthetic_styles_are_skipped`) + `table_style_name_reference_round_trips` (end-to-end); schema validation green. **Table-style borders — DOCX import + render** (2026-07-19): the DOCX styles reader now parses a table style's `w:tblBorders` (the four outer edges **plus** the interior `w:insideH`/`w:insideV` gridlines) into a new `DocxTblBorders`; the mapper translates it to a doc-model `TableBorders` (`style/table_borders.rs`) on `TableProps.borders`. At paint time `loki-layout`'s `cell_style_borders` picks each cell's four effective edges by position — an outer edge on the table boundary, otherwise the matching interior gridline (`TableBorders::edges_for`) — and the Pass-3b cell decorator falls back to it whenever a cell carries no direct `CellProps` border. So a table that references the built-in *Table Grid* style (all six edges a single hairline) now **draws its full grid**, matching Word, with no per-cell borders authored. Tested by `parses_table_grid_borders` (reader), `tbl_borders_edges_pick_outer_vs_interior` (model), and `cell_style_borders_resolve_the_grid_from_a_table_grid_style` (layout). **Not yet:** ODT import of the table-style *definition* back into the catalog (the reference survives but width/align/bg aren't re-read into a `TableStyle` yet), table-style border **export** (DOCX/ODT) and cell padding export, per-region (`w:tblStylePr`) borders, `w:cnfStyle`, conditional character formatting, and the editing UI. | | **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. | --- @@ -251,6 +251,226 @@ Importer coverage exercised by the harness today: DOCX + ODT (import → paginat glyph coverage), XLSX + ODS (import → workbook). ODP/ODG have no importer yet and are catalogued as pending. +### ACID 2 — combined-feature archetypes (`acid2-docx`, 2026-07-14) + +Where `acid_docx` tests one construct per section, **ACID 2** stresses how +features behave *together* in realistic documents — the interaction bugs that +break real files on open. It is a **hand-authored** DOCX packaged from reviewable +OOXML parts under `loki-acid/assets/acid2/` by +`cargo run -p loki-acid --example gen_acid2_docx` (mirrored to +`appthere-conformance/fixtures/docx/acid2-docx.docx`); being synthetic rather than +Word-authored lets it exercise constructs Loki cannot yet *emit*. Parity is still +judged against Microsoft Word's render of the same file (visual axis in the +conformance manifest; golden capture is `PENDING`, CJK-free so no `ja` caveat). It +renders 7 pages: a business report (cover, dot-leader TOC, heading hierarchy, +multi-level list, shaded/merged table, captioned figure, footnotes, block quote, +`REF` cross-reference), a résumé (right-tab dates, small caps, hyperlinks, section +rules), a newsletter (balanced two columns, drop cap, square-wrapped float, pull +quote), an invoice/contract (fixed table, legal numbering, tracked changes, +comment, signature lines), and a feature-matrix appendix. + +**Bugs it surfaced and fixed (2026-07-14):** the empty-style-span layout panic (a +tab-only styled run — see **Tab Stops**), and two **keep-with-next** content-drop +bugs (a `keepNext` caption dropping its following table; a `keepNext` figure +paragraph dropping its inline image — see **keep-with-next**). + +**Fixed against this golden** (2026-07-19): `Calibri Light` (heading/title face) +now resolves to the bundled metric-compatible **Carlito** so headings/titles wrap +like Word (and the exported PDF shrank ~11 MB → ~345 KB with a clean embed); +**table-style (`w:tblStyle`) borders** now render (the *Table Grid* grid, incl. +interior `insideH`/`insideV`); and **page borders (`w:pgBorders`)** now render — +the DOCX reader parses `w:pgBorders` (four edges + `@w:offsetFrom`), the mapper +carries it as `PageLayout.page_border`, and `flow_headers` emits a page-local +border rect around each page of the section (scoped correctly: only the appendix +section draws its frame). Tested by `parses_page_borders`/`maps_page_borders_*`. +**Decimal (and right/centre) tab alignment inside narrow table cells** now holds: +`compute_tab_plans` caps the tab expansion so an aligned "column" run (e.g. a +currency amount whose decimal stop sits near the cell edge) right-aligns against +the line end instead of overflowing → wrapping → losing its alignment (Parley +wraps atomic runs that exceed `line_w`). The invoice amounts now align in a +column like Word's; tested by `decimal_tab_clamps_to_line_when_content_would_overflow`. +**A TOC field's first cached entry** (whose paragraph holds the `fldChar` +begin/separate while the field's `end` lives in the last entry's paragraph) is no +longer dropped: `map_inlines` resets the complex-field state per paragraph, so a +field still `InResult` at a paragraph's end now flushes its accumulated snapshot +(including the leader `\t`) as plain `Inline::Str` instead of discarding it. The +imported ACID2 TOC now shows all four entries ("1. Introduction …" through "2.1 +Per-feature scores") with dot leaders and right-aligned page numbers; tested by +`open_field_flushes_its_result_at_paragraph_end`. +**Underlined-tab signature lines** now render: a run that is only a `\t` carrying +`w:u` (``) draws its +underline across the whole tab gap in Word, but Loki excludes `\t` from the Parley +text (gap #8), collapsing the run to a zero-length span that Parley never strokes. +The flow engine now recovers the underline from the spans (`para_tab_underline`) +and emits a `DecorationKind::Underline` across the tab box it opened — covering +both a standalone signature tab and a tab embedded in a longer underlined run. The +invoice's two signature rules (above "Authorised signature" / "Date") now match +Word's; tested by the `para::tab_underline` module tests. +**Margin line numbering (`w:lnNumType`)** now renders: the DOCX reader parses +`w:lnNumType` (`@w:countBy`/`@w:start`/`@w:restart`/`@w:distance`), the mapper +carries it as `PageLayout.line_numbering`, and the flow engine +(`flow_line_numbers`) prints a right-aligned number in the left margin beside each +body line of the section — advancing a per-section counter that resets each page +for `restart="newPage"`, selecting lines via `count_by`, and skipping tables and +header/footer lines (Word's own defaults). Emitted as content items at a negative +content-local x (the painters composite content offset by the left margin with no +content clip). All emission is gated behind the section actually carrying line +numbering, so every other document is byte-for-byte unchanged. The appendix now +shows numbers down its margin like Word's; tested by the `flow::line_numbers` +module tests plus reader/mapper tests. **Two residual differences** (not the +numbering mechanism): Word's first appendix line is numbered **2** vs Loki's **1** +(Word appears to count the preceding section-break paragraph — Loki follows the +ECMA-376 `@w:start` semantics, first line = `start`); and the callout paragraph +wraps to 3 lines in Loki vs 2 in Word (an independent line-wrapping difference), +so both happen to end at line 18. +**`w:noBreakHyphen` / `w:softHyphen`** now render: the run reader maps +`w:noBreakHyphen` → U+2011 NON-BREAKING HYPHEN (always visible) and +`w:softHyphen` → U+00AD SOFT HYPHEN (shown only when the line breaks there), so +"non-breaking-hyphen" renders its hyphen like Word; the optional hyphen stays +invisible when — as in Loki's wider line — the text does not break at it (Word +shows it only because its narrower line happens to break there). Tested by +`no_break_hyphen_becomes_u2011` / `soft_hyphen_becomes_u00ad`. +**Line/cross `w:shd` texture patterns** (`diagStripe`, `horzStripe`, +`diagCross`, `thin*`, …) now render as **actual hatch lines (2026-07-20)**, not a +flat tint. The mapper preserves the pattern (`resolve_shading_pattern` → +`ShadingPattern { pattern, thin, color, fill }` on `ParaProps`/`CellProps`, kept +alongside the flattened-tint `background_color` fallback); the flow engine emits +a `PositionedItem::HatchRect` for a shaded cell/paragraph +(`resolve::hatch_from_shading`); `loki-layout`'s `hatch.rs` turns the rect + +pattern into rect-clipped line segments (Liang–Barsky clip, a perpendicular +family for the cross variants); and both renderers draw them — `loki-vello` +strokes each segment (`rect::paint_hatch`), `loki-pdf` fills a thin quad per +segment (`render_hatch`, keeping its fill-only colour pipeline). The appendix's +"diagonal stripe" cell now shows real orange `/` stripes matching Word, verified +by rendering the ACID2 DOCX to PDF. Tested by +`shading_pattern_preserves_geometry_and_colors` (mapper) and the `hatch.rs` +geometry unit tests (horizontal/vertical/cross span the rect; diagonals stay +clipped inside it; `thin*` is thinner + closer). The flattened-tint path +(`resolve_shading` + `texture_coverage`) is retained for consumers that cannot +draw the hatch — ODT/EPUB export and the reflow paths. (`pctN` shading already +blended — the "25% pattern" cell matched Word.) +**Character borders (`w:bdr`)** now render: the run reader parses `w:bdr`, the +mapper carries it as `CharProps.character_border` (a `Border`), and the layout +draws a border box around the run — one box per visual line via the same Parley +selection geometry as the highlight underlay (`para_underlays`). The appendix's +red-boxed "char-border" run now matches Word; tested by `maps_character_border` / +`none_valued_bdr_is_dropped`. **DOCX export (2026-07-19):** `emit_char_props` +now writes `w:bdr` back (style → `w:val`, width → `w:sz` eighth-points, spacing → +`w:space`, colour → hex/`auto`) symmetric with the reader, so a run's character +border survives an export→re-import — tested by `character_border_is_emitted` +(unit) and `docx_round_trip_preserves_emboss_imprint_and_char_border` +(import-export-import). **ODT export (2026-07-20):** written as an `fo:border` +shorthand + `fo:padding` inset on `style:text-properties` and read back +(`OdfTextProps.border`/`padding` → `parse_odf_border` → `CharProps.character_border`) +— tested by `emboss_and_char_border_round_trip_through_odt`. **Loro-bridge +round-trip (2026-07-20):** carried as a `char_border` text mark holding the packed +`Border` string (`encode_border`/`decode_border`, reused from the paragraph-border +codec), registered in `CHAR_MARK_KEYS` — tested by +`bridge_emboss_imprint_char_border_roundtrip`. `TODO(char-border-export)` is now +fully closed for character borders. +**A footnote referenced from a keep-with-next paragraph** is no longer dropped, +and footnotes now render **per page** at the foot of the page carrying their +reference (matching Word) rather than dumped at the section end. Two fixes: the +keep-with-next chain (`flow_para_chain`) formerly discarded the notes its +speculative layout collected — it now threads a running note counter and hands +each placed block's notes to `pending_footnotes` (`keep_with_next_paragraph_keeps_its_footnote`); +and `finish_page` (`flow_tail::flow_page_footnotes`) lays out that page's +footnotes in a measured, bottom-aligned band, with pagination disabled for the +self-contained band (no spurious overflow page). The report's two footnotes now +sit together at the foot of page 3 and the fixture stays 7 pages. **Space +reservation (2026-07-20):** the band's height is now *reserved* from the content +area as each footnote reference is placed, so body content stops above the band +instead of overlapping it on a full page. `FlowState::footnote_reserved` +accumulates the measured band height per page (separator + each note, via +`flow_tail::footnote_reservation`); the "space remaining on this page" break +checks (`flow_split`, `flow_para_place`, `flow_para_chain`, `flow_table_main`) +use `FlowState::content_bottom()` = `page_content_height − footnote_reserved` +instead of the full height; and `finish_page` resets the reservation per page. +The reservation is applied *after* the reference paragraph is placed and only if +it stayed on its page (`place_with_footnote_band`), so a paragraph that breaks +does not double-count. An **empty** (section-break) paragraph is exempted — it +has no visible glyphs and may sit within the band, which stops the reservation +from spilling a trailing section mark onto a spurious page (the failure mode that +had deferred this). Verified on the fixture: the intro page's blockquote stops +above the footnote band (no overlap) and the document stays 7 pages; tested by +`footnote_band_stays_within_the_content_area`. **Remaining:** multi-column and +non-paginated (reflow) footnotes keep the section-end fallback; a footnote +reference in the very last line of a full page can still bottom-align its band +into the margin (no visible-text overlap). +**Emboss / imprint / shadow run effects** (`w:emboss`, `w:imprint`, `w:shadow`) +now render as a 3-D relief: a second offset glyph copy behind the run — a darker +copy for shadow/emboss (drop shadow / raised), a lighter copy for imprint +(engraved) — with emboss/imprint bodies drawn in the effect grey. Root cause +that had also left the pre-existing `w:shadow` invisible: Parley **coalesced** +the adjacent effect runs into one glyph run spanning several style spans, so the +per-run effect lookup (`span_covering_range`, which needs full coverage) returned +nothing; pushing the emboss/imprint body grey as the Parley **brush** both +colours the body and keeps the runs from coalescing past their span. Parsed +(`w:emboss`/`w:imprint` toggles) → `CharProps.emboss/imprint` → `StyleSpan` → +`para_emit`. The appendix's "shadow emboss imprint" now render distinctly; tested +by `parses_emboss_imprint_shadow` / `maps_emboss_and_imprint`. **DOCX export +(2026-07-19):** `emit_char_props` writes `w:emboss`/`w:imprint` toggles back, so +both survive an export→re-import (`w:shadow` already round-tripped) — tested by +`emboss_and_imprint_are_emitted` (unit) and +`docx_round_trip_preserves_emboss_imprint_and_char_border`. **ODT export +(2026-07-20):** mapped to the single `style:font-relief` +(embossed = emboss, engraved = imprint) and read back +(`OdfTextProps.font_relief`) — tested by +`emboss_and_char_border_round_trip_through_odt`. **Loro-bridge round-trip +(2026-07-20):** carried as `emboss` / `imprint` boolean text marks +(`CHAR_MARK_KEYS`) — tested by `bridge_emboss_imprint_char_border_roundtrip`. +DOCX, ODT, and the CRDT now all round-trip these effects end-to-end. + +**Floating text boxes** (`wps` shapes with text) — **rendered (2026-07-19).** A +DrawingML `wps:wsp` shape carrying `w:txbxContent` is now imported end-to-end: the +reader (`document_drawing.rs`) parses the shape's `a:ln` border width/colour, its +`a:srgbClr` fill, and recurses into the text-box body paragraphs (`parse_txbx_content` +→ `parse_paragraph`); the mapper (`docx/mapper/images.rs`) maps such a drawing to a +new `Inline::TextBox(NodeAttr, Vec)` (geometry + `textbox-fill`/`textbox-line` +on the attr) instead of an image; resolution collects it as a `CollectedImage` whose +`textbox: Some(CollectedTextBox { blocks, fill, line })`; and the flow engine +(`flow_textbox.rs`) flows the interior blocks in a nested `Pageless` sub-layout at the +inner width, wraps them in a fill + border `ClippedGroup`, grows the box to fit its +content if the authored height is short, and returns a `FloatPlacement` so the +anchoring paragraph reserves a side band and the surrounding copy wraps around it — +reusing the same wrap machinery as a floating image (`plan_textbox` runs ahead of +`plan_float`; `plan_float` skips text boxes). The ACID2 newsletter (section C) now +carries an anchored right-floating sidebar box that visually verifies: the box paints +with its orange border + peach fill in the right column and the two-column body copy +wraps square on its left. Tested: `parses_wps_text_box` (reader), +`text_box_drawing_maps_to_inline_text_box` (mapper). **DOCX export (2026-07-19):** +`Inline::TextBox` now writes back a `w:drawing`/`wp:anchor` whose `a:graphicData` +is a `wps:wsp` shape carrying the fill (`a:solidFill`), border (`a:ln`), wrap, and +a `w:txbxContent` body (the interior blocks via the shared block writer) — the +reverse of `parse_txbx_content` (`docx/write/document_textbox.rs`). The box +round-trips: geometry, fill, border, and interior text all survive +export→re-import, verified by `docx_round_trip_preserves_floating_text_box` +(asserts the re-imported `TextBox` directly *and* import-export-import stability). +**ODT export (2026-07-20):** `Inline::TextBox` writes a `draw:frame`/`draw:text-box` +anchored to the paragraph, with an automatic `family="graphic"` style +(`auto_graphic.rs`) carrying the wrap (`style:wrap`/`style:run-through`), solid +fill (`draw:fill-color`), and border (`svg:stroke-color`); geometry rides on +`svg:width`/`svg:height` (`inlines_frame.rs`). **The import side now maps a +floating text box back to `Inline::TextBox`** instead of a block `Div`: a +`draw:text-box` frame carrying a wrap style (`OdfGraphicWrap` extended with +`fill_color`/`stroke_color`, threaded through the mapper's `frame_fills`/ +`frame_strokes`) becomes an `Inline::TextBox` with geometry + fill/border, so a +text box round-trips **DOCX ↔ ODT**. Tested by +`floating_text_box_round_trips_through_odt`, and verified end-to-end by +converting the ACID2 DOCX → ODT and rendering it (the sidebar box paints with +its orange border + peach fill and the body wraps on its left, matching the DOCX +render). A text box with no wrap style still maps to a block `Div` (unchanged). +**Still pending:** a `w:txbxContent`/`draw:text-box` body carrying a **table** +(only paragraph bodies round-trip today); absolute anchor offsets +(`wp:positionH`/`positionV` / `svg:x`/`svg:y`) fall back to the wrap-side band as +with floating images. + +**Gaps it currently surfaces** (candidate golden-diff regions; not yet fixed): +a block-stacked inline image is +left-aligned rather than honouring the paragraph's `w:jc`; header/footer +references are not **inherited** across section breaks (the fixture declares them +per-section as a workaround). + --- ## 11. Spell Checking (`loki-spell`) @@ -280,3 +500,54 @@ with the `loki-i18n` multi-locale plan. | **Personal dictionary persistence** | Yes | Done (5.10, 2026-07-12): the personal word list persists as a JSON array at `/AppThere/Loki/personal-dictionary.json` (Android-aware; `spell/personal_dict.rs`, mirroring the `recent_documents` load-or-empty / silent-write-failure pattern). `SpellService::bootstrap` replays the persisted words into the fresh checker, `add_word` saves the full list (`SpellChecker::personal_words()`, new), and `activate_language` replays the **in-memory** list into the newly parsed checker (the memory list is the truth — it includes adds whose save failed). Session `ignore_word` entries are deliberately not persisted. The path is injectable so tests never touch the user profile. Tested by `personal_word_survives_activate_language`, `personal_word_survives_a_restart`, the `personal_dict` unit tests, and `personal_words_lists_added_words_sorted_without_ignores` (`loki-spell`). | Coverage of the engine, dictionary, layout, and service layers is ~45 unit/integration tests (incl. a real load of the bundled `en` dictionary and a layout pass asserting squiggle emission). + +--- + +## 12. DOCX Word-Compatibility Repair (`loki-ooxml::repair`) + +Microsoft Word enforces several constraints strictly that a tolerant, +name-matching reader (Loki, LibreOffice) ignores — so a `.docx` that opens fine +in Loki can still trip Word's "Word found unreadable content — recover?" prompt. +These are the dominant "Loki opens it, Word won't" asymmetries, and both +hand-authored files and some tool exporters emit them routinely: + +1. **Schema child order.** OOXML complex types are `xsd:sequence`s, so Word + rejects a file whose `w:pPr` / `w:rPr` / `w:sectPr` / `w:tcPr` / … children + appear out of order. +2. **Undeclared `mc:Ignorable` prefixes.** Every prefix listed in an + `mc:Ignorable` attribute must resolve to an in-scope `xmlns:` declaration + (ISO/IEC 29500-3 §10.1.1); a dangling prefix (e.g. `mc:Ignorable="w14"` with + no `xmlns:w14`) is fatal to Word but silently ignored by tolerant readers. + *This was one cause of the ACID 2 fixture failing in Word — a `styles.xml` + that listed `w14` in `mc:Ignorable` without declaring it.* +3. **Dangling note-separator references.** A ``/`` in + `settings.xml` may reference the separator notes (`w:id="-1"`/`"0"`) that + live in `footnotes.xml`/`endnotes.xml`; if that part (or the referenced id) + is absent, Word reports an error in the Footnotes/Endnotes stream. A + **cross-part** check — the offending element and the missing part are + different parts. *This was the last ACID 2 blocker — a spurious `endnotePr` + with no `endnotes.xml`.* + +| Capability | Status | Notes | +| :--- | :---: | :--- | +| **Detect** | Yes | `loki_ooxml::analyze_docx(&[u8]) -> RepairReport` walks every `WordprocessingML` part and reports each out-of-order container, each undeclared `mc:Ignorable` prefix, and each dangling footnote/endnote separator reference (part, container, offending detail). Non-destructive. | +| **Repair** | Yes | `loki_ooxml::repair_docx(&[u8]) -> (Vec, RepairReport)` reorders children into the ECMA-376 sequence (`repair/order.rs`, covering `pPr`/`rPr`/`sectPr`/`tcPr`/`tblPr`/`trPr`/`lvl`/`style`/`abstractNum`), strips undeclared prefixes from `mc:Ignorable` (`repair/mce.rs`; empties → attribute dropped), and drops dangling note-separator references from `settings.xml` (`repair/notes.rs`, driven by a package-wide `NoteContext`; empties → a valid empty ``). All three are **lossless** — ordering only permutes element children, an undeclared prefix could never have bound anything, and a reference that resolves to nothing conveys nothing; attributes, text, entities, comments, and constructs Loki cannot model are preserved verbatim (`mc:Ignorable` rewriting is byte-surgery on just that one value). Backed by a tiny purpose-built XML DOM (`repair/dom.rs`). Conservative: a container holding a foreign element (`mc:AlternateContent`, `w14:*`) or a comment is left untouched. | +| **CLI** | Yes | `loki-headless repair --in doc.docx [--check | --out fixed.docx]` — `--check` reports problems; `--out` writes a repaired copy. The headless "repair a malformed document with Loki" path. | +| **In-editor banner (`loki-text`)** | Yes | On opening a DOCX, a background effect runs `analyze_docx` on the file bytes (`editor_load::analyze_open_docx`); if problems are found, an amber attention banner (`editor_repair_banner::RepairBanner`, mirroring the font-substitution panel, ADR-0013) appears above the ribbon: *"This document has N issues that can stop it opening in Microsoft Word."* with **Repair** / **Dismiss**. Repair runs `repair_docx` on the file and writes the fixed bytes back in place (`editor_save::repair_document_file`) — lossless, no model round-trip (the tolerant reader already loaded a correct model), reusing the same single-write path as Save; success shows in the status chip. Strings in `editor.ftl` (`editor-repair-*`). | +| **Export canonicalisation** | Yes | Loki's own `DocxExport` did **not** emit all `pPr`/`rPr` children in schema order (e.g. `w:jc` before `w:spacing`, `w:color` after `w:sz`), so files Loki *saved* could themselves trip Word's repair prompt. The export assembly now runs the same canonicalisation pass as its final step (`docx/write/assembly.rs` → `repair::canonicalize_package`), so every DOCX Loki writes is schema-ordered. Regression-locked by `loki_export_is_word_schema_clean` (`tests/repair.rs`). | +| **Package core-properties content type** | Yes | The OPC writer (`loki-opc`) emitted `/docProps/core.xml` and its relationship but **never registered its content-type Override**, so the part fell through to the generic `application/xml` default. Word rejects the whole package as unreadable when the core-properties relationship targets a part not typed `…core-properties+xml` — a **package-integrity** check it runs before parsing any content, so the document simply won't open. This affected **every** DOCX Loki wrote (they all set core metadata). `zip::write::write_package_to_zip` now adds the Override whenever core properties are present (`MEDIA_TYPE_CORE_PROPERTIES`). Regression-locked by `core_properties_part_is_typed_correctly` (`loki-opc/tests/package_tests.rs`). | +| **Note separator ↔ backing part** | Yes | Now a first-class repair axis (`repair/notes.rs`, above): `analyze_docx`/`repair_docx` detect and drop ``/`` separator references in `settings.xml` that have no backing `footnotes.xml`/`endnotes.xml` part (or reference an id the part lacks), while preserving every reference that *does* resolve and every other setting. So the CLI and the in-editor banner catch this class on arbitrary files, not just the fixture. End-to-end tested by `analyze_detects_dangling_endnote_pr_but_not_the_backed_footnote_pr` + `repair_removes_dangling_endnote_pr_refs_and_keeps_footnote_pr` (`tests/repair.rs`); the fixture is additionally guarded by `note_separator_refs_have_a_backing_part` (`loki-acid/tests/acid2_word_valid.rs`). | +| **ACID 2 fixture** | Yes | The hand-authored `acid2-docx` parts are authored for readability, not schema order; `gen_acid2_docx` normalises them through `repair_docx` at build time so the committed fixture opens in Word. It also declares `xmlns:w14` alongside `mc:Ignorable="w14"` in `styles.xml` (matching Word), carries the correct core-properties content type (the loki-opc fix above), and dropped a spurious `` that referenced a non-existent `endnotes.xml`. Each Word-reject cause was found by diffing the package against a real Word-authored file and confirmed with a calibrated OPC validator that passes the Word file cleanly. Guarded by `committed_fixture_has_no_ordering_violations` (WML axes) + `note_separator_refs_have_a_backing_part` (`loki-acid/tests/acid2_word_valid.rs`). | + +**Scope.** The three axes above (schema child order, undeclared `mc:Ignorable` +prefixes, dangling note-separator references) — the dominant Word-vs-tolerant +asymmetries. Other corruption classes (dangling relationships, missing +content-type overrides, malformed XML) are a different error class already +surfaced by the OPC/import layer; extending `repair` to offer fixes for those is +a natural follow-up. Tested by 19 unit tests (`repair/repair_tests.rs`, incl. +entity/whitespace preservation, byte-exact round-trip of already-clean input, +the `mc:Ignorable` strip/keep/drop cases, and the note-separator remove/keep/ +partial cases) + 6 end-to-end tests (`tests/repair.rs`). Detection is surfaced +three ways: the `loki-headless repair` CLI, the `loki-text` open-time banner, and +the +`analyze_docx`/`repair_docx` public API for embedders. diff --git a/loki-acid/Cargo.toml b/loki-acid/Cargo.toml index a38569bb..cab44d23 100644 --- a/loki-acid/Cargo.toml +++ b/loki-acid/Cargo.toml @@ -28,3 +28,5 @@ loki-presentation-model = { path = "../loki-presentation-model" } loki-primitives = { path = "../loki-primitives" } # The `render_acid_pdf` example renders fixtures to PDF for visual comparison. loki-pdf = { path = "../loki-pdf" } +# The `gen_acid2_docx` example packages hand-authored OOXML parts into a DOCX. +loki-opc = { path = "../loki-opc" } diff --git a/loki-acid/TEST_PLAN.md b/loki-acid/TEST_PLAN.md index 0fd2ceaf..75d9080d 100644 --- a/loki-acid/TEST_PLAN.md +++ b/loki-acid/TEST_PLAN.md @@ -207,6 +207,38 @@ see the ODF note in each ODF section). --- +## 8. ACID 2 — combined-feature archetypes (`acid2-docx.docx`) + +Where §1's `acid_docx` isolates one construct per section, **ACID 2** stresses how +features behave *together* in believable documents — the interaction bugs that +break real files on open. It is **hand-authored OOXML**, packaged from reviewable +parts under `loki-acid/assets/acid2/` by +`cargo run -p loki-acid --example gen_acid2_docx` (mirrored into the conformance +corpus). Being synthetic rather than Word-authored is deliberate: it can exercise +constructs Loki cannot yet *emit*. Canonical render = Microsoft 365's render of +this same file (visual axis; golden capture PENDING). Deliberately CJK-free, so no +`ja` line-segmentation caveat. + +| Page | Archetype | Combines | +|----|---------|----------| +| 1 | Report — cover | Title/Subtitle styles, theme colours, `caps`+letter-spacing, accent rule, `DATE` field | +| 2 | Report — TOC | `TOC` field, dot-leader right tabs, page numbers | +| 3 | Report — body | Heading hierarchy (`keepNext`), justified text, multi-level list, shaded **table** with `vMerge`+`gridSpan`+`tblHeader`, captioned **figure** (`SEQ`), footnotes, block quote, `REF` cross-reference | +| 4 | Résumé | Right-tab dates, small caps, section-rule headings, external hyperlinks, bullet hierarchy, tight spacing | +| 5 | Newsletter | Balanced **two columns**, **drop cap**, square-wrapped **floating image**, pull-quote box, theme colours | +| 6 | Invoice / contract | Fixed-layout bordered table, legal clause numbering, **tracked changes** (`w:ins`/`w:del`), a **comment**, signature lines | +| 7 | Appendix | Isolated feature-matrix cells: run decorations & effects, decimal-tab dot leaders, `vMerge` + pattern/solid shading, floating text box, special spaces/hyphens; section carries `pgBorders` + `lnNumType` | + +**Bugs surfaced & fixed (2026-07-14):** empty-style-span layout panic (tab-only +styled run); `keepNext` caption dropping its following table; `keepNext` figure +paragraph dropping its inline image. **Gaps surfaced (open):** table-style borders, +in-cell decimal tabs, `wps` text boxes, page borders, line numbering, pattern +shading, text effects, character borders, `noBreakHyphen`/`softHyphen`, inline-image +justification, cross-section header/footer inheritance. See §10 of +[`docs/fidelity-status.md`](../docs/fidelity-status.md). + +--- + ## Diff-harness hints - **Page-count drift is the canary.** TC-DOCX-001/027 and any font-substitution case diff --git a/loki-acid/assets/acid2-docx.docx b/loki-acid/assets/acid2-docx.docx new file mode 100644 index 00000000..d89ae9cb Binary files /dev/null and b/loki-acid/assets/acid2-docx.docx differ diff --git a/loki-acid/assets/acid2/word/comments.xml b/loki-acid/assets/acid2/word/comments.xml new file mode 100644 index 00000000..0234ebbb --- /dev/null +++ b/loki-acid/assets/acid2/word/comments.xml @@ -0,0 +1,9 @@ + + + + + + Confirm the payment window with Legal before this clause is finalised. + + + diff --git a/loki-acid/assets/acid2/word/document.xml b/loki-acid/assets/acid2/word/document.xml new file mode 100644 index 00000000..08e91722 --- /dev/null +++ b/loki-acid/assets/acid2/word/document.xml @@ -0,0 +1,447 @@ + + + + + + + + + + + + AppThere Engineering + + Rendering Fidelity at Scale + + The ACID 2 parity report for opening real-world documents in Loki + + + Prepared by + the Loki Layout Team + · Date + 14 July 2026 + + + + + Contents + + + TOC \o "1-2" \h \z \u + + 1. Introduction3 + + 1.1 Background and goals3 + + 2. Measured results3 + + 2.1 Per-feature scores4 + + + + + 1. Introduction + 1.1 Background and goals + + When a reader opens an existing document, the first impression is entirely visual: line spacing, tables, headings, and figures must land where the author put them. This report exercises the constructs that most often drift between suites, measured with SSIM + + against the canonical Microsoft 365 render. The bulk of divergence historically comes from a handful of features working + together + , which is exactly what the pages that follow stress. + + Scope + + Word-processing layout: spacing, indents, keep-with-next, widows. + + Tables with merged cells, shading, and repeated header rows. + + Floating figures and captions with sequence fields. + + Cross-references and fields that update on open. + + 2. Measured results + + The results are summarised in + + REF tblResults \h + + Table 1 + + . Scores at or above the calibrated threshold pass; the total row aggregates the section. + + + + + Table + 1 + + . Per-feature parity (illustrative). + + + + + + + + + + + + + + + + Feature + + Region score + + + + SSIM + ΔE + Verdict + + Line spacing + 0.99 + 1.2 + Pass + + Merged tables + 0.97 + 2.6 + Pass + + Total + 0.98 + 1.9 + Pass + + + + + + + + + + + + + + + + + + + + + + + Figure + 1 + . Score distribution across regions. + + + + + Parity is not one big feature; it is a hundred small ones that must all be right at once. + + + + + + + + + + + + + + + Jordan A. Rivera + + jordan@example.com + + linkedin.com/in/jrivera + • San Francisco, CA + + + Experience + + + Staff Layout Engineer, AppThere + 2023 – Present + + Loki word-processing layout & rendering fidelity. + + Drove SSIM parity with Microsoft 365 across the ACID corpus. + + Shipped variable-font instancing and floating-image wrap parity. + + + Software Engineer, Contoso Docs + 2019 – 2023 + + Built the OOXML import pipeline for tables and numbering. + + + Skills + + RustOOXML / ODFTypography + + + + + + + + + + + + + + + + The Loki Ledger + + Volume 2, Issue 7 — News from the rendering desk + + + + P + + arity work this quarter focused on the constructs users hit first. A floating figure now sits beside the body text, wrapped square, while the columns balance across the page. Small drifts in leading and indent are the ones readers notice, so those got the most attention. + + + + + + + right + 0 + + + + + + + + + + + + + + + Text flows around the sidebar image on its left, then reclaims the full column width once the image is cleared. The wrap band is exactly the image width plus its distance insets, matching the reference render used by the visual harness for this page. + + + + “The columns balance, the figure wraps, and the leading holds.” + + + + + + right + 0 + + + + + + + + + + + + + + + + + Sidebar. + A floating text box wraps the body copy on its left, with its own border and fill. + + + + + + + The remaining copy continues in two balanced columns to the foot of the page, exercising the column breaker together with justified text, hyphenation-free wrapping, and the running header and footer that persist across every section of this document. + + + + + + + + + + + + + + + + INVOICE + No. LK-2026-0714 + + AppThere, Inc. · Terms: Net 30 · Due: 13 August 2026 + + + + + + + + + + + + + Description + Qty + Amount + + Layout engineering (hours) + 40 + 6,400.00 + + Conformance harness setup + 1 + 950.50 + + Total due + 7,350.50 + + + Terms and conditions + + Payment. All amounts are due within thirty (30) days of the invoice date. + + + Late payments accrue interest at + 2.0% + 1.5% + per month. + + + + Acceptance. Signature below constitutes acceptance of these terms. + + + + + + + + Authorised signature + Date + + + + + + + + + + + + + + + Appendix A. Feature matrix + Isolated cells for pinpoint diagnosis. This section also carries a page border and line numbering in its section properties. + + A1. Run decorations and effects + + single + double + dotted + wave + strike + dbl-strike + super2 + subn + + highlight + char-border + char-shading + outline + shadow + emboss + imprint + + A2. Decimal tabs with dot leader + Subtotal1,234.50 + Tax98.76 + Grand total1,333.26 + + A3. Cell shading with a vertically merged label + + + + + + Fills + 25% pattern + diagonal stripe + + + plain cell + solid fill + + + A4. Callout box (bordered + shaded paragraph) + + + + + + + + + + + + Callout. + A bordered, shaded paragraph forming a callout box — a four-sided paragraph border, a solid fill, and symmetric side indents, all exercised together and rendered by both Word and Loki. + + A5. Special spaces and hyphens + + Non-breaking space keeps 10 kg together; a non-breaking + hyphen holds compounds, while an optionalhyphen breaks only when needed. + + + + + + + + + + + + + + + + + + + + diff --git a/loki-acid/assets/acid2/word/footer1.xml b/loki-acid/assets/acid2/word/footer1.xml new file mode 100644 index 00000000..f45a11c2 --- /dev/null +++ b/loki-acid/assets/acid2/word/footer1.xml @@ -0,0 +1,25 @@ + + + + + + + + + + AppThere Loki + Page + + PAGE + + 1 + + of + + NUMPAGES + + 1 + + Generated by Loki + + diff --git a/loki-acid/assets/acid2/word/footnotes.xml b/loki-acid/assets/acid2/word/footnotes.xml new file mode 100644 index 00000000..4cc4d38f --- /dev/null +++ b/loki-acid/assets/acid2/word/footnotes.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + Structural Similarity Index Measure — a perceptual metric bounded in [0, 1]; Loki's visual harness gates at the calibrated threshold. + + + + + + + All figures are illustrative and generated deterministically at build time. + + + diff --git a/loki-acid/assets/acid2/word/header1.xml b/loki-acid/assets/acid2/word/header1.xml new file mode 100644 index 00000000..248af018 --- /dev/null +++ b/loki-acid/assets/acid2/word/header1.xml @@ -0,0 +1,14 @@ + + + + + + + + + + Loki ACID 2 + Rendering Fidelity Suite + Confidential Draft + + diff --git a/loki-acid/assets/acid2/word/numbering.xml b/loki-acid/assets/acid2/word/numbering.xml new file mode 100644 index 00000000..c80c73ef --- /dev/null +++ b/loki-acid/assets/acid2/word/numbering.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loki-acid/assets/acid2/word/settings.xml b/loki-acid/assets/acid2/word/settings.xml new file mode 100644 index 00000000..374d0dad --- /dev/null +++ b/loki-acid/assets/acid2/word/settings.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/loki-acid/assets/acid2/word/styles.xml b/loki-acid/assets/acid2/word/styles.xml new file mode 100644 index 00000000..408d44a1 --- /dev/null +++ b/loki-acid/assets/acid2/word/styles.xml @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loki-acid/assets/acid2/word/theme/theme1.xml b/loki-acid/assets/acid2/word/theme/theme1.xml new file mode 100644 index 00000000..f8c4a982 --- /dev/null +++ b/loki-acid/assets/acid2/word/theme/theme1.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loki-acid/examples/gen_acid2_docx.rs b/loki-acid/examples/gen_acid2_docx.rs new file mode 100644 index 00000000..f7a9bd5f --- /dev/null +++ b/loki-acid/examples/gen_acid2_docx.rs @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Generates `loki-acid/assets/acid2-docx.docx`, the **ACID 2** DOCX fixture, +//! and mirrors it into `appthere-conformance/fixtures/docx/acid2-docx.docx`. +//! +//! Run with `cargo run -p loki-acid --example gen_acid2_docx`. +//! +//! # Provenance — read before trusting this fixture +//! +//! Unlike `acid_docx.docx` (authored in real Microsoft Word to test *import* +//! fidelity against genuine Office output), ACID 2 is **hand-authored OOXML**: +//! the WordprocessingML parts live as reviewable XML under `assets/acid2/` and +//! this generator packages them into a Word-valid OPC container via `loki-opc`. +//! That is deliberate — it lets the fixture exercise constructs Loki cannot yet +//! *emit* (text boxes, page borders, tracked changes, pattern shading, …), +//! which a round-trip-through-Loki fixture never could. Parity is still judged +//! against Microsoft Word's render of *this same file*: open it in Word, print +//! to PDF, and run `scripts/generate-office-goldens.sh` (see the golden dir's +//! `PENDING.txt`). ACID 2 combines features into realistic archetypes — a +//! business report, a résumé, a newsletter, an invoice/contract — plus an +//! isolated feature-matrix appendix, because interaction bugs are what break +//! real documents. + +use std::path::{Path, PathBuf}; + +use image::{Rgb, RgbImage}; +use loki_opc::Package; +use loki_opc::part::{PartData, PartName}; +use loki_opc::relationships::{Relationship, TargetMode}; + +// ── OPC relationship-type URIs (ECMA-376 §17 / OPC) ────────────────────────── +const NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +fn rel(kind: &str) -> String { + format!("{NS}/{kind}") +} + +// ── WordprocessingML media types ───────────────────────────────────────────── +const MT_DOCUMENT: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; +const MT_STYLES: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"; +const MT_NUMBERING: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"; +const MT_FOOTNOTES: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"; +const MT_SETTINGS: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"; +const MT_HEADER: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"; +const MT_FOOTER: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"; +const MT_COMMENTS: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"; +const MT_THEME: &str = "application/vnd.openxmlformats-officedocument.theme+xml"; + +/// One document-part relationship to declare on `word/document.xml`. +struct DocRel { + r_id: &'static str, + kind: &'static str, + /// Relationship target (relative to `word/`). + target: &'static str, + /// The part's absolute OPC name, or `None` for external targets. + part: Option<&'static str>, + media_type: &'static str, + external: bool, +} + +/// The document parts, in the order their `rId`s appear in `document.xml`. +const DOC_RELS: &[DocRel] = &[ + rels_part( + "rId1", + "styles", + "styles.xml", + "/word/styles.xml", + MT_STYLES, + ), + rels_part( + "rId2", + "numbering", + "numbering.xml", + "/word/numbering.xml", + MT_NUMBERING, + ), + rels_part( + "rId3", + "footnotes", + "footnotes.xml", + "/word/footnotes.xml", + MT_FOOTNOTES, + ), + rels_part( + "rId4", + "settings", + "settings.xml", + "/word/settings.xml", + MT_SETTINGS, + ), + rels_part( + "rId5", + "theme", + "theme/theme1.xml", + "/word/theme/theme1.xml", + MT_THEME, + ), + rels_part( + "rId6", + "header", + "header1.xml", + "/word/header1.xml", + MT_HEADER, + ), + rels_part( + "rId7", + "footer", + "footer1.xml", + "/word/footer1.xml", + MT_FOOTER, + ), + rels_part( + "rId8", + "comments", + "comments.xml", + "/word/comments.xml", + MT_COMMENTS, + ), + rels_part( + "rId9", + "image", + "media/image1.png", + "/word/media/image1.png", + "image/png", + ), + DocRel { + r_id: "rId10", + kind: "hyperlink", + target: "mailto:jordan@example.com", + part: None, + media_type: "", + external: true, + }, + DocRel { + r_id: "rId11", + kind: "hyperlink", + target: "https://www.linkedin.com/in/jrivera", + part: None, + media_type: "", + external: true, + }, +]; + +/// Const helper: an internal document-part relationship + its part. +const fn rels_part( + r_id: &'static str, + kind: &'static str, + target: &'static str, + part: &'static str, + media_type: &'static str, +) -> DocRel { + DocRel { + r_id, + kind, + target, + part: Some(part), + media_type, + external: false, + } +} + +fn main() { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let src = manifest.join("assets/acid2"); + + let mut pkg = Package::new(); + + // ── Main document part + the package root relationship ────────────────── + let doc_part = PartName::new("/word/document.xml").expect("valid part name"); + pkg.set_part( + doc_part.clone(), + PartData::new(read(&src, "word/document.xml"), MT_DOCUMENT), + ); + pkg.relationships_mut() + .add(Relationship { + id: "rId1".into(), + rel_type: rel("officeDocument"), + target: "word/document.xml".into(), + target_mode: TargetMode::Internal, + }) + .expect("root rel"); + + // ── Document-linked parts + their relationships ───────────────────────── + let ct = pkg.content_type_map_mut(); + ct.add_default( + "rels", + "application/vnd.openxmlformats-package.relationships+xml", + ); + ct.add_default("xml", "application/xml"); + ct.add_default("png", "image/png"); + ct.add_override(&doc_part, MT_DOCUMENT); + + for dr in DOC_RELS { + pkg.part_relationships_mut(&doc_part) + .add(Relationship { + id: dr.r_id.into(), + rel_type: rel(dr.kind), + target: dr.target.into(), + target_mode: if dr.external { + TargetMode::External + } else { + TargetMode::Internal + }, + }) + .expect("doc rel"); + if let Some(name) = dr.part { + let part = PartName::new(name).expect("valid part name"); + let bytes = if name.ends_with("image1.png") { + figure_png() + } else { + // `/word/foo.xml` → assets path `word/foo.xml`. + read(&src, name.trim_start_matches('/')) + }; + pkg.set_part(part.clone(), PartData::new(bytes, dr.media_type)); + if dr.media_type.ends_with("+xml") { + pkg.content_type_map_mut() + .add_override(&part, dr.media_type); + } + } + } + + // ── Core metadata ─────────────────────────────────────────────────────── + let core = pkg.core_properties_mut(); + core.title = Some("Loki ACID 2 — Rendering Fidelity Suite".into()); + core.creator = Some("AppThere Loki".into()); + core.subject = Some("Rendering parity stress test".into()); + + // ── Normalise for Word, then write + mirror into the conformance corpus ── + // The reviewable source parts under `assets/acid2/` are authored in a + // convenient order, which is NOT necessarily the strict `xsd:sequence` order + // that Microsoft Word enforces on open (tolerant readers like Loki accept + // any order). Run Loki's own DOCX repair pass over the packaged bytes so the + // emitted fixture is schema-ordered and opens cleanly in Word — and so this + // generator dogfoods `loki_ooxml::repair_docx`. + let mut buf = std::io::Cursor::new(Vec::new()); + pkg.write(&mut buf).expect("serialize package"); + let (bytes, report) = loki_ooxml::repair_docx(&buf.into_inner()).expect("repair"); + if !report.is_clean() { + println!( + "normalised {} out-of-order container(s) for Word compatibility", + report.findings.len() + ); + } + + let asset = manifest.join("assets/acid2-docx.docx"); + std::fs::write(&asset, &bytes).expect("write docx"); + let mirror = manifest.join("../appthere-conformance/fixtures/docx/acid2-docx.docx"); + std::fs::copy(&asset, &mirror).expect("mirror into fixtures"); + + println!("wrote {}", asset.display()); + println!("mirrored {}", mirror.display()); +} + +/// Read a UTF-8 XML part from the `assets/acid2/` source tree. +fn read(src: &Path, rel_path: &str) -> Vec { + let p: PathBuf = src.join(rel_path); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} + +/// A small, deterministic bar-chart PNG used as the report figure and the +/// newsletter sidebar image. Solid bars on white — no randomness, so the +/// fixture bytes are stable across rebuilds. +fn figure_png() -> Vec { + let (w, h) = (480u32, 288u32); + let mut img = RgbImage::from_pixel(w, h, Rgb([255, 255, 255])); + let bars = [ + (0.55f32, Rgb([68, 114, 196])), + (0.80, Rgb([237, 125, 49])), + (0.40, Rgb([112, 173, 71])), + (0.95, Rgb([91, 155, 213])), + (0.65, Rgb([255, 192, 0])), + ]; + let n = bars.len() as u32; + let gap = 16u32; + let bar_w = (w - gap * (n + 1)) / n; + for (i, (frac, color)) in bars.iter().enumerate() { + let x0 = gap + i as u32 * (bar_w + gap); + let bar_h = (*frac * (h as f32 - 40.0)) as u32; + let y0 = h - 20 - bar_h; + for y in y0..(h - 20) { + for x in x0..(x0 + bar_w) { + img.put_pixel(x, y, *color); + } + } + } + // Baseline axis. + for x in 0..w { + img.put_pixel(x, h - 20, Rgb([120, 120, 120])); + } + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png) + .expect("encode png"); + out.into_inner() +} diff --git a/loki-acid/tests/acid2_word_valid.rs b/loki-acid/tests/acid2_word_valid.rs new file mode 100644 index 00000000..45542f71 --- /dev/null +++ b/loki-acid/tests/acid2_word_valid.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Guards that the committed ACID 2 fixture opens in schema-strict Microsoft +//! Word. [`analyze_docx`] checks both Word-vs-tolerant asymmetries: the +//! `WordprocessingML` parts must be in the required `xsd:sequence` order, and +//! every `mc:Ignorable` prefix must resolve to a declared namespace (an +//! undeclared `w14` in `styles.xml` once made Word reject this file while Loki +//! opened it fine). The `gen_acid2_docx` generator normalises ordering via +//! `loki_ooxml::repair_docx`; if the fixture is regenerated without that step +//! (or hand-edited into either defect), this test fails with the specifics. + +use std::io::Cursor; + +use loki_ooxml::analyze_docx; +use loki_opc::Package; +use loki_opc::part::PartName; + +const ACID2: &[u8] = include_bytes!("../assets/acid2-docx.docx"); + +#[test] +fn committed_fixture_has_no_ordering_violations() { + let report = analyze_docx(ACID2).expect("fixture is a valid OPC package"); + assert!( + report.is_clean(), + "acid2-docx.docx has {} Word-rejecting ordering violation(s) — regenerate with \ + `cargo run -p loki-acid --example gen_acid2_docx`:\n{}", + report.findings.len(), + report + .findings + .iter() + .map(|f| format!(" [{}] <{}>: {}", f.part, f.container, f.detail)) + .collect::>() + .join("\n"), + ); +} + +/// A separator-reference block in `settings.xml` (`` / +/// ``) points at the special separator notes that live in the +/// corresponding part. If that part is absent, Word reports an error in +/// "Footnotes"/"Endnotes" on open — the exact failure this fixture hit with a +/// spurious `` and no `endnotes.xml`. Loki opens it regardless, so +/// only a package-level cross-part check catches it. (This is an OPC-level +/// invariant `analyze_docx` does not yet model — see fidelity-status §12.) +#[test] +fn note_separator_refs_have_a_backing_part() { + let pkg = Package::open(Cursor::new(ACID2)).expect("valid OPC package"); + let settings = pkg + .part(&PartName::new("/word/settings.xml").expect("part name")) + .expect("settings.xml present"); + let settings = String::from_utf8_lossy(&settings.bytes); + + for (block, part) in [ + ("", "/word/footnotes.xml"), + ("", "/word/endnotes.xml"), + ] { + if settings.contains(block) { + assert!( + pkg.part(&PartName::new(part).expect("part name")).is_some(), + "settings.xml declares {block} but the fixture has no {part} to back its \ + separator notes — Word will report an error in that notes stream", + ); + } + } +} diff --git a/loki-doc-model/src/content/inline.rs b/loki-doc-model/src/content/inline.rs index 17e44d39..86e31a07 100644 --- a/loki-doc-model/src/content/inline.rs +++ b/loki-doc-model/src/content/inline.rs @@ -204,6 +204,16 @@ pub enum Inline { /// ODF: `draw:frame/draw:image` inline. OOXML: `w:drawing` inline. Image(NodeAttr, Vec, LinkTarget), + /// A floating/anchored text box — a shape carrying block content, a border, + /// and a fill (OOXML `wps:wsp` + `w:txbxContent`; ODF `draw:frame/draw:text-box`). + /// + /// The [`NodeAttr`] carries the same geometry (`cx_emu`/`cy_emu`), float, and + /// wrap metadata an anchored [`Image`](Self::Image) does, plus the box's + /// `textbox-fill` / `textbox-line` colours. The `Vec` is the flowed + /// interior content. Rendered by the layout as a bordered/filled box with its + /// content laid out inside and surrounding text wrapped around it. + TextBox(NodeAttr, Vec), + /// A footnote or endnote reference and its content. /// Corresponds to pandoc `Note`, extended with a [`NoteKind`] discriminant /// so consumers can distinguish footnotes from endnotes without inspecting diff --git a/loki-doc-model/src/content/table/row.rs b/loki-doc-model/src/content/table/row.rs index d410958f..3d798524 100644 --- a/loki-doc-model/src/content/table/row.rs +++ b/loki-doc-model/src/content/table/row.rs @@ -56,6 +56,10 @@ pub enum CellTextDirection { pub struct CellProps { /// Background fill color. ODF `fo:background-color`; OOXML `w:shd`. pub background_color: Option, + /// Line/cross hatch shading (`w:shd` texture value). When set, the renderer + /// draws the hatch lines over `background_color` (the flattened-tint + /// fallback). `None` for a plain solid fill. + pub shading: Option, /// Top border. pub border_top: Option, /// Bottom border. diff --git a/loki-doc-model/src/layout/mod.rs b/loki-doc-model/src/layout/mod.rs index 47c53fe5..b0d92ce0 100644 --- a/loki-doc-model/src/layout/mod.rs +++ b/loki-doc-model/src/layout/mod.rs @@ -12,5 +12,8 @@ pub mod page; pub mod section; pub use header_footer::{HeaderFooter, HeaderFooterKind}; -pub use page::{PageLayout, PageMargins, PageOrientation, PageSize, SectionColumns}; +pub use page::{ + LineNumberRestart, LineNumbering, PageBorders, PageLayout, PageMargins, PageOrientation, + PageSize, SectionColumns, +}; pub use section::{Section, SectionStart}; diff --git a/loki-doc-model/src/layout/page.rs b/loki-doc-model/src/layout/page.rs index f23c9f3c..f4a2a4aa 100644 --- a/loki-doc-model/src/layout/page.rs +++ b/loki-doc-model/src/layout/page.rs @@ -12,8 +12,74 @@ use crate::content::attr::ExtensionBag; use crate::layout::header_footer::HeaderFooter; use crate::style::list_style::NumberingScheme; +use crate::style::props::border::Border; use loki_primitives::units::Points; +/// A decorative border drawn around each page of a section (`w:pgBorders`, +/// ECMA-376 §17.6.10). ODF: `style:page-layout` border properties. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PageBorders { + pub top: Option, + pub left: Option, + pub bottom: Option, + pub right: Option, + /// `true` when `@w:offsetFrom="text"` (each edge is inset from the text/margin + /// area). `false` (the default `="page"`) insets from the physical page edge. + /// Each edge's inset distance is carried in its [`Border::spacing`] (points). + pub offset_from_text: bool, +} + +impl PageBorders { + /// `true` when no edge is set. + #[must_use] + pub fn is_empty(&self) -> bool { + self.top.is_none() && self.left.is_none() && self.bottom.is_none() && self.right.is_none() + } +} + +/// When the line-number counter restarts (OOXML `w:lnNumType @w:restart`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum LineNumberRestart { + /// Restart at the top of every page (`@w:restart="newPage"`, the default). + #[default] + NewPage, + /// Restart at the start of every section (`@w:restart="newSection"`). + NewSection, + /// Never restart — number continuously across the document + /// (`@w:restart="continuous"`). + Continuous, +} + +/// Line numbering displayed in the margin for a section (`w:lnNumType`, +/// ECMA-376 §17.6.8). ODF: `text:linenumbering-configuration`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct LineNumbering { + /// Print a number every `count_by` lines (`@w:countBy`); e.g. `1` numbers + /// every line, `5` every fifth. `0`/absent is treated as `1`. + pub count_by: u32, + /// The first line number (`@w:start`); defaults to `1`. + pub start: i32, + /// When the counter restarts (`@w:restart`). + pub restart: LineNumberRestart, + /// Distance from the numbers to the text, in points (`@w:distance`). `None` + /// = automatic (the renderer picks a default gutter offset). + pub distance: Option, +} + +impl Default for LineNumbering { + fn default() -> Self { + Self { + count_by: 1, + start: 1, + restart: LineNumberRestart::default(), + distance: None, + } + } +} + /// Page orientation. /// /// TR 29166 §7.2.8. ODF `style:print-orientation`; OOXML inferred from @@ -175,6 +241,13 @@ pub struct PageLayout { /// Page-number restart value for this section (OOXML `w:pgNumType @w:start`). /// `None` = continue numbering from the previous section. pub page_number_start: Option, + /// Decorative border drawn around each page of the section (`w:pgBorders`). + /// `None` = no page border. + #[cfg_attr(feature = "serde", serde(default))] + pub page_border: Option, + /// Margin line numbering for the section (`w:lnNumType`). `None` = off. + #[cfg_attr(feature = "serde", serde(default))] + pub line_numbering: Option, /// Format-specific extension data. pub extensions: ExtensionBag, } diff --git a/loki-doc-model/src/loro_bridge/inlines.rs b/loki-doc-model/src/loro_bridge/inlines.rs index 51ba6494..ded122c0 100644 --- a/loki-doc-model/src/loro_bridge/inlines.rs +++ b/loki-doc-model/src/loro_bridge/inlines.rs @@ -8,6 +8,7 @@ use super::BridgeError; use super::color_codec::encode_document_color; +use super::decode::encode_border; use crate::content::inline::{Inline, QuoteType}; use crate::loro_schema::*; use crate::style::props::char_props::CharProps; @@ -224,6 +225,15 @@ pub(super) fn apply_char_props_marks( if let Some(v) = props.shadow { text.mark(start..end, MARK_SHADOW, v)?; } + if let Some(v) = props.emboss { + text.mark(start..end, MARK_EMBOSS, v)?; + } + if let Some(v) = props.imprint { + text.mark(start..end, MARK_IMPRINT, v)?; + } + if let Some(v) = &props.character_border { + text.mark(start..end, MARK_CHAR_BORDER, encode_border(v))?; + } if let Some(v) = props.small_caps { text.mark(start..end, MARK_SMALL_CAPS, v)?; } diff --git a/loki-doc-model/src/loro_bridge/inlines_read.rs b/loki-doc-model/src/loro_bridge/inlines_read.rs index 8a8817c9..70202f12 100644 --- a/loki-doc-model/src/loro_bridge/inlines_read.rs +++ b/loki-doc-model/src/loro_bridge/inlines_read.rs @@ -9,7 +9,8 @@ use super::BridgeError; use super::color_codec::decode_document_color; use super::decode::{ - decode_highlight_color, decode_strikethrough, decode_underline, decode_vertical_align, + decode_border, decode_highlight_color, decode_strikethrough, decode_underline, + decode_vertical_align, }; use crate::content::attr::NodeAttr; use crate::content::inline::{Inline, QuoteType, StyledRun}; @@ -240,6 +241,8 @@ fn read_char_props_from_marks( read_bool!(italic, MARK_ITALIC); read_bool!(outline, MARK_OUTLINE); read_bool!(shadow, MARK_SHADOW); + read_bool!(emboss, MARK_EMBOSS); + read_bool!(imprint, MARK_IMPRINT); read_bool!(small_caps, MARK_SMALL_CAPS); read_bool!(all_caps, MARK_ALL_CAPS); read_bool!(kerning, MARK_KERNING); @@ -262,6 +265,7 @@ fn read_char_props_from_marks( read_str!(strikethrough, MARK_STRIKETHROUGH, decode_strikethrough); read_str!(vertical_align, MARK_VERTICAL_ALIGN, decode_vertical_align); read_str!(color, MARK_COLOR, decode_document_color); + read_str!(character_border, MARK_CHAR_BORDER, decode_border); read_str!( highlight_color, MARK_HIGHLIGHT_COLOR, diff --git a/loki-doc-model/src/loro_schema/marks.rs b/loki-doc-model/src/loro_schema/marks.rs index 209f9a6c..c5a9f21e 100644 --- a/loki-doc-model/src/loro_schema/marks.rs +++ b/loki-doc-model/src/loro_schema/marks.rs @@ -29,6 +29,13 @@ pub const MARK_SCALE: &str = "scale"; pub const MARK_SMALL_CAPS: &str = "small_caps"; pub const MARK_ALL_CAPS: &str = "all_caps"; pub const MARK_SHADOW: &str = "shadow"; +/// `w:emboss` / ODF `style:font-relief="embossed"` — raised 3-D relief. +pub const MARK_EMBOSS: &str = "emboss"; +/// `w:imprint` / ODF `style:font-relief="engraved"` — engraved 3-D relief. +pub const MARK_IMPRINT: &str = "imprint"; +/// Character border (`w:bdr` / ODF `fo:border` on text) — the packed +/// `Border` string (`loro_bridge::decode::encode_border`). +pub const MARK_CHAR_BORDER: &str = "char_border"; pub const MARK_KERNING: &str = "kerning"; /// Named character style (`StyledRun::style_id`) carried as a mark so that /// run-level style references survive Loro round-trips. @@ -117,6 +124,9 @@ pub const CHAR_MARK_KEYS: &[&str] = &[ MARK_SMALL_CAPS, MARK_ALL_CAPS, MARK_SHADOW, + MARK_EMBOSS, + MARK_IMPRINT, + MARK_CHAR_BORDER, MARK_KERNING, MARK_OUTLINE, MARK_CHAR_STYLE_ID, diff --git a/loki-doc-model/src/style/mod.rs b/loki-doc-model/src/style/mod.rs index 27fe8390..a445ad93 100644 --- a/loki-doc-model/src/style/mod.rs +++ b/loki-doc-model/src/style/mod.rs @@ -17,6 +17,7 @@ pub mod props; pub mod resolve; pub mod resolve_table; pub mod table_banding; +pub mod table_borders; pub mod table_cnf; pub mod table_style; pub mod tree; @@ -30,5 +31,6 @@ pub use page_style::{PageStyle, derive_page_styles, section_page_style_ids}; pub use para_style::ParagraphStyle; pub use resolve::{Provenance, Resolved}; pub use table_banding::resolve_cell_shading; +pub use table_borders::{CellEdges, TableBorders}; pub use table_cnf::TableCnf; pub use table_style::{TableConditionalFormat, TableLook, TableRegion, TableStyle}; diff --git a/loki-doc-model/src/style/props/char_props.rs b/loki-doc-model/src/style/props/char_props.rs index e9a40135..e12acd8e 100644 --- a/loki-doc-model/src/style/props/char_props.rs +++ b/loki-doc-model/src/style/props/char_props.rs @@ -9,6 +9,7 @@ use crate::content::attr::ExtensionBag; use crate::meta::LanguageTag; +use crate::style::props::border::Border; use crate::style::props::revision::RevisionMark; use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; @@ -145,6 +146,12 @@ pub struct CharProps { /// Shadow text effect. ODF `fo:text-shadow`; OOXML `w:shadow`. pub shadow: Option, + /// Embossed (raised) text effect. OOXML `w:emboss`. No direct ODF equivalent. + pub emboss: Option, + + /// Imprint (engraved) text effect. OOXML `w:imprint`. No direct ODF equivalent. + pub imprint: Option, + /// Small caps. ODF `fo:font-variant small-caps`; OOXML `w:smallCaps`. pub small_caps: Option, @@ -176,6 +183,13 @@ pub struct CharProps { /// (named); OOXML `w:highlight`. pub highlight_color: Option, + /// A border box drawn around the run's text. OOXML `w:bdr`; ODF + /// `fo:border` on a text style. `None` = no character border. + /// + /// TODO(char-border-export): imported and rendered, but not yet written back + /// on DOCX/ODF export nor round-tripped through the Loro bridge. + pub character_border: Option, + // ── Spacing ─────────────────────────────────────────────────────────── /// Letter spacing (tracking) in points. ODF `fo:letter-spacing`; /// OOXML `w:spacing`. @@ -250,6 +264,8 @@ impl CharProps { inherit!(strikethrough); inherit!(outline); inherit!(shadow); + inherit!(emboss); + inherit!(imprint); inherit!(small_caps); inherit!(all_caps); inherit!(vertical_align); @@ -257,6 +273,7 @@ impl CharProps { inherit!(color); inherit!(background_color); inherit!(highlight_color); + inherit!(character_border); inherit!(letter_spacing); inherit!(word_spacing); inherit!(kerning); diff --git a/loki-doc-model/src/style/props/mod.rs b/loki-doc-model/src/style/props/mod.rs index f8520fea..b8625303 100644 --- a/loki-doc-model/src/style/props/mod.rs +++ b/loki-doc-model/src/style/props/mod.rs @@ -16,6 +16,7 @@ pub mod char_props; pub mod drop_cap; pub mod para_props; pub mod revision; +pub mod shading; pub mod tab_stop; pub use border::{Border, BorderStyle}; @@ -23,4 +24,5 @@ pub use char_props::CharProps; pub use drop_cap::{DropCap, DropCapLength}; pub use para_props::ParaProps; pub use revision::{RevisionKind, RevisionMark}; +pub use shading::{HatchPattern, ShadingPattern}; pub use tab_stop::{TabAlignment, TabLeader, TabStop}; diff --git a/loki-doc-model/src/style/props/para_props.rs b/loki-doc-model/src/style/props/para_props.rs index aa891f1d..c8c01a29 100644 --- a/loki-doc-model/src/style/props/para_props.rs +++ b/loki-doc-model/src/style/props/para_props.rs @@ -140,6 +140,10 @@ pub struct ParaProps { // ── Background ──────────────────────────────────────────────────────── /// Paragraph background fill color. ODF `fo:background-color`; OOXML `w:shd`. pub background_color: Option, + /// Line/cross hatch shading (`w:shd` texture value). When set, the renderer + /// draws the hatch lines; `background_color` holds the flattened-tint + /// fallback for consumers that cannot. `None` for a plain solid fill. + pub shading: Option, // ── Tab stops ───────────────────────────────────────────────────────── /// Custom tab stops for this paragraph. ODF `style:tab-stop` list; diff --git a/loki-doc-model/src/style/props/shading.rs b/loki-doc-model/src/style/props/shading.rs new file mode 100644 index 00000000..5f9288e2 --- /dev/null +++ b/loki-doc-model/src/style/props/shading.rs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Pattern shading (`w:shd` line/cross textures). +//! +//! A texture `w:shd` (`diagStripe`, `horzCross`, …) is not a solid fill: it is a +//! set of hatch lines in the foreground `@w:color` over the `@w:fill` +//! background. [`ShadingPattern`] carries the pattern kind and both colours so +//! the layout/renderer can draw the actual lines, rather than collapsing them to +//! a single flattened tint. The flattened tint is still stored on +//! `background_color` as a fallback for consumers that cannot draw the hatch +//! (ODF/EPUB export, the reflow paths). +//! +//! ECMA-376 §17.18.78 (`ST_Shd`). + +use loki_primitives::color::DocumentColor; + +/// The geometry of a `w:shd` line/cross texture. +/// +/// Direction names follow Word: `DiagUp` is the `/` of `diagStripe`, `DiagDown` +/// the `\` of `reverseDiagStripe`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum HatchPattern { + /// Horizontal lines (`horzStripe`). + Horizontal, + /// Vertical lines (`vertStripe`). + Vertical, + /// `/` diagonal lines (`diagStripe`). + DiagUp, + /// `\` diagonal lines (`reverseDiagStripe`). + DiagDown, + /// Horizontal + vertical grid (`horzCross`). + Cross, + /// Both diagonals, an `X` grid (`diagCross`). + DiagCross, +} + +/// A `w:shd` texture pattern: hatch geometry plus its foreground/background +/// colours. Drawn by the layout as a background fill overlaid with hatch lines. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ShadingPattern { + /// The hatch geometry. + pub pattern: HatchPattern, + /// `true` for the `thin*` variants — closer, lighter lines. + pub thin: bool, + /// Foreground `@w:color` — the hatch line colour. + pub color: DocumentColor, + /// Background `@w:fill` — the fill drawn behind the lines. `None` leaves the + /// surface (page/cell) unpainted behind the hatch. + pub fill: Option, +} diff --git a/loki-doc-model/src/style/table_borders.rs b/loki-doc-model/src/style/table_borders.rs new file mode 100644 index 00000000..3314c422 --- /dev/null +++ b/loki-doc-model/src/style/table_borders.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The six-sided table border set and its per-cell edge resolution. + +use crate::style::props::border::Border; + +/// The six-sided border set of a table (`w:tblBorders`, ECMA-376 §17.4.39): +/// the four outer edges plus the interior gridlines applied *between* cells. +/// +/// A cell's four effective edges are picked by position — an outer edge on the +/// table boundary, otherwise the matching interior gridline — by +/// [`edges_for`](TableBorders::edges_for). This is how a *Table Grid* style +/// (every side a single hairline) paints a full grid. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TableBorders { + pub top: Option, + pub left: Option, + pub bottom: Option, + pub right: Option, + /// Interior horizontal gridline, drawn between vertically-adjacent cells. + pub inside_h: Option, + /// Interior vertical gridline, drawn between horizontally-adjacent cells. + pub inside_v: Option, +} + +/// The four effective borders of one cell: `(top, right, bottom, left)`. +pub type CellEdges = ( + Option, + Option, + Option, + Option, +); + +impl TableBorders { + /// The `(top, right, bottom, left)` borders for the cell at `(row, col)` in a + /// `rows`×`cols` grid: an outer edge on the table boundary, otherwise the + /// interior gridline for that axis. + #[must_use] + pub fn edges_for(&self, row: usize, col: usize, rows: usize, cols: usize) -> CellEdges { + let top = if row == 0 { + self.top.clone() + } else { + self.inside_h.clone() + }; + let bottom = if row + 1 >= rows { + self.bottom.clone() + } else { + self.inside_h.clone() + }; + let left = if col == 0 { + self.left.clone() + } else { + self.inside_v.clone() + }; + let right = if col + 1 >= cols { + self.right.clone() + } else { + self.inside_v.clone() + }; + (top, right, bottom, left) + } + + /// `true` when every edge is absent. + #[must_use] + pub fn is_empty(&self) -> bool { + self.top.is_none() + && self.left.is_none() + && self.bottom.is_none() + && self.right.is_none() + && self.inside_h.is_none() + && self.inside_v.is_none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::style::props::border::BorderStyle; + use loki_primitives::units::Points; + + #[test] + fn tbl_borders_edges_pick_outer_vs_interior() { + // A "Table Grid"-like set: distinct markers per edge so we can tell which + // one each cell position resolves to. + let mk = |w: f64| { + Some(Border { + style: BorderStyle::Solid, + width: Points::new(w), + color: None, + spacing: None, + }) + }; + let b = TableBorders { + top: mk(1.0), + left: mk(2.0), + bottom: mk(3.0), + right: mk(4.0), + inside_h: mk(5.0), + inside_v: mk(6.0), + }; + let w = |e: &Option| e.as_ref().map(|x| x.width.value()); + + // Top-left cell of a 3×3 grid: outer top+left, interior bottom+right. + let (t, r, bo, l) = b.edges_for(0, 0, 3, 3); + assert_eq!( + (w(&t), w(&r), w(&bo), w(&l)), + (Some(1.0), Some(6.0), Some(5.0), Some(2.0)) + ); + + // Centre cell: interior on all four sides. + let (t, r, bo, l) = b.edges_for(1, 1, 3, 3); + assert_eq!( + (w(&t), w(&r), w(&bo), w(&l)), + (Some(5.0), Some(6.0), Some(5.0), Some(6.0)) + ); + + // Bottom-right cell: interior top+left, outer bottom+right. + let (t, r, bo, l) = b.edges_for(2, 2, 3, 3); + assert_eq!( + (w(&t), w(&r), w(&bo), w(&l)), + (Some(5.0), Some(4.0), Some(3.0), Some(6.0)) + ); + + assert!(!b.is_empty()); + assert!(TableBorders::default().is_empty()); + } +} diff --git a/loki-doc-model/src/style/table_style.rs b/loki-doc-model/src/style/table_style.rs index fcc6e71d..5e0b3dbb 100644 --- a/loki-doc-model/src/style/table_style.rs +++ b/loki-doc-model/src/style/table_style.rs @@ -10,6 +10,7 @@ use crate::content::attr::ExtensionBag; use crate::style::catalog::StyleId; use crate::style::props::border::Border; +use crate::style::table_borders::TableBorders; use indexmap::IndexMap; use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; @@ -64,6 +65,12 @@ pub struct TableProps { pub cell_spacing: Option, /// Default outside border for all table edges. pub border: Option, + /// The six-sided table border set (`w:tblBorders`): the four outer edges + /// plus the interior horizontal/vertical gridlines. This is what a built-in + /// style like *Table Grid* uses to draw a full grid; [`border`] only covers + /// the outer edges. `None` inherits. + #[cfg_attr(feature = "serde", serde(default))] + pub borders: Option, /// Background color of the table. pub background_color: Option, /// Number of rows in each horizontal band. OOXML @@ -243,58 +250,5 @@ pub struct TableStyle { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn table_style_default_props() { - let style = TableStyle { - id: StyleId("TableGrid".into()), - display_name: Some("Table Grid".into()), - parent: None, - table_props: TableProps::default(), - conditional: IndexMap::new(), - extensions: ExtensionBag::default(), - }; - assert!(style.table_props.width.is_none()); - assert!(style.table_props.border.is_none()); - assert!(style.conditional.is_empty()); - } - - #[test] - fn default_table_look_matches_word_04a0() { - let look = TableLook::default(); - assert!(look.first_row); - assert!(look.first_column); - assert!(look.horizontal_banding); - assert!(!look.last_row); - assert!(!look.last_column); - assert!(!look.vertical_banding); - } - - #[test] - fn table_look_attr_round_trips() { - for look in [ - TableLook::default(), - TableLook { - first_row: false, - last_row: true, - first_column: false, - last_column: true, - horizontal_banding: false, - vertical_banding: true, - }, - ] { - assert_eq!(TableLook::decode_attr(&look.encode_attr()), Some(look)); - } - assert_eq!(TableLook::default().encode_attr(), "101010"); - } - - #[test] - fn table_look_decode_rejects_malformed() { - assert_eq!(TableLook::decode_attr(""), None); - assert_eq!(TableLook::decode_attr("10101"), None); - assert_eq!(TableLook::decode_attr("1010102"), None); - assert_eq!(TableLook::decode_attr("10101x"), None); - } -} +#[path = "table_style_tests.rs"] +mod tests; diff --git a/loki-doc-model/src/style/table_style_tests.rs b/loki-doc-model/src/style/table_style_tests.rs new file mode 100644 index 00000000..6483d0a0 --- /dev/null +++ b/loki-doc-model/src/style/table_style_tests.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Unit tests for the named table-style model (extracted from `table_style.rs` +//! to keep it under the file-size ceiling). The `TableBorders` edge-resolution +//! test lives with its type in `table_borders.rs`. + +use super::*; + +#[test] +fn table_style_default_props() { + let style = TableStyle { + id: StyleId("TableGrid".into()), + display_name: Some("Table Grid".into()), + parent: None, + table_props: TableProps::default(), + conditional: IndexMap::new(), + extensions: ExtensionBag::default(), + }; + assert!(style.table_props.width.is_none()); + assert!(style.table_props.border.is_none()); + assert!(style.table_props.borders.is_none()); + assert!(style.conditional.is_empty()); +} + +#[test] +fn default_table_look_matches_word_04a0() { + let look = TableLook::default(); + assert!(look.first_row); + assert!(look.first_column); + assert!(look.horizontal_banding); + assert!(!look.last_row); + assert!(!look.last_column); + assert!(!look.vertical_banding); +} + +#[test] +fn table_look_attr_round_trips() { + for look in [ + TableLook::default(), + TableLook { + first_row: false, + last_row: true, + first_column: false, + last_column: true, + horizontal_banding: false, + vertical_banding: true, + }, + ] { + assert_eq!(TableLook::decode_attr(&look.encode_attr()), Some(look)); + } + assert_eq!(TableLook::default().encode_attr(), "101010"); +} + +#[test] +fn table_look_decode_rejects_malformed() { + assert_eq!(TableLook::decode_attr(""), None); + assert_eq!(TableLook::decode_attr("10101"), None); + assert_eq!(TableLook::decode_attr("1010102"), None); + assert_eq!(TableLook::decode_attr("10101x"), None); +} diff --git a/loki-doc-model/tests/loro_bridge_gap_tests.rs b/loki-doc-model/tests/loro_bridge_gap_tests.rs index 19d5dfcf..3efe27b5 100644 --- a/loki-doc-model/tests/loro_bridge_gap_tests.rs +++ b/loki-doc-model/tests/loro_bridge_gap_tests.rs @@ -319,6 +319,67 @@ fn bridge_para_background_color_roundtrip() { } } +// ── bridge_emboss_imprint_char_border_roundtrip ─────────────────────────────── + +/// `CharProps.emboss`, `imprint`, and `character_border` must survive a Loro +/// CRDT round-trip via text marks (previously read from OOXML/ODF but dropped by +/// the bridge — the last export-refinement round-trip gap for these fields). +#[test] +fn bridge_emboss_imprint_char_border_roundtrip() { + let props = CharProps { + emboss: Some(true), + imprint: Some(true), + character_border: Some(Border { + style: BorderStyle::Solid, + width: Points::new(1.0), + color: Some(DocumentColor::from_hex("#C00000").unwrap()), + spacing: Some(Points::new(1.0)), + }), + ..Default::default() + }; + + let doc = single_block_doc(styled_para_with_char(props)); + let recovered = round_trip(&doc); + + let rp = recovered.sections[0] + .blocks + .iter() + .find_map(|b| { + if let Block::StyledPara(p) = b { + p.inlines.iter().find_map(|i| match i { + Inline::StyledRun(sr) => sr.direct_props.as_deref(), + _ => None, + }) + } else { + None + } + }) + .expect("StyledRun with direct_props must survive round-trip"); + + assert_eq!(rp.emboss, Some(true), "emboss must survive Loro round-trip"); + assert_eq!( + rp.imprint, + Some(true), + "imprint must survive Loro round-trip" + ); + let b = rp + .character_border + .as_ref() + .expect("character_border must survive Loro round-trip"); + assert_eq!(b.style, BorderStyle::Solid); + assert_eq!(b.width.value().round(), 1.0); + assert_eq!( + b.color, + Some(DocumentColor::from_hex("#C00000").unwrap()), + "border colour must survive" + ); + assert_eq!( + b.spacing.map(|p| p.value().round()), + Some(1.0), + "border spacing must survive" + ); +} + // ── bridge_para_fields_roundtrip ────────────────────────────────────────────── /// `ParaProps.page_break_before`, `orphan_control`, `outline_level`, and diff --git a/loki-headless/Cargo.toml b/loki-headless/Cargo.toml index 01b4ef2d..1f857aed 100644 --- a/loki-headless/Cargo.toml +++ b/loki-headless/Cargo.toml @@ -11,7 +11,9 @@ thiserror = { workspace = true } clap = { version = "4", features = ["derive"] } loki-convert = { workspace = true } loki-print = { workspace = true } +# The `repair` subcommand detects/fixes DOCX schema-ordering problems. +loki-ooxml = { path = "../loki-ooxml", features = ["docx"] } [dev-dependencies] loki-doc-model = { path = "../loki-doc-model" } -loki-ooxml = { path = "../loki-ooxml" } +loki-ooxml = { path = "../loki-ooxml", features = ["docx"] } diff --git a/loki-headless/src/cli.rs b/loki-headless/src/cli.rs index dfe63a85..7004b82d 100644 --- a/loki-headless/src/cli.rs +++ b/loki-headless/src/cli.rs @@ -22,10 +22,27 @@ pub enum Command { Render(ConvertArgs), /// Render a document to PDF and dispatch it to an IPP printer. Print(PrintArgs), + /// Detect and fix schema problems that stop a DOCX opening in Microsoft + /// Word (out-of-order elements a tolerant reader accepts but Word rejects). + Repair(RepairArgs), /// List the supported conversion pairs. Formats, } +#[derive(Debug, Args)] +pub struct RepairArgs { + /// Input `.docx` file to check or repair. + #[arg(long = "in", value_name = "FILE")] + pub input: PathBuf, + /// Output `.docx` file for the repaired document. Omit with nothing else to + /// just report; the file is only written when this is given. + #[arg(long = "out", value_name = "FILE")] + pub output: Option, + /// Only report problems; never write an output file. + #[arg(long)] + pub check: bool, +} + #[derive(Debug, Args)] pub struct ConvertArgs { /// Input file (format inferred from the extension unless --from is set). diff --git a/loki-headless/src/commands.rs b/loki-headless/src/commands.rs index 1b203ddb..c87a1e00 100644 --- a/loki-headless/src/commands.rs +++ b/loki-headless/src/commands.rs @@ -8,7 +8,7 @@ use std::time::Duration; use loki_convert::{ConvertError, ConvertOptions, Format, PdfProfile, convert}; use loki_print::{ColorMode, Duplex, IppPrinter, PrintError, PrintOptions}; -use crate::cli::{Cli, Command, ConvertArgs, PrintArgs}; +use crate::cli::{Cli, Command, ConvertArgs, PrintArgs, RepairArgs}; /// CLI failures (all map to exit code 1). #[derive(Debug, thiserror::Error)] @@ -19,6 +19,8 @@ pub enum CliError { Convert(#[from] ConvertError), #[error(transparent)] Print(#[from] PrintError), + #[error(transparent)] + Repair(#[from] loki_ooxml::OoxmlError), #[error("failed to read {path}: {source}")] ReadInput { path: String, @@ -37,6 +39,7 @@ pub fn run(cli: Cli) -> Result<(), CliError> { match cli.command { Command::Convert(args) | Command::Render(args) => run_convert(&args), Command::Print(args) => run_print(&args), + Command::Repair(args) => run_repair(&args), Command::Formats => { for (source, target) in loki_convert::supported_pairs() { println!("{source} -> {target}"); @@ -99,6 +102,67 @@ fn run_convert(args: &ConvertArgs) -> Result<(), CliError> { Ok(()) } +fn run_repair(args: &RepairArgs) -> Result<(), CliError> { + let is_docx = args + .input + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("docx")); + if !is_docx { + return Err(CliError::UnknownFileFormat { + path: args.input.display().to_string(), + reason: "repair currently supports .docx only", + }); + } + let input = std::fs::read(&args.input).map_err(|e| CliError::ReadInput { + path: args.input.display().to_string(), + source: e, + })?; + + // The repair-and-write path needs an `--out` and no `--check`; every other + // combination (no `--out`, or `--check` set) is report-only. Binding `out` + // with `let-else` makes "output present here" a type-level fact, so the + // write path never has to assert it at runtime. + let Some(out) = args.output.as_ref().filter(|_| !args.check) else { + let report = loki_ooxml::analyze_docx(&input)?; + if report.is_clean() { + println!( + "{}: no Word-compatibility problems found", + args.input.display() + ); + } else { + println!( + "{}: {} problem(s) that can stop Microsoft Word from opening this file:", + args.input.display(), + report.findings.len() + ); + for f in &report.findings { + println!(" [{}] <{}>: {}", f.part, f.container, f.detail); + } + if !args.check { + println!("re-run with --out to write a repaired copy"); + } + } + return Ok(()); + }; + + let (bytes, report) = loki_ooxml::repair_docx(&input)?; + for f in &report.findings { + println!("fixed [{}] <{}>: {}", f.part, f.container, f.detail); + } + std::fs::write(out, &bytes).map_err(|e| CliError::WriteOutput { + path: out.display().to_string(), + source: e, + })?; + println!( + "{} -> {} ({} problem(s) fixed)", + args.input.display(), + out.display(), + report.findings.len() + ); + Ok(()) +} + fn run_print(args: &PrintArgs) -> Result<(), CliError> { let source = format_of(&args.input, None)?; let input = std::fs::read(&args.input).map_err(|e| CliError::ReadInput { diff --git a/loki-i18n/i18n/en-US/editor.ftl b/loki-i18n/i18n/en-US/editor.ftl index 53e8ceb9..fa4e7182 100644 --- a/loki-i18n/i18n/en-US/editor.ftl +++ b/loki-i18n/i18n/en-US/editor.ftl @@ -66,6 +66,21 @@ editor-save-error = Could not save: { $reason } editor-save-untitled-hint = Use File → Save As to save new documents editor-dismiss-aria = Dismiss +# Word-compatibility repair banner (shown when an opened DOCX has out-of-order +# OOXML that can stop Microsoft Word opening it; Loki opens it regardless) +editor-repair-title = Compatibility problem +editor-repair-message = { $count -> + [one] This document has 1 issue that can stop it opening in Microsoft Word. + *[other] This document has { $count } issues that can stop it opening in Microsoft Word. +} +editor-repair-action = Repair +editor-repair-dismiss = Dismiss +editor-repair-done = { $count -> + [one] Repaired 1 compatibility issue + *[other] Repaired { $count } compatibility issues +} +editor-repair-error = Could not repair: { $reason } + # Style picker / style editor chrome editor-tab-close-aria = Close tab editor-style-picker-close-aria = Close style picker diff --git a/loki-layout/src/flow.rs b/loki-layout/src/flow.rs index 8afb8f07..b0996673 100644 --- a/loki-layout/src/flow.rs +++ b/loki-layout/src/flow.rs @@ -26,12 +26,16 @@ mod flow_list_marker; mod group; #[path = "flow_headers.rs"] mod headers; +#[path = "flow_line_numbers.rs"] +mod line_numbers; #[path = "flow_page_fields.rs"] mod page_fields; #[path = "flow_para_between.rs"] mod para_between; #[path = "flow_para.rs"] mod para_impl; +#[path = "flow_table_autofit.rs"] +mod table_autofit; #[path = "flow_table_cells.rs"] mod table_cells; #[path = "flow_table_chars.rs"] @@ -44,6 +48,8 @@ mod table_main; mod table_paint; #[path = "flow_tail.rs"] mod tail; +#[path = "flow_textbox.rs"] +mod textbox_impl; pub use group::flow_section_group; pub(crate) use headers::assign_headers_footers; @@ -169,10 +175,23 @@ pub(super) struct FlowState<'a> { pub(super) list_counters: HashMap, /// `ListId` of the most recently placed list item (detects list changes). pub(super) prev_list_id: Option, - /// Footnote/endnote counter for the section (bumped by `walk_inlines`); - /// collected notes render via `flow_footnotes`. + /// Footnote/endnote counter for the section (bumped by `walk_inlines`). pub(super) note_counter: u32, + /// Footnotes whose reference has been placed on the **current page**, laid + /// out at their bottom by `finish_page`. Their height is reserved from + /// [`page_content_height`](Self::page_content_height) as each is collected, + /// so body content stops above the footnote band (per-page placement, + /// matching Word — a footnote sits at the foot of the page carrying its + /// reference, not dumped at the section end). pub(super) pending_footnotes: Vec, + /// Points reserved at the foot of the **current page** for the footnotes + /// collected so far (separator band + each note's measured height). Shrinks + /// [`content_bottom`](Self::content_bottom) so body content stops above the + /// band; reset to `0` at each page boundary (`finish_page`). + pub(super) footnote_reserved: f32, + /// Re-entrancy guard: `true` while `finish_page` is laying out the footnote + /// band, so a nested page flush during that work does not recurse. + pub(super) rendering_footnotes: bool, /// Paragraph metadata for the current page (block index, layout, origin). pub(super) current_paragraphs: Vec, /// Clean-page-top checkpoints for incremental relayout (top-level only). @@ -219,6 +238,9 @@ pub(super) struct FlowState<'a> { /// Table-region character defaults for the cell currently flowing (4a.3); /// merged under the paragraph chain by `flatten_paragraph_with_base`. pub(super) cell_char_defaults: Option, + /// Active margin line-numbering state for the section (`w:lnNumType`), or + /// `None` when the section has no line numbering (the common case). + pub(super) line_num: Option, } impl FlowState<'_> { diff --git a/loki-layout/src/flow_balance.rs b/loki-layout/src/flow_balance.rs index 7d253654..95f21f99 100644 --- a/loki-layout/src/flow_balance.rs +++ b/loki-layout/src/flow_balance.rs @@ -27,7 +27,7 @@ use loki_doc_model::StyleCatalog; use loki_doc_model::content::annotation::Comment; use loki_doc_model::layout::section::Section; -use super::{FlowOutput, flow_footnotes, new_flow_state, run_paginated_loop}; +use super::{FlowOutput, new_flow_state, run_paginated_loop}; use crate::LayoutOptions; use crate::font::FontResources; use crate::incremental::FlowCheckpoint; @@ -197,8 +197,8 @@ fn run_capped( start = start_block; } run_paginated_loop(&mut state, &ctx.section.blocks, start, 0, |_, _| false); - flow_footnotes(&mut state); let has_notes = state.note_counter > 0; + // `finish_page` lays out the final page's footnote band (per-page placement). super::finish_page(&mut state); let pages = state.pages.len(); let candidate = state.tail_candidate.take(); diff --git a/loki-layout/src/flow_dispatch.rs b/loki-layout/src/flow_dispatch.rs index fb4caa74..d541ac7e 100644 --- a/loki-layout/src/flow_dispatch.rs +++ b/loki-layout/src/flow_dispatch.rs @@ -125,6 +125,11 @@ fn flow_blocks(state: &mut FlowState, blocks: &[Block], idx: usize) { // ── Page management ─────────────────────────────────────────────────────────── pub(crate) fn finish_page(state: &mut FlowState) { + // Lay out this page's footnotes in the band reserved at their reference (per + // the `pending_footnotes` doc). Runs before column positioning so the note + // items are placed with the rest of the page's content. + super::tail::flow_page_footnotes(state); + // Position + separate the used columns, then reset for the next page. columns_impl::position_current_column(state); columns_impl::emit_column_separators(state); @@ -162,6 +167,13 @@ pub(crate) fn finish_page(state: &mut FlowState) { state.page_number += 1; state.current_paragraphs.clear(); state.cursor_y = 0.0; + // The footnote band is laid out; release its per-page reservation so the + // fresh page starts with the full content height. + state.footnote_reserved = 0.0; + // Restart margin line numbering at the top of the new page (`newPage`). + if let Some(ln) = &mut state.line_num { + ln.restart_for_page(); + } // Cross-paragraph float wrap does not continue onto the next page. state.active_float = None; } diff --git a/loki-layout/src/flow_entry.rs b/loki-layout/src/flow_entry.rs index 16840a82..bc7581da 100644 --- a/loki-layout/src/flow_entry.rs +++ b/loki-layout/src/flow_entry.rs @@ -58,10 +58,10 @@ pub(crate) fn flow_section_resume( // (base 0). let resynced_at = run_paginated_loop(&mut state, §ion.blocks, start_block, 0, resync); if resynced_at.is_none() { - // Reached the end: flush trailing footnotes and the final partial page. - // On a resync stop the current page is an empty clean page top, so it is - // intentionally left unflushed for the caller to splice the reused suffix. - flow_footnotes(&mut state); + // Reached the end: flush the final partial page (whose `finish_page` + // lays out its footnote band). On a resync stop the current page is an + // empty clean page top, intentionally left unflushed for the caller to + // splice the reused suffix. finish_page(&mut state); } crate::incremental::ResumedFlow { diff --git a/loki-layout/src/flow_float.rs b/loki-layout/src/flow_float.rs index d9555bc6..bb0e9586 100644 --- a/loki-layout/src/flow_float.rs +++ b/loki-layout/src/flow_float.rs @@ -16,11 +16,12 @@ //! taller than its anchoring paragraph, the remaining extent is recorded as an //! [`ActiveFloat`] on the flow state so the *following* paragraphs continue to //! wrap beside it until the float bottom is cleared (cross-paragraph wrap). -//! `Square`, `Tight`, `Through`, and non-behind `None` modes wrap on one side -//! (the tight contour is approximated by the bounding box; a margin-anchored -//! `wrapNone` image reserves its space in Word, so text flows beside rather than -//! under it). `TopAndBottom` and behind-text floats fall through to the -//! block-stacked image path. Cross-paragraph wrap is bounded to a single page +//! `Square`, `Tight`, and `Through` modes wrap on one side (the tight contour +//! is approximated by the bounding box). `wrapNone` never reserves space — +//! Word flows the text at full column width and the object floats over (or, +//! `behindDoc`, under) it; the caller emits those as overlays via +//! [`crate::flow_para`], not as bands. `TopAndBottom` and behind-text floats +//! fall through to the block-stacked image path. Cross-paragraph wrap is bounded to a single page //! and to consecutive plain paragraphs; a table/list/rule (or page break) below //! the float reserves its remaining height instead of wrapping. OOXML //! `wp:anchor` wrap children; ODF `style:wrap`. @@ -29,8 +30,46 @@ use loki_doc_model::content::float::{TextWrap, WrapSide}; use crate::geometry::LayoutRect; use crate::items::{PositionedImage, PositionedItem}; +use crate::para::ResolvedParaProps; use crate::resolve::{CollectedImage, emu_to_pt}; +use super::FlowState; + +/// Plan the paragraph's own float (a `wps` text box first, else an image float), +/// set its wrap band on `resolved`, and drop the floated image from `images` so +/// it is not also block-stacked. Returns the placement (index into `images` + +/// [`FloatPlacement`]) and the `(inset, height, shift_text)` band geometry the +/// caller reuses for the cross-paragraph [`ActiveFloat`]. Split from `flow_para` +/// for the 300-line ceiling. +#[allow(clippy::type_complexity)] // the two returned float descriptors are cohesive +pub(super) fn plan_paragraph_float( + state: &mut FlowState, + images: &mut Vec, + resolved: &mut ResolvedParaProps, +) -> (Option<(usize, FloatPlacement)>, Option<(f32, f32, bool)>) { + let cw = state.content_width; + let float_plan = + super::textbox_impl::plan_textbox(state, images, cw).or_else(|| plan_float(images, cw)); + let own_float: Option<(f32, f32, bool)> = float_plan.as_ref().map(|(_, p)| { + ( + p.indent_start_delta + p.indent_end_delta, + p.height, + p.indent_start_delta > 0.0, + ) + }); + if let Some((idx, _)) = &float_plan + && let Some((inset, height, shift_text)) = own_float + { + resolved.wrap_band = Some(crate::para::WrapBand { + inset, + cover_height: height, + shift_text, + }); + images.remove(*idx); + } + (float_plan, own_float) +} + /// A float whose vertical extent reaches past its anchoring paragraph, so the /// paragraphs that follow on the same page keep wrapping beside it. /// @@ -63,7 +102,7 @@ pub(crate) fn reserve_active_float(state: &mut super::FlowState) { } /// Default gap between a float and the wrapped text, in points (~0.13"). -const FLOAT_WRAP_GAP: f32 = 9.0; +pub(super) const FLOAT_WRAP_GAP: f32 = 9.0; /// A planned float placement for one paragraph. pub(crate) struct FloatPlacement { @@ -91,14 +130,19 @@ pub(crate) fn plan_float( content_width: f32, ) -> Option<(usize, FloatPlacement)> { let (idx, img, fw) = images.iter().enumerate().find_map(|(i, img)| { + // Text boxes are planned by `flow_textbox` (they render a box, not a + // picture); skip them here. + if img.textbox.is_some() { + return None; + } let f = img.float?; - // Side-wrapping modes flow text beside the object. `None` is included - // when the float is not behind the text: Word reserves space for a - // margin-anchored `wrapNone` image (text flows beside, not under it), - // matching the reference. A behind-text float never displaces text. + // Side-wrapping modes flow text beside the object. `wrapNone` is NOT one: + // Word reserves no space for it — the text flows the full column width and + // the image overlaps (drawn over/under it). The caller handles `wrapNone` + // as an overlay; a behind-text float never displaces text either. let side_wraps = matches!( f.wrap, - TextWrap::Square | TextWrap::Tight | TextWrap::Through | TextWrap::None + TextWrap::Square | TextWrap::Tight | TextWrap::Through ); (side_wraps && !f.behind_text).then_some((i, img, f)) })?; diff --git a/loki-layout/src/flow_float_tests.rs b/loki-layout/src/flow_float_tests.rs index ade16e3a..f0368e50 100644 --- a/loki-layout/src/flow_float_tests.rs +++ b/loki-layout/src/flow_float_tests.rs @@ -14,6 +14,7 @@ fn img(w_in: f64, h_in: f64, float: Option) -> CollectedImage { cx_emu: (w_in * 914_400.0) as u64, cy_emu: (h_in * 914_400.0) as u64, float, + textbox: None, } } @@ -46,21 +47,25 @@ fn top_and_bottom_float_is_not_side_wrapped() { } #[test] -fn non_behind_wrap_none_is_side_wrapped() { - // A margin-anchored `wrapNone` image that is not behind text reserves its - // space in Word, so Loki wraps text beside it. - let images = vec![img( - 1.0, - 1.0, - Some(FloatWrap { - wrap: TextWrap::None, - side: WrapSide::Both, - behind_text: false, - }), - )]; - let (idx, p) = plan_float(&images, 468.0).expect("wrapNone (front) wraps"); - assert_eq!(idx, 0); - assert!(p.indent_start_delta > 0.0, "default left float, text right"); +fn wrap_none_is_not_side_wrapped() { + // Word reserves no space for a `wrapNone` object — the text flows at full + // width and the image overlaps it. So `plan_float` declines it (the caller + // emits it as an overlay instead), whether it is in front of or behind text. + for behind_text in [false, true] { + let images = vec![img( + 1.0, + 1.0, + Some(FloatWrap { + wrap: TextWrap::None, + side: WrapSide::Both, + behind_text, + }), + )]; + assert!( + plan_float(&images, 468.0).is_none(), + "wrapNone never reserves a wrap band (behind_text={behind_text})" + ); + } } #[test] diff --git a/loki-layout/src/flow_group.rs b/loki-layout/src/flow_group.rs index 3f49e0c2..cd9b5e03 100644 --- a/loki-layout/src/flow_group.rs +++ b/loki-layout/src/flow_group.rs @@ -10,8 +10,8 @@ use loki_doc_model::StyleCatalog; use loki_doc_model::layout::section::Section; use super::{ - FlowOutput, begin_continuous_section, finish_page, flow_footnotes, flow_section, - new_flow_state, run_paginated_loop, + FlowOutput, begin_continuous_section, finish_page, flow_section, new_flow_state, + run_paginated_loop, }; use crate::LayoutOptions; use crate::font::FontResources; @@ -73,7 +73,7 @@ pub fn flow_section_group( block_base += section.blocks.len(); } - flow_footnotes(&mut state); + // `finish_page` lays out the final page's footnote band (per-page placement). finish_page(&mut state); FlowOutput::Pages { pages: state.pages, diff --git a/loki-layout/src/flow_headers.rs b/loki-layout/src/flow_headers.rs index 5736c294..9cff2622 100644 --- a/loki-layout/src/flow_headers.rs +++ b/loki-layout/src/flow_headers.rs @@ -11,13 +11,15 @@ use loki_doc_model::content::attr::ExtensionBag; use loki_doc_model::content::block::Block; use loki_doc_model::layout::header_footer::HeaderFooter; -use loki_doc_model::layout::page::PageLayout; +use loki_doc_model::layout::page::{PageBorders, PageLayout}; use loki_doc_model::{Section, StyleCatalog}; use super::{FlowOutput, flow_section, page_fields}; use crate::font::FontResources; -use crate::items::PositionedItem; +use crate::geometry::LayoutRect; +use crate::items::{PositionedBorderRect, PositionedItem}; use crate::mode::LayoutMode; +use crate::resolve::{convert_border, pts_to_f32}; use crate::result::LayoutPage; /// Lay out `blocks` in reflow mode using `available_width`. @@ -110,7 +112,6 @@ pub(crate) fn assign_headers_footers( let ftr_first = layout.footer_first.as_ref().map(&mut lay_static); let ftr_even = layout.footer_even.as_ref().map(&mut lay_static); - use crate::resolve::pts_to_f32; let hdr_margin_y = pts_to_f32(layout.margins.header); let ftr_margin = pts_to_f32(layout.margins.footer); let left_margin = pts_to_f32(layout.margins.left); @@ -215,5 +216,47 @@ pub(crate) fn assign_headers_footers( page.footer_items = items; page.footer_height = h; } + + // Page border (`w:pgBorders`): a decoration drawn around every page of + // the section, in the margin area. It goes in the page-local, unclipped + // item list (like headers/footers) rather than the content area. + if let Some(pb) = layout.page_border.as_ref() { + page.header_items.push(page_border_item(page, pb, layout)); + } } } + +/// Builds the page-border decoration rect for one page, in page-local coords. +/// +/// Each edge is inset from the physical page edge by its `w:space` (points); +/// with `offset_from_text` the inset is measured inward from the text/margin +/// area instead. Absent-spacing edges fall back to Word's 24 pt default. +fn page_border_item(page: &LayoutPage, pb: &PageBorders, layout: &PageLayout) -> PositionedItem { + const DEFAULT_SPACE: f32 = 24.0; + let (pw, ph) = (page.page_size.width, page.page_size.height); + let space = |e: &Option| { + e.as_ref() + .and_then(|b| b.spacing) + .map_or(DEFAULT_SPACE, pts_to_f32) + }; + // Inset of one side from the page edge (points). + let inset = |e: &Option, margin: f32| { + if pb.offset_from_text { + (margin - space(e)).max(0.0) + } else { + space(e) + } + }; + let l = inset(&pb.left, pts_to_f32(layout.margins.left)); + let t = inset(&pb.top, pts_to_f32(layout.margins.top)); + let r = pw - inset(&pb.right, pts_to_f32(layout.margins.right)); + let b = ph - inset(&pb.bottom, pts_to_f32(layout.margins.bottom)); + + PositionedItem::BorderRect(PositionedBorderRect { + rect: LayoutRect::new(l, t, (r - l).max(0.0), (b - t).max(0.0)), + top: pb.top.as_ref().and_then(convert_border), + right: pb.right.as_ref().and_then(convert_border), + bottom: pb.bottom.as_ref().and_then(convert_border), + left: pb.left.as_ref().and_then(convert_border), + }) +} diff --git a/loki-layout/src/flow_line_numbers.rs b/loki-layout/src/flow_line_numbers.rs new file mode 100644 index 00000000..ee4043f8 --- /dev/null +++ b/loki-layout/src/flow_line_numbers.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Margin line numbering (`w:lnNumType`, gap: Appendix line numbers). +//! +//! A section that carries [`LineNumbering`] prints a number in the left margin +//! beside each line of body text. The counter advances per placed line and (for +//! `restart="newPage"`) resets at each page boundary; `count_by` selects which +//! lines actually show a number. Numbers are emitted as ordinary content items +//! at a negative content-local x (the painters composite content offset by the +//! left margin with no content clip, so negative x lands in the margin). +//! +//! Scope: paginated, single-column, non-table body paragraphs — Word's own +//! defaults (tables and header/footer lines are not numbered). + +use loki_doc_model::layout::page::{LineNumberRestart, LineNumbering}; + +use crate::color::LayoutColor; +use crate::para::{ParagraphLayout, ResolvedParaProps, StyleSpan, layout_paragraph}; + +use super::FlowState; + +/// Font size (pt) for the margin numbers — slightly smaller than body text, +/// matching Word's default Line Number style. +const NUM_FONT_SIZE: f32 = 9.0; + +/// Default gap (pt) between a number's right edge and the text when the section +/// gives no explicit `@w:distance` (Word's "Auto"). +const DEFAULT_DISTANCE: f32 = 18.0; + +/// Per-section line-numbering counter carried on [`FlowState`]. +pub(crate) struct LineNumberState { + config: LineNumbering, + /// The number the next placed line will receive. + next: i32, +} + +impl LineNumberState { + /// Build the initial state for a section, seeded at its start value. + pub(super) fn new(config: &LineNumbering) -> Self { + Self { + config: config.clone(), + next: config.start, + } + } + + /// Reset to the start value at a page boundary when `restart="newPage"`. + pub(super) fn restart_for_page(&mut self) { + if self.config.restart == LineNumberRestart::NewPage { + self.next = self.config.start; + } + } +} + +/// A number to paint: its string and the page-content-local baseline y. +struct PendingNumber { + text: String, + baseline_y: f32, +} + +/// Emit margin numbers for the lines of `para_layout` whose paragraph-local top +/// lies in `[y0, y1)`, placed at page-content-local offset `ty` (so a +/// paragraph-local y maps to page-local `y + ty`). Advances the section counter +/// once per line regardless of whether the line shows a number. +pub(super) fn emit( + state: &mut FlowState, + para_layout: &ParagraphLayout, + ty: f32, + y0: f32, + y1: f32, +) { + // Gate: active feature, paginated single-column body (not a table cell). + if state.line_num.is_none() + || !state.mode.is_paginated() + || state.columns != 1 + || state.break_long_words + { + return; + } + + // Line-0 ascent (baseline below the paragraph top) — reused for every line as + // a uniform estimate, so each line's baseline ≈ its top + this delta. + let ascent = para_layout + .line_boundaries + .first() + .map_or(para_layout.first_baseline, |&(top, _)| { + para_layout.first_baseline - top + }); + + // Collect the numbers to paint while advancing the counter (mutable borrow of + // `line_num` only), then shape them (mutable borrow of `resources`) below. + let mut pending: Vec = Vec::new(); + { + let ln = match &mut state.line_num { + Some(ln) => ln, + None => return, + }; + let count_by = ln.config.count_by.max(1) as i32; + + // An empty paragraph still counts as one line at its own top. + if para_layout.line_boundaries.is_empty() { + if 0.0 >= y0 && 0.0 < y1 { + let n = ln.next; + ln.next += 1; + if n.rem_euclid(count_by) == 0 { + pending.push(PendingNumber { + text: n.to_string(), + baseline_y: ty + para_layout.first_baseline, + }); + } + } + } else { + for &(top, bottom) in ¶_layout.line_boundaries { + // Membership by the line's midpoint: `top` (Parley's min_coord) + // can sit slightly above the paragraph origin (negative) and the + // last line's `bottom` can round just past the paragraph height, + // so a top/bottom test would mis-bucket the first or last line of + // a fragment. The midpoint lies strictly inside the line, so it + // falls in exactly one fragment's [y0, y1) range. + let mid = (top + bottom) * 0.5; + if mid < y0 || mid >= y1 { + continue; + } + let n = ln.next; + ln.next += 1; + if n.rem_euclid(count_by) == 0 { + pending.push(PendingNumber { + text: n.to_string(), + baseline_y: ty + top + ascent, + }); + } + } + } + } + if pending.is_empty() { + return; + } + + let distance = state + .line_num + .as_ref() + .and_then(|ln| ln.config.distance) + .map_or(DEFAULT_DISTANCE, |d| d.value() as f32); + + for p in pending { + paint_number(state, &p, distance); + } +} + +/// Shape one number and push its glyphs into the current page, right-aligned so +/// its right edge sits `distance` pt left of the text margin. +fn paint_number(state: &mut FlowState, p: &PendingNumber, distance: f32) { + let span = number_span(&p.text); + let props = ResolvedParaProps::default(); + let num_layout = layout_paragraph( + state.resources, + &p.text, + &[span], + &props, + 1_000.0, + state.display_scale, + false, + ); + // Right edge at content-local x = -distance; shift left by the number width. + let dx = -distance - num_layout.width; + // Align the number's own baseline to the target line baseline. + let dy = p.baseline_y - num_layout.first_baseline; + for mut item in num_layout.items { + item.translate(dx, dy); + state.current_items.push(item); + } +} + +/// A single black, default-font style span covering the whole number string. +fn number_span(text: &str) -> StyleSpan { + StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: NUM_FONT_SIZE, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + character_border: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + emboss: false, + imprint: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + language: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use loki_doc_model::layout::page::LineNumbering; + + #[test] + fn seeds_at_start_value() { + let cfg = LineNumbering { + start: 5, + ..Default::default() + }; + assert_eq!(LineNumberState::new(&cfg).next, 5); + } + + #[test] + fn new_page_restart_resets_to_start() { + let cfg = LineNumbering { + start: 1, + restart: LineNumberRestart::NewPage, + ..Default::default() + }; + let mut st = LineNumberState::new(&cfg); + st.next = 17; // advanced across a page + st.restart_for_page(); + assert_eq!(st.next, 1, "newPage restarts each page"); + } + + #[test] + fn continuous_restart_does_not_reset() { + let cfg = LineNumbering { + start: 1, + restart: LineNumberRestart::Continuous, + ..Default::default() + }; + let mut st = LineNumberState::new(&cfg); + st.next = 42; + st.restart_for_page(); + assert_eq!( + st.next, 42, + "continuous numbering never resets on a page break" + ); + } +} diff --git a/loki-layout/src/flow_list_marker.rs b/loki-layout/src/flow_list_marker.rs index 2755f121..d93eeaaf 100644 --- a/loki-layout/src/flow_list_marker.rs +++ b/loki-layout/src/flow_list_marker.rs @@ -32,6 +32,24 @@ pub(super) struct ListMarker { /// (the tab positions the content at the indent). For a **picture bullet** the /// label text is empty (so only a leading tab is prepended) and `bullet_src` is /// returned for out-of-band image placement. +/// Apply the numbering level's `pPr` indent as a fallback when the paragraph +/// carries none (both indents 0.0) — e.g. `w:ind` set only on the abstract num. +pub(super) fn apply_level_indent_fallback(state: &FlowState, resolved: &mut ResolvedParaProps) { + if let Some(ref lm) = resolved.list_marker + && resolved.indent_start == 0.0 + && resolved.indent_hanging == 0.0 + && let Some(list_style) = state.catalog.list_styles.get(&lm.list_id) + && let Some(level_def) = list_style.levels.get(lm.level as usize) + { + let level_indent = crate::resolve::pts_to_f32(level_def.indent_start); + let level_hanging = crate::resolve::pts_to_f32(level_def.hanging_indent); + if level_indent > 0.0 || level_hanging > 0.0 { + resolved.indent_start = level_indent; + resolved.indent_hanging = level_hanging; + } + } +} + pub(super) fn synthesize( state: &mut FlowState, para: &StyledParagraph, diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index 14eb210a..9169b7c5 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -15,11 +15,12 @@ //! deferred to a future Parley (workaround would be U+202B/U+200F controls). use loki_doc_model::content::block::StyledParagraph; +use loki_doc_model::content::float::{TextWrap, WrapSide}; use crate::geometry::LayoutRect; use crate::items::{PositionedImage, PositionedItem}; use crate::para::{ParagraphLayout, ResolvedParaProps, layout_paragraph_spelled}; -use crate::resolve::{emu_to_pt, pts_to_f32, resolve_para_props}; +use crate::resolve::{emu_to_pt, resolve_para_props}; use super::columns_impl::break_column; use super::editing::push_editing_para; @@ -35,7 +36,7 @@ mod split; mod widow_orphan; pub(super) use chain::flow_keep_with_next_chain; -use place::place_paragraph_layout; +use place::{place_paragraph_layout, place_with_footnote_band}; use split::split_and_place_loop; // ── Public(super) API ───────────────────────────────────────────────────────── @@ -43,6 +44,14 @@ use split::split_and_place_loop; /// Resolve, lay out, and place a single paragraph block. pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, block_index: usize) { let mut resolved = resolve_para_props(para, state.catalog); + // A tracked ¶-mark deletion paints a struck end-of-paragraph marker only in + // the All-Markup view; Final/Original render the accepted/rejected document, + // where no revision decoration is shown. (Full paragraph *merge* in the + // Final view is not modelled — the ¶ is simply not marked; see the + // `revision_filter` module docs.) + if state.options.revision_display != crate::options::RevisionDisplay::AllMarkup { + resolved.para_mark_deleted_color = None; + } // Between-border group adjustment (gap #26), staged by the block loop. if let Some(ovr) = state.staged_between.take() { if ovr.suppress_top { @@ -59,22 +68,9 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc resolved.default_tab_stop = pt; } - // ── List level indentation fallback ───────────────────────────────────── - // The numbering level's pPr is the authoritative indent when the paragraph - // carries none (both indents 0.0) — e.g. `w:ind` only on the abstract num. - if let Some(ref lm) = resolved.list_marker - && resolved.indent_start == 0.0 - && resolved.indent_hanging == 0.0 - && let Some(list_style) = state.catalog.list_styles.get(&lm.list_id) - && let Some(level_def) = list_style.levels.get(lm.level as usize) - { - let level_indent = pts_to_f32(level_def.indent_start); - let level_hanging = pts_to_f32(level_def.hanging_indent); - if level_indent > 0.0 || level_hanging > 0.0 { - resolved.indent_start = level_indent; - resolved.indent_hanging = level_hanging; - } - } + // List level indentation fallback (numbering `pPr` indent when the paragraph + // carries none) — extracted to `flow_list_marker` for the 300-line ceiling. + super::flow_list_marker::apply_level_indent_fallback(state, &mut resolved); // ── List marker synthesis ──────────────────────────────────────────────── // Prepend the label (bullet / number) as an `Inline::Str` + tab; a picture @@ -88,36 +84,26 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc state.catalog, &mut state.note_counter, state.cell_char_defaults.as_ref(), + state.options.revision_display, ); - // Tag each note with its owning block + per-block order (see flow_footnotes). + // Tag each note with its owning block + per-block order. The notes render at + // the foot of the page carrying their reference — see `flow_tail`. for (i, note) in notes.iter_mut().enumerate() { note.owner_block_index = block_index; note.note_in_block = i; } + // Measure this paragraph's footnote band now; `place_with_footnote_band` + // applies it after placement (shrinking `content_bottom()` for following + // content) iff the paragraph stays on `page_before_para`. + let footnote_reserve = super::tail::footnote_reservation(state, ¬es); + let page_before_para = state.page_number; state.pending_footnotes.extend(notes); - // ── Floating image wrap (gap #12): reserve a side band so text wraps - // beside the float (emitted after text layout; removed from the - // inline/block set so it is not also stacked above the text). - let float_plan = super::float_impl::plan_float(&images, state.content_width); - // Band geometry shared by this paragraph's own float (below) and the - // `ActiveFloat` it may leave for following paragraphs. - let own_float: Option<(f32, f32, bool)> = float_plan.as_ref().map(|(_, p)| { - let inset = p.indent_start_delta + p.indent_end_delta; - (inset, p.height, p.indent_start_delta > 0.0) - }); - if let Some((idx, _)) = &float_plan - && let Some((inset, height, shift_text)) = own_float - { - // The banded layout path narrows the lines beside the float and reflows - // the rest at full width (one of the deltas is zero — left vs right). - resolved.wrap_band = Some(crate::para::WrapBand { - inset, - cover_height: height, - shift_text, - }); - images.remove(*idx); - } + // Floating image/text-box wrap (gap #12): plan the paragraph's own float, + // set its wrap band on `resolved`, and drop the floated image from the + // block-stacked set (see `flow_float::plan_paragraph_float`). + let (float_plan, own_float) = + super::float_impl::plan_paragraph_float(state, &mut images, &mut resolved); state.cursor_y += resolved.space_before; @@ -165,37 +151,8 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc } // ── Inline image placement (gap #9) ────────────────────────────────────── - // TODO(inline-image-flow): no Parley inline image boxes; images are a - // block-level prefix and existing items shift down to make room. - let mut total_image_height = 0.0f32; - let mut image_items: Vec = Vec::new(); - for img in &images { - if img.cx_emu == 0 && img.cy_emu == 0 { - continue; // zero-size image — skip without crashing - } - let w = emu_to_pt(img.cx_emu); - let h = emu_to_pt(img.cy_emu); - image_items.push(PositionedItem::Image(PositionedImage { - rect: LayoutRect::new(0.0, total_image_height, w, h), - src: img.src.clone(), - alt: img.alt.clone(), - })); - total_image_height += h; - } - if total_image_height > 0.0 { - // Expand background fill to cover image area (first item when present). - if let Some(PositionedItem::FilledRect(bg)) = para_layout.items.first_mut() { - bg.rect.size.height += total_image_height; - } - // Shift all existing paragraph items down by total image height. - for item in &mut para_layout.items { - item.translate(0.0, total_image_height); - } - para_layout.height += total_image_height; - // Prepend image items (they render before paragraph text). - image_items.append(&mut para_layout.items); - para_layout.items = image_items; - } + // Block-stack the non-floating images and collect any `wrapNone` overlays. + let overlay_items = stack_block_images(&mut para_layout, &images, state.content_width); // Emit the float beside the wrapped text; a float taller than its text // becomes an `ActiveFloat` so *following* paragraphs wrap its remainder. @@ -203,12 +160,27 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc para_layout.items.push(placement.item); } + // Emit overlay (`wrapNone`) floats last: behind-text ones go under the + // whole paragraph (drawn first), in-front ones over the text (drawn last). + // Neither reserves vertical space nor shifts the text. + apply_overlay_images(&mut para_layout, overlay_items); + // The paragraph's content top in page coordinates (where the float image's // own top sits), captured before placement may advance/split the cursor. let para_top = state.cursor_y; let page_before = state.page_number; - place_paragraph_layout(state, &resolved, para_layout, block_index); + // Place the paragraph, exempting an empty one from — and otherwise applying — + // this page's footnote-band reservation (see `place_with_footnote_band`). + place_with_footnote_band( + state, + &resolved, + para_layout, + block_index, + text.trim().is_empty(), + footnote_reserve, + page_before_para, + ); // Maintain the cross-paragraph float band. if state.page_number != page_before { @@ -234,3 +206,85 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc finish_page(state); } } + +/// Block-stacks a paragraph's non-floating images above its text (gap #9) and +/// returns any `wrapNone` overlays for the caller to emit after floats. +/// +/// TODO(inline-image-flow): Parley has no inline image boxes, so images are a +/// block-level prefix — existing items shift down to make room. Shared by +/// [`flow_paragraph`] and the keep-with-next chain (`flow_para_chain`) so an +/// image in a `keepNext` paragraph (e.g. a captioned figure) is not dropped. +pub(super) fn stack_block_images( + para_layout: &mut ParagraphLayout, + images: &[crate::resolve::CollectedImage], + content_width: f32, +) -> Vec<(bool, PositionedItem)> { + let mut total_image_height = 0.0f32; + let mut image_items: Vec = Vec::new(); + // Overlay floats (`wrapNone`): Word reserves no space for them, so instead + // of stacking above the text they float at a side-anchored position over + // the full-width text (or under it when `behind_text`). + let mut overlay_items: Vec<(bool, PositionedItem)> = Vec::new(); + for img in images { + if img.cx_emu == 0 && img.cy_emu == 0 { + continue; // zero-size image — skip without crashing + } + let w = emu_to_pt(img.cx_emu); + let h = emu_to_pt(img.cy_emu); + if let Some(f) = img.float.filter(|f| f.wrap == TextWrap::None) { + // Anchor to the same side `plan_float` would have chosen: text on + // the left (`side=Left`) means the object sits on the right. + let x = if matches!(f.side, WrapSide::Left) { + (content_width - w).max(0.0) + } else { + 0.0 + }; + overlay_items.push(( + f.behind_text, + PositionedItem::Image(PositionedImage { + rect: LayoutRect::new(x, 0.0, w, h), + src: img.src.clone(), + alt: img.alt.clone(), + }), + )); + continue; + } + image_items.push(PositionedItem::Image(PositionedImage { + rect: LayoutRect::new(0.0, total_image_height, w, h), + src: img.src.clone(), + alt: img.alt.clone(), + })); + total_image_height += h; + } + if total_image_height > 0.0 { + // Expand background fill to cover image area (first item when present). + if let Some(PositionedItem::FilledRect(bg)) = para_layout.items.first_mut() { + bg.rect.size.height += total_image_height; + } + // Shift all existing paragraph items down by total image height. + for item in &mut para_layout.items { + item.translate(0.0, total_image_height); + } + para_layout.height += total_image_height; + // Prepend image items (they render before paragraph text). + image_items.append(&mut para_layout.items); + para_layout.items = image_items; + } + overlay_items +} + +/// Emits `wrapNone` overlay images: behind-text ones under the whole paragraph +/// (drawn first), in-front ones over the text (drawn last). Neither reserves +/// vertical space nor shifts the text. +pub(super) fn apply_overlay_images( + para_layout: &mut ParagraphLayout, + overlay_items: Vec<(bool, PositionedItem)>, +) { + for (behind, item) in overlay_items { + if behind { + para_layout.items.insert(0, item); + } else { + para_layout.items.push(item); + } + } +} diff --git a/loki-layout/src/flow_para_chain.rs b/loki-layout/src/flow_para_chain.rs index 074378d8..37b34f59 100644 --- a/loki-layout/src/flow_para_chain.rs +++ b/loki-layout/src/flow_para_chain.rs @@ -12,10 +12,16 @@ use loki_doc_model::content::block::{Block, StyledParagraph}; use crate::para::{ParagraphLayout, ResolvedParaProps, layout_paragraph_spelled}; -use crate::resolve::resolve_para_props; +use crate::resolve::{CollectedNote, resolve_para_props}; use super::{FlowState, LayoutWarning, break_column, finish_page, place_paragraph_layout}; +/// A speculatively-built chain member: its resolved props, laid-out paragraph, +/// and the footnotes/endnotes it collected (committed to `pending_footnotes` +/// only when the block is actually placed, so a re-flowed too-tall suffix does +/// not double-collect). +type ChainEntry = (ResolvedParaProps, ParagraphLayout, Vec); + /// Maximum keep-with-next chain length before truncation (ADR 004 §4). const CHAIN_LIMIT: usize = 5; @@ -44,6 +50,17 @@ pub(crate) fn flow_keep_with_next_chain( if !has_kwn || chain_end + 1 >= blocks.len() { break; } + // Only extend the chain into a block this function can actually lay out + // and place. A non-paragraph block (table, rule, nested list) must flow + // through the normal `flow_block` dispatch — pulling it into the chain + // would place it as a zero-height empty paragraph and silently drop its + // content. This is what dropped a table that immediately followed its + // `keepNext` caption (the ubiquitous "Table N" caption pattern). + // TODO(kwn-table): keep a caption *visually* with its table across a + // page break too — needs real table measurement inside the chain. + if !is_chain_compatible(&blocks[chain_end + 1]) { + break; + } natural_len += 1; chain_end += 1; } @@ -66,7 +83,7 @@ pub(crate) fn flow_keep_with_next_chain( let chain = build_chain_layouts(state, blocks, start, chain_end); let total_h: f32 = chain .iter() - .map(|(r, l)| r.space_before + l.height + r.space_after) + .map(|(r, l, _)| r.space_before + l.height + r.space_after) .sum(); let chain_len = chain_end - start + 1; @@ -74,7 +91,7 @@ pub(crate) fn flow_keep_with_next_chain( return place_chain_too_tall(state, chain, start, chain_end, total_h); } - let available = state.page_content_height - state.cursor_y; + let available = state.content_bottom() - state.cursor_y; if total_h > available && state.cursor_y > 0.0 { break_column(state); } @@ -85,86 +102,128 @@ pub(crate) fn flow_keep_with_next_chain( // ── Private helpers ─────────────────────────────────────────────────────────── -/// Speculatively lay out blocks `start..=end` and return `(resolved, layout)` pairs. +/// Whether a block can be laid out as a member of a keep-with-next chain. +/// +/// Mirrors the conversion in [`build_chain_layouts`]: only paragraph-like blocks +/// have a Parley layout the chain can measure and place. Other blocks (tables, +/// rules, lists) flow through the normal dispatch instead, so the chain must not +/// absorb them. +fn is_chain_compatible(block: &Block) -> bool { + matches!( + block, + Block::StyledPara(_) | Block::Heading(..) | Block::Para(_) | Block::Plain(_) + ) +} + +/// Speculatively lay out blocks `start..=end`, returning each member's props, +/// layout, and collected notes. A running note counter is threaded across the +/// blocks so numbering is sequential; `state.note_counter` is **not** advanced +/// here (placement commits it) so this speculative pass has no side effects. fn build_chain_layouts<'s>( state: &mut FlowState<'s>, blocks: &[Block], start: usize, end: usize, -) -> Vec<(ResolvedParaProps, ParagraphLayout)> { - (start..=end) - .map(|idx| { - // Convert every block type to an effective StyledParagraph so that - // all chain members receive a proper parley_layout. Without this, - // Heading (and other non-StyledPara) blocks in a chain end up with - // parley_layout=None, causing cursor_rect to return None. - let effective_para: Option = match &blocks[idx] { - Block::StyledPara(p) => Some(p.clone()), - Block::Heading(lvl, attr, inlines) => { - Some(super::super::synthesize_heading_para(*lvl, attr, inlines)) - } - Block::Para(inlines) | Block::Plain(inlines) => { - Some(super::super::synthesize_plain_para(inlines)) - } - _ => None, - }; - - if let Some(para) = effective_para { - let resolved = resolve_para_props(¶, state.catalog); - let mut temp_counter = state.note_counter; - let (text, spans, _images, _notes) = crate::resolve::flatten_paragraph_with_base( - ¶, - state.catalog, - &mut temp_counter, - state.cell_char_defaults.as_ref(), - ); - let layout = layout_paragraph_spelled( - state.resources, - &text, - &spans, - &resolved, - state.content_width, - state.display_scale, - state.options.preserve_for_editing, - state.options.spell.as_ref(), - ); - (resolved, layout) - } else { - // Non-text block (HR, table, etc.): contribute zero height. - ( - ResolvedParaProps::default(), - ParagraphLayout { - height: 0.0, - width: 0.0, - items: vec![], - first_baseline: 0.0, - last_baseline: 0.0, - line_boundaries: vec![], - parley_layout: None, - orig_to_clean: vec![0], - clean_to_orig: vec![0], - indent_start: 0.0, - indent_hanging: 0.0, - drop_lines: 0, - drop_shift: 0.0, - }, - ) +) -> Vec { + // Seed from the live counter but keep a local copy: the numbers baked into + // the layouts here are re-derived at placement, which advances the real one. + let mut counter = state.note_counter; + let mut out = Vec::with_capacity(end - start + 1); + for block in &blocks[start..=end] { + // Convert every block type to an effective StyledParagraph so that + // all chain members receive a proper parley_layout. Without this, + // Heading (and other non-StyledPara) blocks in a chain end up with + // parley_layout=None, causing cursor_rect to return None. + let effective_para: Option = match block { + Block::StyledPara(p) => Some(p.clone()), + Block::Heading(lvl, attr, inlines) => { + Some(super::super::synthesize_heading_para(*lvl, attr, inlines)) + } + Block::Para(inlines) | Block::Plain(inlines) => { + Some(super::super::synthesize_plain_para(inlines)) } - }) - .collect() + _ => None, + }; + + if let Some(para) = effective_para { + let resolved = resolve_para_props(¶, state.catalog); + let (text, spans, images, notes) = crate::resolve::flatten_paragraph_with_base( + ¶, + state.catalog, + &mut counter, + state.cell_char_defaults.as_ref(), + state.options.revision_display, + ); + let mut layout = layout_paragraph_spelled( + state.resources, + &text, + &spans, + &resolved, + state.content_width, + state.display_scale, + state.options.preserve_for_editing, + state.options.spell.as_ref(), + ); + // Block-stack any inline images (a captioned figure with + // `keepNext` on its image paragraph would otherwise vanish — + // the chain path formerly discarded the collected images). + let overlay = super::stack_block_images(&mut layout, &images, state.content_width); + super::apply_overlay_images(&mut layout, overlay); + out.push((resolved, layout, notes)); + } else { + // Non-text block (HR, table, etc.): contribute zero height. + out.push(( + ResolvedParaProps::default(), + ParagraphLayout { + height: 0.0, + width: 0.0, + items: vec![], + first_baseline: 0.0, + last_baseline: 0.0, + line_boundaries: vec![], + parley_layout: None, + orig_to_clean: vec![0], + clean_to_orig: vec![0], + indent_start: 0.0, + indent_hanging: 0.0, + drop_lines: 0, + drop_shift: 0.0, + }, + Vec::new(), + )); + } + } + out +} + +/// Commit a placed chain block's notes: tag them with their owning block and +/// per-block order, hand them to `pending_footnotes`, and advance the real note +/// counter (mirrors `flow_paragraph`, so a `keepNext` caption's footnote is +/// rendered rather than dropped). +fn collect_chain_notes(state: &mut FlowState, mut notes: Vec, block_index: usize) { + if notes.is_empty() { + return; + } + for (i, note) in notes.iter_mut().enumerate() { + note.owner_block_index = block_index; + note.note_in_block = i; + } + state.note_counter += notes.len() as u32; + // The chain is placed as a single-page unit (it was measured to fit), so its + // notes land on the current page — reserve their band immediately so + // post-chain content stops above it. + state.footnote_reserved += super::super::tail::footnote_reservation(state, ¬es); + state.pending_footnotes.extend(notes); } /// Place chain blocks in order, adding `space_before` to `cursor_y` before each. -fn place_chain_blocks( - state: &mut FlowState, - chain: Vec<(ResolvedParaProps, ParagraphLayout)>, - start: usize, -) { - for (i, (resolved, layout)) in chain.into_iter().enumerate() { +fn place_chain_blocks(state: &mut FlowState, chain: Vec, start: usize) { + for (i, (resolved, layout, notes)) in chain.into_iter().enumerate() { state.cursor_y += resolved.space_before; if resolved.page_break_before && state.mode.is_paginated() { finish_page(state); } + collect_chain_notes(state, notes, start + i); place_paragraph_layout(state, &resolved, layout, start + i); } } @@ -176,7 +235,7 @@ fn place_chain_blocks( /// blocks fall back to the caller's main loop). fn place_chain_too_tall( state: &mut FlowState, - chain: Vec<(ResolvedParaProps, ParagraphLayout)>, + chain: Vec, start: usize, chain_end: usize, _total_h: f32, @@ -184,7 +243,7 @@ fn place_chain_too_tall( // Find largest prefix whose total height fits on one fresh page. let mut prefix_h = 0.0f32; let mut last_fits = start; - for (i, (resolved, layout)) in chain.iter().enumerate() { + for (i, (resolved, layout, _)) in chain.iter().enumerate() { let block_h = resolved.space_before + layout.height + resolved.space_after; if prefix_h + block_h > state.page_content_height { break; @@ -211,14 +270,17 @@ fn place_chain_too_tall( } let consumed = last_fits - start + 1; - for (i, (resolved, layout)) in chain.into_iter().enumerate() { + for (i, (resolved, layout, notes)) in chain.into_iter().enumerate() { if start + i > last_fits { + // Un-placed suffix falls back to the caller's main loop, which + // re-flows it (re-collecting its notes) — so drop these here. break; } state.cursor_y += resolved.space_before; if resolved.page_break_before && state.mode.is_paginated() { finish_page(state); } + collect_chain_notes(state, notes, start + i); place_paragraph_layout(state, &resolved, layout, start + i); } consumed diff --git a/loki-layout/src/flow_para_place.rs b/loki-layout/src/flow_para_place.rs index c050c4ee..56d48945 100644 --- a/loki-layout/src/flow_para_place.rs +++ b/loki-layout/src/flow_para_place.rs @@ -13,6 +13,40 @@ use super::{ split_and_place_loop, }; +/// [`place_paragraph_layout`] wrapped with footnote-band bookkeeping. +/// +/// An empty (whitespace-only) paragraph — e.g. the trailing paragraph holding a +/// section break — is exempted from the current page's footnote reservation +/// (`text_empty`), since it carries no visible glyphs and may sit within the +/// reserved band; this stops the reservation from spilling it (and its section +/// mark) onto a spurious page. After placement, `reserve` (the band this +/// paragraph's own notes need) is applied iff the paragraph stayed on +/// `page_before_para` — a paragraph that broke to a new page already flushed its +/// notes on the boundary, and the fresh page's reservation resets in `finish_page`. +#[allow(clippy::too_many_arguments)] // cohesive footnote-band placement bundle +pub(super) fn place_with_footnote_band( + state: &mut FlowState, + resolved: &ResolvedParaProps, + para_layout: ParagraphLayout, + block_index: usize, + text_empty: bool, + reserve: f32, + page_before_para: usize, +) { + let saved = state.footnote_reserved; + let page_before = state.page_number; + if text_empty { + state.footnote_reserved = 0.0; + } + place_paragraph_layout(state, resolved, para_layout, block_index); + if text_empty && state.page_number == page_before { + state.footnote_reserved = saved; + } + if reserve > 0.0 && state.page_number == page_before_para { + state.footnote_reserved += reserve; + } +} + /// Place a pre-computed paragraph layout, handling `keep_together` and splitting. /// /// `space_before` must already be reflected in `state.cursor_y` by the caller. @@ -57,7 +91,7 @@ pub(super) fn place_paragraph_layout( ); // Fall through to normal splitting. } else { - let available = state.page_content_height - state.cursor_y; + let available = state.content_bottom() - state.cursor_y; if needed > available && state.cursor_y > 0.0 { break_column(state); state.cursor_y += resolved.space_before; @@ -68,6 +102,7 @@ pub(super) fn place_paragraph_layout( if state.options.preserve_for_editing { push_editing_para(state, block_index, Arc::new(para_layout.clone()), (0.0, dy)); } + super::super::line_numbers::emit(state, ¶_layout, dy, 0.0, para_layout.height); for mut item in para_layout.items { item.translate(dx, dy); state.current_items.push(item); diff --git a/loki-layout/src/flow_run.rs b/loki-layout/src/flow_run.rs index d316ad21..a58d68f0 100644 --- a/loki-layout/src/flow_run.rs +++ b/loki-layout/src/flow_run.rs @@ -69,6 +69,7 @@ pub(super) fn new_flow_state<'a>( page_size: LayoutSize::new(page_w, page_h), margins, page_content_height: (page_h - margins.vertical()).max(0.0), + rendering_footnotes: false, page_number: 1, warnings: Vec::new(), current_indent: 0.0, @@ -76,6 +77,7 @@ pub(super) fn new_flow_state<'a>( prev_list_id: None, note_counter: 0, pending_footnotes: Vec::new(), + footnote_reserved: 0.0, current_paragraphs: Vec::new(), checkpoints: Vec::new(), columns, @@ -94,6 +96,20 @@ pub(super) fn new_flow_state<'a>( staged_between: None, tail_candidate: None, cell_char_defaults: None, + line_num: pl + .line_numbering + .as_ref() + .map(super::line_numbers::LineNumberState::new), + } +} + +impl FlowState<'_> { + /// Lowest `y` body content may reach before breaking — `page_content_height` + /// minus this page's [`footnote_reserved`](FlowState::footnote_reserved), + /// floored at `cursor_y` (so an over-full page never reports negative room). + /// Used only by the "space remaining on this page" break checks. + pub(super) fn content_bottom(&self) -> f32 { + (self.page_content_height - self.footnote_reserved).max(self.cursor_y) } } diff --git a/loki-layout/src/flow_split.rs b/loki-layout/src/flow_split.rs index 6ce6c797..87e28e30 100644 --- a/loki-layout/src/flow_split.rs +++ b/loki-layout/src/flow_split.rs @@ -40,7 +40,9 @@ pub(super) fn split_and_place_loop( loop { let frag_height = para_layout.height - frag_start; - let page_remaining = state.page_content_height - state.cursor_y; + // Break against the footnote-reserved content limit so lines stop above + // this page's footnote band instead of overlapping it. + let page_remaining = state.content_bottom() - state.cursor_y; if frag_height <= page_remaining { // Remaining fragment fits on the current page. @@ -71,6 +73,13 @@ pub(super) fn split_and_place_loop( .current_items .push(PositionedItem::ClippedGroup { clip_rect, items }); } + super::super::line_numbers::emit( + state, + para_layout, + ty, + frag_start, + para_layout.height, + ); state.cursor_y += frag_height; return; } @@ -205,5 +214,6 @@ fn emit_fragment( state .current_items .push(PositionedItem::ClippedGroup { clip_rect, items }); + super::super::line_numbers::emit(state, para_layout, ty, frag_start, split_y); state.cursor_y += clip_height; } diff --git a/loki-layout/src/flow_table_autofit.rs b/loki-layout/src/flow_table_autofit.rs new file mode 100644 index 00000000..ec8e739d --- /dev/null +++ b/loki-layout/src/flow_table_autofit.rs @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Autofit table column-width resolution (Word's min/max-content behaviour) and +//! the shared cell-measurement [`FlowState`] builder used by the table +//! geometry passes. +//! +//! A `w:tblLayout="autofit"` table (the OOXML default when no `tblLayout` is +//! present) does **not** honour the preferred grid widths literally the way a +//! fixed-layout table does. Word first guarantees every column at least its +//! *minimum content width* — the widest unbreakable unit (longest word) in any +//! of its cells — and only then distributes the surplus by the preferred +//! widths. Without that guarantee a column whose preferred width is far +//! narrower than its content (e.g. a 0.28"/400-twip callout label holding the +//! word "INSIGHT") is kept pathologically narrow, so the word wraps one +//! character per line and the row grows absurdly tall. This module restores the +//! minimum-content guarantee so such columns widen to fit, matching Word. + +use std::collections::HashMap; + +use loki_doc_model::StyleCatalog; +use loki_doc_model::content::table::row::{Cell, Row}; +use loki_doc_model::style::props::char_props::CharProps; + +use crate::LayoutOptions; +use crate::font::FontResources; +use crate::geometry::{LayoutInsets, LayoutSize}; +use crate::mode::LayoutMode; +use crate::resolve::pts_to_f32; + +use super::{FlowState, flow_block, get_items_max_x}; + +/// Build the isolated, single-column [`FlowState`] every cell-measurement pass +/// uses: `Pageless`, no comment gutter, no column machinery. `break_long_words` +/// and `content_width` vary by caller — height/content measurement flows at the +/// real cell width and breaks over-long words to it (Word); min-content +/// measurement flows at ~0 width **without** long-word breaking so each word +/// lands on its own line. +#[allow(clippy::too_many_arguments)] +pub(super) fn cell_flow_state<'a>( + resources: &'a mut FontResources, + catalog: &'a StyleCatalog, + display_scale: f32, + options: &'a LayoutOptions, + content_width: f32, + starting_indent: f32, + break_long_words: bool, + cell_chars: Option<&CharProps>, +) -> FlowState<'a> { + FlowState { + resources, + catalog, + mode: &LayoutMode::Pageless, + display_scale, + options, + cursor_y: 0.0, + content_width, + current_items: Vec::new(), + pages: Vec::new(), + page_size: LayoutSize::default(), + margins: LayoutInsets::default(), + page_content_height: 0.0, + rendering_footnotes: false, + page_number: 1, + warnings: Vec::new(), + current_indent: starting_indent, + list_counters: HashMap::new(), + prev_list_id: None, + note_counter: 0, + pending_footnotes: Vec::new(), + footnote_reserved: 0.0, + current_paragraphs: Vec::new(), + checkpoints: Vec::new(), + columns: 1, + column_widths: Vec::new(), + column_gap: 0.0, + column_separator: false, + col_index: 0, + column_top_y: 0.0, + column_item_start: 0, + column_para_start: 0, + comments: &[], + pending_comment_anchors: Vec::new(), + break_long_words, + active_float: None, + nested_editing: None, + staged_between: None, + tail_candidate: None, + cell_char_defaults: cell_chars.cloned(), + line_num: None, + } +} + +/// The minimum content width of a single cell: the widest line extent when the +/// cell is flowed at ~0 width without breaking long words, i.e. every word on +/// its own line. Cell padding is added by the caller. +fn measure_cell_min_width( + resources: &mut FontResources, + catalog: &StyleCatalog, + display_scale: f32, + options: &LayoutOptions, + cell: &Cell, +) -> f32 { + let mut state = cell_flow_state( + resources, + catalog, + display_scale, + options, + 1.0, + 0.0, + false, + None, + ); + for block in &cell.blocks { + // `idx` only tags editing paths; measured items are discarded. + flow_block(&mut state, block, 0); + } + get_items_max_x(&state.current_items) +} + +/// Per-column minimum content width: over every single-column cell, the max of +/// its `measure_cell_min_width` plus horizontal padding. Column-spanning cells +/// don't pin a single column, so they're skipped (their content still fits +/// because the spanned columns sum to the cell width). +fn column_min_widths( + state: &mut FlowState, + rows: &[&Row], + cell_cols: &[Vec<(usize, usize)>], + col_count: usize, +) -> Vec { + // Copy the shared refs/scalars out first so `state.resources` can be + // reborrowed mutably in the loop without conflicting. + let catalog = state.catalog; + let display_scale = state.display_scale; + let options = state.options; + + let mut mins = vec![0.0f32; col_count]; + for (row_idx, row) in rows.iter().enumerate() { + for (c_idx, cell) in row.cells.iter().enumerate() { + let (col_start, col_end) = cell_cols[row_idx][c_idx]; + if col_end - col_start != 1 || col_start >= col_count { + continue; + } + let pad = cell.props.padding_left.map(pts_to_f32).unwrap_or(0.0) + + cell.props.padding_right.map(pts_to_f32).unwrap_or(0.0); + let w = measure_cell_min_width(state.resources, catalog, display_scale, options, cell) + + pad; + if w > mins[col_start] { + mins[col_start] = w; + } + } + } + mins +} + +/// Distribute `table_width` across columns so each is at least its minimum +/// content width, sharing the remainder in proportion to the preferred +/// (`scaled`) widths. When no column violates its minimum this returns `scaled` +/// unchanged, so well-proportioned tables are unaffected. When the minimums +/// alone exceed `table_width` the columns keep their minimums and the table +/// overflows — Word's behaviour. +fn distribute_with_mins(scaled: &[f32], mins: &[f32], table_width: f32) -> Vec { + let n = scaled.len(); + let mut out = scaled.to_vec(); + if (0..n).all(|i| out[i] + 0.01 >= mins[i]) { + return out; + } + let mut pinned = vec![false; n]; + // Each iteration pins at least one more column; at most `n` need pinning. + for _ in 0..=n { + let pinned_sum: f32 = (0..n).filter(|&i| pinned[i]).map(|i| mins[i]).sum(); + let free_scaled: f32 = (0..n).filter(|&i| !pinned[i]).map(|i| scaled[i]).sum(); + let remaining = (table_width - pinned_sum).max(0.0); + let unpinned = (0..n).filter(|&i| !pinned[i]).count().max(1) as f32; + let share = |i: usize| { + if free_scaled > 0.0 { + scaled[i] / free_scaled + } else { + 1.0 / unpinned + } + }; + let mut changed = false; + for i in 0..n { + if !pinned[i] && remaining * share(i) + 0.01 < mins[i] { + pinned[i] = true; + changed = true; + } + } + if !changed { + for i in 0..n { + out[i] = if pinned[i] { + mins[i] + } else { + remaining * share(i) + }; + } + break; + } + } + out +} + +/// Resolve autofit column widths: start from the preferred widths scaled to the +/// table width (`scaled`), then guarantee each column its minimum content +/// width. +pub(super) fn autofit_column_widths( + state: &mut FlowState, + rows: &[&Row], + cell_cols: &[Vec<(usize, usize)>], + scaled: &[f32], + table_width: f32, +) -> Vec { + let mins = column_min_widths(state, rows, cell_cols, scaled.len()); + distribute_with_mins(scaled, &mins, table_width) +} + +#[cfg(test)] +#[path = "flow_table_autofit_tests.rs"] +mod tests; diff --git a/loki-layout/src/flow_table_autofit_tests.rs b/loki-layout/src/flow_table_autofit_tests.rs new file mode 100644 index 00000000..5d442550 --- /dev/null +++ b/loki-layout/src/flow_table_autofit_tests.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Unit tests for the autofit min-content width distribution. + +use super::distribute_with_mins; + +fn sum(v: &[f32]) -> f32 { + v.iter().sum() +} + +#[test] +fn no_violation_returns_scaled_unchanged() { + // Every column already meets its minimum → proportional result is kept, so + // well-proportioned tables are unaffected by the min-content guarantee. + let scaled = [200.0, 400.0]; + let mins = [50.0, 100.0]; + let out = distribute_with_mins(&scaled, &mins, 600.0); + assert_eq!(out, vec![200.0, 400.0]); +} + +#[test] +fn narrow_column_widens_to_min_and_others_absorb_it() { + // The classic callout: a tiny preferred label column (20) whose content + // needs 80. It must widen to 80; the wide body column gives up the 60, + // and the total table width is preserved. + let scaled = [20.0, 580.0]; + let mins = [80.0, 100.0]; + let out = distribute_with_mins(&scaled, &mins, 600.0); + assert!( + (out[0] - 80.0).abs() < 0.5, + "label pinned to its min: {out:?}" + ); + assert!( + (out[1] - 520.0).abs() < 0.5, + "body absorbs the deficit: {out:?}" + ); + assert!((sum(&out) - 600.0).abs() < 0.5, "total preserved: {out:?}"); +} + +#[test] +fn mins_exceeding_table_width_overflow_at_minimums() { + // When the minimums alone don't fit, every column keeps its minimum and the + // table overflows — Word's behaviour, not a rescale-to-fit. + let scaled = [100.0, 100.0]; + let mins = [250.0, 250.0]; + let out = distribute_with_mins(&scaled, &mins, 300.0); + assert!((out[0] - 250.0).abs() < 0.5, "{out:?}"); + assert!((out[1] - 250.0).abs() < 0.5, "{out:?}"); +} + +#[test] +fn multiple_narrow_columns_each_reach_min() { + // Two under-min columns and one generous column: both narrows reach their + // min, the generous one absorbs the combined deficit. + let scaled = [10.0, 10.0, 580.0]; + let mins = [70.0, 90.0, 50.0]; + let out = distribute_with_mins(&scaled, &mins, 600.0); + assert!(out[0] + 0.5 >= 70.0, "{out:?}"); + assert!(out[1] + 0.5 >= 90.0, "{out:?}"); + assert!((sum(&out) - 600.0).abs() < 1.0, "total preserved: {out:?}"); +} diff --git a/loki-layout/src/flow_table_geom.rs b/loki-layout/src/flow_table_geom.rs index 8d8d7549..fd79101f 100644 --- a/loki-layout/src/flow_table_geom.rs +++ b/loki-layout/src/flow_table_geom.rs @@ -6,16 +6,12 @@ //! assignment (vertical-merge coverage). Split out of `flow.rs` (Phase 7.1); //! the main `flow_table` orchestrator stays in `flow.rs` and calls these. -use std::collections::HashMap; - use loki_doc_model::StyleCatalog; use loki_doc_model::content::block::Block; use crate::LayoutOptions; use crate::font::FontResources; -use crate::geometry::{LayoutInsets, LayoutSize}; use crate::items::PositionedItem; -use crate::mode::LayoutMode; use crate::resolve::pts_to_f32; use crate::result::PageParagraphData; @@ -48,48 +44,17 @@ pub(super) fn measure_cell_height( cell_content_width }; - let mut temp_state = FlowState { + let mut temp_state = super::table_autofit::cell_flow_state( resources, catalog, - mode: &LayoutMode::Pageless, display_scale, options, - cursor_y: 0.0, - content_width: flow_w, - current_items: Vec::new(), - pages: Vec::new(), - page_size: LayoutSize::default(), - margins: LayoutInsets::default(), - page_content_height: 0.0, - page_number: 1, - warnings: Vec::new(), - current_indent: 0.0, - list_counters: HashMap::new(), - prev_list_id: None, - note_counter: 0, - pending_footnotes: Vec::new(), - current_paragraphs: Vec::new(), - checkpoints: Vec::new(), - // Table cells are always laid out single-column (widths unused here). - columns: 1, - column_widths: Vec::new(), - column_gap: 0.0, - column_separator: false, - col_index: 0, - column_top_y: 0.0, - column_item_start: 0, - column_para_start: 0, - // Cells never render the comment gutter panel. - comments: &[], - pending_comment_anchors: Vec::new(), + flow_w, + 0.0, // Cell content: break over-long words to the column width (Word). - break_long_words: true, - active_float: None, - nested_editing: None, - staged_between: None, - tail_candidate: None, - cell_char_defaults: cell_chars.cloned(), - }; + true, + cell_chars, + ); for block in &cell.blocks { flow_block(&mut temp_state, block, idx); @@ -105,8 +70,10 @@ pub(super) fn measure_cell_height( } pub(super) fn resolve_column_widths( - state: &FlowState, + state: &mut FlowState, tbl: &loki_doc_model::content::table::core::Table, + rows: &[&loki_doc_model::content::table::row::Row], + cell_cols: &[Vec<(usize, usize)>], ) -> Vec { use loki_doc_model::content::table::col::{ColWidth, TableWidth}; @@ -167,9 +134,17 @@ pub(super) fn resolve_column_widths( .any(|c| c == loki_doc_model::content::table::core::TABLE_FIXED_LAYOUT_CLASS); if !fixed_layout { let scale = table_width / total_fixed_width; - for w in &mut resolved_widths { - *w *= scale; - } + let scaled: Vec = resolved_widths.iter().map(|w| w * scale).collect(); + // Word autofit: honour the scaled preferred widths, but guarantee + // each column at least its minimum content width so a too-narrow + // preferred width can't force one-character-per-line wrapping. + resolved_widths = super::table_autofit::autofit_column_widths( + state, + rows, + cell_cols, + &scaled, + table_width, + ); } } else { let uniform_w = table_width / col_count as f32; @@ -199,48 +174,18 @@ pub(super) fn flow_cell_blocks( cell_flat: usize, cell_chars: Option<&loki_doc_model::style::props::char_props::CharProps>, ) -> (Vec, Vec) { - let mut temp_state = FlowState { + let mut temp_state = super::table_autofit::cell_flow_state( resources, catalog, - mode: &LayoutMode::Pageless, display_scale, options, - cursor_y: starting_y, content_width, - current_items: Vec::new(), - pages: Vec::new(), - page_size: LayoutSize::default(), - margins: LayoutInsets::default(), - page_content_height: 0.0, - page_number: 1, - warnings: Vec::new(), - current_indent: starting_indent, - list_counters: HashMap::new(), - prev_list_id: None, - note_counter: 0, - pending_footnotes: Vec::new(), - current_paragraphs: Vec::new(), - checkpoints: Vec::new(), - // Table cells are always laid out single-column (widths unused here). - columns: 1, - column_widths: Vec::new(), - column_gap: 0.0, - column_separator: false, - col_index: 0, - column_top_y: 0.0, - column_item_start: 0, - column_para_start: 0, - // Cells never render the comment gutter panel. - comments: &[], - pending_comment_anchors: Vec::new(), + starting_indent, // Cell content: break over-long words to the column width (Word). - break_long_words: true, - active_float: None, - nested_editing: None, - staged_between: None, - tail_candidate: None, - cell_char_defaults: cell_chars.cloned(), - }; + true, + cell_chars, + ); + temp_state.cursor_y = starting_y; for (bi, block) in blocks.iter().enumerate() { // Tag cell paragraphs so a click in the rotated cell resolves to it. diff --git a/loki-layout/src/flow_table_main.rs b/loki-layout/src/flow_table_main.rs index e37e2cd0..0c16bbcd 100644 --- a/loki-layout/src/flow_table_main.rs +++ b/loki-layout/src/flow_table_main.rs @@ -14,8 +14,6 @@ pub(super) fn flow_table( tbl: &loki_doc_model::content::table::core::Table, idx: usize, ) { - let col_widths = table_geom::resolve_column_widths(state, tbl); - let mut rows = Vec::new(); rows.extend(&tbl.head.rows); for body in &tbl.bodies { @@ -28,7 +26,10 @@ pub(super) fn flow_table( // `row_span` (vMerge) cell from an earlier row (`cell_cols[row][cell] = // (col_start, col_end)`). Without it a cell whose leading column is occupied // by a vertical merge above is placed too far left — the TC-DOCX-005 bug. - let cell_cols = table_geom::assign_cell_columns(&rows, col_widths.len()); + // Resolved before column widths because autofit measures per-column content. + let cell_cols = table_geom::assign_cell_columns(&rows, tbl.col_count().max(1)); + + let col_widths = table_geom::resolve_column_widths(state, tbl, &rows, &cell_cols); // Named style + `w:tblLook` → conditional/banding shading (under direct). let table_style = resolve_table_style(state.catalog, tbl.style_name()); @@ -55,7 +56,9 @@ pub(super) fn flow_table( let row_max_h = row_heights[row_idx]; if state.mode.is_paginated() { - let remaining_h = state.page_content_height - state.cursor_y; + // Remaining space honours this page's footnote reservation so a row + // does not overlap the footnote band. + let remaining_h = state.content_bottom() - state.cursor_y; if row_max_h > remaining_h && row_max_h <= state.page_content_height { // A whole row that fits in a band but not the remaining space // moves to the next column (or page). diff --git a/loki-layout/src/flow_table_paint.rs b/loki-layout/src/flow_table_paint.rs index 25bd882f..db189365 100644 --- a/loki-layout/src/flow_table_paint.rs +++ b/loki-layout/src/flow_table_paint.rs @@ -11,7 +11,7 @@ use loki_doc_model::style::{TableLook, TableStyle}; use crate::geometry::{LayoutPoint, LayoutRect, LayoutSize}; use crate::items::{PositionedBorderRect, PositionedItem, PositionedRect}; use crate::resolve::{convert_border, pts_to_f32, resolve_color}; -use crate::table_shading::cell_style_shading_cnf; +use crate::table_shading::{cell_style_borders, cell_style_shading_cnf}; use super::{FlowState, table_geom}; @@ -183,10 +183,20 @@ pub(super) fn emit_row_cell_decorations( }, }; - let has_borders = cell.props.border_top.is_some() - || cell.props.border_bottom.is_some() - || cell.props.border_left.is_some() - || cell.props.border_right.is_some(); + // A direct cell border wins; otherwise the table style's borders + // (e.g. the Table Grid style's outer edges + interior gridlines) + // fill in each edge, so a styled table draws its grid without the + // cells carrying explicit borders. `sb` is (top, right, bottom, left). + let sb = cell_style_borders(table_style, row_idx, col_start, grid_rows, grid_cols); + let eff_top = cell.props.border_top.as_ref().or(sb.0.as_ref()); + let eff_right = cell.props.border_right.as_ref().or(sb.1.as_ref()); + let eff_bottom = cell.props.border_bottom.as_ref().or(sb.2.as_ref()); + let eff_left = cell.props.border_left.as_ref().or(sb.3.as_ref()); + + let has_borders = eff_top.is_some() + || eff_bottom.is_some() + || eff_left.is_some() + || eff_right.is_some(); // Direct cell shading wins, else the table style's banding — via // the cell's explicit w:cnfStyle mask when it carries one (4a.3). @@ -206,17 +216,17 @@ pub(super) fn emit_row_cell_decorations( let is_last = p == row_page_end; let border_top = if is_first { - cell.props.border_top.as_ref().and_then(convert_border) + eff_top.and_then(convert_border) } else { None }; let border_bottom = if is_last { - cell.props.border_bottom.as_ref().and_then(convert_border) + eff_bottom.and_then(convert_border) } else { None }; - let border_left = cell.props.border_left.as_ref().and_then(convert_border); - let border_right = cell.props.border_right.as_ref().and_then(convert_border); + let border_left = eff_left.and_then(convert_border); + let border_right = eff_right.and_then(convert_border); let insert_idx = if p == cell_page_start { cell_item_start @@ -243,7 +253,17 @@ pub(super) fn emit_row_cell_decorations( }), ); } - if let Some(bg) = cell_bg.as_ref() { + // A `w:shd` line/cross texture paints as a hatch (bg + lines); + // a plain fill paints as a flat rect. Direct cell shading only — + // table-style banding has no texture. + if let Some(shading) = cell.props.shading.as_ref() { + items.insert( + insert_idx, + PositionedItem::HatchRect(crate::resolve::hatch_from_shading( + shading, cell_rect, + )), + ); + } else if let Some(bg) = cell_bg.as_ref() { items.insert( insert_idx, PositionedItem::FilledRect(PositionedRect { diff --git a/loki-layout/src/flow_tail.rs b/loki-layout/src/flow_tail.rs index ff51e39d..9ffae01c 100644 --- a/loki-layout/src/flow_tail.rs +++ b/loki-layout/src/flow_tail.rs @@ -16,6 +16,7 @@ use super::{FlowState, editing, flow_block, flow_paragraph}; use crate::color::LayoutColor; use crate::geometry::LayoutRect; use crate::items::{PositionedItem, PositionedRect}; +use crate::resolve::CollectedNote; // ── Miscellaneous block renderers ───────────────────────────────────────────── @@ -33,20 +34,114 @@ pub(super) fn flow_hrule(state: &mut FlowState) { // ── Footnote rendering ──────────────────────────────────────────────────────── -/// Render all accumulated footnotes at the end of the section. +/// Separator geometry above a page's first footnote (0.5 pt rule, 4 pt of gap +/// above and below), reserved once per page. +const SEP_HEIGHT: f32 = 0.5; +const SEP_GAP: f32 = 4.0; +const SEP_BAND: f32 = SEP_GAP + SEP_HEIGHT + SEP_GAP; + +/// Measure the foot-of-page space `notes` need on the current page — the +/// separator band (only on the page's first reservation) plus each note's +/// laid-out height. **Pure** (no mutation), so the caller applies it only once +/// the reference paragraph is placed on this page. `0.0` when there is nothing +/// to reserve or the flow is multi-column (footnotes are single-column only). +pub(super) fn footnote_reservation(state: &mut FlowState, notes: &[CollectedNote]) -> f32 { + if notes.is_empty() || state.columns != 1 { + return 0.0; + } + let sep = if state.footnote_reserved == 0.0 { + SEP_BAND + } else { + 0.0 + }; + sep + notes + .iter() + .map(|n| measure_note_height(state, n)) + .sum::() +} + +/// Height one footnote will occupy, laid out (with its reference mark) exactly +/// as [`render_footnote_bodies`] will render it, so the reserved band matches. +fn measure_note_height(state: &mut FlowState, note: &CollectedNote) -> f32 { + let mark = format!("{} ", footnote_mark(note.number)); + let mut h = 0.0; + let mut first = true; + for block in ¬e.blocks { + if let Block::StyledPara(p) = block { + let mut p = p.clone(); + if first { + p.inlines.insert(0, Inline::Str(mark.clone())); + } + first = false; + let resolved = crate::resolve::resolve_para_props(&p, state.catalog); + let mut counter = state.note_counter; + let (text, spans, _images, _notes) = crate::resolve::flatten_paragraph_with_base( + &p, + state.catalog, + &mut counter, + None, + state.options.revision_display, + ); + let layout = crate::para::layout_paragraph_spelled( + state.resources, + &text, + &spans, + &resolved, + state.content_width, + state.display_scale, + false, + None, + ); + h += resolved.space_before + layout.height + resolved.space_after; + } + } + h +} + +/// Lay out the current page's footnotes at the foot of the page. Called by +/// `finish_page` before the page is finalized (so each footnote sits on the page +/// carrying its reference, matching Word). Single-column body flow only. /// -/// Places a 1/3-width separator rule followed by each note body. The note -/// reference mark (e.g. "¹") is prepended to the first block of each note. -/// End-of-section placement is used for v0.1; end-of-page is deferred. +/// The band is bottom-aligned at `page_content_height − total`, but never above +/// where content stopped (`cursor_y`). Body content already stops above it via +/// the per-reference reservation ([`footnote_reservation`] + +/// [`FlowState::content_bottom`]); this bottom-aligns the actual render. +/// Pagination is disabled during rendering (the band is self-contained) and a +/// re-entrancy guard blocks recursion. +pub(super) fn flow_page_footnotes(state: &mut FlowState) { + if state.pending_footnotes.is_empty() || state.rendering_footnotes || state.columns != 1 { + return; + } + let notes = std::mem::take(&mut state.pending_footnotes); + let total: f32 = SEP_BAND + + notes + .iter() + .map(|n| measure_note_height(state, n)) + .sum::(); + let band_top = (state.page_content_height - total).max(state.cursor_y); + state.cursor_y = band_top; + state.rendering_footnotes = true; + // Disable pagination for the self-contained band (avoids a spurious break / + // finish_page recursion if the measured height rounds under the rendered). + let saved = state.page_content_height; + state.page_content_height = f32::MAX; + render_footnote_bodies(state, notes); + state.page_content_height = saved; + state.rendering_footnotes = false; +} + +/// Render any remaining footnotes at the current position — the non-paginated +/// (canvas / reflow) tail, which has no per-page bands to place them in. pub(super) fn flow_footnotes(state: &mut FlowState) { if state.pending_footnotes.is_empty() { return; } let notes = std::mem::take(&mut state.pending_footnotes); + render_footnote_bodies(state, notes); +} - // Separator: 1/3-width, 0.5 pt tall, 4 pt spacing above and below. - const SEP_HEIGHT: f32 = 0.5; - const SEP_GAP: f32 = 4.0; +/// Emit the separator rule and each note body from `state.cursor_y` downward. +fn render_footnote_bodies(state: &mut FlowState, notes: Vec) { let sep_w = state.content_width / 3.0; state.cursor_y += SEP_GAP; state @@ -181,6 +276,7 @@ pub(super) fn get_items_max_x(items: &[PositionedItem]) -> f32 { PositionedItem::FilledRect(r) | PositionedItem::HorizontalRule(r) => { r.rect.origin.x + r.rect.size.width } + PositionedItem::HatchRect(h) => h.rect.origin.x + h.rect.size.width, PositionedItem::BorderRect(r) => r.rect.origin.x + r.rect.size.width, PositionedItem::Image(r) => r.rect.origin.x + r.rect.size.width, PositionedItem::Decoration(d) => d.x + d.width, diff --git a/loki-layout/src/flow_tests.rs b/loki-layout/src/flow_tests.rs index 18c936d7..238b161b 100644 --- a/loki-layout/src/flow_tests.rs +++ b/loki-layout/src/flow_tests.rs @@ -1011,6 +1011,146 @@ fn keep_with_next_chain_fits_on_current_page_no_flush() { assert_eq!(pages.len(), 1, "chain fits on one page, expected 1 page"); } +/// REGRESSION: an inline image in a `keep_with_next` paragraph (a captioned +/// figure whose image paragraph is kept with its caption) must not be dropped. +/// The keep-with-next chain formerly discarded the collected images, so the +/// figure vanished while its caption remained. +#[test] +fn keep_with_next_paragraph_keeps_its_inline_image() { + use loki_doc_model::content::inline::LinkTarget; + + let mut r = test_resources(); + let mut attr = NodeAttr::default(); + attr.kv.push(("cx_emu".into(), "914400".into())); // 1 in + attr.kv.push(("cy_emu".into(), "914400".into())); + let image_para = StyledParagraph { + style_id: None, + direct_para_props: Some(Box::new(ParaProps { + keep_with_next: Some(true), + ..Default::default() + })), + direct_char_props: None, + inlines: vec![Inline::Image( + attr, + vec![], + LinkTarget::new("data:image/png;base64,AAAA"), + )], + attr: NodeAttr::default(), + }; + let caption = make_para("Figure 1. Caption."); + let section = section_of(vec![image_para, caption], PageLayout::default()); + + let (items, _h, _w) = flow_pageless(&mut r, §ion); + let has_image = items.iter().any(|i| matches!(i, PositionedItem::Image(_))); + assert!( + has_image, + "an inline image in a keep_with_next paragraph must not be dropped" + ); +} + +#[test] +fn keep_with_next_paragraph_keeps_its_footnote() { + use loki_doc_model::content::inline::NoteKind; + + let mut r = test_resources(); + // A keep-with-next paragraph whose text carries a footnote reference. The + // chain path used to discard the collected note, dropping the body entirely. + let note_body = vec![Block::StyledPara(make_para("Note body text."))]; + let ref_para = StyledParagraph { + style_id: None, + direct_para_props: Some(Box::new(ParaProps { + keep_with_next: Some(true), + ..Default::default() + })), + direct_char_props: None, + inlines: vec![ + Inline::Str("See".into()), + Inline::Note(NoteKind::Footnote, note_body), + ], + attr: NodeAttr::default(), + }; + let follow = make_para("Following paragraph."); + let section = section_of(vec![ref_para, follow], PageLayout::default()); + + let (pages, _) = flow_paginated(&mut r, §ion); + // The footnote separator rule is emitted only when a note body renders. + let has_note = pages.iter().any(|p| { + p.content_items + .iter() + .any(|i| matches!(i, PositionedItem::HorizontalRule(_))) + }); + assert!( + has_note, + "a footnote referenced from a keep-with-next paragraph must render, not be dropped" + ); +} + +#[test] +fn footnote_band_stays_within_the_content_area() { + use loki_doc_model::content::inline::NoteKind; + + let mut r = test_resources(); + // A paragraph early on a small page carries a footnote; several fillers then + // pack the page. The reservation must stop body content above the band so the + // whole band fits inside the content area instead of spilling past it. + let note_body = vec![Block::StyledPara(make_para( + "A footnote body occupying a line of its own at the foot of the page.", + ))]; + let mut paras = vec![StyledParagraph { + style_id: None, + direct_para_props: None, + direct_char_props: None, + inlines: vec![ + Inline::Str("Reference".into()), + Inline::Note(NoteKind::Footnote, note_body), + ], + attr: NodeAttr::default(), + }]; + for i in 0..12 { + paras.push(make_para(&format!( + "Filler body line number {i} on this page." + ))); + } + let section = section_of(paras, tiny_layout()); + let (pages, _) = flow_paginated(&mut r, §ion); + + // The page carrying the footnote band (its separator rule). + let page = pages + .iter() + .find(|p| { + p.content_items + .iter() + .any(|i| matches!(i, PositionedItem::HorizontalRule(_))) + }) + .expect("a page must carry the footnote band"); + + // tiny_layout: 100 pt page − 2×5 pt margins = 90 pt content height. + const CONTENT_H: f32 = 90.0; + let bottom = |it: &PositionedItem| -> f32 { + match it { + // Baseline y; the descender adds a little below but stays < content_h. + PositionedItem::GlyphRun(g) => g.origin.y, + PositionedItem::FilledRect(r) | PositionedItem::HorizontalRule(r) => { + r.rect.origin.y + r.rect.size.height + } + PositionedItem::ClippedGroup { clip_rect, .. } => { + clip_rect.origin.y + clip_rect.size.height + } + _ => 0.0, + } + }; + let max_bottom = page + .content_items + .iter() + .map(bottom) + .fold(0.0_f32, f32::max); + assert!( + max_bottom <= CONTENT_H + 1.0, + "footnote band + body must fit within the {CONTENT_H} pt content area, \ + but content reaches {max_bottom} pt (band overlaps the margin)" + ); +} + #[test] fn keep_with_next_chain_pushed_to_next_page() { let mut r = test_resources(); @@ -1878,6 +2018,90 @@ mod page_fields { ); } + /// A `wrapNone` float reserves no space: Word flows the text at full column + /// width and the object overlaps it. So the anchoring paragraph's text must + /// start at the column edge (not shifted to clear a band), and the following + /// paragraph must not be pushed down by the image height. + #[test] + fn wrap_none_float_overlaps_text_without_reserving_space() { + use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; + use loki_doc_model::content::inline::LinkTarget; + + fn wrap_none_image(cx_emu: u64, cy_emu: u64) -> Inline { + let mut attr = NodeAttr::default(); + attr.kv.push(("cx_emu".into(), cx_emu.to_string())); + attr.kv.push(("cy_emu".into(), cy_emu.to_string())); + FloatWrap { + wrap: TextWrap::None, + side: WrapSide::Both, + behind_text: false, + } + .store(&mut attr); + Inline::Image(attr, vec![], LinkTarget::new("data:image/png;base64,AAAA")) + } + + let mut r = test_resources(); + // 1 in × 1 in (72 × 72 pt) wrapNone float in a multi-line paragraph. + let body = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let anchor = StyledParagraph { + style_id: None, + direct_para_props: None, + direct_char_props: None, + inlines: vec![wrap_none_image(914_400, 914_400), Inline::Str(body)], + attr: NodeAttr::default(), + }; + let follower = make_para("Follower paragraph after the anchor."); + let section = section_of(vec![anchor, follower], PageLayout::default()); + + let (items, _h, _w) = flow_pageless(&mut r, §ion); + + let img = items + .iter() + .find_map(|i| match i { + PositionedItem::Image(im) => Some(im), + _ => None, + }) + .expect("wrapNone float image emitted"); + assert!((img.rect.size.height - 72.0).abs() < 1.0, "1 in tall float"); + let left_edge = img.rect.origin.x; + + let glyphs: Vec<(f32, f32)> = items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(g) => Some((g.origin.y, g.origin.x)), + _ => None, + }) + .collect(); + + // Lines within the float's vertical extent are NOT shifted right — the + // text overlaps the image (full-width flow), unlike a Square wrap. + let beside_min_x = glyphs + .iter() + .filter(|(y, _)| *y > 5.0 && *y < 60.0) + .map(|(_, x)| *x) + .fold(f32::INFINITY, f32::min); + assert!( + beside_min_x < left_edge + 5.0, + "wrapNone text must overlap the float, not clear a band; \ + min x = {beside_min_x}, float left = {left_edge}" + ); + + // The image reserved no vertical space: the anchor text starts at the + // top (it was not shifted down by a block image), and the whole anchor + + // follower fits well within the 72 pt the image would otherwise occupy — + // block-stacking would push the follower to y ≳ (anchor lines × 12 + 72). + let min_y = glyphs.iter().map(|(y, _)| *y).fold(f32::INFINITY, f32::min); + let max_y = glyphs.iter().map(|(y, _)| *y).fold(0.0_f32, f32::max); + assert!( + min_y < 20.0, + "anchor text must start at the top, not below the image (min y = {min_y})" + ); + assert!( + max_y < 90.0, + "no paragraph may be pushed past a reserved 72 pt band (max y = {max_y})" + ); + } + /// A generated table of contents flows its cached body exactly like the same /// paragraphs at the top level — before this the layout dropped the block /// entirely (the `_ => {}` catch-all), so an inserted or imported TOC was diff --git a/loki-layout/src/flow_textbox.rs b/loki-layout/src/flow_textbox.rs new file mode 100644 index 00000000..415a8a54 --- /dev/null +++ b/loki-layout/src/flow_textbox.rs @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Floating text-box (`wps` shape) placement for the flow engine. +//! +//! A [`CollectedImage`] whose [`textbox`](crate::resolve::CollectedImage::textbox) +//! is `Some` is a bordered/filled box carrying block content. This module flows +//! that content in a nested (`Pageless`) sub-layout, wraps it in a fill + border +//! group clipped to the box, and returns a [`FloatPlacement`] so the anchoring +//! paragraph reserves a side band and the surrounding text wraps around it — +//! reusing the same wrap machinery as a floating image. + +use loki_doc_model::content::float::{TextWrap, WrapSide}; + +use super::float_impl::{FLOAT_WRAP_GAP, FloatPlacement}; +use super::table_autofit::cell_flow_state; +use super::{FlowState, flow_block}; +use crate::color::LayoutColor; +use crate::geometry::LayoutRect; +use crate::items::{BorderEdge, BorderStyle, PositionedBorderRect, PositionedItem, PositionedRect}; +use crate::resolve::{CollectedImage, emu_to_pt}; + +/// Interior padding (points) between the box edge and its content — Word's +/// default `bodyPr` insets are 0.1"/0.05"; ~6 pt reads well at this scale. +const BOX_PAD: f32 = 6.0; + +/// Parse a `"RRGGBB"` hex string into a [`LayoutColor`]. +fn hex_to_color(s: &str) -> Option { + let s = s.strip_prefix('#').unwrap_or(s); + if s.len() != 6 { + return None; + } + let ch = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok(); + Some(LayoutColor::new( + f32::from(ch(0)?) / 255.0, + f32::from(ch(2)?) / 255.0, + f32::from(ch(4)?) / 255.0, + 1.0, + )) +} + +/// Plan the first side-wrapping floating **text box** in `images`. +/// +/// Returns its index (so the caller drops it from the block-stacked set) and a +/// [`FloatPlacement`] whose item is a clipped fill/border/content group. Returns +/// `None` when there is no wrapping text box or it would leave too little text. +pub(super) fn plan_textbox( + state: &mut FlowState, + images: &[CollectedImage], + content_width: f32, +) -> Option<(usize, FloatPlacement)> { + let idx = images.iter().position(|img| { + img.textbox.is_some() + && img.float.is_some_and(|f| { + !f.behind_text + && matches!( + f.wrap, + TextWrap::Square | TextWrap::Tight | TextWrap::Through + ) + }) + })?; + let img = &images[idx]; + let tb = img.textbox.as_ref()?; + let fw = img.float?; + + let w = emu_to_pt(img.cx_emu); + if w <= 0.0 { + return None; + } + let band = w + FLOAT_WRAP_GAP; + if band >= content_width * 0.75 { + return None; + } + + // WrapSide names the side TEXT occupies, so the box sits opposite (see + // `plan_float`). Both/Largest default to a right float in a pull-quote. + let float_left = matches!(fw.side, WrapSide::Right); + let (indent_start_delta, indent_end_delta, x) = if float_left { + (band, 0.0, 0.0) + } else { + (0.0, band, content_width - w) + }; + + // Flow the interior blocks in a nested Pageless sub-layout at the inner width. + let inner_w = (w - 2.0 * BOX_PAD).max(1.0); + let (inner_items, inner_h) = { + let mut nested = cell_flow_state( + state.resources, + state.catalog, + state.display_scale, + state.options, + inner_w, + 0.0, + true, + None, + ); + for block in &tb.blocks { + flow_block(&mut nested, block, 0); + } + (nested.current_items, nested.cursor_y) + }; + + // The box grows to fit its content if the authored height is too short. + let authored_h = emu_to_pt(img.cy_emu); + let box_h = authored_h.max(inner_h + 2.0 * BOX_PAD).max(1.0); + let rect = LayoutRect::new(x, 0.0, w, box_h); + + let mut items: Vec = Vec::new(); + if let Some(fill) = tb.fill.as_deref().and_then(hex_to_color) { + items.push(PositionedItem::FilledRect(PositionedRect { + rect, + color: fill, + })); + } + for mut it in inner_items { + it.translate(x + BOX_PAD, BOX_PAD); + items.push(it); + } + if let Some(line) = tb.line.as_deref().and_then(hex_to_color) { + let edge = Some(BorderEdge { + color: line, + width: 1.0, + style: BorderStyle::Solid, + }); + items.push(PositionedItem::BorderRect(PositionedBorderRect { + rect, + top: edge, + right: edge, + bottom: edge, + left: edge, + })); + } + + Some(( + idx, + FloatPlacement { + indent_start_delta, + indent_end_delta, + item: PositionedItem::ClippedGroup { + clip_rect: rect, + items, + }, + height: box_h, + }, + )) +} diff --git a/loki-layout/src/font.rs b/loki-layout/src/font.rs index db3dca91..0264a127 100644 --- a/loki-layout/src/font.rs +++ b/loki-layout/src/font.rs @@ -161,8 +161,14 @@ impl FontResources { "arial" => Some("Arimo"), "courier new" => Some("Cousine"), "times new roman" => Some("Tinos"), - "calibri" => Some("Carlito"), - "cambria" => Some("Caladea"), + // "Calibri Light" is a distinct family in Word (the default heading + // face) with the same metrics as Calibri, so it maps to the same + // metric-compatible substitute. Without this arm it falls through to + // a wider system fallback and headings/titles wrap differently. + "calibri" | "calibri light" => Some("Carlito"), + // "Cambria Math" is the math-glyph companion of Cambria; the text + // face substitute keeps its Latin metrics. + "cambria" | "cambria math" => Some("Caladea"), "georgia" => Some("Gelasio"), _ => None, }; @@ -232,6 +238,14 @@ mod tests { assert_eq!(resolved, "calibri"); assert_eq!(r.substitutions.get("calibri"), Some(&None)); } + + // "Calibri Light" (Word's default heading face) must resolve to the same + // metric-compatible substitute as Calibri — otherwise headings/titles + // fall back to a wider face and wrap differently from Word. + let resolved = r.resolve_font_name("Calibri Light"); + if r.font_cx.collection.family_id("Carlito").is_some() { + assert_eq!(resolved, "Carlito", "Calibri Light must map to Carlito"); + } } // Regression guard: the embedded metric-compatible faces must be available on diff --git a/loki-layout/src/hatch.rs b/loki-layout/src/hatch.rs new file mode 100644 index 00000000..69ce488c --- /dev/null +++ b/loki-layout/src/hatch.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Hatch-line geometry for [`PositionedHatch`](crate::hatch::PositionedHatch). +//! +//! A `w:shd` line/cross texture is drawn as a set of thin parallel lines (plus a +//! perpendicular family for the cross variants). This module turns a hatch +//! rect and pattern into rect-clipped line segments in layout space (y-down), so +//! each renderer only has to draw thin filled quads — no clip-state juggling and +//! no renderer-specific geometry. + +use crate::color::LayoutColor; +use crate::geometry::LayoutRect; + +/// A hatch-shaded rectangle: an optional background fill overlaid with hatch +/// lines in [`color`](Self::color). Emitted for a `w:shd` line/cross texture so +/// the renderer draws the actual lines rather than a flattened tint. +#[derive(Debug, Clone)] +pub struct PositionedHatch { + /// Position and dimensions. + pub rect: LayoutRect, + /// Background fill drawn behind the hatch, or `None` to leave the surface. + pub fill: Option, + /// Hatch line colour. + pub color: LayoutColor, + /// Hatch geometry. + pub pattern: HatchPattern, + /// `true` for the `thin*` variants — closer, thinner lines. + pub thin: bool, +} + +/// The geometry of a hatch pattern (renderer-agnostic mirror of the doc-model +/// `HatchPattern`; `loki-layout` keeps its own so [`PositionedItem`](crate::items::PositionedItem) +/// stays free of document-model types, matching the +/// [`BorderStyle`](crate::items::BorderStyle) precedent). +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HatchPattern { + /// Horizontal lines. + Horizontal, + /// Vertical lines. + Vertical, + /// `/` diagonal lines. + DiagUp, + /// `\` diagonal lines. + DiagDown, + /// Horizontal + vertical grid. + Cross, + /// Both diagonals (an `X` grid). + DiagCross, +} + +/// A clipped hatch line segment in layout coordinates (y increases downward). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HatchSegment { + /// Start x. + pub x0: f32, + /// Start y. + pub y0: f32, + /// End x. + pub x1: f32, + /// End y. + pub y1: f32, +} + +impl PositionedHatch { + /// Stroke width of each hatch line in points. + #[must_use] + pub fn line_width(&self) -> f32 { + if self.thin { 0.5 } else { 0.85 } + } + + /// Perpendicular spacing between adjacent hatch lines in points. + #[must_use] + pub fn spacing(&self) -> f32 { + if self.thin { 3.5 } else { 6.0 } + } + + /// The hatch lines as rect-clipped segments in layout space. Empty when the + /// rect has no area. + #[must_use] + pub fn segments(&self) -> Vec { + let r = &self.rect; + let (x0, y0) = (r.origin.x, r.origin.y); + let (w, h) = (r.size.width, r.size.height); + if w <= 0.0 || h <= 0.0 { + return Vec::new(); + } + let s = self.spacing().max(0.5); + let mut out = Vec::new(); + let mut push = |seg: Option| { + if let Some(seg) = seg { + out.push(seg); + } + }; + match self.pattern { + HatchPattern::Horizontal => horizontal(x0, y0, w, h, s, &mut push), + HatchPattern::Vertical => vertical(x0, y0, w, h, s, &mut push), + HatchPattern::Cross => { + horizontal(x0, y0, w, h, s, &mut push); + vertical(x0, y0, w, h, s, &mut push); + } + HatchPattern::DiagUp => diagonal(x0, y0, w, h, s, true, &mut push), + HatchPattern::DiagDown => diagonal(x0, y0, w, h, s, false, &mut push), + HatchPattern::DiagCross => { + diagonal(x0, y0, w, h, s, true, &mut push); + diagonal(x0, y0, w, h, s, false, &mut push); + } + } + out + } +} + +fn horizontal( + x0: f32, + y0: f32, + w: f32, + h: f32, + s: f32, + push: &mut impl FnMut(Option), +) { + let mut y = y0 + s; + while y < y0 + h { + push(Some(HatchSegment { + x0, + y0: y, + x1: x0 + w, + y1: y, + })); + y += s; + } +} + +fn vertical(x0: f32, y0: f32, w: f32, h: f32, s: f32, push: &mut impl FnMut(Option)) { + let mut x = x0 + s; + while x < x0 + w { + push(Some(HatchSegment { + x0: x, + y0, + x1: x, + y1: y0 + h, + })); + x += s; + } +} + +/// Emit clipped diagonal lines. `up` = `/` (slope such that x+y is constant), +/// else `\` (y−x constant). Perpendicular spacing is `s`, so the family step +/// along the diagonal invariant is `s·√2`. +fn diagonal( + x0: f32, + y0: f32, + w: f32, + h: f32, + s: f32, + up: bool, + push: &mut impl FnMut(Option), +) { + let step = s * std::f32::consts::SQRT_2; + let (x1, y1) = (x0 + w, y0 + h); + if up { + // Lines x + y = c crossing the rect; c ∈ [x0+y0, x1+y1]. + let (lo, hi) = (x0 + y0, x1 + y1); + let mut c = lo + step; + while c < hi { + // Direction (1, -1): a long segment, then clip to the rect. + push(clip(c - y0 - h, y1, c - y0, y0, x0, y0, x1, y1)); + c += step; + } + } else { + // Lines y − x = c crossing the rect; c ∈ [y0−x1, y1−x0]. + let (lo, hi) = (y0 - x1, y1 - x0); + let mut c = lo + step; + while c < hi { + // Direction (1, 1): a long segment, then clip to the rect. + push(clip(x0, x0 + c, x1, x1 + c, x0, y0, x1, y1)); + c += step; + } + } +} + +/// Liang–Barsky clip of segment `(ax,ay)-(bx,by)` to the rect +/// `[rx0,rx1]×[ry0,ry1]`. Returns the clipped segment, or `None` if it misses. +#[allow(clippy::too_many_arguments)] +fn clip( + ax: f32, + ay: f32, + bx: f32, + by: f32, + rx0: f32, + ry0: f32, + rx1: f32, + ry1: f32, +) -> Option { + let dx = bx - ax; + let dy = by - ay; + let mut t0 = 0.0_f32; + let mut t1 = 1.0_f32; + let edges = [ + (-dx, ax - rx0), + (dx, rx1 - ax), + (-dy, ay - ry0), + (dy, ry1 - ay), + ]; + for (p, q) in edges { + if p == 0.0 { + if q < 0.0 { + return None; // parallel and outside + } + } else { + let t = q / p; + if p < 0.0 { + if t > t1 { + return None; + } + if t > t0 { + t0 = t; + } + } else { + if t < t0 { + return None; + } + if t < t1 { + t1 = t; + } + } + } + } + Some(HatchSegment { + x0: ax + t0 * dx, + y0: ay + t0 * dy, + x1: ax + t1 * dx, + y1: ay + t1 * dy, + }) +} + +#[cfg(test)] +#[path = "hatch_tests.rs"] +mod tests; diff --git a/loki-layout/src/hatch_tests.rs b/loki-layout/src/hatch_tests.rs new file mode 100644 index 00000000..8b91ceb3 --- /dev/null +++ b/loki-layout/src/hatch_tests.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use crate::color::LayoutColor; +use crate::geometry::LayoutRect; +use crate::hatch::{HatchPattern, PositionedHatch}; + +fn hatch(pattern: HatchPattern, thin: bool) -> PositionedHatch { + PositionedHatch { + rect: LayoutRect::new(10.0, 20.0, 40.0, 30.0), + fill: None, + color: LayoutColor::BLACK, + pattern, + thin, + } +} + +#[test] +fn horizontal_lines_span_full_width_inside_the_rect() { + let segs = hatch(HatchPattern::Horizontal, false).segments(); + assert!(!segs.is_empty(), "expected horizontal hatch lines"); + for s in &segs { + assert!((s.x0 - 10.0).abs() < 1e-3, "starts at left edge"); + assert!((s.x1 - 50.0).abs() < 1e-3, "ends at right edge"); + assert!((s.y0 - s.y1).abs() < 1e-3, "line is horizontal"); + assert!(s.y0 > 20.0 && s.y0 < 50.0, "within the rect vertically"); + } +} + +#[test] +fn vertical_lines_span_full_height_inside_the_rect() { + let segs = hatch(HatchPattern::Vertical, false).segments(); + assert!(!segs.is_empty()); + for s in &segs { + assert!((s.x0 - s.x1).abs() < 1e-3, "line is vertical"); + assert!((s.y0 - 20.0).abs() < 1e-3 && (s.y1 - 50.0).abs() < 1e-3); + assert!(s.x0 > 10.0 && s.x0 < 50.0, "within the rect horizontally"); + } +} + +#[test] +fn cross_is_horizontal_plus_vertical() { + let h = hatch(HatchPattern::Horizontal, false).segments().len(); + let v = hatch(HatchPattern::Vertical, false).segments().len(); + let cross = hatch(HatchPattern::Cross, false).segments().len(); + assert_eq!(cross, h + v); +} + +#[test] +fn diagonals_stay_within_the_rect_and_are_non_empty() { + for p in [ + HatchPattern::DiagUp, + HatchPattern::DiagDown, + HatchPattern::DiagCross, + ] { + let segs = hatch(p, false).segments(); + assert!(!segs.is_empty(), "expected diagonal hatch lines for {p:?}"); + for s in &segs { + for (x, y) in [(s.x0, s.y0), (s.x1, s.y1)] { + assert!((10.0 - 1e-3..=50.0 + 1e-3).contains(&x), "x {x} in rect"); + assert!((20.0 - 1e-3..=50.0 + 1e-3).contains(&y), "y {y} in rect"); + } + } + } +} + +#[test] +fn thin_lines_are_thinner_and_closer() { + let normal = hatch(HatchPattern::Horizontal, false); + let thin = hatch(HatchPattern::Horizontal, true); + assert!(thin.line_width() < normal.line_width()); + assert!(thin.spacing() < normal.spacing()); + // Closer spacing → more lines. + assert!(thin.segments().len() > normal.segments().len()); +} + +#[test] +fn zero_area_rect_has_no_segments() { + let mut h = hatch(HatchPattern::Cross, false); + h.rect = LayoutRect::new(0.0, 0.0, 0.0, 0.0); + assert!(h.segments().is_empty()); +} diff --git a/loki-layout/src/items.rs b/loki-layout/src/items.rs index 2ddb100d..08751f83 100644 --- a/loki-layout/src/items.rs +++ b/loki-layout/src/items.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use crate::color::LayoutColor; use crate::geometry::{LayoutPoint, LayoutRect}; +use crate::hatch::PositionedHatch; /// A single renderer-agnostic draw item with an absolute position in layout /// space. @@ -24,6 +25,10 @@ pub enum PositionedItem { GlyphRun(PositionedGlyphRun), /// A filled rectangle (backgrounds, table cell fills, etc.). FilledRect(PositionedRect), + /// A hatch-shaded rectangle (`w:shd` line/cross texture): an optional + /// background fill overlaid with hatch lines. The renderer draws the lines; + /// see [`PositionedHatch`]. + HatchRect(PositionedHatch), /// A border rectangle (stroked, not filled). BorderRect(PositionedBorderRect), /// An image at a position. @@ -73,6 +78,10 @@ impl PositionedItem { r.rect.origin.x += dx; r.rect.origin.y += dy; } + Self::HatchRect(h) => { + h.rect.origin.x += dx; + h.rect.origin.y += dy; + } Self::BorderRect(r) => { r.rect.origin.x += dx; r.rect.origin.y += dy; @@ -121,6 +130,14 @@ pub struct PositionedGlyphRun { pub color: LayoutColor, /// Synthesis flags (bold/italic synthesis). pub synthesis: GlyphSynthesis, + /// Normalized variation coordinates (F2Dot14 raw i16, one per fvar axis) + /// for this run's selected face, as resolved by Parley. Non-empty only for + /// variable fonts — e.g. the bundled Arimo (Arial substitute) is a `wght` + /// variable font, so a bold run carries its `wght=700` coordinate here. + /// Both painters must apply these; rendering the default (all-zero) master + /// instead paints regular-weight glyphs with bold advances (gap: bold Arial + /// looked "wide but not bold"). + pub normalized_coords: Vec, /// Hyperlink URL if this run is part of a link. `None` for non-link text. /// /// A blue-tint underlay hint is rendered by `loki-vello`, a point resolves diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index 3d195d6b..b4d518cf 100644 --- a/loki-layout/src/lib.rs +++ b/loki-layout/src/lib.rs @@ -28,6 +28,8 @@ pub mod flow; pub mod font; pub mod font_handle; pub mod geometry; +#[path = "hatch.rs"] +pub mod hatch; pub mod incremental; pub mod items; mod layout_entry; @@ -43,6 +45,8 @@ mod para_drop_cap; mod para_emit; pub mod resolve; pub mod result; +#[path = "revision_filter.rs"] +mod revision_filter; mod revision_style; mod table_shading; pub use color::LayoutColor; @@ -51,6 +55,7 @@ pub use flow::{FlowOutput, LayoutWarning, flow_section}; pub use font::FontResources; pub use font_handle::SharedFontResources; pub use geometry::{LayoutInsets, LayoutPoint, LayoutRect, LayoutSize}; +pub use hatch::{HatchPattern, HatchSegment, PositionedHatch}; pub use incremental::{ FlowCheckpoint, PageStart, PaginatedReuse, document_has_notes, relayout_paginated_incremental, }; @@ -60,7 +65,7 @@ pub use items::{ }; pub use layout_entry::{layout_document, layout_paginated_full}; pub use mode::LayoutMode; -pub use options::{FieldContext, LayoutOptions, SpellState}; +pub use options::{FieldContext, LayoutOptions, RevisionDisplay, SpellState}; pub use para::{ Affinity, CursorRect, HitTestResult, ParagraphLayout, ResolvedLineHeight, ResolvedParaProps, StyleSpan, layout_paragraph, diff --git a/loki-layout/src/math/shape.rs b/loki-layout/src/math/shape.rs index 6b4934ee..f07dcce2 100644 --- a/loki-layout/src/math/shape.rs +++ b/loki-layout/src/math/shape.rs @@ -107,6 +107,7 @@ pub(super) fn shape_token( bold: synthesis.embolden(), italic: synthesis.skew().is_some(), }, + normalized_coords: run.normalized_coords().to_vec(), link_url: None, })); } diff --git a/loki-layout/src/options.rs b/loki-layout/src/options.rs index 9b1b36be..7758ce2a 100644 --- a/loki-layout/src/options.rs +++ b/loki-layout/src/options.rs @@ -41,6 +41,33 @@ pub struct LayoutOptions { /// width is unchanged (left + right is constant), only the content /// area's horizontal position swaps. pub mirror_margins: bool, + + /// How tracked changes (`CharProps::revision`) are displayed. The default + /// [`RevisionDisplay::AllMarkup`] shows insertions underlined and deletions + /// struck through; [`RevisionDisplay::Final`] / [`RevisionDisplay::Original`] + /// render the accepted / rejected document **non-destructively** (the + /// revision marks are untouched — only the view changes), matching Word's + /// "Show Markup" dropdown. Because the mode changes the flattened text + + /// spans, the content-addressed paragraph cache keys on it automatically. + pub revision_display: RevisionDisplay, +} + +/// How tracked-change (`CharProps::revision`) runs are rendered — a +/// **non-destructive** view over the document, mirroring Word's Review-tab +/// "Show Markup" dropdown. Switching modes never mutates the revision marks +/// (unlike `accept_revisions` / `reject_revisions`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RevisionDisplay { + /// Show insertions (author-coloured, underlined) and deletions + /// (author-coloured, struck through) inline — Word's "All Markup". Default. + #[default] + AllMarkup, + /// Show the document as if every change were **accepted**: insertions render + /// as normal text, deletions are hidden — Word's "No Markup" / Final. + Final, + /// Show the document as if every change were **rejected**: deletions render + /// as normal text, insertions are hidden — Word's "Original". + Original, } /// A spell checker plus a cache-invalidation generation, supplied via diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index d1ab4cb3..860e6a4c 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -16,7 +16,7 @@ use parley::{AlignmentOptions, InlineBox, InlineBoxKind, PositionedLayoutItem}; use crate::font::FontResources; use crate::geometry::LayoutRect; -use crate::items::{PositionedBorderRect, PositionedItem, PositionedRect}; +use crate::items::{PositionedBorderRect, PositionedItem}; #[path = "para_build.rs"] mod build; @@ -24,6 +24,8 @@ mod build; mod layout_types; #[path = "para_query.rs"] mod query; +#[path = "para_tab_underline.rs"] +mod tab_underline; #[path = "para_tabs.rs"] mod tabs; #[path = "para_types.rs"] @@ -61,7 +63,10 @@ fn clean_text_and_spans( for c in text.chars() { let c_len = c.len_utf8(); - let keep = c == '\t' || c == '\n' || (!c.is_control() && c != '\u{feff}'); + // Drop `\t`: a tab is pure positioning (an inline box). Left in, it + // shapes to a `.notdef` (fonts lacking a tab glyph, e.g. Arimo) whose + // advance stacks on the box and overshoots the stop; byte maps anyway. + let keep = c == '\n' || (!c.is_control() && c != '\u{feff}'); if keep { for i in 0..c_len { orig_to_clean[orig_idx + i] = clean_idx + i; @@ -195,15 +200,19 @@ pub(crate) fn layout_paragraph_spelled( } /// Prepends the paragraph's border and background-fill rects to `items` (so -/// they render beneath the text). The box spans the full indented width and the -/// paragraph height. Background is inserted last so it sits behind the border. +/// they render beneath the text). The box spans the **content column** — from +/// the start indent to the end indent, for the paragraph's full height — +/// matching Word, where a paragraph border/shading fills the column rather than +/// hugging the text ink. `available_width` is the paragraph's available width +/// (before indents). Background is inserted last so it sits behind the border. fn prepend_para_box( items: &mut Vec, para_props: &ResolvedParaProps, - width: f32, + available_width: f32, height: f32, ) { - let bw = width + para_props.indent_start + para_props.indent_end; + let x = para_props.indent_start; + let w = (available_width - para_props.indent_start - para_props.indent_end).max(0.0); let has_border = para_props.border_top.is_some() || para_props.border_right.is_some() || para_props.border_bottom.is_some() @@ -212,7 +221,7 @@ fn prepend_para_box( items.insert( 0, PositionedItem::BorderRect(PositionedBorderRect { - rect: LayoutRect::new(0.0, 0.0, bw, height), + rect: LayoutRect::new(x, 0.0, w, height), top: para_props.border_top, right: para_props.border_right, bottom: para_props.border_bottom, @@ -220,14 +229,11 @@ fn prepend_para_box( }), ); } - if let Some(bg) = para_props.background_color { - items.insert( - 0, - PositionedItem::FilledRect(PositionedRect { - rect: LayoutRect::new(0.0, 0.0, bw, height), - color: bg, - }), - ); + // A `w:shd` texture paints as a hatch (bg + lines); a solid fill as a flat + // rect (`para_background_item`). + let bg_rect = LayoutRect::new(x, 0.0, w, height); + if let Some(item) = crate::resolve::para_background_item(para_props, bg_rect) { + items.insert(0, item); } } @@ -278,28 +284,11 @@ fn layout_paragraph_uncached( } if clean_text.is_empty() { - if !preserve_for_editing { - return ParagraphLayout { - height: 0.0, - width: 0.0, - items: vec![], - first_baseline: 0.0, - last_baseline: 0.0, - line_boundaries: vec![], - parley_layout: None, - orig_to_clean, - clean_to_orig, - indent_start: para_props.indent_start, - indent_hanging: para_props.indent_hanging, - drop_lines: 0, - drop_shift: 0.0, - }; - } - // Build a phantom single-space layout so cursor_rect can return a - // properly-sized caret for empty paragraphs. The space forces Parley - // to produce one line with the paragraph's resolved font metrics. - // height/line_boundaries are left at zero so empty paragraphs do not - // affect vertical flow — they remain un-clickable but navigable. + // An empty paragraph still occupies one line (its resolved line height) + // and draws its border/background box — matching Word, where a blank + // paragraph takes vertical space and an empty paragraph with a bottom + // border is a horizontal rule. Shape a phantom single space (no ink) for + // the metrics, and keep it for the editor's caret. let mut builder = resources .layout_cx @@ -307,19 +296,32 @@ fn layout_paragraph_uncached( push_para_styles(&mut builder, para_props, &[]); let mut phantom = builder.build(" "); phantom.break_all_lines(Some(available_width)); + let line_h = phantom.height(); let first_baseline = phantom .lines() .next() .map(|l| l.metrics().baseline) .unwrap_or(0.0); + let line_boundaries: Vec<(f32, f32)> = phantom + .lines() + .map(|l| { + let m = l.metrics(); + (m.block_min_coord, m.block_max_coord) + }) + .collect(); + // The border/rule spans the full content column (indent to indent). + let content_w = + (available_width - para_props.indent_start - para_props.indent_end).max(0.0); + let mut items = Vec::new(); + prepend_para_box(&mut items, para_props, available_width, line_h); return ParagraphLayout { - height: 0.0, - width: 0.0, - items: vec![], + height: line_h, + width: content_w, + items, first_baseline, last_baseline: first_baseline, - line_boundaries: vec![], - parley_layout: Some(Arc::new(phantom)), + line_boundaries, + parley_layout: preserve_for_editing.then(|| Arc::new(phantom)), orig_to_clean, clean_to_orig, indent_start: para_props.indent_start, @@ -329,25 +331,22 @@ fn layout_paragraph_uncached( }; } - // NOTE(indent-hanging-width): Parley 0.6 does not expose per-line width - // control. The first line of a hanging-indent paragraph wraps at the same - // `line_w` as subsequent lines, meaning it gets `indent_hanging` px less - // space than it should. Fix requires Parley to expose per-line measure. - // Tracked: fidelity audit gap #8 (partial). + // NOTE(indent-hanging-width): Parley 0.6 exposes no per-line width control, + // so a hanging-indent paragraph's first line wraps at the same `line_w` as + // the rest, getting `indent_hanging` px less space. Fidelity gap #8 (partial). let line_w = (available_width - para_props.indent_start - para_props.indent_end).max(0.0); // ── Tab stop expansion (gap #7) ─────────────────────────────────────────── - // Parley 0.8 has no native tab stop API. Two-pass approach: - // Pass 1 (probe): zero-width InlineBoxes at each \t → measure x-positions. - // Pass 2 (final): InlineBoxes sized to advance to the next tab stop. - let tab_char_positions: Vec = clean_text + // Parley has no native tab-stop API. Two passes: (1) probe with zero-width + // InlineBoxes to measure x-positions; (2) final boxes sized to the next stop. + // `\t` is excluded from `clean_text`; map each tab to its following clean offset (box site). + let tab_char_positions: Vec = text_content .char_indices() .filter(|(_, c)| *c == '\t') - .map(|(i, _)| i) + .map(|(i, _)| orig_to_clean[i]) .collect(); - // Byte offset of the first decimal separator after each tab (before the - // next tab / end), for Decimal-aligned stops. + // First decimal separator in each tab's following content (for Decimal stops); `t` points at it. let decimal_positions: Vec> = tab_char_positions .iter() .enumerate() @@ -356,7 +355,7 @@ fn layout_paragraph_uncached( .get(i + 1) .copied() .unwrap_or(clean_text.len()); - clean_text[t + 1..end].find('.').map(|rel| t + 1 + rel) + clean_text[t..end].find('.').map(|rel| t + rel) }) .collect(); @@ -511,7 +510,7 @@ fn layout_paragraph_uncached( } content_bottom = content_bottom.max(p.bottom); } - prepend_para_box(&mut items, para_props, body.width, body.height); + prepend_para_box(&mut items, para_props, available_width, body.height); return ParagraphLayout { height: content_bottom, width: body.width, @@ -574,10 +573,9 @@ fn layout_paragraph_uncached( // can hang below the line's descent; grow the paragraph height to cover it. let mut content_bottom = total_height; - // OOXML lineRule="exact" (ODF fixed line height): the line box is a fixed - // height and content taller than it is clipped — unlike "atLeast", which - // grows. Each line's items are wrapped in a clip layer sized to the exact - // line box so over-tall glyphs / inline objects are cut off as in Word. + // OOXML lineRule="exact" (ODF fixed line height): each line's items are + // wrapped in a clip layer sized to the fixed line box, so content taller than + // it is cut off (unlike "atLeast", which grows the line) — as in Word. let exact_line_pts = match para_props.line_height { Some(ResolvedLineHeight::Exact(pts)) => Some(pts), _ => None, @@ -622,9 +620,11 @@ fn layout_paragraph_uncached( indent_x += drop_shift; } let line_baseline = line.metrics().baseline; - // Extra horizontal offset accumulated from horizontally-scaled (w:w) - // runs earlier on this line, so later items shift right by the width the - // scaling added instead of overlapping. Reset per line. + // Descent of the line's first (body) run, used to anchor the exact-line + // clip box (below) so a raised/over-tall run can't inflate it. + let mut primary_descent: Option = None; + // Extra horizontal offset from horizontally-scaled (w:w) runs earlier on + // this line, so later items shift right instead of overlapping. let mut extra_x = 0.0f32; for item in line.items() { // Math inline box: emit the typeset equation's draw items, offset to @@ -638,28 +638,29 @@ fn layout_paragraph_uncached( prim.translate(pib.x + indent_x + extra_x, pib.y); items.push(prim); } - // The box top is at `pib.y` and its baseline at - // `pib.y + ascent`; the descent hangs below that. + // Box top at `pib.y`, baseline `+ ascent`, descent below. content_bottom = content_bottom.max(pib.y + render.ascent + render.descent); } } else if (pib.id as usize) < tab_char_positions.len() { - // Tab inline box: draw the stop's leader (if any) across the - // gap the box opened. - if let Some(plan) = tab_plans.get(pib.id as usize) { - tabs::emit_tab_leader( - &mut items, - plan.leader, - pib.x + indent_x + extra_x, - pib.x + indent_x + extra_x + pib.width, - line_baseline, - ); - } + // Tab box: stop leader + any underlined tab run's rule (gap #8). + tab_underline::emit_tab_box( + &mut items, + &clean_spans, + tab_char_positions.get(pib.id as usize).copied(), + tab_plans.get(pib.id as usize).map(|p| p.leader), + pib.x + indent_x + extra_x, + pib.width, + line_baseline, + ); } continue; } let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue; }; + if primary_descent.is_none() { + primary_descent = Some(glyph_run.run().metrics().descent); + } let scale = span_scale_for_range(&clean_spans, glyph_run.run().text_range()).unwrap_or(1.0); // Reserve the extra width the run rendered (scaling, per-glyph or @@ -676,21 +677,17 @@ fn layout_paragraph_uncached( ); } if let Some(pts) = exact_line_pts { - // Clip this line's items to its fixed-height box. The clip is wide - // horizontally (exact governs the vertical extent only; horizontal - // overflow is handled by margins/wrapping, as in Word) and exactly - // `pts` tall. - // - // Word anchors the exact line box at the BOTTOM of the text: the box - // bottom sits at the baseline + descent and the top is `pts` above - // it, so when the font is taller than `pts` the ascenders (and a - // raised superscript) are clipped while descenders are preserved — - // the well-known "tops cut off" behaviour of small exact spacing. - // (A symmetric/centered box would instead clip descenders too, which - // does not match Word.) Consecutive boxes still tile exactly because - // Parley advances the baseline by `pts`. + // Clip this line's items to its fixed `pts`-tall box (wide + // horizontally; exact governs only the vertical extent, as in Word). + // Bottom-anchor at `baseline + descent` (top `pts` above) so the body + // text fills the box while a raised superscript / over-tall element is + // clipped at the top. Anchor on the *body* (first) run's descent, not + // the line's aggregate: a tall raised run would otherwise inflate it + // and push the box down over the body text's tops (the "small text + // tops cut off" bug). Boxes tile — Parley advances the baseline by `pts`. let lm = line.metrics(); - let top = lm.baseline + lm.descent - pts; + let descent = primary_descent.unwrap_or(lm.descent); + let top = lm.baseline + descent - pts; let clipped: Vec = items.split_off(line_item_start); items.push(PositionedItem::ClippedGroup { clip_rect: LayoutRect::new(-line_w, top, line_w * 3.0, pts), @@ -712,7 +709,7 @@ fn layout_paragraph_uncached( content_bottom = content_bottom.max(p.bottom); } - prepend_para_box(&mut items, para_props, total_width, total_height); + prepend_para_box(&mut items, para_props, available_width, total_height); let parley_layout = if preserve_for_editing { Some(Arc::new(layout)) diff --git a/loki-layout/src/para_band_tests.rs b/loki-layout/src/para_band_tests.rs index e134ca93..08835716 100644 --- a/loki-layout/src/para_band_tests.rs +++ b/loki-layout/src/para_band_tests.rs @@ -30,10 +30,13 @@ fn span(text: &str) -> StyleSpan { line_height: None, vertical_align: None, highlight_color: None, + character_border: None, letter_spacing: None, font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, link_url: None, math: None, scale: None, diff --git a/loki-layout/src/para_build.rs b/loki-layout/src/para_build.rs index a0101c4d..e80c40eb 100644 --- a/loki-layout/src/para_build.rs +++ b/loki-layout/src/para_build.rs @@ -74,6 +74,14 @@ pub(crate) fn push_para_styles( continue; } let r = span.range.clone(); + // A span whose shaped text is empty — e.g. a run that is only a tab + // (tabs are excluded from the Parley text, gap #8) carrying its own + // char props, as in an underlined signature-line tab — remaps to a + // zero-length range. Parley asserts `start < end` on every style span, + // so drop empties here (matching the guard in `para_underlays`). + if r.start >= r.end { + continue; + } // For super/subscript (gap #3), reduce font size to 58 %. The shift is // applied in `para_emit` (`va_offset`) — TODO(super-sub): no native API. let effective_font_size = if span.vertical_align.is_some() { @@ -82,7 +90,19 @@ pub(crate) fn push_para_styles( span.font_size }; builder.push(StyleProperty::FontSize(effective_font_size), r.clone()); - builder.push(StyleProperty::Brush(span.color), r.clone()); + // Emboss/imprint draw the body in a relief grey (the raised surface / + // engraved floor). Pushing it as the Parley brush both colours the body + // and — because the brush now differs from the neighbours — stops Parley + // coalescing the run into a glyph run spanning several style spans (which + // would defeat the per-run effect lookup in `para_emit`). + let brush = if span.emboss { + LayoutColor::new(0.78, 0.78, 0.78, 1.0) + } else if span.imprint { + LayoutColor::new(0.42, 0.42, 0.42, 1.0) + } else { + span.color + }; + builder.push(StyleProperty::Brush(brush), r.clone()); // Push the effective numeric weight. `weight` already folds in `bold` // (700 when bold, else 400) plus any explicit `font_weight` style, so a // non-default weight is honoured even when the bold flag is unset. diff --git a/loki-layout/src/para_cache.rs b/loki-layout/src/para_cache.rs index bb0830ea..0369c1c5 100644 --- a/loki-layout/src/para_cache.rs +++ b/loki-layout/src/para_cache.rs @@ -164,10 +164,13 @@ mod tests { line_height: None, vertical_align: None, highlight_color: None, + character_border: None, letter_spacing: None, font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, link_url: None, math: None, scale: None, diff --git a/loki-layout/src/para_drop_cap.rs b/loki-layout/src/para_drop_cap.rs index 2f468908..f6f2cf6c 100644 --- a/loki-layout/src/para_drop_cap.rs +++ b/loki-layout/src/para_drop_cap.rs @@ -251,6 +251,7 @@ fn shape_cap( bold: synthesis.embolden(), italic: synthesis.skew().is_some(), }, + normalized_coords: run.normalized_coords().to_vec(), link_url: None, })); } diff --git a/loki-layout/src/para_emit.rs b/loki-layout/src/para_emit.rs index ca9d332c..30ce6e65 100644 --- a/loki-layout/src/para_emit.rs +++ b/loki-layout/src/para_emit.rs @@ -24,7 +24,7 @@ use crate::items::{ }; use crate::para::{StrikethroughStyle, StyleSpan, UnderlineStyle, VerticalAlign, span_at_offset}; -fn underline_deco_style(u: UnderlineStyle) -> DecorationStyle { +pub(crate) fn underline_deco_style(u: UnderlineStyle) -> DecorationStyle { match u { UnderlineStyle::Single => DecorationStyle::Solid, UnderlineStyle::Double => DecorationStyle::Double, @@ -101,13 +101,10 @@ pub(crate) fn emit_glyph_run( // does not (Parley split the run on a real style change), we fall back to the // caller's per-run `scale` and no extra rise — unchanged behaviour. // - // The cluster/glyph/per-glyph vectors below exist only to detect and serve - // the per-glyph case, so they are built only when some span actually - // carries a scale or baseline shift — three avoided heap allocations per - // glyph run on the common no-`w:w`/no-`w:position` path. The per-glyph - // path accumulates scaled advances (a scaled sub-run stretches, a raised - // neighbour in the SAME run keeps its size); the uniform fast path - // reproduces the previous geometry exactly. + // The cluster/glyph vectors below are built only when some span carries a + // scale or baseline shift (three avoided heap allocations on the common + // no-`w:w`/no-`w:position` path). The per-glyph path accumulates scaled + // advances; the uniform fast path reproduces the previous geometry exactly. let has_per_glyph = spans .iter() .any(|s| s.scale.is_some() || s.baseline_shift.is_some()); @@ -209,47 +206,50 @@ pub(crate) fn emit_glyph_run( })); } - // ── Shadow copy (gap #24) ───────────────────────────────────────────────── - // Emit a dark-grey copy of the run offset by (0.5 pt, 0.5 pt) so it appears - // as a hard shadow behind the main run. - // TODO(shadow): replace with Vello blur filter for soft shadow once - // scene.rs blur pipeline is verified stable (see TODO in scene.rs). - if covering_span.is_some_and(|s| s.shadow) { + // Builder for a glyph-run copy (shared by the relief copy and the main run) + // capturing this run's shared shaping state (font, synthesis, VF coords). + let synth = GlyphSynthesis { + bold: synthesis.embolden(), + italic: synthesis.skew().is_some(), + }; + let coords = run.normalized_coords().to_vec(); + let (fidx, fsize) = (run.font().index, run.font_size()); + let mut push_run = |x: f32, y: f32, gs: Vec, color, link: Option| { items.push(PositionedItem::GlyphRun(PositionedGlyphRun { - origin: LayoutPoint { - x: run_offset + indent_x + 0.5, - y: run_baseline + va_offset + 0.5, - }, + origin: LayoutPoint { x, y }, font_data: font_data.clone(), - font_index: run.font().index, - font_size: run.font_size(), - glyphs: glyphs.clone(), - color: LayoutColor::new(0.4, 0.4, 0.4, 1.0), - synthesis: GlyphSynthesis { - bold: synthesis.embolden(), - italic: synthesis.skew().is_some(), - }, - link_url: None, // shadows don't carry link metadata + font_index: fidx, + font_size: fsize, + glyphs: gs, + color, + synthesis: synth, + normalized_coords: coords.clone(), + link_url: link, })); - } + }; - // ── Main glyph run ──────────────────────────────────────────────────────── - items.push(PositionedItem::GlyphRun(PositionedGlyphRun { - origin: LayoutPoint { - x: run_offset + indent_x, - y: run_baseline + va_offset, - }, - font_data, - font_index: run.font().index, - font_size: run.font_size(), - glyphs, - color: style.brush, - synthesis: GlyphSynthesis { - bold: synthesis.embolden(), - italic: synthesis.skew().is_some(), - }, - link_url, - })); + // Relief copy: an offset copy behind the run for the 3-D effect — a darker + // copy for `w:shadow`/`w:emboss` (drop shadow / raised), a lighter copy for + // `w:imprint` (engraved). Emboss/imprint bodies are the effect grey (pushed + // as the Parley brush in `push_para_styles`, which also stops the run + // coalescing past its span, so `style.brush` carries it below); shadow keeps + // the text colour. TODO(shadow): swap for a Vello blur (see scene.rs). + let relief = covering_span.and_then(|s| { + if s.emboss || s.shadow { + Some(LayoutColor::new(0.42, 0.42, 0.42, 1.0)) + } else if s.imprint { + Some(LayoutColor::new(0.82, 0.82, 0.82, 1.0)) + } else { + None + } + }); + let bx = run_offset + indent_x; + let by = run_baseline + va_offset; + if let Some(color) = relief { + push_run(bx + 0.6, by + 0.6, glyphs.clone(), color, None); + } + // Main glyph run. + push_run(bx, by, glyphs, style.brush, link_url); // Underline decoration. Parley supplies the geometry (offset/size) but not // the `w:u` variant, so recover it from our spans by the run's text range. diff --git a/loki-layout/src/para_layout_types.rs b/loki-layout/src/para_layout_types.rs index 2946600a..f76a20de 100644 --- a/loki-layout/src/para_layout_types.rs +++ b/loki-layout/src/para_layout_types.rs @@ -38,6 +38,9 @@ pub struct ResolvedParaProps { pub line_height: Option, /// Optional paragraph background fill. pub background_color: Option, + /// Optional `w:shd` line/cross hatch shading. When set, the renderer draws + /// the hatch lines instead of a flat `background_color` fill. + pub background_hatch: Option, /// Top border edge, or `None`. pub border_top: Option, /// Bottom border edge, or `None`. @@ -116,6 +119,7 @@ impl Default for ResolvedParaProps { indent_first_line: 0.0, line_height: None, // None → MetricsRelative(1.0) default in Parley background_color: None, + background_hatch: None, border_top: None, border_bottom: None, border_left: None, diff --git a/loki-layout/src/para_props_map.rs b/loki-layout/src/para_props_map.rs index 57be67e7..5f7a087a 100644 --- a/loki-layout/src/para_props_map.rs +++ b/loki-layout/src/para_props_map.rs @@ -97,6 +97,7 @@ pub(super) fn map_para_props(p: &ParaProps) -> ResolvedParaProps { _ => None, }), background_color: p.background_color.as_ref().map(|c| resolve_color(Some(c))), + background_hatch: p.shading.clone(), border_top: p.border_top.as_ref().and_then(convert_border), border_bottom: p.border_bottom.as_ref().and_then(convert_border), border_left: p.border_left.as_ref().and_then(convert_border), diff --git a/loki-layout/src/para_tab_underline.rs b/loki-layout/src/para_tab_underline.rs new file mode 100644 index 00000000..81a5703d --- /dev/null +++ b/loki-layout/src/para_tab_underline.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Underline decoration across an underlined tab's expansion gap. +//! +//! A run that is only a `\t` carrying `w:u` (the classic signature line: +//! ``) draws its +//! underline across the whole tab gap in Word. Loki excludes `\t` from the +//! Parley text (gap #8), so such a run maps to a zero-length style span and +//! Parley — which only strokes underlines beneath real glyphs — draws nothing. +//! This module recovers the underline from the spans and emits it across the +//! tab box the flow engine opened, matching Word. It also covers a tab embedded +//! inside a longer underlined run, where Word likewise fills the gap. + +use loki_doc_model::style::props::tab_stop::TabLeader; + +use crate::color::LayoutColor; +use crate::items::{DecorationKind, PositionedDecoration, PositionedItem}; +use crate::para_emit::underline_deco_style; + +use super::{StyleSpan, UnderlineStyle}; + +/// Emit a tab inline box's decorations across the gap `[x0, x0 + width]` it +/// opened at `baseline`: the stop's `leader` (when present) and, for an +/// underlined tab run at clean offset `p`, its underline rule. Parley draws +/// neither — the `\t` is excluded from its text (gap #8). +pub(super) fn emit_tab_box( + items: &mut Vec, + spans: &[StyleSpan], + p: Option, + leader: Option, + x0: f32, + width: f32, + baseline: f32, +) { + let x1 = x0 + width; + if let Some(leader) = leader { + super::tabs::emit_tab_leader(items, leader, x0, x1, baseline); + } + if let Some(p) = p + && let Some((style, color, fs)) = tab_underline(spans, p) + { + emit_tab_underline(items, style, color, fs, x0, x1, baseline); + } +} + +/// The underline (style, colour, font size) that applies to the tab whose box +/// site is at clean-text offset `p`, or `None` when the tab is not underlined. +/// +/// Prefers the tab's own run — a zero-length span at `p` (the standalone +/// signature tab) — then falls back to a longer span covering `p` (a tab inside +/// underlined body text). Font size rides along so the rule can be positioned +/// from font metrics (no glyph run exists on a tab-only line to measure). +pub(super) fn tab_underline( + spans: &[StyleSpan], + p: usize, +) -> Option<(UnderlineStyle, LayoutColor, f32)> { + spans + .iter() + .find(|s| s.range.start == p && s.range.end == p && s.underline.is_some()) + .or_else(|| { + spans + .iter() + .find(|s| s.range.start <= p && p < s.range.end && s.underline.is_some()) + }) + .and_then(|s| s.underline.map(|u| (u, s.color, s.font_size))) +} + +/// Emit the underline rule across a tab gap `[x0, x1]` at `baseline`. +/// +/// The rule sits just below the baseline, sourced from the run's `font_size` +/// (Parley `RunMetrics` are unavailable — the line carries no glyph run). Draws +/// nothing for a sub-pixel gap. +pub(super) fn emit_tab_underline( + items: &mut Vec, + style: UnderlineStyle, + color: LayoutColor, + font_size: f32, + x0: f32, + x1: f32, + baseline: f32, +) { + let width = x1 - x0; + if width < 1.0 { + return; + } + items.push(PositionedItem::Decoration(PositionedDecoration { + x: x0, + // Screen-y is down: a positive offset places the rule below the baseline + // (≈ the underline position Parley derives for the same face/size). + y: baseline + font_size * 0.12, + width, + thickness: (font_size * 0.06).max(0.75), + kind: DecorationKind::Underline, + style: underline_deco_style(style), + color, + })); +} + +#[cfg(test)] +#[path = "para_tab_underline_tests.rs"] +mod tests; diff --git a/loki-layout/src/para_tab_underline_tests.rs b/loki-layout/src/para_tab_underline_tests.rs new file mode 100644 index 00000000..6973254d --- /dev/null +++ b/loki-layout/src/para_tab_underline_tests.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for underline decorations across an underlined tab's expansion gap. + +use super::{emit_tab_underline, tab_underline}; +use crate::color::LayoutColor; +use crate::items::{DecorationKind, PositionedItem}; +use crate::para::{StyleSpan, UnderlineStyle}; + +/// A minimal span at `range` with an optional underline, otherwise defaulted. +fn span(range: std::ops::Range, underline: Option) -> StyleSpan { + StyleSpan { + range, + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + character_border: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + emboss: false, + imprint: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + language: None, + } +} + +#[test] +fn zero_length_underlined_span_is_the_signature_tab() { + // The classic signature tab: a tab-only run whose span collapsed to [p, p). + let spans = [span(5..5, Some(UnderlineStyle::Single))]; + let found = tab_underline(&spans, 5).expect("underline recovered"); + assert_eq!(found.0, UnderlineStyle::Single); +} + +#[test] +fn no_underline_returns_none() { + let spans = [span(5..5, None), span(5..10, None)]; + assert!(tab_underline(&spans, 5).is_none()); +} + +#[test] +fn tab_inside_a_longer_underlined_run_is_covered() { + // A tab embedded in underlined body text: the covering span carries it. + let spans = [span(0..12, Some(UnderlineStyle::Double))]; + let found = tab_underline(&spans, 4).expect("underline recovered"); + assert_eq!(found.0, UnderlineStyle::Double); +} + +#[test] +fn own_tab_run_wins_over_a_non_underlined_neighbour() { + // The underlined tab run [5,5) must win over the following non-underlined + // text run that also starts at 5 — otherwise the rule would be dropped. + let spans = [span(5..5, Some(UnderlineStyle::Single)), span(5..9, None)]; + assert!(tab_underline(&spans, 5).is_some()); +} + +#[test] +fn emit_pushes_a_decoration_below_the_baseline() { + let mut items = Vec::new(); + emit_tab_underline( + &mut items, + UnderlineStyle::Single, + LayoutColor::BLACK, + 12.0, + 10.0, + 90.0, + 100.0, + ); + assert_eq!(items.len(), 1); + let PositionedItem::Decoration(d) = &items[0] else { + panic!("expected a decoration"); + }; + assert_eq!(d.kind, DecorationKind::Underline); + assert_eq!(d.x, 10.0); + assert_eq!(d.width, 80.0); + assert!(d.y > 100.0, "underline sits below the baseline"); +} + +#[test] +fn emit_skips_a_sub_pixel_gap() { + let mut items = Vec::new(); + emit_tab_underline( + &mut items, + UnderlineStyle::Single, + LayoutColor::BLACK, + 12.0, + 10.0, + 10.5, + 100.0, + ); + assert!(items.is_empty(), "no rule for a <1pt gap"); +} diff --git a/loki-layout/src/para_tabs.rs b/loki-layout/src/para_tabs.rs index 52ff6d69..09ebb4a9 100644 --- a/loki-layout/src/para_tabs.rs +++ b/loki-layout/src/para_tabs.rs @@ -141,6 +141,7 @@ pub(super) fn compute_tab_plans( x_dec: &[f32], x_end: f32, line_end: usize, + line_w: f32, ) -> Vec { let n = x_tab.len(); let mut plans = Vec::with_capacity(n); @@ -178,7 +179,23 @@ pub(super) fn compute_tab_plans( _ => 0.0, }; - let width = (stop.position - offset - final_tab_x).max(0.0); + let mut width = (stop.position - offset - final_tab_x).max(0.0); + + // Keep aligned column content (decimal/right/centre — atomic runs like a + // currency amount) within `line_w`: a stop near the line end (e.g. a + // decimal stop in a narrow cell) would otherwise expand the tab past the + // edge, so Parley wraps the run to the next line and loses the + // alignment. Cap the expansion so the run's right edge lands at the edge + // (right-aligned there). Left tabs are excluded — their content is + // flowing text that *should* wrap. + let aligned = matches!( + stop.alignment, + TabAlignment::Decimal | TabAlignment::Right | TabAlignment::Center + ); + if content_w > 0.0 && aligned { + width = width.min((line_w - final_tab_x - content_w).max(0.0)); + } + plans.push(TabPlan { width, leader: stop.leader, @@ -276,5 +293,6 @@ pub(super) fn measure_tab_plans( &x_dec, x_end, line_end, + line_w, ) } diff --git a/loki-layout/src/para_tests.rs b/loki-layout/src/para_tests.rs index ec991194..76e35970 100644 --- a/loki-layout/src/para_tests.rs +++ b/loki-layout/src/para_tests.rs @@ -42,10 +42,13 @@ fn single_span(text: &str, font_size: f32) -> StyleSpan { line_height: None, vertical_align: None, highlight_color: None, + character_border: None, letter_spacing: None, font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, link_url: None, math: None, scale: None, @@ -268,6 +271,45 @@ fn background_color_is_first_item() { ); } +/// REGRESSION: a run that is only a tab (excluded from the Parley text, gap #8) +/// carrying its own char props — e.g. an underlined signature-line tab — remaps +/// to a zero-length style span. Parley asserts `start < end` on every span, so +/// the empty span must be dropped; otherwise layout panics. +#[test] +fn tab_only_styled_run_does_not_panic() { + let mut r = test_resources(); + let text = "a\tb"; // bytes: a=0, \t=1, b=2 + let spans = [ + StyleSpan { + range: 0..1, + ..single_span("a", 12.0) + }, + StyleSpan { + range: 1..2, // the tab, with a distinct (underlined) style + underline: Some(UnderlineStyle::Single), + ..single_span("t", 12.0) + }, + StyleSpan { + range: 2..3, + ..single_span("b", 12.0) + }, + ]; + let result = layout_paragraph( + &mut r, + text, + &spans, + &ResolvedParaProps::default(), + 400.0, + 1.0, + false, + ); + // The point is that layout returned at all (no panic); it also shaped "ab". + assert!( + result.height > 0.0, + "layout with a tab-only styled run must succeed" + ); +} + #[test] fn underline_span_emits_decoration() { let mut r = test_resources(); @@ -438,7 +480,10 @@ fn line_boundaries_populated_for_multiline_paragraph() { } #[test] -fn empty_paragraph_has_no_line_boundaries() { +fn empty_paragraph_occupies_one_line() { + // Word gives an empty paragraph one line of height (and it can carry a + // border — an empty paragraph with a bottom border is a horizontal rule), + // so it must occupy exactly one line, not collapse to zero. let mut r = test_resources(); let result = layout_paragraph( &mut r, @@ -449,9 +494,43 @@ fn empty_paragraph_has_no_line_boundaries() { 1.0, false, ); + assert_eq!( + result.line_boundaries.len(), + 1, + "empty paragraph must occupy exactly one line" + ); + assert!( + result.height > 0.0, + "empty paragraph must take one line's height, got {}", + result.height + ); +} + +#[test] +fn empty_paragraph_with_bottom_border_emits_a_rule() { + // Word's horizontal-rule idiom: an empty paragraph carrying only a bottom + // border must emit a border rect spanning the content column. + let mut r = test_resources(); + let edge = BorderEdge { + color: LayoutColor::BLACK, + width: 1.0, + style: BorderStyle::Solid, + }; + let props = ResolvedParaProps { + border_bottom: Some(edge), + ..Default::default() + }; + let result = layout_paragraph(&mut r, "", &[], &props, 400.0, 1.0, false); + let border = result.items.iter().find_map(|i| match i { + PositionedItem::BorderRect(b) => Some(b), + _ => None, + }); + let b = border.expect("empty bordered paragraph must emit a border rect"); + assert!(b.bottom.is_some(), "the bottom edge must be set"); assert!( - result.line_boundaries.is_empty(), - "empty paragraph must have no line boundaries" + b.rect.size.width > 300.0, + "the rule must span the content column (~400), got {}", + b.rect.size.width ); } @@ -488,6 +567,52 @@ fn border_follows_background() { )); } +#[test] +fn paragraph_border_spans_the_content_column() { + // A bordered paragraph fills the content column — from the start indent to + // the end indent — not just the (~short) text ink, matching Word. + let mut r = test_resources(); + let text = "Short."; + let edge = BorderEdge { + color: LayoutColor::BLACK, + width: 1.0, + style: BorderStyle::Solid, + }; + let props = ResolvedParaProps { + border_bottom: Some(edge), + indent_start: 30.0, + indent_end: 20.0, + ..Default::default() + }; + let result = layout_paragraph( + &mut r, + text, + &[single_span(text, 12.0)], + &props, + 400.0, + 1.0, + false, + ); + let b = result + .items + .iter() + .find_map(|i| match i { + PositionedItem::BorderRect(b) => Some(b), + _ => None, + }) + .expect("border rect"); + assert!( + (b.rect.origin.x - 30.0).abs() < 0.5, + "border must start at the start indent (30), got {}", + b.rect.origin.x + ); + assert!( + (b.rect.size.width - 350.0).abs() < 0.5, + "border must span the column (400-30-20=350), not the short text ink; got {}", + b.rect.size.width + ); +} + #[test] fn superscript_span_uses_smaller_font() { // A span with vertical_align=Superscript should use font_size * 0.58. @@ -624,6 +749,56 @@ fn exact_line_height_clips_each_line() { ); } +#[test] +fn exact_line_clip_is_anchored_by_body_text_not_a_raised_run() { + // The exact-line clip box must be anchored by the body run's descent, so a + // raised super/subscript (or over-tall run) can't inflate the line's + // aggregate descent and push the box down over the body text's tops. The + // box's extent above the baseline must match with or without the raised run. + fn clip_top(p: &ParagraphLayout) -> f32 { + p.items + .iter() + .find_map(|i| match i { + PositionedItem::ClippedGroup { clip_rect, .. } => Some(clip_rect.y()), + _ => None, + }) + .expect("exact line height wraps items in a clip group") + } + let mut r = test_resources(); + let props = ResolvedParaProps { + line_height: Some(ResolvedLineHeight::Exact(12.0)), + ..Default::default() + }; + + // Body text only (11pt). + let plain = layout_paragraph( + &mut r, + "Body", + &[single_span("Body", 11.0)], + &props, + 400.0, + 1.0, + false, + ); + let plain_above = plain.first_baseline - clip_top(&plain); + + // Same body plus a large raised superscript. + let text = "Body BIG"; + let mut body = single_span(text, 11.0); + body.range = 0..5; + let mut sup = single_span(text, 28.0); + sup.range = 5..8; + sup.vertical_align = Some(VerticalAlign::Superscript); + let raised = layout_paragraph(&mut r, text, &[body, sup], &props, 400.0, 1.0, false); + let raised_above = raised.first_baseline - clip_top(&raised); + + assert!( + (plain_above - raised_above).abs() < 0.5, + "the exact clip box's extent above the baseline must be body-anchored \ + and unchanged by a raised run; plain={plain_above} raised={raised_above}" + ); +} + #[test] fn misspelled_word_emits_spelling_squiggle() { let mut r = test_resources(); @@ -1560,3 +1735,30 @@ fn line_end_offset_read_only_returns_none() { "line_end_offset must return None in read-only mode" ); } + +#[test] +fn decimal_tab_clamps_to_line_when_content_would_overflow() { + use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader}; + let stops = vec![ResolvedTabStop { + position: 126.0, + alignment: TabAlignment::Decimal, + leader: TabLeader::None, + }]; + // Leading tab at x=0; the amount's decimal sits 34.4 in, its run ends at + // 53.4. On a narrow line (133) the natural decimal expansion (126-34.4=91.6) + // would push the run to 145 > 133 and Parley would wrap it, losing the + // alignment — so it clamps to right-align the run against the edge. + let narrow = tabs::compute_tab_plans(&stops, 0.0, 36.0, &[0.0], &[0], &[34.4], 53.4, 0, 133.0); + assert!( + (narrow[0].width - (133.0 - 53.4)).abs() < 0.2, + "narrow line clamps to the edge: {}", + narrow[0].width + ); + // On a wide line (468) the run fits, so the full decimal expansion is kept. + let wide = tabs::compute_tab_plans(&stops, 0.0, 36.0, &[0.0], &[0], &[34.4], 53.4, 0, 468.0); + assert!( + (wide[0].width - (126.0 - 34.4)).abs() < 0.2, + "wide line is unclamped (true decimal align): {}", + wide[0].width + ); +} diff --git a/loki-layout/src/para_types.rs b/loki-layout/src/para_types.rs index c27108fb..c2182674 100644 --- a/loki-layout/src/para_types.rs +++ b/loki-layout/src/para_types.rs @@ -155,6 +155,8 @@ pub struct StyleSpan { pub vertical_align: Option, /// Highlight colour to paint behind the run. `None` = no highlight. pub highlight_color: Option, + /// A border box drawn around the run's text (OOXML `w:bdr`). `None` = none. + pub character_border: Option, /// Letter spacing (tracking) in points. `None` = font default. pub letter_spacing: Option, /// Caps variant for this run, retained as metadata. @@ -172,6 +174,12 @@ pub struct StyleSpan { /// TODO(shadow): replace with Vello blur filter for soft shadow once /// scene.rs blur pipeline is verified stable (see TODO in scene.rs). pub shadow: bool, + /// Embossed (raised) text effect — OOXML `w:emboss`. Painted in `para_emit` + /// as a light body over a darker offset copy. + pub emboss: bool, + /// Imprint (engraved) text effect — OOXML `w:imprint`. Painted as a dark + /// body over a lighter offset copy. + pub imprint: bool, /// Hyperlink URL if this run belongs to a link inline. `None` otherwise. /// /// Set by `resolve.rs` `walk_inlines` when recursing into `Inline::Link` diff --git a/loki-layout/src/para_underlays.rs b/loki-layout/src/para_underlays.rs index bb0242e9..ff15ce6f 100644 --- a/loki-layout/src/para_underlays.rs +++ b/loki-layout/src/para_underlays.rs @@ -15,7 +15,8 @@ use parley::{Cursor, Layout, Selection}; use crate::color::LayoutColor; use crate::geometry::LayoutRect; use crate::items::{ - DecorationKind, DecorationStyle, PositionedDecoration, PositionedItem, PositionedRect, + DecorationKind, DecorationStyle, PositionedBorderRect, PositionedDecoration, PositionedItem, + PositionedRect, }; use super::{ResolvedParaProps, StyleSpan}; @@ -48,8 +49,37 @@ fn line_indent( indent } -/// Emit a filled rect behind each highlighted span. `span.highlight_color` -/// already folds in the run-shading (`w:shd` / `fo:background-color`) fallback. +/// Invoke `f` with each per-line layout rect of `span`'s byte range (Parley +/// selection geometry, indent-adjusted). Shared by the highlight and +/// character-border underlays. +fn for_span_line_rects( + layout: &Layout, + span: &StyleSpan, + para_props: &ResolvedParaProps, + drop_lines: usize, + drop_shift: f32, + mut f: impl FnMut(LayoutRect), +) { + if span.range.start >= span.range.end { + return; + } + let anchor = Cursor::from_byte_index(layout, span.range.start, parley::Affinity::Downstream); + let focus = Cursor::from_byte_index(layout, span.range.end, parley::Affinity::Downstream); + for (bb, line_idx) in Selection::new(anchor, focus).geometry(layout) { + let indent = line_indent(para_props, line_idx, drop_lines, drop_shift); + f(LayoutRect::new( + bb.x0 as f32 + indent, + bb.y0 as f32, + (bb.x1 - bb.x0) as f32, + (bb.y1 - bb.y0) as f32, + )); + } +} + +/// Emit each run's background/border underlays: a filled rect behind a +/// highlighted span (`span.highlight_color` folds in the `w:shd` / +/// `fo:background-color` fallback) and a border box around a `w:bdr` character +/// border — both resolved per visual line via Parley selection geometry. pub(super) fn emit_highlight_underlays( items: &mut Vec, layout: &Layout, @@ -59,26 +89,24 @@ pub(super) fn emit_highlight_underlays( drop_shift: f32, ) { for span in clean_spans { - let Some(hl) = span.highlight_color else { - continue; - }; - if span.range.start >= span.range.end { - continue; + if let Some(hl) = span.highlight_color { + for_span_line_rects(layout, span, para_props, drop_lines, drop_shift, |rect| { + items.push(PositionedItem::FilledRect(PositionedRect { + rect, + color: hl, + })); + }); } - let anchor = - Cursor::from_byte_index(layout, span.range.start, parley::Affinity::Downstream); - let focus = Cursor::from_byte_index(layout, span.range.end, parley::Affinity::Downstream); - for (bb, line_idx) in Selection::new(anchor, focus).geometry(layout) { - let indent = line_indent(para_props, line_idx, drop_lines, drop_shift); - items.push(PositionedItem::FilledRect(PositionedRect { - rect: LayoutRect::new( - bb.x0 as f32 + indent, - bb.y0 as f32, - (bb.x1 - bb.x0) as f32, - (bb.y1 - bb.y0) as f32, - ), - color: hl, - })); + if let Some(edge) = span.character_border { + for_span_line_rects(layout, span, para_props, drop_lines, drop_shift, |rect| { + items.push(PositionedItem::BorderRect(PositionedBorderRect { + rect, + top: Some(edge), + right: Some(edge), + bottom: Some(edge), + left: Some(edge), + })); + }); } } } diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index b840d24c..46cb5830 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -57,6 +57,48 @@ pub fn resolve_color(color: Option<&DocumentColor>) -> LayoutColor { } } +/// Build a [`PositionedHatch`](crate::hatch::PositionedHatch) for `rect` from a +/// doc-model [`ShadingPattern`], resolving both colours and mapping the pattern +/// to the layout-level [`HatchPattern`](crate::hatch::HatchPattern). +pub fn hatch_from_shading( + shading: &loki_doc_model::style::props::shading::ShadingPattern, + rect: crate::geometry::LayoutRect, +) -> crate::hatch::PositionedHatch { + use crate::hatch::HatchPattern as L; + use loki_doc_model::style::props::shading::HatchPattern as M; + let pattern = match shading.pattern { + M::Horizontal => L::Horizontal, + M::Vertical => L::Vertical, + M::DiagUp => L::DiagUp, + M::DiagDown => L::DiagDown, + M::Cross => L::Cross, + M::DiagCross => L::DiagCross, + }; + crate::hatch::PositionedHatch { + rect, + fill: shading.fill.as_ref().map(|c| resolve_color(Some(c))), + color: resolve_color(Some(&shading.color)), + pattern, + thin: shading.thin, + } +} + +/// Build the paragraph background draw item for `rect`: a [`PositionedItem::HatchRect`] +/// when the paragraph carries a `w:shd` texture, else a flat +/// [`PositionedItem::FilledRect`] for a solid `background_color`, else `None`. +pub fn para_background_item( + pp: &ResolvedParaProps, + rect: crate::geometry::LayoutRect, +) -> Option { + use crate::items::{PositionedItem, PositionedRect}; + if let Some(shading) = pp.background_hatch.as_ref() { + Some(PositionedItem::HatchRect(hatch_from_shading(shading, rect))) + } else { + pp.background_color + .map(|color| PositionedItem::FilledRect(PositionedRect { rect, color })) + } +} + /// Convert a [`Points`] value to `f32`. pub fn pts_to_f32(pts: Points) -> f32 { pts.value() as f32 @@ -89,6 +131,21 @@ pub struct CollectedImage { /// `None` for an inline drawing. Read from the image's `NodeAttr` (see /// [`loki_doc_model::content::float::FloatWrap`]). pub float: Option, + /// When `Some`, this "image" is actually a `wps` **text box**: `src` is empty + /// and the flow engine renders a bordered/filled box with this content flowed + /// inside instead of a picture. `cx_emu`/`cy_emu`/`float` still apply. + pub textbox: Option, +} + +/// The interior of a floating text box ([`CollectedImage::textbox`]). +#[derive(Debug, Clone)] +pub struct CollectedTextBox { + /// Block content flowed inside the box. + pub blocks: Vec, + /// Fill colour hex (`"RRGGBB"`), or `None` for no fill. + pub fill: Option, + /// Border colour hex (`"RRGGBB"`), or `None` for no border. + pub line: Option, } /// A footnote or endnote body collected during paragraph flattening. @@ -167,7 +224,15 @@ pub fn flatten_paragraph( Vec, Vec, ) { - flatten_paragraph_with_base(block, catalog, note_counter, None) + // The convenience entry point renders full markup (the default view); the + // flow engine calls `_with_base` directly to pass the document's mode. + flatten_paragraph_with_base( + block, + catalog, + note_counter, + None, + crate::options::RevisionDisplay::AllMarkup, + ) } // ── Border conversion ────────────────────────────────────────────────────────── diff --git a/loki-layout/src/resolve_char_span.rs b/loki-layout/src/resolve_char_span.rs index c39617f6..47867122 100644 --- a/loki-layout/src/resolve_char_span.rs +++ b/loki-layout/src/resolve_char_span.rs @@ -159,13 +159,20 @@ pub(super) fn char_props_to_style_span(props: &CharProps, range: Range) - line_height: None, vertical_align, highlight_color, + // `w:bdr` character border — reuse the paragraph border converter. + character_border: props + .character_border + .as_ref() + .and_then(crate::resolve::convert_border), letter_spacing: props.letter_spacing.map(pts_to_f32), // gap #13 font_variant, word_spacing: props.word_spacing.map(pts_to_f32), // gap #22 shadow: props.shadow.unwrap_or(false), // gap #24 - kerning: props.kerning, // gap #23 - link_url: None, // set by walk_inlines when inside Inline::Link (gap #11) - math: None, // set by walk_inlines for Inline::Math placeholders + emboss: props.emboss.unwrap_or(false), + imprint: props.imprint.unwrap_or(false), + kerning: props.kerning, // gap #23 + link_url: None, // set by walk_inlines when inside Inline::Link (gap #11) + math: None, // set by walk_inlines for Inline::Math placeholders // Horizontal text scale (gap #14): only forward a non-trivial, positive // factor so the common 100 % case stays on the fast (unscaled) path. scale: props diff --git a/loki-layout/src/resolve_inlines.rs b/loki-layout/src/resolve_inlines.rs index d8538171..a38c6484 100644 --- a/loki-layout/src/resolve_inlines.rs +++ b/loki-layout/src/resolve_inlines.rs @@ -32,6 +32,7 @@ pub fn flatten_paragraph_with_base( catalog: &StyleCatalog, note_counter: &mut u32, region_base: Option<&CharProps>, + revision_display: crate::options::RevisionDisplay, ) -> ( String, Vec, @@ -62,8 +63,11 @@ pub fn flatten_paragraph_with_base( let mut spans: Vec = Vec::new(); let mut images: Vec = Vec::new(); let mut notes: Vec = Vec::new(); + // Non-destructive tracked-change display: hide/normalise revision runs for + // Final/Original modes (borrowed unchanged for All-Markup / no revisions). + let inlines = crate::revision_filter::display_inlines(&block.inlines, revision_display); walk_inlines( - &block.inlines, + &inlines, &mut base, catalog, &mut buf, @@ -261,6 +265,36 @@ pub(super) fn collect_inline_image( cx_emu, cy_emu, float: FloatWrap::read_or_class_default(attr), + textbox: None, }); } } + +/// Collect an `Inline::TextBox` (a floating `wps` text box) as a +/// [`CollectedImage`] whose [`textbox`](CollectedImage::textbox) carries the +/// interior blocks + fill/border; the flow engine renders it as a bordered box. +pub(super) fn collect_textbox( + attr: &NodeAttr, + blocks: &[loki_doc_model::content::block::Block], + images: &mut Vec, +) { + let get = |key: &str| { + attr.kv + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + }; + let emu = |key: &str| get(key).and_then(|v| v.parse::().ok()).unwrap_or(0); + images.push(CollectedImage { + src: String::new(), + alt: None, + cx_emu: emu("cx_emu"), + cy_emu: emu("cy_emu"), + float: FloatWrap::read_or_class_default(attr), + textbox: Some(crate::resolve::CollectedTextBox { + blocks: blocks.to_vec(), + fill: get("textbox-fill"), + line: get("textbox-line"), + }), + }); +} diff --git a/loki-layout/src/resolve_tests.rs b/loki-layout/src/resolve_tests.rs index fdaa4d0f..1b8e8679 100644 --- a/loki-layout/src/resolve_tests.rs +++ b/loki-layout/src/resolve_tests.rs @@ -93,6 +93,66 @@ fn flatten_strong_sets_bold() { assert!(spans[0].bold, "Strong should produce bold=true"); } +#[test] +fn revision_display_modes_change_flattened_text_and_decoration() { + use crate::options::RevisionDisplay; + use loki_doc_model::style::props::revision::{RevisionKind, RevisionMark}; + + let tracked = |text: &str, kind: RevisionKind| { + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(CharProps { + revision: Some(RevisionMark { + kind, + author: Some("Ada".into()), + date: None, + id: Some("1".into()), + }), + ..Default::default() + })), + content: vec![Inline::Str(text.into())], + attr: NodeAttr::default(), + }) + }; + let catalog = StyleCatalog::new(); + let para = empty_para(vec![ + Inline::Str("keep ".into()), + tracked("new", RevisionKind::Insertion), + tracked("gone", RevisionKind::Deletion), + ]); + let flatten = + |mode| crate::resolve::flatten_paragraph_with_base(¶, &catalog, &mut 0u32, None, mode); + + // All-Markup: both runs shown; insertion underlined, deletion struck. + let (text, spans, _, _) = flatten(RevisionDisplay::AllMarkup); + assert_eq!(text, "keep newgone"); + assert!( + spans.iter().any(|s| s.underline.is_some()), + "insertion underlined" + ); + assert!( + spans.iter().any(|s| s.strikethrough.is_some()), + "deletion struck" + ); + + // Final: deletion hidden, insertion shown as normal text (no decoration). + let (text, spans, _, _) = flatten(RevisionDisplay::Final); + assert_eq!(text, "keep new"); + assert!( + spans.iter().all(|s| s.underline.is_none()), + "no ins underline" + ); + assert!(spans.iter().all(|s| s.strikethrough.is_none())); + + // Original: insertion hidden, deletion shown as normal text. + let (text, spans, _, _) = flatten(RevisionDisplay::Original); + assert_eq!(text, "keep gone"); + assert!( + spans.iter().all(|s| s.strikethrough.is_none()), + "no del strike" + ); +} + #[test] fn flatten_emph_sets_italic() { let catalog = StyleCatalog::new(); diff --git a/loki-layout/src/resolve_walk.rs b/loki-layout/src/resolve_walk.rs index 086a6216..d17e66ca 100644 --- a/loki-layout/src/resolve_walk.rs +++ b/loki-layout/src/resolve_walk.rs @@ -213,6 +213,11 @@ pub(super) fn walk_inlines( Inline::Image(attr, alt_inlines, target) => { collect_inline_image(attr, alt_inlines, target, effective, catalog, images); } + // Floating text box: collect like an image (post-Parley placement), + // carrying its interior blocks + fill/border; emits no inline text. + Inline::TextBox(attr, blocks) => { + super::inlines::collect_textbox(attr, blocks, images); + } Inline::Cite(_, ch) => walk_inlines( ch, effective, diff --git a/loki-layout/src/result_tests.rs b/loki-layout/src/result_tests.rs index 04061f33..90238902 100644 --- a/loki-layout/src/result_tests.rs +++ b/loki-layout/src/result_tests.rs @@ -82,6 +82,7 @@ fn link_para(origin: (f32, f32), url: Option<&str>) -> PageParagraphData { }], color: LayoutColor::BLACK, synthesis: GlyphSynthesis::default(), + normalized_coords: Vec::new(), link_url: url.map(String::from), }; let layout = ParagraphLayout { @@ -195,10 +196,13 @@ fn para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagraphData line_height: None, vertical_align: None, highlight_color: None, + character_border: None, letter_spacing: None, font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, link_url: None, math: None, scale: None, diff --git a/loki-layout/src/revision_filter.rs b/loki-layout/src/revision_filter.rs new file mode 100644 index 00000000..91d828e2 --- /dev/null +++ b/loki-layout/src/revision_filter.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Non-destructive tracked-change **display** filtering. +//! +//! [`display_inlines`] rewrites a paragraph's inline list for the chosen +//! [`RevisionDisplay`] mode *without touching the document*: in +//! [`RevisionDisplay::Final`] the deletion runs are dropped and the insertion +//! runs keep their text but lose their revision mark (so they render as normal, +//! un-coloured text); [`RevisionDisplay::Original`] is the mirror. In +//! [`RevisionDisplay::AllMarkup`] (and whenever the paragraph has no tracked +//! runs) the input is returned borrowed — zero allocation on the common path. +//! +//! A tracked run is a `StyledRun` whose `direct_props.revision` is set — the +//! shape the DOCX/ODT importers and the editor produce for `w:ins`/`w:del`. + +use std::borrow::Cow; + +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::style::props::revision::RevisionKind; + +use crate::options::RevisionDisplay; + +/// The revision kind carried directly on a run, if any. +fn run_revision_kind(run: &StyledRun) -> Option { + run.direct_props + .as_deref() + .and_then(|p| p.revision.as_ref()) + .map(|r| r.kind) +} + +/// Whether `inlines` contains any tracked run at the top level (cheap guard so +/// [`display_inlines`] can return `Borrowed` for the overwhelmingly common +/// no-revision paragraph). +fn has_tracked_run(inlines: &[Inline]) -> bool { + inlines.iter().any(|i| match i { + Inline::StyledRun(run) => run_revision_kind(run).is_some(), + _ => false, + }) +} + +/// Rewrite `inlines` for the tracked-change display `mode` (see the module doc). +/// +/// Only the paragraph's **top-level** runs are filtered — the shape the +/// importers/editor produce (each `w:ins`/`w:del` is a top-level `StyledRun`). +/// A revision nested inside another inline wrapper is left as-is (it still +/// renders as markup); this matches how such content is authored in practice. +pub(crate) fn display_inlines(inlines: &[Inline], mode: RevisionDisplay) -> Cow<'_, [Inline]> { + if mode == RevisionDisplay::AllMarkup || !has_tracked_run(inlines) { + return Cow::Borrowed(inlines); + } + let mut out: Vec = Vec::with_capacity(inlines.len()); + for inl in inlines { + let Inline::StyledRun(run) = inl else { + out.push(inl.clone()); + continue; + }; + match (run_revision_kind(run), mode) { + // Hidden in this view (accepted deletion / rejected insertion). + (Some(RevisionKind::Deletion), RevisionDisplay::Final) + | (Some(RevisionKind::Insertion), RevisionDisplay::Original) => {} + // Shown as normal text: keep the run but strip its revision so + // `revision_style::apply` adds no colour/underline/strikethrough. + (Some(RevisionKind::Insertion), RevisionDisplay::Final) + | (Some(RevisionKind::Deletion), RevisionDisplay::Original) => { + let mut cleared = run.clone(); + if let Some(props) = cleared.direct_props.as_deref_mut() { + props.revision = None; + } + out.push(Inline::StyledRun(cleared)); + } + // Untracked run, or a mode that keeps it as-is: clone unchanged. + _ => out.push(inl.clone()), + } + } + Cow::Owned(out) +} + +#[cfg(test)] +#[path = "revision_filter_tests.rs"] +mod tests; diff --git a/loki-layout/src/revision_filter_tests.rs b/loki-layout/src/revision_filter_tests.rs new file mode 100644 index 00000000..1439934d --- /dev/null +++ b/loki-layout/src/revision_filter_tests.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use std::borrow::Cow; + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::revision::{RevisionKind, RevisionMark}; + +use super::display_inlines; +use crate::options::RevisionDisplay; + +fn tracked_run(text: &str, kind: RevisionKind) -> Inline { + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(CharProps { + revision: Some(RevisionMark { + kind, + author: Some("Ada".into()), + date: None, + id: Some("1".into()), + }), + ..Default::default() + })), + content: vec![Inline::Str(text.to_string())], + attr: NodeAttr::default(), + }) +} + +/// `[plain, ins, del]` under each display mode. +fn sample() -> Vec { + vec![ + Inline::Str("keep ".to_string()), + tracked_run("added", RevisionKind::Insertion), + tracked_run("removed", RevisionKind::Deletion), + ] +} + +fn run_revision(inl: &Inline) -> Option { + match inl { + Inline::StyledRun(r) => r + .direct_props + .as_deref() + .and_then(|p| p.revision.as_ref()) + .map(|r| r.kind), + _ => None, + } +} + +#[test] +fn all_markup_returns_the_input_borrowed_unchanged() { + let inl = sample(); + let out = display_inlines(&inl, RevisionDisplay::AllMarkup); + assert!(matches!(out, Cow::Borrowed(_))); + assert_eq!(out.len(), 3); +} + +#[test] +fn paragraph_without_revisions_is_borrowed_even_in_final() { + let inl = vec![Inline::Str("plain".to_string())]; + let out = display_inlines(&inl, RevisionDisplay::Final); + assert!(matches!(out, Cow::Borrowed(_))); +} + +#[test] +fn final_hides_deletions_and_normalises_insertions() { + let inl = sample(); + let out = display_inlines(&inl, RevisionDisplay::Final); + // plain + the (now un-tracked) insertion; the deletion is gone. + assert_eq!(out.len(), 2); + assert!(matches!(&out[0], Inline::Str(s) if s == "keep ")); + // The insertion survives as normal text with no revision mark. + assert!(matches!(&out[1], Inline::StyledRun(r) + if matches!(&r.content[..], [Inline::Str(s)] if s == "added"))); + assert_eq!(run_revision(&out[1]), None, "insertion revision stripped"); +} + +#[test] +fn original_hides_insertions_and_normalises_deletions() { + let inl = sample(); + let out = display_inlines(&inl, RevisionDisplay::Original); + // plain + the (now un-tracked) deletion; the insertion is gone. + assert_eq!(out.len(), 2); + assert!(matches!(&out[1], Inline::StyledRun(r) + if matches!(&r.content[..], [Inline::Str(s)] if s == "removed"))); + assert_eq!(run_revision(&out[1]), None, "deletion revision stripped"); +} diff --git a/loki-layout/src/revision_style_tests.rs b/loki-layout/src/revision_style_tests.rs index 237c7191..0a60456c 100644 --- a/loki-layout/src/revision_style_tests.rs +++ b/loki-layout/src/revision_style_tests.rs @@ -21,10 +21,13 @@ fn bare_span() -> StyleSpan { line_height: None, vertical_align: None, highlight_color: None, + character_border: None, letter_spacing: None, font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, kerning: None, link_url: None, math: None, diff --git a/loki-layout/src/table_shading.rs b/loki-layout/src/table_shading.rs index ec3e20e7..32a9a22c 100644 --- a/loki-layout/src/table_shading.rs +++ b/loki-layout/src/table_shading.rs @@ -9,7 +9,7 @@ use loki_doc_model::StyleCatalog; use loki_doc_model::content::table::core::Table; -use loki_doc_model::style::{StyleId, TableLook, TableStyle, resolve_cell_shading}; +use loki_doc_model::style::{CellEdges, StyleId, TableLook, TableStyle, resolve_cell_shading}; use loki_primitives::color::DocumentColor; /// The named table style a table references, if any, resolved against the @@ -65,6 +65,25 @@ pub fn cell_style_shading_cnf( cell_style_shading(style, look, row, col, rows, cols) } +/// The `(top, right, bottom, left)` borders a table style contributes to the +/// cell at `(row, col)` in a `rows`×`cols` grid — an outer edge on the table +/// boundary, otherwise the interior gridline for that axis. Each edge is `None` +/// where the style leaves it unset, so a caller can fall back to it only when a +/// direct cell border is absent. This is how a *Table Grid* style paints a full +/// grid even though the cells carry no explicit borders. +pub fn cell_style_borders( + style: Option<&TableStyle>, + row: usize, + col: usize, + rows: usize, + cols: usize, +) -> CellEdges { + style + .and_then(|s| s.table_props.borders.as_ref()) + .map(|b| b.edges_for(row, col, rows, cols)) + .unwrap_or_default() +} + #[cfg(test)] #[path = "table_shading_tests.rs"] mod tests; diff --git a/loki-layout/src/table_shading_tests.rs b/loki-layout/src/table_shading_tests.rs index ad1d55e4..b5125eb9 100644 --- a/loki-layout/src/table_shading_tests.rs +++ b/loki-layout/src/table_shading_tests.rs @@ -77,6 +77,49 @@ fn no_style_means_no_shading() { assert_eq!(cell_style_shading(None, &look, 0, 0, 4, 4), None); } +#[test] +fn cell_style_borders_resolve_the_grid_from_a_table_grid_style() { + use loki_doc_model::style::TableBorders; + use loki_doc_model::style::props::border::{Border, BorderStyle}; + + let hair = Some(Border { + style: BorderStyle::Solid, + width: loki_primitives::units::Points::new(0.5), + color: None, + spacing: None, + }); + let mut style = styled("Grid", TableRegion::FirstRow, rgb(1, 2, 3)); + style.table_props.borders = Some(TableBorders { + top: hair.clone(), + left: hair.clone(), + bottom: hair.clone(), + right: hair.clone(), + inside_h: hair.clone(), + inside_v: hair.clone(), + }); + + // Every cell of a Table-Grid table gets all four edges (outer or interior), + // so the whole grid is drawn. + for (row, col) in [(0, 0), (1, 1), (2, 2)] { + let (t, r, b, l) = cell_style_borders(Some(&style), row, col, 3, 3); + assert!( + t.is_some() && r.is_some() && b.is_some() && l.is_some(), + "cell ({row},{col}) should have all four grid edges" + ); + } + + // No style, or a style without a border set → no edges. + assert_eq!( + cell_style_borders(None, 0, 0, 3, 3), + (None, None, None, None) + ); + let plain = styled("Plain", TableRegion::FirstRow, rgb(1, 2, 3)); + assert_eq!( + cell_style_borders(Some(&plain), 0, 0, 3, 3), + (None, None, None, None) + ); +} + #[test] fn table_look_reads_the_encoded_attr_or_defaults() { use loki_doc_model::content::table::core::Table; diff --git a/loki-layout/tests/kerning_applied.rs b/loki-layout/tests/kerning_applied.rs index b8c7d22b..f08f9c32 100644 --- a/loki-layout/tests/kerning_applied.rs +++ b/loki-layout/tests/kerning_applied.rs @@ -33,10 +33,13 @@ fn carlito_span(text: &str, font_size: f32, kerning: Option) -> StyleSpan line_height: None, vertical_align: None, highlight_color: None, + character_border: None, letter_spacing: None, font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, link_url: None, math: None, scale: None, diff --git a/loki-layout/tests/tab_no_overshoot.rs b/loki-layout/tests/tab_no_overshoot.rs new file mode 100644 index 00000000..8cda5e46 --- /dev/null +++ b/loki-layout/tests/tab_no_overshoot.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Regression: a `\t` must not add its own glyph advance on top of the tab +//! box, or the following content overshoots the stop. +//! +//! The bullet-list case: a marker `●\t` at a 0.25in hanging indent with the +//! text indent at 0.5in. The tab is realised by an inline box that advances the +//! pen to the stop; the `\t` itself is excluded from the shaped text (in a font +//! without a tab glyph — e.g. Arimo — it would otherwise shape to a `.notdef` +//! whose ~8pt advance pushed the text to ~0.61in, visibly past Word's 0.5in). + +use loki_layout::{ + FontResources, LayoutColor, PositionedItem, ResolvedParaProps, StyleSpan, layout_paragraph, +}; + +fn span(text: &str) -> StyleSpan { + StyleSpan { + range: 0..text.len(), + font_name: Some("Arimo".into()), + font_size: 11.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + character_border: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + emboss: false, + imprint: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + language: None, + } +} + +/// Absolute x (pt) of the first drawable (non-`.notdef`) glyph. +fn first_text_x(text: &str, indent_start: f32, indent_hanging: f32) -> f32 { + let mut r = FontResources::new(); + for b in loki_fonts::fallback_font_blobs() { + r.register_font(b.to_vec()); + } + let props = ResolvedParaProps { + indent_start, + indent_hanging, + ..Default::default() + }; + let para = layout_paragraph(&mut r, text, &[span(text)], &props, 468.0, 1.0, false); + // Skip the marker run (origin at the hanging position, `indent_start - + // indent_hanging`); the text run's origin sits at `indent_start`. + for item in ¶.items { + if let PositionedItem::GlyphRun(g) = item + && g.origin.x >= indent_start - 1.0 + && let Some(gl) = g.glyphs.iter().find(|gl| gl.id != 0) + { + return g.origin.x + gl.x; + } + } + panic!("no text glyph run at the indent produced"); +} + +#[test] +fn list_marker_tab_lands_text_on_the_indent_not_past_it() { + // 0.5in indent = 36pt, 0.25in hanging = 18pt. + let x = first_text_x("\u{25CF}\tSource Nodes", 36.0, 18.0); + assert!( + (x - 36.0).abs() < 1.5, + "list text must land on the 36pt (0.5in) indent, not overshoot; got {x}pt" + ); +} diff --git a/loki-layout/tests/table_tests.rs b/loki-layout/tests/table_tests.rs index 08712b42..e19a1ea6 100644 --- a/loki-layout/tests/table_tests.rs +++ b/loki-layout/tests/table_tests.rs @@ -661,10 +661,12 @@ fn vmerge_gridspan_l_merge_places_cells_correctly() { ); } -/// A long unbreakable word in a narrow fixed-width cell must wrap *within* the -/// column (CSS `overflow-wrap: anywhere`, matching Word's fixed-layout +/// A long unbreakable word in a narrow **fixed-layout** cell must wrap *within* +/// the column (CSS `overflow-wrap: anywhere`, matching Word's fixed-layout /// behaviour) — making the row tall — instead of overflowing horizontally into -/// the neighbouring cell. Pins the TC-DOCX-006 fix. +/// the neighbouring cell. Pins the TC-DOCX-006 fixed-layout case. (The autofit +/// counterpart — where the column *grows* to fit the word — is +/// `long_word_grows_autofit_column`.) #[test] fn long_word_wraps_within_narrow_cell() { use loki_doc_model::content::table::col::TableWidth; @@ -674,7 +676,7 @@ fn long_word_wraps_within_narrow_cell() { None, 1, ); - let table = Block::Table(Box::new(Table { + let mut table = Block::Table(Box::new(Table { attr: loki_doc_model::content::attr::NodeAttr::default(), caption: Default::default(), width: Some(TableWidth::Fixed(60.0)), @@ -686,6 +688,13 @@ fn long_word_wraps_within_narrow_cell() { bodies: vec![TableBody::from_rows(vec![Row::new(vec![cell])])], foot: TableFoot::empty(), })); + // Fixed layout (`w:tblLayout="fixed"`): the 60pt column is honoured exactly, + // so the over-long word must break to fit rather than grow the column. + if let Block::Table(t) = &mut table { + t.attr + .classes + .push(loki_doc_model::content::table::core::TABLE_FIXED_LAYOUT_CLASS.to_string()); + } let section = Section { page_style: None, layout: PageLayout::default(), @@ -737,6 +746,79 @@ fn long_word_wraps_within_narrow_cell() { ); } +/// The autofit counterpart of `long_word_wraps_within_narrow_cell`: with no +/// `w:tblLayout="fixed"`, a column whose preferred width (60pt) is far narrower +/// than its content's minimum width must **grow** to fit the unbreakable word on +/// one line — Word's autofit behaviour — rather than wrap it. This is the fix +/// for the over-tall "KEY INSIGHT"/"ONBOARDING" callout boxes: a narrow label +/// column no longer forces one-character-per-line stacking. +#[test] +fn long_word_grows_autofit_column() { + use loki_doc_model::content::table::col::TableWidth; + let mut r = test_resources(); + let word = "Narrowcolumnshouldnotgrowtofitthislongunbrokenword"; + let cell = make_cell_tall(vec![word], None, 1); + // Autofit (no fixed-layout class), preferred column 60pt but page-wide table. + let table = Block::Table(Box::new(Table { + attr: loki_doc_model::content::attr::NodeAttr::default(), + caption: Default::default(), + width: Some(TableWidth::Percent(100.0)), + col_specs: vec![ColSpec { + alignment: ColAlignment::Default, + width: ColWidth::Fixed(loki_primitives::units::Points::new(60.0)), + }], + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![cell])])], + foot: TableFoot::empty(), + })); + let section = Section { + page_style: None, + layout: PageLayout::default(), + start: Default::default(), + blocks: vec![table], + extensions: ExtensionBag::default(), + }; + let (items, height) = flow_pageless(&mut r, §ion); + let mut flat = Vec::new(); + flatten(&items, &mut flat); + + // The word landed on a single line wider than the 60pt preferred width — the + // column grew to fit it instead of wrapping into a tall stack. + let widest_line = flat + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(run) => { + Some(run.glyphs.iter().map(|g| g.advance).sum::()) + } + _ => None, + }) + .fold(0.0_f32, f32::max); + assert!( + widest_line > 62.0, + "autofit must grow the column past 60pt to fit the word; widest line = {widest_line}" + ); + let baselines = { + let mut ys: Vec = flat + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(run) => Some((run.origin.y * 4.0).round()), + _ => None, + }) + .collect(); + ys.sort_by(|a, b| a.partial_cmp(b).unwrap()); + ys.dedup(); + ys.len() + }; + assert_eq!( + baselines, 1, + "the word must not wrap; got {baselines} lines" + ); + assert!( + height < 40.0, + "a single unwrapped line stays short; height = {height}" + ); +} + #[test] fn test_table_cell_vertical_alignment() { use loki_doc_model::content::table::row::CellVerticalAlign; @@ -830,3 +912,57 @@ fn test_table_cell_vertical_alignment() { y1 ); } + +/// REGRESSION: a `keep_with_next` paragraph (the ubiquitous "Table N" caption) +/// immediately followed by a table must not drop the table. The keep-with-next +/// chain used to absorb the table as a zero-height empty paragraph, silently +/// discarding every cell — so a shaded cell emitted no `FilledRect` at all. +#[test] +fn keep_next_caption_does_not_drop_the_following_table() { + use appthere_color::RgbColor; + use loki_doc_model::content::table::col::TableWidth; + use loki_doc_model::style::props::para_props::ParaProps; + + let mut r = test_resources(); + let bg = Some(DocumentColor::Rgb(RgbColor::new(0.2, 0.4, 0.8))); + + let mut caption = make_para("Table 1. Caption"); + caption.direct_para_props = Some(Box::new(ParaProps { + keep_with_next: Some(true), + ..Default::default() + })); + + let cell = make_cell_tall(vec!["CellText"], bg, 1); + let table = Block::Table(Box::new(Table { + attr: loki_doc_model::content::attr::NodeAttr::default(), + caption: Default::default(), + width: Some(TableWidth::Fixed(200.0)), + col_specs: vec![ColSpec { + alignment: ColAlignment::Default, + width: ColWidth::Fixed(loki_primitives::units::Points::new(200.0)), + }], + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![cell])])], + foot: TableFoot::empty(), + })); + + let section = Section { + page_style: None, + layout: PageLayout::default(), + start: Default::default(), + blocks: vec![Block::StyledPara(caption), table], + extensions: ExtensionBag::default(), + }; + let (items, _) = flow_pageless(&mut r, §ion); + let mut flat = Vec::new(); + flatten(&items, &mut flat); + + let has_cell_fill = flat + .iter() + .any(|i| matches!(i, PositionedItem::FilledRect(_))); + assert!( + has_cell_fill, + "the table following a keep_with_next caption must render (its shaded \ + cell should emit a FilledRect); items = {flat:?}" + ); +} diff --git a/loki-layout/tests/variable_font_weight.rs b/loki-layout/tests/variable_font_weight.rs new file mode 100644 index 00000000..a4553d64 --- /dev/null +++ b/loki-layout/tests/variable_font_weight.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Regression lock: a bold run in a **variable** font must carry the shaped +//! instance's normalized variation coordinates on its `PositionedGlyphRun`. +//! +//! The bundled Arimo (Arial substitute) is a `wght` variable font. Parley +//! shapes a bold run at `wght=700` (bold advances), but the painters render +//! whatever normalized coordinates the run carries — so if that vector is +//! empty/default they draw the *regular* master with bold advances, i.e. "bold +//! Arial looks wide but not bold". This pins the fix: the bold instance carries +//! a non-zero coordinate and differs from the regular instance. + +use loki_layout::{ + FontResources, LayoutColor, PositionedItem, ResolvedParaProps, StyleSpan, layout_paragraph, +}; + +fn arimo_span(text: &str, weight: u16, bold: bool) -> StyleSpan { + StyleSpan { + range: 0..text.len(), + font_name: Some("Arimo".into()), + font_size: 24.0, + bold, + weight, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + character_border: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + emboss: false, + imprint: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + language: None, + } +} + +/// The first glyph run's normalized variation coordinates for `text` at the +/// given weight. +fn coords(text: &str, weight: u16, bold: bool) -> Vec { + let mut resources = FontResources::new(); + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + let para = layout_paragraph( + &mut resources, + text, + &[arimo_span(text, weight, bold)], + &ResolvedParaProps::default(), + 1000.0, + 1.0, + false, + ); + for item in ¶.items { + if let PositionedItem::GlyphRun(run) = item { + return run.normalized_coords.clone(); + } + } + panic!("no glyph run produced for {text:?}"); +} + +#[test] +fn bold_variable_font_carries_nonzero_weight_coord() { + let bold = coords("Weight", 700, true); + assert!( + bold.iter().any(|&c| c != 0), + "bold Arimo must carry a non-zero wght variation coord (else the \ + painter draws the regular master with bold advances); got {bold:?}" + ); +} + +#[test] +fn bold_and_regular_variable_instances_differ() { + let regular = coords("Weight", 400, false); + let bold = coords("Weight", 700, true); + assert_ne!( + regular, bold, + "the 400 and 700 instances of a variable font must differ; \ + regular={regular:?} bold={bold:?}" + ); +} diff --git a/loki-odf/src/odt/mapper/document/document_tests.rs b/loki-odf/src/odt/mapper/document/document_tests.rs index ed47f624..8c94b43a 100644 --- a/loki-odf/src/odt/mapper/document/document_tests.rs +++ b/loki-odf/src/odt/mapper/document/document_tests.rs @@ -57,6 +57,8 @@ fn graphic_style(name: &str, wrap: &str) -> OdfStyle { graphic_wrap: Some(OdfGraphicWrap { wrap: Some(wrap.into()), run_through: None, + fill_color: None, + stroke_color: None, }), table_props: None, is_automatic: true, diff --git a/loki-odf/src/odt/mapper/document/frames.rs b/loki-odf/src/odt/mapper/document/frames.rs index 741f030a..41775f55 100644 --- a/loki-odf/src/odt/mapper/document/frames.rs +++ b/loki-odf/src/odt/mapper/document/frames.rs @@ -132,7 +132,6 @@ pub(super) fn map_frame(frame: &OdfFrame, ctx: &mut OdfMappingContext<'_>) -> Op } } OdfFrameKind::TextBox { paragraphs } => { - // Map the text box content as a Div pushed to pending_figures. let inner: Vec = paragraphs .iter() .flat_map(|p| { @@ -141,6 +140,27 @@ pub(super) fn map_frame(frame: &OdfFrame, ctx: &mut OdfMappingContext<'_>) -> Op std::iter::once(block).chain(figs) }) .collect(); + // A floating text box (one carrying a wrap style) maps to + // `Inline::TextBox` so the flow engine renders it as a bordered box + // with side-wrap — the same shape as the DOCX `wps` path, so a text + // box round-trips DOCX ↔ ODT. Fill/border come from the frame's + // graphic style; geometry from `svg:width`/`svg:height`. A text box + // with no wrap stays a block-stacked `Div` (unchanged). + let wrap = (!is_as_char).then(|| frame_wrap(frame, ctx)).flatten(); + if let Some(wrap) = wrap { + let mut attr = image_attr_with_size(frame); + wrap.store(&mut attr); + let by_style = |m: &std::collections::HashMap| { + frame.style_name.as_deref().and_then(|n| m.get(n)).cloned() + }; + if let Some(fill) = by_style(ctx.frame_fills) { + attr.kv.push(("textbox-fill".to_string(), fill)); + } + if let Some(line) = by_style(ctx.frame_strokes) { + attr.kv.push(("textbox-line".to_string(), line)); + } + return Some(Inline::TextBox(attr, inner)); + } ctx.pending_figures .push(Block::Div(NodeAttr::default(), inner)); None diff --git a/loki-odf/src/odt/mapper/document/mod.rs b/loki-odf/src/odt/mapper/document/mod.rs index f7af2952..9b5d8743 100644 --- a/loki-odf/src/odt/mapper/document/mod.rs +++ b/loki-odf/src/odt/mapper/document/mod.rs @@ -66,6 +66,12 @@ pub(crate) struct OdfMappingContext<'a> { pub cell_style_props: &'a HashMap, /// Frame text-wrap from `style:graphic-properties` (pre-built): name → wrap. pub frame_wraps: &'a HashMap, + /// Frame solid-fill colour from `style:graphic-properties` (pre-built): + /// name → bare `RRGGBB` hex. Recovers a floating text box's fill. + pub frame_fills: &'a HashMap, + /// Frame stroke colour from `style:graphic-properties` (pre-built): + /// name → bare `RRGGBB` hex. Recovers a floating text box's border. + pub frame_strokes: &'a HashMap, /// Non-fatal issues accumulated during mapping. pub warnings: Vec, /// Floating frames (images and text boxes that are not `as-char` anchored) @@ -127,6 +133,26 @@ pub(crate) fn map_document( .chain(stylesheet.auto_styles.iter()) .filter_map(|s| Some((s.name.clone(), map_graphic_wrap(s.graphic_wrap.as_ref()?)?))) .collect(); + // Frame fill / stroke colours (name → bare RRGGBB hex), for text-box recovery. + let strip_hash = |c: &str| c.trim_start_matches('#').to_string(); + let frame_fills: HashMap = stylesheet + .named_styles + .iter() + .chain(stylesheet.auto_styles.iter()) + .filter_map(|s| { + let hex = s.graphic_wrap.as_ref()?.fill_color.as_deref()?; + Some((s.name.clone(), strip_hash(hex))) + }) + .collect(); + let frame_strokes: HashMap = stylesheet + .named_styles + .iter() + .chain(stylesheet.auto_styles.iter()) + .filter_map(|s| { + let hex = s.graphic_wrap.as_ref()?.stroke_color.as_deref()?; + Some((s.name.clone(), strip_hash(hex))) + }) + .collect(); // ── 3. Build style lookup for master-page resolution ───────────────────── let all_styles: HashMap<&str, &OdfStyle> = stylesheet @@ -166,6 +192,8 @@ pub(crate) fn map_document( col_style_widths: &col_style_widths, cell_style_props: &cell_style_props, frame_wraps: &frame_wraps, + frame_fills: &frame_fills, + frame_strokes: &frame_strokes, warnings: Vec::new(), pending_figures: Vec::new(), comments: Vec::new(), diff --git a/loki-odf/src/odt/mapper/props/cell.rs b/loki-odf/src/odt/mapper/props/cell.rs index 29ce67a1..0757674a 100644 --- a/loki-odf/src/odt/mapper/props/cell.rs +++ b/loki-odf/src/odt/mapper/props/cell.rs @@ -53,6 +53,9 @@ pub(crate) fn map_cell_props(cell_props: &OdfCellProps) -> CellProps { .border_right .as_deref() .and_then(parse_odf_border), + // ODF has no line/cross hatch shading equivalent — cells carry a solid + // `fo:background-color` only. + shading: None, } } diff --git a/loki-odf/src/odt/mapper/props/character.rs b/loki-odf/src/odt/mapper/props/character.rs index 145cca3c..b35f17c0 100644 --- a/loki-odf/src/odt/mapper/props/character.rs +++ b/loki-odf/src/odt/mapper/props/character.rs @@ -92,6 +92,27 @@ pub(crate) fn map_text_props(props: &OdfTextProps) -> CharProps { out.shadow = Some(!shadow.is_empty() && shadow != "none"); } + // ── Relief (emboss / imprint) ────────────────────────────────────────── + // ODF style:font-relief maps 1:1 to the two toggles (embossed = raised, + // engraved = imprinted). ODF 1.3 §20.191. + match props.font_relief.as_deref() { + Some("embossed") => out.emboss = Some(true), + Some("engraved") => out.imprint = Some(true), + _ => {} + } + + // ── Character border ─────────────────────────────────────────────────── + // ODF puts a character border on style:text-properties as an fo:border + // shorthand, with fo:padding carrying the border↔glyph inset. + if let Some(mut border) = props + .border + .as_deref() + .and_then(super::paragraph::parse_odf_border) + { + border.spacing = props.padding.as_deref().and_then(parse_length); + out.character_border = Some(border); + } + // ── Spacing ──────────────────────────────────────────────────────────── if let Some(pts) = props.letter_spacing.as_deref().and_then(parse_length) { out.letter_spacing = Some(pts); diff --git a/loki-odf/src/odt/model/styles.rs b/loki-odf/src/odt/model/styles.rs index ad6be700..f591d4cc 100644 --- a/loki-odf/src/odt/model/styles.rs +++ b/loki-odf/src/odt/model/styles.rs @@ -100,6 +100,12 @@ pub(crate) struct OdfGraphicWrap { pub wrap: Option, /// `style:run-through` — `"foreground"` or `"background"` (behind text). pub run_through: Option, + /// `draw:fill-color` — frame solid-fill colour (`"#RRGGBB"`), when + /// `draw:fill="solid"`. Used to recover a floating text box's fill. + pub fill_color: Option, + /// `svg:stroke-color` — frame border colour (`"#RRGGBB"`), when + /// `draw:stroke="solid"`. Used to recover a floating text box's border. + pub stroke_color: Option, } /// The family of ODF elements a style applies to. diff --git a/loki-odf/src/odt/model/styles_props.rs b/loki-odf/src/odt/model/styles_props.rs index 0d4031e7..ccf58fad 100644 --- a/loki-odf/src/odt/model/styles_props.rs +++ b/loki-odf/src/odt/model/styles_props.rs @@ -136,6 +136,12 @@ pub(crate) struct OdfTextProps { pub font_name_asian: Option, /// `style:text-outline` — hollow/outline text effect. pub text_outline: Option, + /// `style:font-relief` — `"embossed"` / `"engraved"` / `"none"` (3-D relief). + pub font_relief: Option, + /// `fo:border` — character border shorthand (`"1pt solid #C00000"`). + pub border: Option, + /// `fo:padding` — character-border inset (e.g. `"1pt"`). + pub padding: Option, /// `fo:word-spacing` — additional space between words (e.g. `"0.2cm"`). pub word_spacing: Option, /// `style:letter-kerning` — enable font kerning (`"true"` / `"false"`). diff --git a/loki-odf/src/odt/reader/styles_props.rs b/loki-odf/src/odt/reader/styles_props.rs index 78da5018..7fb82951 100644 --- a/loki-odf/src/odt/reader/styles_props.rs +++ b/loki-odf/src/odt/reader/styles_props.rs @@ -43,6 +43,9 @@ pub(super) fn parse_text_props_attrs(e: &quick_xml::events::BytesStart<'_>) -> O font_name_complex: local_attr_val(e, b"font-name-complex"), font_name_asian: local_attr_val(e, b"font-name-asian"), text_outline: local_attr_val(e, b"text-outline").map(|v| v != "false"), + font_relief: local_attr_val(e, b"font-relief"), + border: local_attr_val(e, b"border"), + padding: local_attr_val(e, b"padding"), word_spacing: local_attr_val(e, b"word-spacing"), letter_kerning: local_attr_val(e, b"letter-kerning").map(|v| v == "true"), text_scale: local_attr_val(e, b"text-scale"), @@ -154,9 +157,19 @@ pub(super) fn parse_style_props( /// Build an [`OdfGraphicWrap`] from a `style:graphic-properties` element. fn parse_graphic_wrap_element(e: &quick_xml::events::BytesStart<'_>) -> OdfGraphicWrap { + // `draw:fill-color` / `svg:stroke-color` are only meaningful when their + // matching `draw:fill` / `draw:stroke` is `"solid"`; a `"none"` toggle drops + // the colour so an unfilled/unstroked frame does not resurrect one. + let solid = |toggle: &[u8]| local_attr_val(e, toggle).as_deref() != Some("none"); OdfGraphicWrap { wrap: local_attr_val(e, b"wrap"), run_through: local_attr_val(e, b"run-through"), + fill_color: solid(b"fill") + .then(|| local_attr_val(e, b"fill-color")) + .flatten(), + stroke_color: solid(b"stroke") + .then(|| local_attr_val(e, b"stroke-color")) + .flatten(), } } diff --git a/loki-odf/src/odt/write/auto.rs b/loki-odf/src/odt/write/auto.rs index 80f601c6..6021e675 100644 --- a/loki-odf/src/odt/write/auto.rs +++ b/loki-odf/src/odt/write/auto.rs @@ -25,6 +25,8 @@ pub(super) struct AutoStyles { para: HashMap<(String, String, String), String>, /// table-cell-properties element → style name (`TC{n}`). cell: HashMap, + /// graphic-properties element → style name (`gr{n}`). + graphic: HashMap, /// Rendered `` elements, in creation order. rendered: Vec, } @@ -147,12 +149,40 @@ impl AutoStyles { Some(name) } + /// Returns the automatic `family="graphic"` style name for a floating + /// frame's `style:graphic-properties` (`wrap` / `run-through`, solid fill, + /// solid stroke), or `None` when none of them is set. Used by the text-box + /// writer so a `draw:frame`'s wrap + fill + border round-trip. + pub(super) fn graphic_style( + &mut self, + wrap: Option, + fill: Option<&str>, + stroke: Option<&str>, + ) -> Option { + let props = graphic::emit_graphic_properties(wrap, fill, stroke); + if props.is_empty() { + return None; + } + if let Some(name) = self.graphic.get(&props) { + return Some(name.clone()); + } + let name = format!("gr{}", self.rendered.len() + 1); + self.rendered.push(format!( + "{props}" + )); + self.graphic.insert(props, name.clone()); + Some(name) + } + /// Renders all collected automatic styles as concatenated XML. pub(super) fn render(&self) -> String { self.rendered.concat() } } +#[path = "auto_graphic.rs"] +mod graphic; + /// Serialises a cell's exportable direct properties — background, borders, /// and padding (4a.3) — as a `` element, or an /// empty string when the cell carries none. diff --git a/loki-odf/src/odt/write/auto_graphic.rs b/loki-odf/src/odt/write/auto_graphic.rs new file mode 100644 index 00000000..d7493b24 --- /dev/null +++ b/loki-odf/src/odt/write/auto_graphic.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Frame `style:graphic-properties` serialisation, split from `auto.rs` for the +//! 300-line ceiling. Called by `AutoStyles::graphic_style` (the parent module). + +use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; + +use super::attr_str; + +/// Serialises a floating frame's ``: the text-wrap +/// (`style:wrap` + `style:run-through`), a solid fill (`draw:fill` + +/// `draw:fill-color`), and a solid stroke (`draw:stroke` + `svg:stroke-color`). +/// `fill`/`stroke` are bare `RRGGBB` hex (as stored on the text box's attr); +/// a leading `#` is added for the ODF colour value. Returns an empty string when +/// nothing is set. +pub(super) fn emit_graphic_properties( + wrap: Option, + fill: Option<&str>, + stroke: Option<&str>, +) -> String { + let mut s = String::new(); + if let Some(w) = wrap { + // Mirror of the import `map_graphic_wrap` (frames.rs): the ODF wrap token + // and, for a run-through/behind float, the run-through position. + let wrap_val = match (w.wrap, w.side) { + (TextWrap::None, _) => "run-through", + (TextWrap::TopAndBottom, _) => "none", + (_, WrapSide::Left) => "left", + (_, WrapSide::Right) => "right", + (_, WrapSide::Largest) => "dynamic", + _ => "parallel", + }; + attr_str(&mut s, "style:wrap", wrap_val); + let run_through = if w.behind_text { + "background" + } else { + "foreground" + }; + attr_str(&mut s, "style:run-through", run_through); + } + if let Some(hex) = fill { + attr_str(&mut s, "draw:fill", "solid"); + attr_str(&mut s, "draw:fill-color", &with_hash(hex)); + } + if let Some(hex) = stroke { + attr_str(&mut s, "draw:stroke", "solid"); + attr_str(&mut s, "svg:stroke-color", &with_hash(hex)); + } + if s.is_empty() { + String::new() + } else { + format!("") + } +} + +/// Prefix a bare `RRGGBB` hex string with `#` for an ODF colour attribute +/// value; leaves an already-`#`-prefixed value unchanged. +fn with_hash(hex: &str) -> String { + if hex.starts_with('#') { + hex.to_string() + } else { + format!("#{hex}") + } +} diff --git a/loki-odf/src/odt/write/inlines.rs b/loki-odf/src/odt/write/inlines.rs index 95692768..c1b9a5e2 100644 --- a/loki-odf/src/odt/write/inlines.rs +++ b/loki-odf/src/odt/write/inlines.rs @@ -15,6 +15,9 @@ use loki_doc_model::style::props::char_props::{ use super::content::{Cx, write_block}; use super::xml::{attr, escape}; +#[path = "inlines_frame.rs"] +mod frame; + /// Writes a sequence of inline runs. pub(super) fn write_inlines(out: &mut String, inlines: &[Inline], cx: &mut Cx) { for inl in inlines { @@ -55,6 +58,9 @@ fn write_inline(out: &mut String, inl: &Inline, cx: &mut Cx) { out.push_str("/>"); } Inline::Field(field) => write_field(out, field), + Inline::TextBox(node_attr, blocks) => { + frame::write_text_box(out, node_attr, blocks, cx); + } Inline::Image(_, alt, target) => write_image(out, alt, &target.url, cx), Inline::Note(kind, blocks) => note(out, *kind, blocks, cx), Inline::Comment(c) => write_comment(out, c, cx), diff --git a/loki-odf/src/odt/write/inlines_frame.rs b/loki-odf/src/odt/write/inlines_frame.rs new file mode 100644 index 00000000..4ad0d1ac --- /dev/null +++ b/loki-odf/src/odt/write/inlines_frame.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Floating text-box (`Inline::TextBox`) serialisation, split from `inlines.rs` +//! for the 300-line ceiling. Writes a `draw:frame`/`draw:text-box`. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::float::FloatWrap; + +use super::super::content::{Cx, write_block}; +use super::super::xml::attr; + +/// Writes a floating `wps`/text-box (`Inline::TextBox`) as a +/// `` anchored to the +/// paragraph, with an automatic graphic style carrying the wrap + fill + border. +/// The reverse of the importer's floating-text-box path (`mapper::…::frames`). +pub(super) fn write_text_box( + out: &mut String, + node_attr: &NodeAttr, + blocks: &[Block], + cx: &mut Cx, +) { + let kv = |key: &str| { + node_attr + .kv + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + }; + // EMU → pt (1 pt = 12700 EMU); the importer reads svg:width/height back as pt. + let emu_pt = |key: &str| -> Option { + let emu: f64 = kv(key)?.parse().ok()?; + (emu > 0.0).then(|| format!("{}pt", emu / 12700.0)) + }; + let wrap = FloatWrap::read_or_class_default(node_attr); + let style = cx + .auto + .graphic_style(wrap, kv("textbox-fill"), kv("textbox-line")); + + out.push_str(""); + for block in blocks { + write_block(out, block, cx); + } + out.push_str(""); +} diff --git a/loki-odf/src/odt/write/props.rs b/loki-odf/src/odt/write/props.rs index 46cc1909..3fcee786 100644 --- a/loki-odf/src/odt/write/props.rs +++ b/loki-odf/src/odt/write/props.rs @@ -76,6 +76,21 @@ fn text_properties_attrs(cp: &CharProps) -> String { if cp.shadow == Some(true) { attr(&mut s, "fo:text-shadow", "1pt 1pt"); } + // Emboss / imprint → the single ODF style:font-relief (embossed wins if both + // are set, which the model never produces from a real import). ODF 1.3 §20.191. + if cp.emboss == Some(true) { + attr(&mut s, "style:font-relief", "embossed"); + } else if cp.imprint == Some(true) { + attr(&mut s, "style:font-relief", "engraved"); + } + // Character border → fo:border shorthand on text-properties, with fo:padding + // carrying the border↔glyph inset (Border::spacing). + if let Some(b) = &cp.character_border { + super::para_props::border_attr(&mut s, "fo:border", Some(b)); + if let Some(pad) = b.spacing { + attr(&mut s, "fo:padding", &pt(pad)); + } + } if let Some(va) = cp.vertical_align { let v = match va { VerticalAlign::Superscript => "super 58%", diff --git a/loki-odf/tests/odt_export_round_trip.rs b/loki-odf/tests/odt_export_round_trip.rs index 4c1b9108..ec7cc04e 100644 --- a/loki-odf/tests/odt_export_round_trip.rs +++ b/loki-odf/tests/odt_export_round_trip.rs @@ -275,6 +275,161 @@ fn full_character_and_paragraph_props_round_trip() { assert_eq!(p.page_break_before, Some(true)); } +/// Emboss (`style:font-relief="embossed"`) and a character border +/// (`fo:border` + `fo:padding` on `style:text-properties`) must survive an ODT +/// export→re-import. Emboss and imprint share the single `font-relief` +/// attribute, so they are tested in separate styles. +#[test] +fn emboss_and_char_border_round_trip_through_odt() { + use loki_doc_model::style::props::border::{Border, BorderStyle}; + use loki_primitives::color::DocumentColor; + + let embossed = CharProps { + emboss: Some(true), + character_border: Some(Border { + style: BorderStyle::Solid, + width: Points::new(1.0), + color: Some(DocumentColor::from_hex("#C00000").unwrap()), + spacing: Some(Points::new(1.0)), + }), + ..Default::default() + }; + let engraved = CharProps { + imprint: Some(true), + ..Default::default() + }; + + let mut doc = sample_doc(); + doc.styles.paragraph_styles.insert( + StyleId::new("Embossed"), + para_style("Embossed", "Embossed", embossed, ParaProps::default()), + ); + doc.styles.paragraph_styles.insert( + StyleId::new("Engraved"), + para_style("Engraved", "Engraved", engraved, ParaProps::default()), + ); + + let out = round_trip(&doc); + + let emb = &out + .styles + .paragraph_styles + .get(&StyleId::new("Embossed")) + .expect("Embossed style survives") + .char_props; + assert_eq!(emb.emboss, Some(true), "emboss survives"); + assert_eq!(emb.imprint, None, "emboss is not imprint"); + let border = emb.character_border.as_ref().expect("char border survives"); + assert_eq!(border.style, BorderStyle::Solid); + assert_eq!(border.width.value().round(), 1.0); + assert!(border.color.is_some(), "border colour survives"); + assert_eq!( + border.spacing.map(|p| p.value().round()), + Some(1.0), + "border padding survives" + ); + + let eng = &out + .styles + .paragraph_styles + .get(&StyleId::new("Engraved")) + .expect("Engraved style survives") + .char_props; + assert_eq!(eng.imprint, Some(true), "imprint survives"); + assert_eq!(eng.emboss, None, "imprint is not emboss"); +} + +/// A floating text box (`Inline::TextBox`) must survive an ODT export→re-import: +/// the writer emits a `draw:frame`/`draw:text-box` with a graphic auto-style +/// (wrap + fill + border), and the importer maps that floating frame back to an +/// `Inline::TextBox` — so a text box round-trips DOCX ↔ ODT. +#[test] +fn floating_text_box_round_trips_through_odt() { + use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; + + let mut attr = NodeAttr::default(); + attr.kv.push(("cx_emu".to_string(), "1828800".to_string())); // 144 pt + attr.kv.push(("cy_emu".to_string(), "731520".to_string())); // 57.6 pt + attr.kv + .push(("textbox-fill".to_string(), "FDF0E6".to_string())); + attr.kv + .push(("textbox-line".to_string(), "ED7D31".to_string())); + FloatWrap { + wrap: TextWrap::Square, + side: WrapSide::Right, + behind_text: false, + } + .store(&mut attr); + let text_box = Inline::TextBox( + attr, + vec![Block::Para(vec![Inline::Str("Sidebar body.".to_string())])], + ); + + let mut doc = sample_doc(); + doc.sections[0].blocks.push(Block::Para(vec![ + text_box, + Inline::Str("Body copy beside the box.".to_string()), + ])); + + let out = round_trip(&doc); + + let (at, blocks) = out + .sections + .iter() + .flat_map(|s| &s.blocks) + .flat_map(|b| match b { + Block::Para(i) | Block::Plain(i) => i.clone(), + Block::StyledPara(sp) => sp.inlines.clone(), + _ => vec![], + }) + .find_map(|i| match i { + Inline::TextBox(at, blocks) => Some((at, blocks)), + _ => None, + }) + .expect("a floating text box must survive as Inline::TextBox"); + + assert!( + at.kv + .iter() + .any(|(k, v)| k == "textbox-fill" && v == "FDF0E6"), + "fill survives: {:?}", + at.kv + ); + assert!( + at.kv + .iter() + .any(|(k, v)| k == "textbox-line" && v == "ED7D31"), + "border survives: {:?}", + at.kv + ); + assert!( + at.kv.iter().any(|(k, v)| k == "cx_emu" && v == "1828800"), + "geometry survives: {:?}", + at.kv + ); + assert!( + FloatWrap::read_or_class_default(&at).is_some(), + "wrap survives so the flow engine still floats it: {:?}", + at.kv + ); + let inner: String = blocks + .iter() + .flat_map(|b| match b { + Block::Para(i) | Block::Plain(i) => i.clone(), + Block::StyledPara(sp) => sp.inlines.clone(), + _ => vec![], + }) + .filter_map(|i| match i { + Inline::Str(s) => Some(s), + _ => None, + }) + .collect(); + assert!( + inner.contains("Sidebar body."), + "interior text survives: {inner:?}" + ); +} + #[test] fn inline_bookmark_field_and_image_round_trip() { use loki_doc_model::content::field::types::{Field, FieldKind}; diff --git a/loki-ooxml/src/docx/mapper/document_page.rs b/loki-ooxml/src/docx/mapper/document_page.rs index fb739663..02e88d21 100644 --- a/loki-ooxml/src/docx/mapper/document_page.rs +++ b/loki-ooxml/src/docx/mapper/document_page.rs @@ -12,7 +12,10 @@ use std::collections::HashMap; use loki_doc_model::content::block::Block; use loki_doc_model::layout::header_footer::{HeaderFooter, HeaderFooterKind}; -use loki_doc_model::layout::page::{PageLayout, PageMargins, PageOrientation, PageSize}; +use loki_doc_model::layout::page::{ + LineNumberRestart, LineNumbering, PageBorders, PageLayout, PageMargins, PageOrientation, + PageSize, +}; use loki_doc_model::layout::section::SectionStart; use loki_doc_model::style::list_style::NumberingScheme; use loki_primitives::units::Points; @@ -83,9 +86,56 @@ fn map_page_layout(sect_pr: Option<&DocxSectPr>) -> PageLayout { layout.page_number_format = sp.pg_num_fmt.as_deref().map(map_page_num_fmt); layout.page_number_start = sp.pg_num_start; + layout.page_border = sp + .pg_borders + .as_ref() + .map(map_pg_borders) + .filter(|b| !b.is_empty()); + + layout.line_numbering = sp.ln_num_type.as_ref().map(map_ln_num_type); + layout } +/// Maps a parsed `w:lnNumType` to the doc-model [`LineNumbering`], applying +/// Word's defaults (`countBy=1`, `start=1`, `restart="newPage"`) and converting +/// `@w:distance` from twips to points. +fn map_ln_num_type(t: &crate::docx::model::section::DocxLnNumType) -> LineNumbering { + let restart = match t.restart.as_deref() { + Some("newSection") => LineNumberRestart::NewSection, + Some("continuous") => LineNumberRestart::Continuous, + // "newPage", absent, or unknown → the default. + _ => LineNumberRestart::NewPage, + }; + LineNumbering { + count_by: t.count_by.filter(|&n| n > 0).unwrap_or(1), + start: t.start.unwrap_or(1), + restart, + distance: t + .distance + .filter(|&d| d > 0) + .map(|d| Points::new(f64::from(d) / 20.0)), + } +} + +/// Maps a parsed `w:pgBorders` set to the doc-model [`PageBorders`], dropping +/// edges that are absent or explicitly `none`/`nil`. +fn map_pg_borders(b: &crate::docx::model::section::DocxPgBorders) -> PageBorders { + use loki_doc_model::style::props::border::BorderStyle; + let edge = |e: &Option| { + e.as_ref() + .map(crate::docx::mapper::props::map_border_edge) + .filter(|bd| bd.style != BorderStyle::None) + }; + PageBorders { + top: edge(&b.top), + left: edge(&b.left), + bottom: edge(&b.bottom), + right: edge(&b.right), + offset_from_text: b.offset_from_text, + } +} + /// Maps an OOXML `w:pgNumType @w:fmt` token to a [`NumberingScheme`]. /// /// Unknown formats fall back to decimal (ECMA-376 §17.6.12 lists the same @@ -167,3 +217,7 @@ pub(super) fn map_page_layout_with_hf( layout } + +#[cfg(test)] +#[path = "document_page_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/mapper/document_page_tests.rs b/loki-ooxml/src/docx/mapper/document_page_tests.rs new file mode 100644 index 00000000..16e16039 --- /dev/null +++ b/loki-ooxml/src/docx/mapper/document_page_tests.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `document_page` (extracted for the 300-line file ceiling). + +use super::*; +use crate::docx::model::paragraph::DocxBorderEdge; +use crate::docx::model::section::DocxPgBorders; + +fn edge() -> DocxBorderEdge { + DocxBorderEdge { + val: "single".into(), + sz: Some(8), + color: Some("4472C4".into()), + space: Some(24), + } +} + +#[test] +fn maps_page_borders_onto_the_layout() { + let sect = DocxSectPr { + pg_borders: Some(DocxPgBorders { + top: Some(edge()), + bottom: Some(edge()), + left: Some(edge()), + right: Some(edge()), + offset_from_text: false, + }), + ..Default::default() + }; + let layout = map_page_layout(Some(§)); + let pb = layout.page_border.expect("page border mapped"); + assert!(pb.top.is_some() && pb.left.is_some() && pb.bottom.is_some() && pb.right.is_some()); + assert!(!pb.offset_from_text); +} + +#[test] +fn all_none_edges_map_to_no_border() { + let sect = DocxSectPr { + pg_borders: Some(DocxPgBorders::default()), + ..Default::default() + }; + assert!(map_page_layout(Some(§)).page_border.is_none()); +} + +#[test] +fn maps_line_numbering_with_defaults_and_distance() { + use crate::docx::model::section::DocxLnNumType; + let sect = DocxSectPr { + ln_num_type: Some(DocxLnNumType { + count_by: None, // → 1 + start: Some(1), + restart: Some("newPage".into()), + distance: Some(360), // twips → 18 pt + }), + ..Default::default() + }; + let ln = map_page_layout(Some(§)) + .line_numbering + .expect("line numbering mapped"); + assert_eq!(ln.count_by, 1); + assert_eq!(ln.start, 1); + assert_eq!(ln.restart, LineNumberRestart::NewPage); + assert!((ln.distance.unwrap().value() - 18.0).abs() < 0.01); +} + +#[test] +fn maps_line_numbering_restart_and_count_by() { + use crate::docx::model::section::DocxLnNumType; + let sect = DocxSectPr { + ln_num_type: Some(DocxLnNumType { + count_by: Some(5), + start: Some(10), + restart: Some("continuous".into()), + distance: None, + }), + ..Default::default() + }; + let ln = map_page_layout(Some(§)).line_numbering.unwrap(); + assert_eq!(ln.count_by, 5); + assert_eq!(ln.start, 10); + assert_eq!(ln.restart, LineNumberRestart::Continuous); + assert!(ln.distance.is_none()); +} + +#[test] +fn no_line_numbering_maps_to_none() { + assert!( + map_page_layout(Some(&DocxSectPr::default())) + .line_numbering + .is_none() + ); +} diff --git a/loki-ooxml/src/docx/mapper/document_tests.rs b/loki-ooxml/src/docx/mapper/document_tests.rs index cee930d2..538cf1ea 100644 --- a/loki-ooxml/src/docx/mapper/document_tests.rs +++ b/loki-ooxml/src/docx/mapper/document_tests.rs @@ -55,6 +55,8 @@ fn sect_pr_a4() -> DocxSectPr { pg_num_fmt: None, pg_num_start: None, section_type: None, + pg_borders: None, + ln_num_type: None, } } diff --git a/loki-ooxml/src/docx/mapper/images.rs b/loki-ooxml/src/docx/mapper/images.rs index 5856d09a..aa0a569d 100644 --- a/loki-ooxml/src/docx/mapper/images.rs +++ b/loki-ooxml/src/docx/mapper/images.rs @@ -12,11 +12,51 @@ use crate::error::OoxmlWarning; use super::document::MappingContext; -/// Maps a `w:drawing` element to an [`Inline::Image`]. +/// Store an anchored drawing's geometry, float, and wrap metadata onto `attr` +/// (shared by the image and text-box paths). +fn store_anchor_geometry(drawing: &DocxDrawing, attr: &mut NodeAttr) { + if let Some(cx) = drawing.cx { + attr.kv.push(("cx_emu".to_string(), cx.to_string())); + } + if let Some(cy) = drawing.cy { + attr.kv.push(("cy_emu".to_string(), cy.to_string())); + } + if let Some(wrap) = drawing.wrap { + wrap.store(attr); + } else if drawing.is_anchor { + attr.classes.push(FLOATING_CLASS.to_string()); + } +} + +/// Maps a `wps` text-box drawing (one carrying `w:txbxContent`) to an +/// [`Inline::TextBox`]: its inner paragraphs become the box's block content, and +/// its geometry / wrap / fill / border ride on the [`NodeAttr`]. +fn map_textbox(drawing: &DocxDrawing, ctx: &mut MappingContext<'_>) -> Inline { + let blocks = drawing + .txbx + .iter() + .flat_map(|p| crate::docx::mapper::paragraph::map_paragraph(p, ctx)) + .collect(); + let mut attr = NodeAttr::default(); + store_anchor_geometry(drawing, &mut attr); + if let Some(fill) = &drawing.fill_color { + attr.kv.push(("textbox-fill".to_string(), fill.clone())); + } + if let Some(line) = &drawing.line_color { + attr.kv.push(("textbox-line".to_string(), line.clone())); + } + Inline::TextBox(attr, blocks) +} + +/// Maps a `w:drawing` element to an [`Inline::Image`] or, when it carries +/// `w:txbxContent`, an [`Inline::TextBox`]. /// /// Returns `None` and pushes a warning if the relationship id is missing /// or cannot be resolved to an image part. pub(crate) fn map_drawing(drawing: &DocxDrawing, ctx: &mut MappingContext<'_>) -> Option { + if !drawing.txbx.is_empty() { + return Some(map_textbox(drawing, ctx)); + } let Some(rel_id) = drawing.rel_id.clone() else { ctx.warnings.push(OoxmlWarning::UnresolvedImage { rel_id: String::new(), @@ -41,18 +81,7 @@ pub(crate) fn map_drawing(drawing: &DocxDrawing, ctx: &mut MappingContext<'_>) - }; let mut attr = NodeAttr::default(); - if let Some(cx) = drawing.cx { - attr.kv.push(("cx_emu".to_string(), cx.to_string())); - } - if let Some(cy) = drawing.cy { - attr.kv.push(("cy_emu".to_string(), cy.to_string())); - } - if let Some(wrap) = drawing.wrap { - // Floating drawing with an explicit wrap mode: store wrap + floating class. - wrap.store(&mut attr); - } else if drawing.is_anchor { - attr.classes.push(FLOATING_CLASS.to_string()); - } + store_anchor_geometry(drawing, &mut attr); let alt = match &drawing.descr { Some(d) if !d.is_empty() => vec![Inline::Str(d.clone())], @@ -68,200 +97,5 @@ pub(crate) fn map_drawing(drawing: &DocxDrawing, ctx: &mut MappingContext<'_>) - } #[cfg(test)] -mod tests { - use super::*; - use crate::docx::import::DocxImportOptions; - use loki_doc_model::style::catalog::StyleCatalog; - use loki_opc::PartData; - use std::collections::HashMap; - - fn make_ctx<'a>( - images: &'a HashMap, - styles: &'a StyleCatalog, - footnotes: &'a HashMap>, - endnotes: &'a HashMap>, - hyperlinks: &'a HashMap, - options: &'a DocxImportOptions, - ) -> MappingContext<'a> { - MappingContext { - styles, - footnotes, - endnotes, - hyperlinks, - images, - options, - warnings: Vec::new(), - open_bookmarks: Vec::new(), - } - } - - #[test] - fn missing_rel_id_returns_none_with_warning() { - let images = HashMap::new(); - let catalog = StyleCatalog::default(); - let fn_map = HashMap::new(); - let en_map = HashMap::new(); - let hl_map = HashMap::new(); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); - - let drawing = DocxDrawing { - rel_id: None, - cx: None, - cy: None, - descr: None, - name: None, - is_anchor: false, - wrap: None, - }; - let result = map_drawing(&drawing, &mut ctx); - assert!(result.is_none()); - assert_eq!(ctx.warnings.len(), 1); - assert!(matches!( - &ctx.warnings[0], - OoxmlWarning::UnresolvedImage { rel_id } if rel_id.is_empty() - )); - } - - #[test] - fn unresolved_rel_id_returns_none_with_warning() { - let images = HashMap::new(); - let catalog = StyleCatalog::default(); - let fn_map = HashMap::new(); - let en_map = HashMap::new(); - let hl_map = HashMap::new(); - let opts = DocxImportOptions::default(); - let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); - - let drawing = DocxDrawing { - rel_id: Some("rId99".into()), - cx: None, - cy: None, - descr: None, - name: None, - is_anchor: false, - wrap: None, - }; - let result = map_drawing(&drawing, &mut ctx); - assert!(result.is_none()); - assert!(matches!( - &ctx.warnings[0], - OoxmlWarning::UnresolvedImage { rel_id } if rel_id == "rId99" - )); - } - - #[test] - fn resolved_image_no_embed_returns_rel_id_as_url() { - let mut images = HashMap::new(); - images.insert("rId1".into(), PartData::new(vec![0u8, 1, 2], "image/png")); - - let catalog = StyleCatalog::default(); - let fn_map = HashMap::new(); - let en_map = HashMap::new(); - let hl_map = HashMap::new(); - let opts = DocxImportOptions { - embed_images: false, - ..Default::default() - }; - let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); - - let drawing = DocxDrawing { - rel_id: Some("rId1".into()), - cx: Some(914_400), - cy: Some(685_800), - descr: Some("A test image".into()), - name: Some("img1".into()), - is_anchor: false, - wrap: None, - }; - let result = map_drawing(&drawing, &mut ctx).unwrap(); - if let Inline::Image(attr, alt, target) = result { - assert_eq!(target.url, "rId1"); - assert_eq!(target.title.as_deref(), Some("img1")); - assert!(matches!(&alt[..], [Inline::Str(s)] if s == "A test image")); - assert!(attr.kv.iter().any(|(k, v)| k == "cx_emu" && v == "914400")); - assert!(attr.kv.iter().any(|(k, v)| k == "cy_emu" && v == "685800")); - assert!(!attr.classes.contains(&"floating".to_string())); - } else { - panic!("expected Image inline"); - } - } - - #[test] - fn anchor_drawing_gets_floating_class() { - let mut images = HashMap::new(); - images.insert("rId2".into(), PartData::new(vec![], "image/jpeg")); - - let catalog = StyleCatalog::default(); - let fn_map = HashMap::new(); - let en_map = HashMap::new(); - let hl_map = HashMap::new(); - let opts = DocxImportOptions { - embed_images: false, - ..Default::default() - }; - let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); - - let drawing = DocxDrawing { - rel_id: Some("rId2".into()), - cx: None, - cy: None, - descr: None, - name: None, - is_anchor: true, - wrap: None, - }; - let result = map_drawing(&drawing, &mut ctx).unwrap(); - if let Inline::Image(attr, _, _) = result { - assert!(attr.classes.contains(&"floating".to_string())); - } else { - panic!("expected Image"); - } - } - - #[test] - fn anchor_drawing_carries_wrap_mode() { - use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; - - let mut images = HashMap::new(); - images.insert("rId3".into(), PartData::new(vec![], "image/png")); - - let catalog = StyleCatalog::default(); - let fn_map = HashMap::new(); - let en_map = HashMap::new(); - let hl_map = HashMap::new(); - let opts = DocxImportOptions { - embed_images: false, - ..Default::default() - }; - let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); - - let drawing = DocxDrawing { - rel_id: Some("rId3".into()), - cx: None, - cy: None, - descr: None, - name: None, - is_anchor: true, - wrap: Some(FloatWrap { - wrap: TextWrap::Tight, - side: WrapSide::Left, - behind_text: false, - }), - }; - let result = map_drawing(&drawing, &mut ctx).unwrap(); - if let Inline::Image(attr, _, _) = result { - assert!(attr.classes.contains(&FLOATING_CLASS.to_string())); - assert_eq!( - FloatWrap::read(&attr), - Some(FloatWrap { - wrap: TextWrap::Tight, - side: WrapSide::Left, - behind_text: false, - }) - ); - } else { - panic!("expected Image"); - } - } -} +#[path = "images_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/mapper/images_tests.rs b/loki-ooxml/src/docx/mapper/images_tests.rs new file mode 100644 index 00000000..285e121f --- /dev/null +++ b/loki-ooxml/src/docx/mapper/images_tests.rs @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for the image / text-box drawing mapper (extracted for the ceiling). + +use super::*; +use crate::docx::import::DocxImportOptions; +use loki_doc_model::style::catalog::StyleCatalog; +use loki_opc::PartData; +use std::collections::HashMap; + +fn make_ctx<'a>( + images: &'a HashMap, + styles: &'a StyleCatalog, + footnotes: &'a HashMap>, + endnotes: &'a HashMap>, + hyperlinks: &'a HashMap, + options: &'a DocxImportOptions, +) -> MappingContext<'a> { + MappingContext { + styles, + footnotes, + endnotes, + hyperlinks, + images, + options, + warnings: Vec::new(), + open_bookmarks: Vec::new(), + } +} + +#[test] +fn missing_rel_id_returns_none_with_warning() { + let images = HashMap::new(); + let catalog = StyleCatalog::default(); + let fn_map = HashMap::new(); + let en_map = HashMap::new(); + let hl_map = HashMap::new(); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); + + let drawing = DocxDrawing { + rel_id: None, + cx: None, + cy: None, + descr: None, + name: None, + is_anchor: false, + wrap: None, + txbx: Vec::new(), + fill_color: None, + line_color: None, + line_w_emu: None, + }; + let result = map_drawing(&drawing, &mut ctx); + assert!(result.is_none()); + assert_eq!(ctx.warnings.len(), 1); + assert!(matches!( + &ctx.warnings[0], + OoxmlWarning::UnresolvedImage { rel_id } if rel_id.is_empty() + )); +} + +#[test] +fn unresolved_rel_id_returns_none_with_warning() { + let images = HashMap::new(); + let catalog = StyleCatalog::default(); + let fn_map = HashMap::new(); + let en_map = HashMap::new(); + let hl_map = HashMap::new(); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); + + let drawing = DocxDrawing { + rel_id: Some("rId99".into()), + cx: None, + cy: None, + descr: None, + name: None, + is_anchor: false, + wrap: None, + txbx: Vec::new(), + fill_color: None, + line_color: None, + line_w_emu: None, + }; + let result = map_drawing(&drawing, &mut ctx); + assert!(result.is_none()); + assert!(matches!( + &ctx.warnings[0], + OoxmlWarning::UnresolvedImage { rel_id } if rel_id == "rId99" + )); +} + +#[test] +fn resolved_image_no_embed_returns_rel_id_as_url() { + let mut images = HashMap::new(); + images.insert("rId1".into(), PartData::new(vec![0u8, 1, 2], "image/png")); + + let catalog = StyleCatalog::default(); + let fn_map = HashMap::new(); + let en_map = HashMap::new(); + let hl_map = HashMap::new(); + let opts = DocxImportOptions { + embed_images: false, + ..Default::default() + }; + let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); + + let drawing = DocxDrawing { + rel_id: Some("rId1".into()), + cx: Some(914_400), + cy: Some(685_800), + descr: Some("A test image".into()), + name: Some("img1".into()), + is_anchor: false, + wrap: None, + txbx: Vec::new(), + fill_color: None, + line_color: None, + line_w_emu: None, + }; + let result = map_drawing(&drawing, &mut ctx).unwrap(); + if let Inline::Image(attr, alt, target) = result { + assert_eq!(target.url, "rId1"); + assert_eq!(target.title.as_deref(), Some("img1")); + assert!(matches!(&alt[..], [Inline::Str(s)] if s == "A test image")); + assert!(attr.kv.iter().any(|(k, v)| k == "cx_emu" && v == "914400")); + assert!(attr.kv.iter().any(|(k, v)| k == "cy_emu" && v == "685800")); + assert!(!attr.classes.contains(&"floating".to_string())); + } else { + panic!("expected Image inline"); + } +} + +#[test] +fn anchor_drawing_gets_floating_class() { + let mut images = HashMap::new(); + images.insert("rId2".into(), PartData::new(vec![], "image/jpeg")); + + let catalog = StyleCatalog::default(); + let fn_map = HashMap::new(); + let en_map = HashMap::new(); + let hl_map = HashMap::new(); + let opts = DocxImportOptions { + embed_images: false, + ..Default::default() + }; + let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); + + let drawing = DocxDrawing { + rel_id: Some("rId2".into()), + cx: None, + cy: None, + descr: None, + name: None, + is_anchor: true, + wrap: None, + txbx: Vec::new(), + fill_color: None, + line_color: None, + line_w_emu: None, + }; + let result = map_drawing(&drawing, &mut ctx).unwrap(); + if let Inline::Image(attr, _, _) = result { + assert!(attr.classes.contains(&"floating".to_string())); + } else { + panic!("expected Image"); + } +} + +#[test] +fn anchor_drawing_carries_wrap_mode() { + use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; + + let mut images = HashMap::new(); + images.insert("rId3".into(), PartData::new(vec![], "image/png")); + + let catalog = StyleCatalog::default(); + let fn_map = HashMap::new(); + let en_map = HashMap::new(); + let hl_map = HashMap::new(); + let opts = DocxImportOptions { + embed_images: false, + ..Default::default() + }; + let mut ctx = make_ctx(&images, &catalog, &fn_map, &en_map, &hl_map, &opts); + + let drawing = DocxDrawing { + rel_id: Some("rId3".into()), + cx: None, + cy: None, + descr: None, + name: None, + is_anchor: true, + wrap: Some(FloatWrap { + wrap: TextWrap::Tight, + side: WrapSide::Left, + behind_text: false, + }), + txbx: Vec::new(), + fill_color: None, + line_color: None, + line_w_emu: None, + }; + let result = map_drawing(&drawing, &mut ctx).unwrap(); + if let Inline::Image(attr, _, _) = result { + assert!(attr.classes.contains(&FLOATING_CLASS.to_string())); + assert_eq!( + FloatWrap::read(&attr), + Some(FloatWrap { + wrap: TextWrap::Tight, + side: WrapSide::Left, + behind_text: false, + }) + ); + } else { + panic!("expected Image"); + } +} + +#[test] +fn text_box_drawing_maps_to_inline_text_box() { + use crate::docx::model::paragraph::DocxParagraph; + let images = std::collections::HashMap::new(); + let styles = StyleCatalog::default(); + let fns = std::collections::HashMap::new(); + let ens = std::collections::HashMap::new(); + let links = std::collections::HashMap::new(); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&images, &styles, &fns, &ens, &links, &opts); + let drawing = DocxDrawing { + cx: Some(1_828_800), + cy: Some(731_520), + is_anchor: true, + txbx: vec![DocxParagraph::default()], + fill_color: Some("FDF0E6".into()), + line_color: Some("ED7D31".into()), + ..Default::default() + }; + let result = map_drawing(&drawing, &mut ctx).expect("maps"); + match result { + Inline::TextBox(attr, _blocks) => { + assert!( + attr.kv + .iter() + .any(|(k, v)| k == "textbox-fill" && v == "FDF0E6") + ); + assert!( + attr.kv + .iter() + .any(|(k, v)| k == "textbox-line" && v == "ED7D31") + ); + assert!(attr.kv.iter().any(|(k, v)| k == "cx_emu" && v == "1828800")); + } + other => panic!("expected Inline::TextBox, got {other:?}"), + } +} diff --git a/loki-ooxml/src/docx/mapper/inline.rs b/loki-ooxml/src/docx/mapper/inline.rs index 97ae70f6..0d5ad07f 100644 --- a/loki-ooxml/src/docx/mapper/inline.rs +++ b/loki-ooxml/src/docx/mapper/inline.rs @@ -159,6 +159,17 @@ pub(crate) fn map_inlines(children: &[DocxParaChild], ctx: &mut MappingContext<' } } + // A complex field still open at the paragraph's end spans into the next + // paragraph (e.g. a multi-entry TOC field, whose `begin`/`separate` sit in + // the first entry's paragraph and whose `end` is in the last). Emit the + // result text accumulated so far as plain inline content so this paragraph's + // entry is not dropped; the continuation paragraphs render as normal text. + if let FieldState::InResult { snapshot, .. } = state + && !snapshot.trim().is_empty() + { + result.push(Inline::Str(snapshot)); + } + result } diff --git a/loki-ooxml/src/docx/mapper/inline_run.rs b/loki-ooxml/src/docx/mapper/inline_run.rs index 799640f9..c102cc9b 100644 --- a/loki-ooxml/src/docx/mapper/inline_run.rs +++ b/loki-ooxml/src/docx/mapper/inline_run.rs @@ -125,11 +125,16 @@ fn process_run_child( } _ => {} // inside field code or nested field }, - DocxRunChild::Tab => { - if matches!(state, FieldState::Normal) { - raw.push(Inline::Str("\t".to_string())); - } - } + DocxRunChild::Tab => match state { + FieldState::Normal => raw.push(Inline::Str("\t".to_string())), + // A tab inside a field's cached result (e.g. a TOC entry's leader + // tab between the heading text and its page number) is part of the + // visible result — keep it in the snapshot so it survives. + FieldState::InResult { + snapshot, depth, .. + } if *depth == 1 => snapshot.push('\t'), + _ => {} + }, DocxRunChild::Break { break_type } => { if matches!(state, FieldState::Normal) { match break_type.as_deref() { diff --git a/loki-ooxml/src/docx/mapper/inline_tests.rs b/loki-ooxml/src/docx/mapper/inline_tests.rs index 6bf6775b..9d741365 100644 --- a/loki-ooxml/src/docx/mapper/inline_tests.rs +++ b/loki-ooxml/src/docx/mapper/inline_tests.rs @@ -172,6 +172,60 @@ fn page_field_assembled() { } } +#[test] +fn open_field_flushes_its_result_at_paragraph_end() { + // A multi-entry TOC field: `begin`/`separate` and the first entry's result + // (with a leader tab before the page number) live in one paragraph, but the + // `end` is in a later paragraph. The result-so-far must be emitted (not + // dropped, as it once was), with the tab preserved so the leader renders. + let (styles, fn_m, en_m, hl_m, img_m, opts) = default_ctx(); + let mut ctx = make_ctx(&fn_m, &en_m, &hl_m, &img_m, &styles, &opts); + let children = vec![ + DocxParaChild::Run(DocxRun { + rpr: None, + children: vec![DocxRunChild::FldChar { + fld_char_type: "begin".into(), + }], + }), + DocxParaChild::Run(DocxRun { + rpr: None, + children: vec![DocxRunChild::InstrText { + text: " TOC \\o \"1-2\" \\h ".into(), + }], + }), + DocxParaChild::Run(DocxRun { + rpr: None, + children: vec![DocxRunChild::FldChar { + fld_char_type: "separate".into(), + }], + }), + DocxParaChild::Run(DocxRun { + rpr: None, + children: vec![ + DocxRunChild::Text { + text: "1. Introduction".into(), + preserve: false, + }, + DocxRunChild::Tab, + DocxRunChild::Text { + text: "3".into(), + preserve: false, + }, + ], + }), + // No `end` — the field continues into the following paragraph. + ]; + let inlines = map_inlines(&children, &mut ctx); + assert_eq!(inlines.len(), 1, "flushed result: {inlines:?}"); + match &inlines[0] { + Inline::Str(s) => { + assert!(s.contains("Introduction"), "result text kept: {s:?}"); + assert!(s.contains('\t'), "leader tab preserved: {s:?}"); + } + other => panic!("expected a flushed Str, got {other:?}"), + } +} + #[test] fn simple_field_maps_to_field_inline() { let (styles, fn_m, en_m, hl_m, img_m, opts) = default_ctx(); diff --git a/loki-ooxml/src/docx/mapper/props.rs b/loki-ooxml/src/docx/mapper/props.rs index eec9f8c3..c0b3802d 100644 --- a/loki-ooxml/src/docx/mapper/props.rs +++ b/loki-ooxml/src/docx/mapper/props.rs @@ -228,6 +228,13 @@ pub(crate) fn map_ppr(ppr: &DocxPPr) -> ParaProps { ) { props.background_color = Some(DocumentColor::Rgb(rgb)); } + // Preserve a line/cross texture so the renderer can draw the actual hatch + // lines (the flattened tint above is the fallback for non-hatch consumers). + props.shading = crate::xml_util::resolve_shading_pattern( + ppr.shd_fill.as_deref(), + ppr.shd_val.as_deref(), + ppr.shd_color.as_deref(), + ); // Tab stops: w:tabs → ParaProps.tab_stops. // "clear" entries remove inherited stops and are not forwarded as explicit stops. diff --git a/loki-ooxml/src/docx/mapper/props_rpr.rs b/loki-ooxml/src/docx/mapper/props_rpr.rs index a5b04385..1c8d6c33 100644 --- a/loki-ooxml/src/docx/mapper/props_rpr.rs +++ b/loki-ooxml/src/docx/mapper/props_rpr.rs @@ -98,8 +98,68 @@ pub(crate) fn map_rpr(rpr: &DocxRPr) -> CharProps { }), baseline_shift: rpr.position.map(|hp| Points::new(f64::from(hp) / 2.0)), outline: rpr.outline, + emboss: rpr.emboss, + imprint: rpr.imprint, + // `w:bdr` character border, dropping an explicit none/nil edge. + character_border: rpr + .bdr + .as_ref() + .map(crate::docx::mapper::props::map_border_edge) + .filter(|b| b.style != loki_doc_model::style::props::border::BorderStyle::None), // A paragraph mark's w:ins/w:del (tracked ¶ deletion) → CharProps.revision. revision: rpr.mark_rev.as_ref().map(DocxMarkRevision::to_mark), ..Default::default() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::docx::model::paragraph::DocxBorderEdge; + use loki_doc_model::style::props::border::BorderStyle; + + #[test] + fn maps_character_border() { + let rpr = DocxRPr { + bdr: Some(DocxBorderEdge { + val: "single".into(), + sz: Some(4), + color: Some("C00000".into()), + space: Some(0), + }), + ..Default::default() + }; + let b = map_rpr(&rpr).character_border.expect("bdr mapped"); + assert_eq!(b.style, BorderStyle::Solid); + } + + #[test] + fn none_valued_bdr_is_dropped() { + let rpr = DocxRPr { + bdr: Some(DocxBorderEdge { + val: "none".into(), + sz: None, + color: None, + space: None, + }), + ..Default::default() + }; + assert!(map_rpr(&rpr).character_border.is_none()); + } + + #[test] + fn no_bdr_maps_to_none() { + assert!(map_rpr(&DocxRPr::default()).character_border.is_none()); + } + #[test] + fn maps_emboss_and_imprint() { + let rpr = DocxRPr { + emboss: Some(true), + imprint: Some(true), + ..Default::default() + }; + let cp = map_rpr(&rpr); + assert_eq!(cp.emboss, Some(true)); + assert_eq!(cp.imprint, Some(true)); + } +} diff --git a/loki-ooxml/src/docx/mapper/styles.rs b/loki-ooxml/src/docx/mapper/styles.rs index a099cdde..93620711 100644 --- a/loki-ooxml/src/docx/mapper/styles.rs +++ b/loki-ooxml/src/docx/mapper/styles.rs @@ -9,6 +9,7 @@ use loki_doc_model::style::char_style::CharacterStyle; use loki_doc_model::style::para_style::ParagraphStyle; use loki_doc_model::style::props::char_props::CharProps; use loki_doc_model::style::props::para_props::ParaProps; +use loki_doc_model::style::table_borders::TableBorders; use loki_doc_model::style::table_style::{ TableConditionalFormat, TableProps, TableRegion, TableStyle, }; @@ -223,10 +224,16 @@ fn shd_color(fill: Option<&str>) -> Option { fn map_table_style_props( t: &DocxTableStyleProps, ) -> (TableProps, IndexMap) { + let borders = t + .tbl_borders + .as_ref() + .map(map_tbl_borders) + .filter(|b| !b.is_empty()); let table_props = TableProps { background_color: shd_color(t.base_shd_fill.as_deref()), row_band_size: t.row_band_size, col_band_size: t.col_band_size, + borders, ..TableProps::default() }; let mut conditional = IndexMap::new(); @@ -254,6 +261,26 @@ fn map_table_style_props( (table_props, conditional) } +/// Maps a parsed `w:tblBorders` set to the doc-model [`TableBorders`], dropping +/// edges that are absent or explicitly `none`/`nil` (they draw nothing). +fn map_tbl_borders(b: &crate::docx::model::styles::DocxTblBorders) -> TableBorders { + use crate::docx::model::paragraph::DocxBorderEdge; + use loki_doc_model::style::props::border::BorderStyle; + let edge = |e: &Option| { + e.as_ref() + .map(super::props::map_border_edge) + .filter(|bd| bd.style != BorderStyle::None) + }; + TableBorders { + top: edge(&b.top), + left: edge(&b.left), + bottom: edge(&b.bottom), + right: edge(&b.right), + inside_h: edge(&b.inside_h), + inside_v: edge(&b.inside_v), + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/loki-ooxml/src/docx/mapper/styles_tests.rs b/loki-ooxml/src/docx/mapper/styles_tests.rs index 39512f4b..3a43eab0 100644 --- a/loki-ooxml/src/docx/mapper/styles_tests.rs +++ b/loki-ooxml/src/docx/mapper/styles_tests.rs @@ -371,6 +371,7 @@ fn table_style_conditional_formatting_maps() { row_band_size: Some(2), col_band_size: None, base_shd_fill: Some("FFFFFF".into()), + tbl_borders: None, conditional: vec![ DocxTblStylePr { region: "firstRow".into(), diff --git a/loki-ooxml/src/docx/mapper/table.rs b/loki-ooxml/src/docx/mapper/table.rs index db38b765..d06b8d5e 100644 --- a/loki-ooxml/src/docx/mapper/table.rs +++ b/loki-ooxml/src/docx/mapper/table.rs @@ -176,6 +176,13 @@ fn map_cell(tc: &crate::docx::model::styles::DocxTableCell, ctx: &mut MappingCon use loki_primitives::color::DocumentColor; props.background_color = Some(DocumentColor::Rgb(rgb)); } + // Preserve a line/cross texture for hatch-line rendering (the flattened + // tint above is the fallback for consumers that cannot draw the hatch). + props.shading = crate::xml_util::resolve_shading_pattern( + tc_pr.shd_fill.as_deref(), + tc_pr.shd_val.as_deref(), + tc_pr.shd_color.as_deref(), + ); // Cell borders from `w:tcBorders`. if let Some(ref borders) = tc_pr.tc_borders { props.border_top = borders.top.as_ref().map(map_border_edge); diff --git a/loki-ooxml/src/docx/mod.rs b/loki-ooxml/src/docx/mod.rs index 4f8db514..940d18d9 100644 --- a/loki-ooxml/src/docx/mod.rs +++ b/loki-ooxml/src/docx/mod.rs @@ -23,4 +23,5 @@ pub mod mapper; pub(crate) mod model; pub(crate) mod omml; pub(crate) mod reader; +pub mod repair; pub(crate) mod write; diff --git a/loki-ooxml/src/docx/model/paragraph_run.rs b/loki-ooxml/src/docx/model/paragraph_run.rs index 96daf5b8..af7aa12a 100644 --- a/loki-ooxml/src/docx/model/paragraph_run.rs +++ b/loki-ooxml/src/docx/model/paragraph_run.rs @@ -62,6 +62,10 @@ pub struct DocxRPr { pub all_caps: Option, /// `w:shadow` toggle. pub shadow: Option, + /// `w:emboss` toggle — embossed (raised) text. + pub emboss: Option, + /// `w:imprint` toggle — imprinted (engraved) text. + pub imprint: Option, /// `w:color @w:val` — foreground color hex string. pub color: Option, /// `w:highlight @w:val` — named highlight color. @@ -96,6 +100,8 @@ pub struct DocxRPr { pub shd_color: Option, /// `w:outline` toggle — hollow/outline text effect. pub outline: Option, + /// `w:bdr` — a border box drawn around the run's text (ECMA-376 §17.3.2.4). + pub bdr: Option, } /// `w:rFonts` font name attributes (ECMA-376 §17.3.2.26). @@ -140,4 +146,14 @@ pub struct DocxDrawing { /// Text-wrap configuration for a floating (anchored) drawing. /// `None` for inline drawings or anchored drawings without a wrap element. pub wrap: Option, + /// `w:txbxContent` paragraphs when this drawing is a `wps` text box + /// (empty otherwise). Their presence marks the drawing as a text box + /// rather than a picture. + pub txbx: Vec, + /// Text-box fill colour from `wps:spPr/a:solidFill/a:srgbClr` (hex, no `#`). + pub fill_color: Option, + /// Text-box border colour from `wps:spPr/a:ln/.../a:srgbClr` (hex). + pub line_color: Option, + /// Text-box border width in EMUs from `a:ln @w`. + pub line_w_emu: Option, } diff --git a/loki-ooxml/src/docx/model/section.rs b/loki-ooxml/src/docx/model/section.rs index ee67db35..43bd59bb 100644 --- a/loki-ooxml/src/docx/model/section.rs +++ b/loki-ooxml/src/docx/model/section.rs @@ -30,6 +30,36 @@ pub struct DocxSectPr { /// (`continuous`, `nextPage`, `evenPage`, `oddPage`). `None` = `nextPage` /// (ECMA-376 §17.6.22). pub section_type: Option, + /// Page borders from `w:pgBorders` (ECMA-376 §17.6.10). + pub pg_borders: Option, + /// Margin line numbering from `w:lnNumType` (ECMA-376 §17.6.8). + pub ln_num_type: Option, +} + +/// `w:lnNumType` — margin line numbering (ECMA-376 §17.6.8). Attribute values +/// are raw; the mapper applies defaults and unit conversion. +#[derive(Debug, Clone, Default)] +pub struct DocxLnNumType { + /// `@w:countBy` — print a number every N lines. `None` = every line. + pub count_by: Option, + /// `@w:start` — the first line number. `None` = `1`. + pub start: Option, + /// `@w:restart` — `newPage` (default) / `newSection` / `continuous`. + pub restart: Option, + /// `@w:distance` — gutter between numbers and text, in twips. `None` = auto. + pub distance: Option, +} + +/// `w:pgBorders` — decorative border drawn around each page (ECMA-376 §17.6.10). +#[derive(Debug, Clone, Default)] +pub struct DocxPgBorders { + pub top: Option, + pub bottom: Option, + pub left: Option, + pub right: Option, + /// `@w:offsetFrom` — `"text"` insets each edge from the text area; the + /// default `"page"` (or absent) insets from the physical page edge. + pub offset_from_text: bool, } /// `w:cols` multi-column section layout (ECMA-376 §17.6.4). diff --git a/loki-ooxml/src/docx/model/styles.rs b/loki-ooxml/src/docx/model/styles.rs index eb487bae..8009cc2b 100644 --- a/loki-ooxml/src/docx/model/styles.rs +++ b/loki-ooxml/src/docx/model/styles.rs @@ -71,10 +71,25 @@ pub struct DocxTableStyleProps { pub col_band_size: Option, /// Base whole-table cell shading fill from `w:tcPr/w:shd @w:fill`. pub base_shd_fill: Option, + /// Whole-table borders from `w:tblPr/w:tblBorders` — the outer edges plus + /// the interior gridlines a *Table Grid* style draws. + pub tbl_borders: Option, /// Per-region conditional formats from `w:tblStylePr`. pub conditional: Vec, } +/// Table borders from `w:tblBorders` (ECMA-376 §17.4.39): the four outer edges +/// plus the interior horizontal/vertical gridlines. +#[derive(Debug, Clone, Default)] +pub struct DocxTblBorders { + pub top: Option, + pub bottom: Option, + pub left: Option, + pub right: Option, + pub inside_h: Option, + pub inside_v: Option, +} + /// One `w:tblStylePr` conditional format (ECMA-376 §17.7.6.6). Only cell /// shading is captured today; borders and run/paragraph props are future work. #[derive(Debug, Clone, Default)] diff --git a/loki-ooxml/src/docx/reader/document_drawing.rs b/loki-ooxml/src/docx/reader/document_drawing.rs index 99431653..696a27e9 100644 --- a/loki-ooxml/src/docx/reader/document_drawing.rs +++ b/loki-ooxml/src/docx/reader/document_drawing.rs @@ -21,6 +21,9 @@ pub(crate) fn parse_drawing(reader: &mut Reader<&[u8]>) -> OoxmlResult = None; let mut wrap_side = WrapSide::Both; let mut behind_doc = false; + // `true` while inside an `a:ln` (border) element, so its `a:srgbClr` is read + // as the border colour rather than the shape fill. + let mut in_ln = false; let mut buf = Vec::new(); loop { match reader.read_event_into(&mut buf) { @@ -41,6 +44,20 @@ pub(crate) fn parse_drawing(reader: &mut Reader<&[u8]>) -> OoxmlResult { drawing.rel_id = attr_val(e, b"embed"); } + // `wps` text box: `a:ln` border, `a:srgbClr` fill/border, and + // `w:txbxContent` block content. + b"ln" => { + in_ln = true; + drawing.line_w_emu = attr_val(e, b"w").as_deref().and_then(parse_emu); + } + b"srgbClr" => { + if in_ln { + drawing.line_color = attr_val(e, b"val"); + } else if drawing.fill_color.is_none() { + drawing.fill_color = attr_val(e, b"val"); + } + } + b"txbxContent" => parse_txbx_content(reader, &mut drawing)?, b"wrapSquare" => { wrap_mode = Some(TextWrap::Square); wrap_side = parse_wrap_text(e); @@ -58,6 +75,9 @@ pub(crate) fn parse_drawing(reader: &mut Reader<&[u8]>) -> OoxmlResult {} } } + Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"ln" => { + in_ln = false; + } Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"drawing" => { break; } @@ -82,6 +102,35 @@ pub(crate) fn parse_drawing(reader: &mut Reader<&[u8]>) -> OoxmlResult, drawing: &mut DocxDrawing) -> OoxmlResult<()> { + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) if local_name(e.local_name().as_ref()) == b"p" => { + drawing + .txbx + .push(crate::docx::reader::document::parse_paragraph(reader)?); + } + Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"txbxContent" => { + break; + } + Ok(Event::Eof) => break, + Err(e) => { + return Err(OoxmlError::Xml { + part: "word/document.xml".into(), + source: e, + }); + } + _ => {} + } + buf.clear(); + } + Ok(()) +} + /// Reads the `wrapText` attribute of a `wp:wrap*` element into a [`WrapSide`]. fn parse_wrap_text(e: &quick_xml::events::BytesStart<'_>) -> WrapSide { match attr_val(e, b"wrapText").as_deref() { @@ -91,3 +140,43 @@ fn parse_wrap_text(e: &quick_xml::events::BytesStart<'_>) -> WrapSide { _ => WrapSide::Both, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(xml: &str) -> DocxDrawing { + let mut reader = Reader::from_reader(xml.as_bytes()); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf).unwrap() { + Event::Start(ref e) if local_name(e.local_name().as_ref()) == b"drawing" => break, + Event::Eof => panic!("no drawing"), + _ => {} + } + } + parse_drawing(&mut reader).unwrap() + } + + #[test] + fn parses_wps_text_box() { + let xml = r#" + + + + + + + + Box text. + + + "#; + let d = parse(xml); + assert_eq!(d.txbx.len(), 1, "one text-box paragraph"); + assert_eq!(d.fill_color.as_deref(), Some("FDF0E6")); + assert_eq!(d.line_color.as_deref(), Some("ED7D31")); + assert!(d.is_anchor); + assert_eq!(d.cx, Some(1_828_800)); + } +} diff --git a/loki-ooxml/src/docx/reader/document_run.rs b/loki-ooxml/src/docx/reader/document_run.rs index 7576054d..a8ac3651 100644 --- a/loki-ooxml/src/docx/reader/document_run.rs +++ b/loki-ooxml/src/docx/reader/document_run.rs @@ -46,6 +46,12 @@ pub(crate) fn parse_rpr_element(reader: &mut Reader<&[u8]>) -> OoxmlResult { rpr.shadow = Some(toggle_prop(attr_val(e, b"val").as_deref())); } + b"emboss" => { + rpr.emboss = Some(toggle_prop(attr_val(e, b"val").as_deref())); + } + b"imprint" => { + rpr.imprint = Some(toggle_prop(attr_val(e, b"val").as_deref())); + } b"color" => rpr.color = attr_val(e, b"val"), b"highlight" => rpr.highlight = attr_val(e, b"val"), b"position" => { @@ -88,6 +94,17 @@ pub(crate) fn parse_rpr_element(reader: &mut Reader<&[u8]>) -> OoxmlResult { rpr.outline = Some(toggle_prop(attr_val(e, b"val").as_deref())); } + b"bdr" => { + // `w:bdr` character border (ECMA-376 §17.3.2.4). + rpr.bdr = Some(crate::docx::model::paragraph::DocxBorderEdge { + val: attr_val(e, b"val").unwrap_or_default(), + sz: attr_val(e, b"sz").as_deref().and_then(|v| v.parse().ok()), + color: attr_val(e, b"color"), + space: attr_val(e, b"space") + .as_deref() + .and_then(|v| v.parse().ok()), + }); + } // A tracked ¶ deletion/insertion on a paragraph mark's rPr. n @ (b"del" | b"ins") => { rpr.mark_rev = Some(parse_mark_revision(n, e)); @@ -186,6 +203,22 @@ pub(crate) fn parse_run(reader: &mut Reader<&[u8]>) -> OoxmlResult { b"tab" => { run.children.push(DocxRunChild::Tab); } + b"noBreakHyphen" => { + // ECMA-376 §17.3.3.18: an always-visible, non-breaking hyphen + // → U+2011 NON-BREAKING HYPHEN (kept as literal run text). + run.children.push(DocxRunChild::Text { + text: "\u{2011}".to_string(), + preserve: true, + }); + } + b"softHyphen" => { + // ECMA-376 §17.3.3.29: an optional hyphen, shown only when the + // line breaks there → U+00AD SOFT HYPHEN. + run.children.push(DocxRunChild::Text { + text: "\u{00AD}".to_string(), + preserve: true, + }); + } b"fldChar" => { if let Some(ft) = attr_val(e, b"fldCharType") { run.children @@ -220,3 +253,7 @@ pub(crate) fn parse_run(reader: &mut Reader<&[u8]>) -> OoxmlResult { } Ok(run) } + +#[cfg(test)] +#[path = "document_run_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/reader/document_run_tests.rs b/loki-ooxml/src/docx/reader/document_run_tests.rs new file mode 100644 index 00000000..1c034f24 --- /dev/null +++ b/loki-ooxml/src/docx/reader/document_run_tests.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for `document_run` (extracted for the 300-line file ceiling). + +use super::*; +use crate::docx::model::paragraph::DocxRunChild; + +/// Concatenate the text of a `` fragment's `Text` children. +fn run_text(xml: &str) -> String { + let mut reader = Reader::from_reader(xml.as_bytes()); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf).unwrap() { + Event::Start(ref e) if local_name(e.local_name().as_ref()) == b"r" => break, + Event::Eof => panic!("no w:r"), + _ => {} + } + } + parse_run(&mut reader) + .unwrap() + .children + .iter() + .filter_map(|c| match c { + DocxRunChild::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect() +} + +#[test] +fn no_break_hyphen_becomes_u2011() { + let xml = r#" + nonbreaking"#; + assert_eq!(run_text(xml), "non\u{2011}breaking"); +} + +#[test] +fn soft_hyphen_becomes_u00ad() { + let xml = r#" + optionalhyphen"#; + assert_eq!(run_text(xml), "optional\u{00ad}hyphen"); +} + +#[test] +fn parses_emboss_imprint_shadow() { + let xml = r#"x"#; + let mut reader = Reader::from_reader(xml.as_bytes()); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf).unwrap() { + Event::Start(ref e) if local_name(e.local_name().as_ref()) == b"r" => break, + Event::Eof => panic!("no w:r"), + _ => {} + } + } + let rpr = parse_run(&mut reader).unwrap().rpr.expect("rpr"); + assert_eq!(rpr.emboss, Some(true), "emboss"); + assert_eq!(rpr.imprint, Some(true), "imprint"); + assert_eq!(rpr.shadow, Some(true), "shadow"); +} diff --git a/loki-ooxml/src/docx/reader/document_table.rs b/loki-ooxml/src/docx/reader/document_table.rs index 4e867767..8507d49c 100644 --- a/loki-ooxml/src/docx/reader/document_table.rs +++ b/loki-ooxml/src/docx/reader/document_table.rs @@ -17,9 +17,17 @@ use super::cell; /// Maximum table/content-control nesting depth accepted from a file. Nested /// tables recurse (`parse_table` → row → cell → `parse_table`), so without a -/// cap a crafted document can exhaust the stack (audit-2026-06 S-1b). Matches -/// `loki-odf`'s `MAX_NESTING_DEPTH`; real documents rarely exceed ~5. -pub(super) const MAX_NESTING_DEPTH: usize = 100; +/// cap a crafted document can exhaust the stack (audit-2026-06 S-1b). +/// +/// A single **table** level costs three stack frames (`parse_table`, +/// `parse_table_row`, `parse_table_cell`), so a depth of 100 recurses ~300 +/// frames — enough to overflow a 2-`MiB` worker-thread stack (a real denial of +/// service when a server parses an uploaded document off the main thread). The +/// cap is chosen to reject deep nesting *before* that point: 50 levels stay +/// comfortably within a 2-`MiB` stack while still being ~10× the deepest real +/// documents (which rarely exceed ~5). The content-control (`w:sdt`) parser +/// shares this budget. +pub(super) const MAX_NESTING_DEPTH: usize = 50; /// Parses a `w:tbl` element. Called after Start("tbl") is consumed. `depth` /// counts enclosing tables/content controls; the top-level caller passes 0. diff --git a/loki-ooxml/src/docx/reader/document_tests.rs b/loki-ooxml/src/docx/reader/document_tests.rs index 072a726a..003c8712 100644 --- a/loki-ooxml/src/docx/reader/document_tests.rs +++ b/loki-ooxml/src/docx/reader/document_tests.rs @@ -256,7 +256,7 @@ fn two_level_table_nesting_parses() { fn excessive_table_nesting_is_rejected_not_stack_overflow() { let err = parse_document(&nested_table_doc(150)).unwrap_err(); assert!( - matches!(err, OoxmlError::NestingTooDeep { limit: 100 }), + matches!(err, OoxmlError::NestingTooDeep { limit: 50 }), "expected NestingTooDeep, got {err:?}" ); } @@ -277,7 +277,7 @@ fn excessive_sdt_nesting_is_rejected() { xml.push_str(""); let err = parse_document(xml.as_bytes()).unwrap_err(); assert!( - matches!(err, OoxmlError::NestingTooDeep { limit: 100 }), + matches!(err, OoxmlError::NestingTooDeep { limit: 50 }), "expected NestingTooDeep, got {err:?}" ); } diff --git a/loki-ooxml/src/docx/reader/sectpr.rs b/loki-ooxml/src/docx/reader/sectpr.rs index f4005399..9a17da2e 100644 --- a/loki-ooxml/src/docx/reader/sectpr.rs +++ b/loki-ooxml/src/docx/reader/sectpr.rs @@ -10,7 +10,10 @@ use quick_xml::{Reader, events::Event}; -use crate::docx::model::paragraph::{DocxCols, DocxHdrFtrRef, DocxPgMar, DocxPgSz, DocxSectPr}; +use crate::docx::model::paragraph::{ + DocxBorderEdge, DocxCols, DocxHdrFtrRef, DocxPgMar, DocxPgSz, DocxSectPr, +}; +use crate::docx::model::section::{DocxLnNumType, DocxPgBorders}; use crate::docx::reader::util::{attr_val, local_name}; use crate::error::{OoxmlError, OoxmlResult}; @@ -107,6 +110,20 @@ pub(crate) fn parse_sect_pr(reader: &mut Reader<&[u8]>) -> OoxmlResult { + let offset_from_text = + attr_val(e, b"offsetFrom").as_deref() == Some("text"); + sect.pg_borders = Some(parse_pg_borders(reader, offset_from_text)?); + } + b"lnNumType" => { + // ECMA-376 §17.6.8: margin line numbering (attribute-only). + sect.ln_num_type = Some(DocxLnNumType { + count_by: attr_val(e, b"countBy").and_then(|v| v.parse().ok()), + start: attr_val(e, b"start").and_then(|v| v.parse().ok()), + restart: attr_val(e, b"restart"), + distance: attr_val(e, b"distance").and_then(|v| v.parse().ok()), + }); + } _ => {} } } @@ -126,3 +143,106 @@ pub(crate) fn parse_sect_pr(reader: &mut Reader<&[u8]>) -> OoxmlResult, + offset_from_text: bool, +) -> OoxmlResult { + let mut borders = DocxPgBorders { + offset_from_text, + ..Default::default() + }; + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Empty(ref e) | Event::Start(ref e)) => { + let edge = DocxBorderEdge { + val: attr_val(e, b"val").unwrap_or_default(), + sz: attr_val(e, b"sz").and_then(|v| v.parse().ok()), + color: attr_val(e, b"color"), + space: attr_val(e, b"space").and_then(|v| v.parse().ok()), + }; + match local_name(e.local_name().as_ref()) { + b"top" => borders.top = Some(edge), + b"bottom" => borders.bottom = Some(edge), + b"left" => borders.left = Some(edge), + b"right" => borders.right = Some(edge), + _ => {} + } + } + Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"pgBorders" => break, + Ok(Event::Eof) => break, + Err(e) => { + return Err(OoxmlError::Xml { + part: "word/document.xml".into(), + source: e, + }); + } + _ => {} + } + buf.clear(); + } + Ok(borders) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Runs `parse_sect_pr` over a `w:sectPr` fragment (advancing past its + /// opening tag first, which the caller normally consumes). + fn parse(xml: &str) -> DocxSectPr { + let mut reader = Reader::from_reader(xml.as_bytes()); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf).unwrap() { + Event::Start(ref e) if local_name(e.local_name().as_ref()) == b"sectPr" => break, + Event::Eof => panic!("no sectPr"), + _ => {} + } + } + parse_sect_pr(&mut reader).unwrap() + } + + #[test] + fn parses_page_borders() { + let xml = r#" + + + + + + + + "#; + let pb = parse(xml).pg_borders.expect("pg_borders parsed"); + assert!(pb.top.is_some() && pb.left.is_some() && pb.bottom.is_some() && pb.right.is_some()); + assert!(!pb.offset_from_text, "offsetFrom=page → not from text"); + let top = pb.top.as_ref().unwrap(); + assert_eq!(top.space, Some(24)); + assert_eq!(top.sz, Some(8)); + } + + #[test] + fn offset_from_text_is_captured() { + let xml = r#" + + "#; + let pb = parse(xml).pg_borders.expect("pg_borders"); + assert!(pb.offset_from_text); + } + + #[test] + fn parses_line_numbering() { + let xml = r#" + + "#; + let ln = parse(xml).ln_num_type.expect("lnNumType parsed"); + assert_eq!(ln.count_by, Some(1)); + assert_eq!(ln.start, Some(1)); + assert_eq!(ln.restart.as_deref(), Some("newPage")); + assert_eq!(ln.distance, None); + } +} diff --git a/loki-ooxml/src/docx/reader/styles.rs b/loki-ooxml/src/docx/reader/styles.rs index 3fc55675..c9e201c1 100644 --- a/loki-ooxml/src/docx/reader/styles.rs +++ b/loki-ooxml/src/docx/reader/styles.rs @@ -6,8 +6,9 @@ use quick_xml::Reader; use quick_xml::events::Event; +use crate::docx::model::paragraph::DocxBorderEdge; use crate::docx::model::styles::{ - DocxStyle, DocxStyleType, DocxStyles, DocxTableStyleProps, DocxTblStylePr, + DocxStyle, DocxStyleType, DocxStyles, DocxTableStyleProps, DocxTblBorders, DocxTblStylePr, }; use crate::docx::reader::util::{attr_val, local_name}; use crate::error::{OoxmlError, OoxmlResult}; @@ -142,6 +143,13 @@ pub fn parse_styles(xml: &[u8]) -> OoxmlResult { }); } } + b"tblBorders" if in_style && current_region.is_none() => { + if let Ok(borders) = parse_tbl_borders(&mut reader) + && let Some(t) = table_props_mut(&mut current_style) + { + t.tbl_borders = Some(borders); + } + } b"tcPr" if in_style => in_tcpr = true, b"shd" if in_style && in_tcpr => { let fill = attr_val(e, b"fill"); @@ -192,108 +200,45 @@ fn table_props_mut(style: &mut Option) -> Option<&mut DocxTableStyleP style.as_mut().and_then(|s| s.table.as_mut()) } -#[cfg(test)] -mod tests { - use super::*; - - const MINIMAL_STYLES: &[u8] = br#" - - - - - - - - - - - - - -"#; - - #[test] - fn parses_normal_style() { - let styles = parse_styles(MINIMAL_STYLES).unwrap(); - assert!( - styles - .styles - .iter() - .any(|s| s.style_id == "Normal" && s.is_default) - ); - } - - #[test] - fn parses_heading1_based_on() { - let styles = parse_styles(MINIMAL_STYLES).unwrap(); - let h1 = styles - .styles - .iter() - .find(|s| s.style_id == "Heading1") - .unwrap(); - assert_eq!(h1.based_on.as_deref(), Some("Normal")); - } - - #[test] - fn parses_character_style() { - let styles = parse_styles(MINIMAL_STYLES).unwrap(); - assert!( - styles - .styles - .iter() - .any(|s| s.style_type == DocxStyleType::Character) - ); - } - - const TABLE_STYLE: &[u8] = br#" - - - - - - - - - - - - - - - - -"#; - - #[test] - fn parses_table_style_banding() { - let styles = parse_styles(TABLE_STYLE).unwrap(); - let t = styles - .styles - .iter() - .find(|s| s.style_id == "Banded") - .and_then(|s| s.table.as_ref()) - .expect("table props parsed"); - assert_eq!(t.row_band_size, Some(2)); - assert_eq!(t.col_band_size, Some(1)); - assert_eq!(t.base_shd_fill.as_deref(), Some("FFFFFF")); - assert_eq!(t.conditional.len(), 2); - let first_row = t - .conditional - .iter() - .find(|c| c.region == "firstRow") - .unwrap(); - assert_eq!(first_row.shd_fill.as_deref(), Some("4472C4")); - let band = t - .conditional - .iter() - .find(|c| c.region == "band1Horz") - .unwrap(); - assert_eq!(band.shd_fill.as_deref(), Some("D9E2F3")); - } - - #[test] - fn non_table_style_has_no_table_props() { - let styles = parse_styles(MINIMAL_STYLES).unwrap(); - assert!(styles.styles.iter().all(|s| s.table.is_none())); +/// Parses a `w:tblBorders` element (six edges incl. interior gridlines). +/// Called after its Start event; consumes through the matching End. +fn parse_tbl_borders(reader: &mut Reader<&[u8]>) -> OoxmlResult { + let mut borders = DocxTblBorders::default(); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Empty(ref e) | Event::Start(ref e)) => { + let edge = DocxBorderEdge { + val: attr_val(e, b"val").unwrap_or_default(), + sz: attr_val(e, b"sz").and_then(|v| v.parse().ok()), + color: attr_val(e, b"color"), + space: attr_val(e, b"space").and_then(|v| v.parse().ok()), + }; + match local_name(e.local_name().as_ref()) { + b"top" => borders.top = Some(edge), + b"bottom" => borders.bottom = Some(edge), + b"left" | b"start" => borders.left = Some(edge), + b"right" | b"end" => borders.right = Some(edge), + b"insideH" => borders.inside_h = Some(edge), + b"insideV" => borders.inside_v = Some(edge), + _ => {} + } + } + Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"tblBorders" => break, + Ok(Event::Eof) => break, + Err(e) => { + return Err(OoxmlError::Xml { + part: "word/styles.xml".into(), + source: e, + }); + } + _ => {} + } + buf.clear(); } + Ok(borders) } + +#[cfg(test)] +#[path = "styles_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/reader/styles_tests.rs b/loki-ooxml/src/docx/reader/styles_tests.rs new file mode 100644 index 00000000..cf76da3f --- /dev/null +++ b/loki-ooxml/src/docx/reader/styles_tests.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Unit tests for the `word/styles.xml` reader (extracted from +//! `styles.rs` to keep it under the file-size ceiling). + +use super::*; + +const MINIMAL_STYLES: &[u8] = br#" + + + + + + + + + + + + + +"#; + +#[test] +fn parses_normal_style() { + let styles = parse_styles(MINIMAL_STYLES).unwrap(); + assert!( + styles + .styles + .iter() + .any(|s| s.style_id == "Normal" && s.is_default) + ); +} + +#[test] +fn parses_heading1_based_on() { + let styles = parse_styles(MINIMAL_STYLES).unwrap(); + let h1 = styles + .styles + .iter() + .find(|s| s.style_id == "Heading1") + .unwrap(); + assert_eq!(h1.based_on.as_deref(), Some("Normal")); +} + +#[test] +fn parses_character_style() { + let styles = parse_styles(MINIMAL_STYLES).unwrap(); + assert!( + styles + .styles + .iter() + .any(|s| s.style_type == DocxStyleType::Character) + ); +} + +const TABLE_STYLE: &[u8] = br#" + + + + + + + + + + + + + + + + +"#; + +const TABLE_GRID_STYLE: &[u8] = br#" + + + + + + + + + + + + + + +"#; + +#[test] +fn parses_table_grid_borders() { + let styles = parse_styles(TABLE_GRID_STYLE).unwrap(); + let b = styles + .styles + .iter() + .find(|s| s.style_id == "TableGrid") + .and_then(|s| s.table.as_ref()) + .and_then(|t| t.tbl_borders.as_ref()) + .expect("tblBorders parsed"); + // All six edges present, including the interior gridlines. + assert!(b.top.is_some() && b.bottom.is_some() && b.left.is_some() && b.right.is_some()); + assert!(b.inside_h.is_some(), "insideH gridline parsed"); + assert!(b.inside_v.is_some(), "insideV gridline parsed"); + assert_eq!(b.inside_h.as_ref().unwrap().sz, Some(4)); +} + +#[test] +fn parses_table_style_banding() { + let styles = parse_styles(TABLE_STYLE).unwrap(); + let t = styles + .styles + .iter() + .find(|s| s.style_id == "Banded") + .and_then(|s| s.table.as_ref()) + .expect("table props parsed"); + assert_eq!(t.row_band_size, Some(2)); + assert_eq!(t.col_band_size, Some(1)); + assert_eq!(t.base_shd_fill.as_deref(), Some("FFFFFF")); + assert_eq!(t.conditional.len(), 2); + let first_row = t + .conditional + .iter() + .find(|c| c.region == "firstRow") + .unwrap(); + assert_eq!(first_row.shd_fill.as_deref(), Some("4472C4")); + let band = t + .conditional + .iter() + .find(|c| c.region == "band1Horz") + .unwrap(); + assert_eq!(band.shd_fill.as_deref(), Some("D9E2F3")); +} + +#[test] +fn non_table_style_has_no_table_props() { + let styles = parse_styles(MINIMAL_STYLES).unwrap(); + assert!(styles.styles.iter().all(|s| s.table.is_none())); +} diff --git a/loki-ooxml/src/docx/repair/dom.rs b/loki-ooxml/src/docx/repair/dom.rs new file mode 100644 index 00000000..75880242 --- /dev/null +++ b/loki-ooxml/src/docx/repair/dom.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! A tiny, lossless XML tree used only by the repair pass. +//! +//! `quick-xml` is an event streamer with no DOM, but reordering an element's +//! children needs random access to a subtree. This module parses a part into an +//! owned [`Node`] tree and serialises it back. It preserves declarations, +//! comments, processing instructions, CDATA, entity references, attributes, and +//! significant text verbatim — the repair pass only reorders element children, +//! never rewrites content. + +use quick_xml::events::{BytesStart, Event}; +use quick_xml::{Reader, Writer}; + +use crate::error::OoxmlError; + +/// One node in the tree. +pub(super) enum Node { + /// An element: its start tag (name + attributes), children, and whether it + /// was written self-closing (``). + Elem(Elem), + /// Any non-element event — text, comment, CDATA, PI, XML declaration, + /// doctype, or entity reference — carried verbatim. + Leaf(Event<'static>), +} + +/// An element node. +pub(super) struct Elem { + pub start: BytesStart<'static>, + pub children: Vec, + pub self_closing: bool, +} + +impl Elem { + /// The element's local name (namespace prefix stripped) as a string. + pub fn local(&self) -> String { + String::from_utf8_lossy(self.start.name().local_name().as_ref()).into_owned() + } +} + +/// Parses `xml` into a flat list of top-level nodes (declaration + root). +pub(super) fn parse(xml: &[u8]) -> Result, OoxmlError> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(false); + reader.config_mut().expand_empty_elements = false; + reader.config_mut().check_end_names = false; + + let mut stack: Vec<(BytesStart<'static>, Vec)> = Vec::new(); + let mut roots: Vec = Vec::new(); + let mut buf = Vec::new(); + loop { + let ev = reader + .read_event_into(&mut buf) + .map_err(|source| OoxmlError::Xml { + part: "repair".into(), + source, + })?; + match ev { + Event::Start(e) => stack.push((e.into_owned(), Vec::new())), + Event::End(_) => { + let (start, children) = + stack.pop().ok_or_else(|| OoxmlError::MalformedElement { + element: String::new(), + part: "repair".into(), + reason: "unbalanced end tag".into(), + })?; + push( + &mut stack, + &mut roots, + Node::Elem(Elem { + start, + children, + self_closing: false, + }), + ); + } + Event::Empty(e) => push( + &mut stack, + &mut roots, + Node::Elem(Elem { + start: e.into_owned(), + children: Vec::new(), + self_closing: true, + }), + ), + Event::Eof => break, + other => push(&mut stack, &mut roots, Node::Leaf(other.into_owned())), + } + buf.clear(); + } + if !stack.is_empty() { + return Err(OoxmlError::MalformedElement { + element: String::new(), + part: "repair".into(), + reason: "unclosed element".into(), + }); + } + Ok(roots) +} + +fn push(stack: &mut [(BytesStart<'static>, Vec)], roots: &mut Vec, node: Node) { + if let Some((_, children)) = stack.last_mut() { + children.push(node); + } else { + roots.push(node); + } +} + +/// Serialises a node list back to XML bytes. +pub(super) fn serialize(nodes: &[Node]) -> Vec { + let mut w = Writer::new(Vec::new()); + for n in nodes { + write_node(&mut w, n); + } + w.into_inner() +} + +fn write_node(w: &mut Writer>, node: &Node) { + match node { + Node::Elem(e) if e.self_closing && e.children.is_empty() => { + let _ = w.write_event(Event::Empty(e.start.borrow())); + } + Node::Elem(e) => { + let _ = w.write_event(Event::Start(e.start.borrow())); + for c in &e.children { + write_node(w, c); + } + let _ = w.write_event(Event::End(e.start.to_end())); + } + Node::Leaf(ev) => { + let _ = w.write_event(ev.clone()); + } + } +} + +/// `true` when a leaf is a text node consisting only of XML whitespace — the +/// insignificant inter-element indentation the repair pass may drop when it +/// reorders a container's children. +pub(super) fn is_whitespace_text(node: &Node) -> bool { + matches!(node, Node::Leaf(Event::Text(t)) if t.iter().all(u8::is_ascii_whitespace)) +} diff --git a/loki-ooxml/src/docx/repair/mce.rs b/loki-ooxml/src/docx/repair/mce.rs new file mode 100644 index 00000000..ac076891 --- /dev/null +++ b/loki-ooxml/src/docx/repair/mce.rs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Markup-Compatibility repair: strip **undeclared** namespace prefixes from +//! `mc:Ignorable`. +//! +//! Every prefix listed in an `mc:Ignorable` attribute must resolve to an +//! in-scope namespace declaration (ISO/IEC 29500-3 §10.1.1 "Understanding +//! Namespaces"). A prefix that is never declared is a fatal error in Microsoft +//! Word — the classic "Word found unreadable content" repair prompt — even +//! though tolerant readers (Loki, `LibreOffice`) simply ignore the whole +//! Markup-Compatibility layer and open the file. Word's own output always pairs +//! `mc:Ignorable="w14 …"` with a matching `xmlns:w14="…"` on the same element. +//! +//! The fix is **lossless**: an undeclared prefix cannot bind any element or +//! attribute anywhere in scope, so removing it from `mc:Ignorable` changes +//! nothing a consumer could ever have processed. When stripping empties the +//! attribute, the attribute itself is dropped. Only the `mc:Ignorable` value is +//! rewritten; every other attribute is preserved byte-for-byte. + +use quick_xml::events::BytesStart; + +use super::RepairFinding; +use super::dom::{Elem, Node}; + +/// Walks the tree fixing `mc:Ignorable` on every element, threading the set of +/// namespace prefixes declared by ancestor elements so nested scopes resolve +/// correctly (in practice `mc:Ignorable` sits on a part root, but tracking the +/// scope keeps the check correct wherever it appears). +pub(super) fn fix_ignorable_tree( + nodes: &mut [Node], + part: &str, + apply: bool, + findings: &mut Vec, +) { + walk(nodes, &[], part, apply, findings); +} + +fn walk( + nodes: &mut [Node], + ancestor_prefixes: &[String], + part: &str, + apply: bool, + findings: &mut Vec, +) { + for node in nodes.iter_mut() { + if let Node::Elem(e) = node { + // Prefixes declared on this element extend the ancestor scope. + let mut scope = ancestor_prefixes.to_vec(); + scope.extend(declared_prefixes(&e.start)); + fix_ignorable(e, &scope, part, apply, findings); + walk(&mut e.children, &scope, part, apply, findings); + } + } +} + +/// The namespace prefixes declared by `xmlns:PREFIX="…"` attributes on `start` +/// (the default `xmlns="…"` declaration has no prefix and is skipped). +fn declared_prefixes(start: &BytesStart<'_>) -> Vec { + start + .attributes() + .flatten() + .filter_map(|a| { + a.key + .as_ref() + .strip_prefix(b"xmlns:") + .map(|p| String::from_utf8_lossy(p).into_owned()) + }) + .collect() +} + +/// The raw `mc:Ignorable` attribute value on `start`, if present. +fn ignorable_value(start: &BytesStart<'_>) -> Option { + start.attributes().flatten().find_map(|a| { + (a.key.as_ref() == b"mc:Ignorable").then(|| String::from_utf8_lossy(&a.value).into_owned()) + }) +} + +/// Detects (and, when `apply`, removes) undeclared prefixes in one element's +/// `mc:Ignorable`. +fn fix_ignorable( + e: &mut Elem, + scope: &[String], + part: &str, + apply: bool, + findings: &mut Vec, +) { + let Some(value) = ignorable_value(&e.start) else { + return; + }; + let (kept, dropped): (Vec<&str>, Vec<&str>) = value + .split_whitespace() + .partition(|p| scope.iter().any(|d| d == p)); + if dropped.is_empty() { + return; // every listed prefix resolves — nothing to fix + } + + findings.push(RepairFinding { + part: part.to_string(), + container: "mc:Ignorable".to_string(), + detail: format!( + "mc:Ignorable lists undeclared namespace prefix(es) ({}); Word \ + rejects this as unreadable content — removing the unresolvable prefix(es)", + dropped.join(", ") + ), + }); + + if !apply { + return; + } + rewrite_ignorable(&mut e.start, &kept.join(" ")); +} + +/// Rewrites the `mc:Ignorable` value in `start` to `new_value` (or drops the +/// whole attribute when `new_value` is empty), leaving every other byte of the +/// start tag untouched. Byte-surgery avoids re-escaping the surrounding +/// attributes the way a rebuild via `push_attribute` would. +fn rewrite_ignorable(start: &mut BytesStart<'static>, new_value: &str) { + let name_len = start.name().as_ref().len(); + let buf = start.to_vec(); + let Some(span) = ignorable_span(&buf) else { + return; + }; + + let mut out = Vec::with_capacity(buf.len()); + if new_value.is_empty() { + // Drop the attribute together with one run of leading whitespace so the + // tag does not keep a doubled or trailing separator. + let mut ws = span.key_start; + while ws > 0 && buf[ws - 1].is_ascii_whitespace() { + ws -= 1; + } + out.extend_from_slice(&buf[..ws]); + out.extend_from_slice(&buf[span.attr_end..]); + } else { + out.extend_from_slice(&buf[..span.value_start]); + out.extend_from_slice(new_value.as_bytes()); + out.extend_from_slice(&buf[span.value_end..]); + } + let content = String::from_utf8_lossy(&out).into_owned(); + *start = BytesStart::from_content(content, name_len); +} + +/// Byte offsets of the `mc:Ignorable="…"` attribute inside a start-tag buffer. +struct IgnorableSpan { + /// Offset of the `m` in `mc:Ignorable`. + key_start: usize, + /// Offset just past the opening quote (first byte of the value). + value_start: usize, + /// Offset of the closing quote (one past the last value byte). + value_end: usize, + /// Offset just past the closing quote (end of the whole attribute). + attr_end: usize, +} + +/// Locates the `mc:Ignorable` attribute in a raw start-tag buffer. Requires the +/// key to begin at an attribute boundary (preceded by whitespace) so a stray +/// occurrence inside another attribute's value can never match. +fn ignorable_span(buf: &[u8]) -> Option { + const KEY: &[u8] = b"mc:Ignorable"; + let key_start = (0..buf.len()) + .find(|&i| buf[i..].starts_with(KEY) && i > 0 && buf[i - 1].is_ascii_whitespace())?; + + let mut i = key_start + KEY.len(); + while i < buf.len() && buf[i] != b'=' { + i += 1; + } + i += 1; // step past '=' + while i < buf.len() && buf[i].is_ascii_whitespace() { + i += 1; + } + let quote = *buf.get(i)?; + if quote != b'"' && quote != b'\'' { + return None; + } + let value_start = i + 1; + let value_end = (value_start..buf.len()).find(|&j| buf[j] == quote)?; + Some(IgnorableSpan { + key_start, + value_start, + value_end, + attr_end: value_end + 1, + }) +} diff --git a/loki-ooxml/src/docx/repair/mod.rs b/loki-ooxml/src/docx/repair/mod.rs new file mode 100644 index 00000000..f59411fd --- /dev/null +++ b/loki-ooxml/src/docx/repair/mod.rs @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! DOCX repair: detect and fix defects that make a document unreadable in +//! Microsoft Word while a tolerant reader (Loki, `LibreOffice`) opens it fine. +//! +//! Three axes, all of which Word enforces strictly and tolerant readers ignore: +//! +//! 1. **Schema child order** ([`order`]). OOXML complex types are +//! `xsd:sequence`s, so Word rejects a file whose `w:pPr`/`w:rPr`/`w:sectPr`/… +//! children appear out of order. [`fix_container`] reorders them. +//! 2. **Undeclared `mc:Ignorable` prefixes** ([`mce`]). Every prefix listed in +//! `mc:Ignorable` must resolve to an in-scope `xmlns:` declaration; an +//! unresolvable prefix is fatal to Word. [`mce::fix_ignorable_tree`] strips +//! the dangling prefix(es). +//! 3. **Dangling note-separator references** ([`notes`]). A +//! ``/`` in `settings.xml` may reference the +//! separator notes in `footnotes.xml`/`endnotes.xml`; if that part is absent, +//! Word reports an error in the notes stream. This is a **cross-part** check +//! (the offending element and the missing part are different parts), so it is +//! driven by a [`NoteContext`] built from the whole package. +//! +//! All three produce the classic "Word found unreadable content" repair prompt, +//! and all are **lossless**: ordering only permutes element children (never +//! touches attributes or text), stripping an undeclared `mc:Ignorable` prefix +//! removes a token that could never have bound anything, and dropping a +//! note-separator reference that resolves to nothing conveys no information. +//! Constructs Loki itself cannot model survive untouched. +//! +//! Scope: these three axes (the dominant Word-vs-tolerant asymmetries). Other +//! corruption classes (dangling relationships, missing content types) are out +//! of scope here — the OPC layer already validates those on open. + +use std::io::Cursor; + +use loki_opc::Package; +use loki_opc::part::{PartData, PartName}; + +use crate::error::OoxmlError; + +mod dom; +mod mce; +mod notes; +mod order; + +use dom::{Elem, Node}; +use notes::NoteContext; + +/// Builds the cross-part [`NoteContext`] for a package: the separator-note ids +/// each notes part contains, or `None` when the part is absent. Consulted by the +/// note-separator repair so it can tell a resolvable reference from a dangling +/// one. +fn build_note_context(pkg: &Package) -> NoteContext { + let ids = |path: &str, note: &str| { + PartName::new(path) + .ok() + .and_then(|n| pkg.part(&n)) + .map(|d| notes::collect_note_ids(&d.bytes, note)) + }; + NoteContext { + footnotes: ids("/word/footnotes.xml", "footnote"), + endnotes: ids("/word/endnotes.xml", "endnote"), + } +} + +/// The position of a child `local` name in a schema order table, if present. +fn schema_index(order: &[&str], local: &str) -> Option { + order.iter().position(|&n| n == local) +} + +#[cfg(test)] +#[path = "repair_tests.rs"] +mod tests; + +/// One schema-ordering violation found in a document part. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepairFinding { + /// The OPC part path, e.g. `"word/document.xml"`. + pub part: String, + /// The container element whose children were out of order, e.g. `"w:pPr"`. + pub container: String, + /// Human-readable description of the violation and its fix. + pub detail: String, +} + +/// The outcome of analysing or repairing a document. +#[derive(Debug, Clone, Default)] +pub struct RepairReport { + /// Every ordering violation found (in document order). + pub findings: Vec, + /// `true` if this report came from [`repair_docx`] (fixes applied) rather + /// than [`analyze_docx`] (detection only). + pub repaired: bool, +} + +impl RepairReport { + /// `true` when no violations were found. + #[must_use] + pub fn is_clean(&self) -> bool { + self.findings.is_empty() + } +} + +/// Analyses `docx_bytes` for schema child-ordering violations **without** +/// modifying anything. Use the report to decide whether to offer a repair. +pub fn analyze_docx(docx_bytes: &[u8]) -> Result { + let (_, report) = process(docx_bytes, false)?; + Ok(report) +} + +/// Repairs `docx_bytes`, returning the corrected package bytes and a report of +/// what was fixed. When the report [`is_clean`](RepairReport::is_clean), the +/// returned bytes are a faithful re-serialisation with no ordering changes. +pub fn repair_docx(docx_bytes: &[u8]) -> Result<(Vec, RepairReport), OoxmlError> { + process(docx_bytes, true) +} + +/// `true` for the `WordprocessingML` XML parts whose element order Word +/// enforces. The `DrawingML` theme part uses a different schema (its elements +/// never match our tables, but we skip it explicitly), and media/binary parts +/// are not XML. +// OPC part names for WordprocessingML are always lowercase `.xml` (§7.3), so a +// case-sensitive suffix test is correct here. +#[allow(clippy::case_sensitive_file_extension_comparisons)] +fn is_wml_part(name: &str) -> bool { + name.starts_with("/word/") + && name.ends_with(".xml") + && !name.starts_with("/word/theme/") + && !name.starts_with("/word/media/") +} + +/// Canonicalises the child order of every `WordprocessingML` part in an open +/// package, in place. Shared by [`repair_docx`] and the DOCX **export** path, +/// so files Loki writes open in schema-strict consumers (Word) regardless of +/// the order the serialisers happen to emit children. +pub(crate) fn canonicalize_package(pkg: &mut Package) -> RepairReport { + let names: Vec = pkg + .part_names() + .filter(|n| is_wml_part(n.as_str())) + .cloned() + .collect(); + + let note_ctx = build_note_context(pkg); + let mut findings = Vec::new(); + for name in names { + let Some(part) = pkg.part(&name) else { + continue; + }; + let (bytes, media_type) = (part.bytes.clone(), part.media_type.clone()); + let display = name.as_str().trim_start_matches('/').to_string(); + if let Some(repaired) = repair_part(&display, &bytes, ¬e_ctx, true, &mut findings) { + pkg.set_part(name, PartData::new(repaired, media_type)); + } + } + RepairReport { + findings, + repaired: true, + } +} + +/// Reorders one part's children (best effort). Returns the rewritten bytes when +/// `apply` and something changed, else `None`. A part that does not parse as XML +/// is left untouched — malformed XML is a different error class handled on +/// import, not by the ordering pass. +fn repair_part( + display: &str, + bytes: &[u8], + note_ctx: &NoteContext, + apply: bool, + findings: &mut Vec, +) -> Option> { + let mut nodes = dom::parse(bytes).ok()?; + let before = findings.len(); + reorder_tree(&mut nodes, display, apply, findings); + mce::fix_ignorable_tree(&mut nodes, display, apply, findings); + notes::fix_note_separators(&mut nodes, display, note_ctx, apply, findings); + (apply && findings.len() > before).then(|| dom::serialize(&nodes)) +} + +fn process(docx_bytes: &[u8], apply: bool) -> Result<(Vec, RepairReport), OoxmlError> { + let mut pkg = Package::open(Cursor::new(docx_bytes)).map_err(OoxmlError::Opc)?; + + if !apply { + // Analysis only: walk each WML part, collect findings, change nothing. + let names: Vec = pkg + .part_names() + .filter(|n| is_wml_part(n.as_str())) + .cloned() + .collect(); + let note_ctx = build_note_context(&pkg); + let mut findings = Vec::new(); + for name in names { + if let Some(part) = pkg.part(&name) { + let display = name.as_str().trim_start_matches('/').to_string(); + let _ = repair_part( + &display, + &part.bytes.clone(), + ¬e_ctx, + false, + &mut findings, + ); + } + } + return Ok(( + docx_bytes.to_vec(), + RepairReport { + findings, + repaired: false, + }, + )); + } + + let report = canonicalize_package(&mut pkg); + let out = if report.is_clean() { + docx_bytes.to_vec() + } else { + let mut buf = Cursor::new(Vec::new()); + pkg.write(&mut buf).map_err(OoxmlError::Opc)?; + buf.into_inner() + }; + Ok((out, report)) +} + +/// Depth-first walk: recurse into every element, then reorder the children of +/// any order-checked container. +fn reorder_tree(nodes: &mut [Node], part: &str, apply: bool, findings: &mut Vec) { + for node in nodes.iter_mut() { + if let Node::Elem(e) = node { + reorder_tree(&mut e.children, part, apply, findings); + if let Some(order) = order::schema_order(&e.local()) { + fix_container(e, order, part, apply, findings); + } + } + } +} + +/// Checks (and optionally fixes) one container's child order. +fn fix_container( + e: &mut Elem, + order: &[&str], + part: &str, + apply: bool, + findings: &mut Vec, +) { + // Classify children. Bail out conservatively if the container holds anything + // we can't safely position: a foreign element (e.g. an `mc:AlternateContent` + // or `w14:*` extension) or significant text/comment content. + let mut idxs = Vec::new(); + for c in &e.children { + match c { + Node::Elem(ce) => match schema_index(order, &ce.local()) { + Some(i) => idxs.push(i), + None => return, // foreign element — leave the whole container alone + }, + Node::Leaf(_) if dom::is_whitespace_text(c) => {} + Node::Leaf(_) => return, // comment / significant text — leave it alone + } + } + if idxs.windows(2).all(|w| w[0] <= w[1]) { + return; // already in schema order + } + + let names: Vec = e + .children + .iter() + .filter_map(|c| match c { + Node::Elem(ce) => Some(format!("w:{}", ce.local())), + Node::Leaf(_) => None, + }) + .collect(); + findings.push(RepairFinding { + part: part.to_string(), + container: format!("w:{}", e.local()), + detail: format!( + "child elements out of the required ECMA-376 order ({}); \ + Word rejects this — reorder to schema sequence", + names.join(", ") + ), + }); + + if !apply { + return; + } + // Rebuild: keep only the element children, stable-sorted by schema index + // (dropping the insignificant inter-element whitespace). + let mut elems: Vec = e + .children + .drain(..) + .filter_map(|c| match c { + Node::Elem(ce) => Some(ce), + Node::Leaf(_) => None, + }) + .collect(); + elems.sort_by_key(|ce| schema_index(order, &ce.local()).unwrap_or(usize::MAX)); + e.children = elems.into_iter().map(Node::Elem).collect(); +} diff --git a/loki-ooxml/src/docx/repair/notes.rs b/loki-ooxml/src/docx/repair/notes.rs new file mode 100644 index 00000000..e9d67a16 --- /dev/null +++ b/loki-ooxml/src/docx/repair/notes.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Cross-part repair: drop footnote/endnote **separator references** in +//! `settings.xml` that have no backing notes part. +//! +//! A ``/`` block in `settings.xml` may carry +//! ``/`` children — references to the +//! special *separator* notes (ids `-1`/`0`) that live in `footnotes.xml` / +//! `endnotes.xml`. Word resolves each reference against that stream and reports +//! an error in "Footnotes"/"Endnotes" when the stream (or the referenced id) is +//! absent; a tolerant reader (Loki) ignores the dangling reference and opens the +//! file. Word's own output always pairs such a reference with a backing part. +//! +//! Unlike the ordering and `mc:Ignorable` passes this is a **cross-part** check: +//! the offending element is in `settings.xml`, but whether it is offending +//! depends on the *other* parts. [`NoteContext`] carries the ids each notes part +//! actually contains (or `None` when the part is absent). +//! +//! The fix is **lossless**: a reference that resolves to nothing conveys no +//! information, so removing the dangling ``/`` child — +//! leaving every other setting, and any reference that *does* resolve, intact — +//! changes nothing a consumer could have acted on. An emptied `` block +//! remains a valid empty element. + +use std::collections::HashSet; + +use super::RepairFinding; +use super::dom::{Elem, Node}; + +/// Which note stream a `` block concerns. +#[derive(Clone, Copy)] +enum Kind { + Footnote, + Endnote, +} + +impl Kind { + /// The separator-reference element name (`footnote`/`endnote`). + fn note(self) -> &'static str { + match self { + Kind::Footnote => "footnote", + Kind::Endnote => "endnote", + } + } + /// The settings container name (`footnotePr`/`endnotePr`). + fn pr(self) -> &'static str { + match self { + Kind::Footnote => "footnotePr", + Kind::Endnote => "endnotePr", + } + } + /// The backing part path, for the finding message. + fn part(self) -> &'static str { + match self { + Kind::Footnote => "word/footnotes.xml", + Kind::Endnote => "word/endnotes.xml", + } + } +} + +/// The separator-note ids each notes part contains, or `None` when that part is +/// absent from the package. +pub(super) struct NoteContext { + pub footnotes: Option>, + pub endnotes: Option>, +} + +impl NoteContext { + fn ids_for(&self, kind: Kind) -> Option<&HashSet> { + match kind { + Kind::Footnote => self.footnotes.as_ref(), + Kind::Endnote => self.endnotes.as_ref(), + } + } +} + +/// Collects the `w:id` values of the `` elements in a notes part +/// (`footnotes.xml`/`endnotes.xml`), for building a [`NoteContext`]. A part that +/// does not parse yields an empty set (the ordering pass reports malformed XML). +pub(super) fn collect_note_ids(bytes: &[u8], note: &str) -> HashSet { + let mut ids = HashSet::new(); + if let Ok(nodes) = super::dom::parse(bytes) { + collect(&nodes, note, &mut ids); + } + ids +} + +fn collect(nodes: &[Node], note: &str, ids: &mut HashSet) { + for n in nodes { + if let Node::Elem(e) = n { + if e.local() == note { + if let Some(id) = attr_id(e) { + ids.insert(id); + } + } + collect(&e.children, note, ids); + } + } +} + +/// The `w:id` attribute value of an element, if present. +fn attr_id(e: &Elem) -> Option { + e.start.attributes().flatten().find_map(|a| { + (a.key.as_ref() == b"w:id").then(|| String::from_utf8_lossy(&a.value).into_owned()) + }) +} + +/// Removes dangling footnote/endnote separator references from `settings.xml`. +/// A no-op for every other part. +pub(super) fn fix_note_separators( + nodes: &mut [Node], + part: &str, + ctx: &NoteContext, + apply: bool, + findings: &mut Vec, +) { + if part != "word/settings.xml" { + return; + } + walk(nodes, ctx, apply, findings); +} + +fn walk(nodes: &mut [Node], ctx: &NoteContext, apply: bool, findings: &mut Vec) { + for n in nodes.iter_mut() { + if let Node::Elem(e) = n { + let local = e.local(); + for kind in [Kind::Footnote, Kind::Endnote] { + if local == kind.pr() { + fix_block(e, kind, ctx, apply, findings); + } + } + walk(&mut e.children, ctx, apply, findings); + } + } +} + +/// Checks one `` block and, when `apply`, drops its dangling refs. +fn fix_block( + e: &mut Elem, + kind: Kind, + ctx: &NoteContext, + apply: bool, + findings: &mut Vec, +) { + let backing = ctx.ids_for(kind); + let dangling: Vec = e + .children + .iter() + .filter_map(|c| match c { + Node::Elem(ce) if ce.local() == kind.note() => attr_id(ce), + _ => None, + }) + .filter(|id| !backing.is_some_and(|set| set.contains(id))) + .collect(); + if dangling.is_empty() { + return; + } + + let reason = if backing.is_none() { + format!("{} is absent", kind.part()) + } else { + format!("{} does not contain those separator notes", kind.part()) + }; + findings.push(RepairFinding { + part: "word/settings.xml".to_string(), + container: format!("w:{}", kind.pr()), + detail: format!( + " references separator {}(s) with id(s) {} but {}; Word reports an error \ + in the notes stream — removing the dangling reference(s)", + kind.pr(), + kind.note(), + dangling.join(", "), + reason, + ), + }); + if !apply { + return; + } + + let remove: HashSet = dangling.into_iter().collect(); + e.children.retain(|c| match c { + Node::Elem(ce) if ce.local() == kind.note() => { + attr_id(ce).is_none_or(|id| !remove.contains(&id)) + } + _ => true, + }); +} diff --git a/loki-ooxml/src/docx/repair/order.rs b/loki-ooxml/src/docx/repair/order.rs new file mode 100644 index 00000000..c4b379c9 --- /dev/null +++ b/loki-ooxml/src/docx/repair/order.rs @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Canonical child-element order for the ordering-sensitive `WordprocessingML` +//! complex types (ECMA-376 Part 1, §17). +//! +//! OOXML schemas define each complex type as an `xsd:sequence`, so a conforming +//! consumer (Microsoft Word) rejects a file whose child elements appear out of +//! order — even though a tolerant, name-matching reader (Loki, `LibreOffice`) +//! loads it fine. These tables give the required order for the containers that +//! most commonly carry hand-authored or tool-emitted violations. Names are +//! **local names** (namespace prefix stripped), matched case-sensitively. + +/// The canonical child order for a container `local`, or `None` if the +/// container is not order-checked (its children may appear in any order). +#[must_use] +pub(super) fn schema_order(local: &str) -> Option<&'static [&'static str]> { + Some(match local { + "pPr" => &PPR, + "rPr" => &RPR, + "sectPr" => &SECT_PR, + "tcPr" => &TC_PR, + "tblPr" => &TBL_PR, + "trPr" => &TR_PR, + "lvl" => &LVL, + "style" => &STYLE, + "abstractNum" => &ABSTRACT_NUM, + _ => return None, + }) +} + +/// `CT_PPr` / `CT_PPrBase` (§17.3.1.26) — paragraph properties. +const PPR: [&str; 36] = [ + "pStyle", + "keepNext", + "keepLines", + "pageBreakBefore", + "framePr", + "widowControl", + "numPr", + "suppressLineNumbers", + "pBdr", + "shd", + "tabs", + "suppressAutoHyphens", + "kinsoku", + "wordWrap", + "overflowPunct", + "topLinePunct", + "autoSpaceDE", + "autoSpaceDN", + "bidi", + "adjustRightInd", + "snapToGrid", + "spacing", + "ind", + "contextualSpacing", + "mirrorIndents", + "suppressOverlap", + "jc", + "textDirection", + "textAlignment", + "textboxTightWrap", + "outlineLvl", + "divId", + "cnfStyle", + "rPr", + "sectPr", + "pPrChange", +]; + +/// `CT_RPr` (§17.3.2.28) — run properties. +const RPR: [&str; 41] = [ + "rStyle", + "rFonts", + "b", + "bCs", + "i", + "iCs", + "caps", + "smallCaps", + "strike", + "dstrike", + "outline", + "shadow", + "emboss", + "imprint", + "noProof", + "snapToGrid", + "vanish", + "webHidden", + "color", + "spacing", + "w", + "kern", + "position", + "sz", + "szCs", + "highlight", + "u", + "effect", + "bdr", + "shd", + "fitText", + "vertAlign", + "rtl", + "cs", + "em", + "lang", + "eastAsianLayout", + "specVanish", + "oMath", + "rPrChange", + "del", +]; + +/// `CT_SectPr` (§17.6.17) — section properties. +const SECT_PR: [&str; 21] = [ + "headerReference", + "footerReference", + "footnotePr", + "endnotePr", + "type", + "pgSz", + "pgMar", + "paperSrc", + "pgBorders", + "lnNumType", + "pgNumType", + "cols", + "formProt", + "vAlign", + "noEndnote", + "titlePg", + "textDirection", + "bidi", + "rtlGutter", + "docGrid", + "printerSettings", +]; + +/// `CT_TcPr` (§17.4.70) — table-cell properties. +const TC_PR: [&str; 18] = [ + "cnfStyle", + "tcW", + "gridSpan", + "hMerge", + "vMerge", + "tcBorders", + "shd", + "noWrap", + "tcMar", + "textDirection", + "tcFitText", + "vAlign", + "hideMark", + "headers", + "cellIns", + "cellDel", + "cellMerge", + "tcPrChange", +]; + +/// `CT_TblPr` (§17.4.60) — table properties. +const TBL_PR: [&str; 18] = [ + "tblStyle", + "tblpPr", + "tblOverlap", + "bidiVisual", + "tblStyleRowBandSize", + "tblStyleColBandSize", + "tblW", + "jc", + "tblCellSpacing", + "tblInd", + "tblBorders", + "shd", + "tblLayout", + "tblCellMar", + "tblLook", + "tblCaption", + "tblDescription", + "tblPrChange", +]; + +/// `CT_TrPr` (§17.4.79) — table-row properties. +const TR_PR: [&str; 15] = [ + "cnfStyle", + "divId", + "gridBefore", + "gridAfter", + "wBefore", + "wAfter", + "cantSplit", + "trHeight", + "tblHeader", + "tblCellSpacing", + "jc", + "hidden", + "ins", + "del", + "trPrChange", +]; + +/// `CT_Lvl` (§17.9.6) — a numbering level definition. +const LVL: [&str; 12] = [ + "start", + "numFmt", + "lvlRestart", + "pStyle", + "isLgl", + "suff", + "lvlText", + "lvlPicBulletId", + "legacy", + "lvlJc", + "pPr", + "rPr", +]; + +/// `CT_Style` (§17.7.4.17) — a style definition. +const STYLE: [&str; 22] = [ + "name", + "aliases", + "basedOn", + "next", + "link", + "autoRedefine", + "hidden", + "uiPriority", + "semiHidden", + "unhideWhenUsed", + "qFormat", + "locked", + "personal", + "personalCompose", + "personalReply", + "rsid", + "pPr", + "rPr", + "tblPr", + "trPr", + "tcPr", + "tblStylePr", +]; + +/// `CT_AbstractNum` (§17.9.1) — an abstract numbering definition. +const ABSTRACT_NUM: [&str; 7] = [ + "nsid", + "multiLevelType", + "tmpl", + "name", + "styleLink", + "numStyleLink", + "lvl", +]; diff --git a/loki-ooxml/src/docx/repair/repair_tests.rs b/loki-ooxml/src/docx/repair/repair_tests.rs new file mode 100644 index 00000000..42b54579 --- /dev/null +++ b/loki-ooxml/src/docx/repair/repair_tests.rs @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Unit tests for the DOCX child-ordering repair pass, exercising the internal +//! DOM + reorder directly on XML fragments (no OPC packaging needed). + +use super::*; + +/// Parse → walk → serialize a WML fragment; return (output, findings). +fn run(xml: &str, apply: bool) -> (String, Vec) { + let mut nodes = dom::parse(xml.as_bytes()).expect("parse"); + let mut findings = Vec::new(); + reorder_tree(&mut nodes, "test.xml", apply, &mut findings); + ( + String::from_utf8(dom::serialize(&nodes)).expect("utf8"), + findings, + ) +} + +#[test] +fn ppr_jc_before_spacing_is_reordered() { + let xml = r#""#; + let (out, f) = run(xml, true); + assert_eq!(f.len(), 1, "one violation"); + assert_eq!(f[0].container, "w:pPr"); + let (js, ss) = (out.find("w:jc").unwrap(), out.find("w:spacing").unwrap()); + assert!(ss < js, "spacing must now precede jc: {out}"); +} + +#[test] +fn rpr_color_must_precede_sz() { + let xml = r#""#; + let (out, f) = run(xml, true); + assert_eq!(f.len(), 1); + assert!( + out.find("w:color").unwrap() < out.find("w:sz").unwrap(), + "{out}" + ); +} + +#[test] +fn tcpr_shd_must_precede_valign() { + let xml = r#""#; + let (out, f) = run(xml, true); + assert_eq!(f.len(), 1); + assert!( + out.find("w:shd").unwrap() < out.find("w:vAlign").unwrap(), + "{out}" + ); + // vMerge stays before shd (both already in order relative to each other). + assert!(out.find("w:vMerge").unwrap() < out.find("w:shd").unwrap()); +} + +#[test] +fn already_ordered_is_untouched_and_clean() { + let xml = + r#""#; + let (out, f) = run(xml, true); + assert!(f.is_empty(), "no findings for ordered input"); + assert_eq!(out, xml, "clean input must serialize byte-for-byte"); +} + +#[test] +fn foreign_child_is_left_alone() { + // A container holding an element not in the schema table (an extension) is + // conservatively skipped — we cannot know where to place the unknown child. + let xml = + r#""#; + let (out, f) = run(xml, true); + assert!(f.is_empty(), "foreign child → no repair"); + assert_eq!(out, xml, "unchanged"); +} + +#[test] +fn analyze_only_reports_but_does_not_reorder() { + let xml = r#""#; + let (out, f) = run(xml, false); // apply = false + assert_eq!(f.len(), 1, "still reported"); + assert!( + out.find("w:jc").unwrap() < out.find("w:spacing").unwrap(), + "order NOT changed" + ); +} + +#[test] +fn entities_and_significant_whitespace_are_preserved() { + // The out-of-order pPr must be fixed WITHOUT disturbing the run text, which + // carries a preserved space and character/entity references. A naive + // re-escape would double-encode ` ` → `&#160;`. + let xml = concat!( + r#""#, + r#""#, + r#"a & b c "#, + r#""#, + ); + let (out, f) = run(xml, true); + assert_eq!(f.len(), 1); + assert!( + out.contains("a & b c "), + "text/entities preserved: {out}" + ); + assert!(out.contains(r#"xml:space="preserve""#), "attr preserved"); + // The pPr got reordered but the run is intact after it. + assert!(out.find("w:spacing").unwrap() < out.find("w:jc").unwrap()); +} + +#[test] +fn nested_rpr_in_ppr_is_also_reordered() { + // Paragraph-mark run props (w:rPr inside w:pPr) are reached by the recursion. + let xml = concat!( + r#""#, + r#""#, + r#""#, + ); + let (out, f) = run(xml, true); + assert_eq!(f.len(), 1, "the inner rPr is out of order"); + assert!( + out.find("w:color").unwrap() < out.find("w:sz").unwrap(), + "{out}" + ); +} + +#[test] +fn comment_nodes_are_preserved_and_block_reorder() { + // An XML comment inside a container is significant to a maintainer; we do not + // reorder around it (conservative) and must not drop it. + let xml = r#""#; + let (out, f) = run(xml, true); + assert!(f.is_empty(), "comment present → skip reorder"); + assert!(out.contains(""), "comment preserved: {out}"); +} + +// ── mc:Ignorable (undeclared-prefix) repair ────────────────────────────────── + +/// Parse → mc:Ignorable fix → serialize; return (output, findings). +fn run_mce(xml: &str, apply: bool) -> (String, Vec) { + let mut nodes = dom::parse(xml.as_bytes()).expect("parse"); + let mut findings = Vec::new(); + mce::fix_ignorable_tree(&mut nodes, "test.xml", apply, &mut findings); + ( + String::from_utf8(dom::serialize(&nodes)).expect("utf8"), + findings, + ) +} + +#[test] +fn undeclared_ignorable_prefix_is_stripped_declared_one_kept() { + // The real ACID2 bug: `mc:Ignorable="w14"` with no `xmlns:w14`. Here w15 is + // declared and must survive; w14 is not and must be removed. + let xml = concat!( + r#""#, + ); + let (out, f) = run_mce(xml, true); + assert_eq!(f.len(), 1, "one violation"); + assert_eq!(f[0].container, "mc:Ignorable"); + assert!( + f[0].detail.contains("w14"), + "names the dropped prefix: {out}" + ); + assert!(out.contains(r#"mc:Ignorable="w15""#), "w14 stripped: {out}"); + assert!(!out.contains("w14"), "no trace of w14 remains: {out}"); +} + +#[test] +fn fully_declared_ignorable_is_untouched_and_clean() { + // Matches Word's own output (and the fixed ACID2 styles.xml): the prefix is + // declared, so there is nothing to fix and the bytes are preserved exactly. + let xml = r#""#; + let (out, f) = run_mce(xml, true); + assert!(f.is_empty(), "declared prefix → no finding"); + assert_eq!(out, xml, "clean input serializes byte-for-byte"); +} + +#[test] +fn stripping_the_only_prefix_removes_the_whole_attribute() { + // Nothing left to ignore → drop `mc:Ignorable` entirely, without leaving a + // doubled space, and preserve the surrounding attributes byte-for-byte. + let xml = r#""#; + let (out, f) = run_mce(xml, true); + assert_eq!(f.len(), 1); + assert!(!out.contains("mc:Ignorable"), "attribute dropped: {out}"); + assert!(out.contains(r#"xmlns:mc="mc""#), "other attrs kept: {out}"); + assert!(!out.contains(" "), "no doubled whitespace: {out}"); +} + +#[test] +fn ignorable_analyze_only_reports_but_does_not_edit() { + let xml = r#""#; + let (out, f) = run_mce(xml, false); // apply = false + assert_eq!(f.len(), 1, "still reported"); + assert!( + out.contains(r#"mc:Ignorable="w14""#), + "value NOT changed: {out}" + ); +} + +#[test] +fn ignorable_prefix_declared_on_ancestor_resolves() { + // Scope threading: a prefix declared on the root covers `mc:Ignorable` on a + // descendant, so it must NOT be flagged. + let xml = concat!( + r#""#, + r#""#, + r#""#, + ); + let (_out, f) = run_mce(xml, true); + assert!(f.is_empty(), "ancestor-declared prefix resolves"); +} + +// ── note-separator (cross-part) repair ─────────────────────────────────────── + +/// Runs the note-separator fix on a `settings.xml` fragment with a synthetic +/// [`NoteContext`] (`None` = part absent; `Some(ids)` = part with those ids). +fn run_notes( + xml: &str, + footnotes: Option<&[&str]>, + endnotes: Option<&[&str]>, + apply: bool, +) -> (String, Vec) { + let to_set = |v: &[&str]| v.iter().map(ToString::to_string).collect(); + let ctx = notes::NoteContext { + footnotes: footnotes.map(to_set), + endnotes: endnotes.map(to_set), + }; + let mut nodes = dom::parse(xml.as_bytes()).expect("parse"); + let mut findings = Vec::new(); + notes::fix_note_separators(&mut nodes, "word/settings.xml", &ctx, apply, &mut findings); + ( + String::from_utf8(dom::serialize(&nodes)).expect("utf8"), + findings, + ) +} + +const SETTINGS_BOTH: &str = concat!( + r#""#, + r#""#, + r#""#, + r#""#, +); + +#[test] +fn endnote_refs_without_backing_part_are_removed() { + // The real ACID2 bug: endnotePr references separators but there is no + // endnotes.xml. footnotePr is backed by footnotes.xml and must survive. + let (out, f) = run_notes(SETTINGS_BOTH, Some(&["-1", "0"]), None, true); + assert_eq!(f.len(), 1, "one violation (the endnotePr)"); + assert_eq!(f[0].container, "w:endnotePr"); + assert!(!out.contains("w:endnote "), "endnote refs removed: {out}"); + assert!( + out.contains(r#""#) && out.contains(r#""#), + "footnote refs preserved: {out}" + ); + // The now-empty endnotePr block remains a valid element. + assert!(out.contains(""), "empty block kept: {out}"); +} + +#[test] +fn refs_with_a_matching_backing_part_are_untouched() { + // Both parts present and containing the separator ids → nothing to fix, + // byte-for-byte preservation. + let (out, f) = run_notes(SETTINGS_BOTH, Some(&["-1", "0"]), Some(&["-1", "0"]), true); + assert!(f.is_empty(), "all refs resolve → no finding"); + assert_eq!(out, SETTINGS_BOTH, "clean input unchanged"); +} + +#[test] +fn only_the_unresolved_ref_is_removed() { + // Part exists but is missing one referenced id → drop only that reference. + let xml = concat!( + r#""#, + r#""#, + r#""#, + ); + let (out, f) = run_notes(xml, None, Some(&["-1", "0"]), true); + assert_eq!(f.len(), 1); + assert!(f[0].detail.contains('7'), "names the dangling id: {out}"); + assert!(!out.contains(r#"w:id="7""#), "id 7 removed: {out}"); + assert!( + out.contains(r#"w:id="-1""#) && out.contains(r#"w:id="0""#), + "resolvable refs kept: {out}" + ); +} + +#[test] +fn notes_analyze_only_reports_but_does_not_edit() { + let (out, f) = run_notes(SETTINGS_BOTH, Some(&["-1", "0"]), None, false); + assert_eq!(f.len(), 1, "still reported"); + assert!( + out.contains(r#""#), + "NOT edited: {out}" + ); +} + +#[test] +fn note_fix_ignores_non_settings_parts() { + // The same element names can appear in footnotes.xml itself; the fix must + // only touch settings.xml. + let xml = r#""#; + let ctx = notes::NoteContext { + footnotes: None, + endnotes: None, + }; + let mut nodes = dom::parse(xml.as_bytes()).expect("parse"); + let mut findings = Vec::new(); + notes::fix_note_separators(&mut nodes, "word/footnotes.xml", &ctx, true, &mut findings); + assert!(findings.is_empty(), "non-settings part untouched"); +} diff --git a/loki-ooxml/src/docx/write/assembly.rs b/loki-ooxml/src/docx/write/assembly.rs index 4963592b..f0134b43 100644 --- a/loki-ooxml/src/docx/write/assembly.rs +++ b/loki-ooxml/src/docx/write/assembly.rs @@ -246,6 +246,14 @@ pub(crate) fn assemble_docx_kind( ct.add_default(ext, mime); } - // ── Step 5: Write ZIP ───────────────────────────────────────────────── + // ── Step 5: Canonicalise child order, then write ZIP ────────────────── + // The per-part serialisers emit content correctly but do not all emit + // `pPr`/`rPr`/… children in the strict `xsd:sequence` order that + // schema-validating consumers (Microsoft Word) require to open the file — + // tolerant readers (Loki, LibreOffice) accept any order. This pass reorders + // them so a document Loki writes opens in Word. It is semantics-preserving + // (only element order changes), so it does not affect re-import. See + // `docx::repair`. + crate::docx::repair::canonicalize_package(&mut pkg); pkg.write(writer).map_err(OoxmlError::Opc) } diff --git a/loki-ooxml/src/docx/write/document.rs b/loki-ooxml/src/docx/write/document.rs index c8a02b08..f2f98182 100644 --- a/loki-ooxml/src/docx/write/document.rs +++ b/loki-ooxml/src/docx/write/document.rs @@ -30,6 +30,8 @@ mod inlines; mod para; #[path = "document_table.rs"] mod table; +#[path = "document_textbox.rs"] +mod textbox; use blocks::{write_code_block, write_horizontal_rule, write_line_block, write_list_item}; pub(super) use inlines::write_text_run; diff --git a/loki-ooxml/src/docx/write/document_drawing.rs b/loki-ooxml/src/docx/write/document_drawing.rs index a6d576fd..3ee0a5a4 100644 --- a/loki-ooxml/src/docx/write/document_drawing.rs +++ b/loki-ooxml/src/docx/write/document_drawing.rs @@ -146,7 +146,7 @@ pub(super) fn write_anchor_drawing( } /// Emits the single `wp:wrap*` child selecting the wrap mode/side (§20.4.2.*). -fn write_wrap_element(w: &mut Writer, wrap: FloatWrap) { +pub(super) fn write_wrap_element(w: &mut Writer, wrap: FloatWrap) { let side = match wrap.side { WrapSide::Left => "left", WrapSide::Right => "right", diff --git a/loki-ooxml/src/docx/write/document_inlines.rs b/loki-ooxml/src/docx/write/document_inlines.rs index ff8261b4..b2081f88 100644 --- a/loki-ooxml/src/docx/write/document_inlines.rs +++ b/loki-ooxml/src/docx/write/document_inlines.rs @@ -16,6 +16,7 @@ use super::RunProps; use super::drawing::{ inlines_to_string, write_anchor_drawing, write_bookmark, write_inline_drawing, }; +use super::textbox::write_textbox_drawing; use crate::docx::write::collector::ExportCollector; use crate::docx::write::run_props::emit_char_props; use crate::docx::write::xml::{write_empty, write_end, write_start, wval}; @@ -151,6 +152,9 @@ fn write_inline( write_text_run(w, "[Image]", props); } } + Inline::TextBox(attr, blocks) => { + write_textbox_drawing(w, attr, blocks, collector); + } Inline::Note(kind, blocks) => { let note_id = match kind { NoteKind::Footnote => collector.add_footnote(blocks.clone()), diff --git a/loki-ooxml/src/docx/write/document_textbox.rs b/loki-ooxml/src/docx/write/document_textbox.rs new file mode 100644 index 00000000..3b9cceaf --- /dev/null +++ b/loki-ooxml/src/docx/write/document_textbox.rs @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Floating `wps` text-box export (`Inline::TextBox`), split from +//! `document_inlines.rs`/`document_drawing.rs` for the 300-line ceiling. +//! +//! Writes a `w:drawing`/`wp:anchor` whose `a:graphicData` is a +//! `wps:wsp` shape carrying a `w:txbxContent` body — the reverse of the +//! reader's `parse_txbx_content` (`docx/reader/document_drawing.rs`), so an +//! imported text box round-trips: geometry (`wp:extent`), wrap +//! (`wp:wrapSquare`/…), the shape fill + border (`a:solidFill` / `a:ln`), and +//! the interior block content all survive an export→re-import. + +use quick_xml::Writer; +use quick_xml::events::{BytesText, Event}; + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::float::FloatWrap; + +use crate::docx::write::collector::ExportCollector; +use crate::docx::write::xml::{NS_A, NS_WPS, write_empty, write_end, write_start}; + +use super::drawing::write_wrap_element; +use super::write_blocks; + +// The `a:graphicData/@uri` identifying a WordprocessingShape (the DrawingML +// content type the reader keys `wps:txbx` off) is the same URI as the `wps` +// namespace, so `NS_WPS` serves both. + +/// Reads a `u64` value stored on `attr.kv` under `key` (geometry EMU), or `0`. +fn kv_u64(attr: &NodeAttr, key: &str) -> u64 { + attr.kv + .iter() + .find(|(k, _)| k == key) + .and_then(|(_, v)| v.parse().ok()) + .unwrap_or(0) +} + +/// Reads a `&str` value stored on `attr.kv` under `key`. +fn kv_str<'a>(attr: &'a NodeAttr, key: &str) -> Option<&'a str> { + attr.kv + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) +} + +/// Emits an [`Inline::TextBox`] as an anchored `wps` shape drawing. +/// +/// Position is not modelled (as with [`super::drawing::write_anchor_drawing`]) — +/// the anchor is written at a zero column/paragraph offset and the reader +/// recovers only the wrap; re-import reconstructs the same box. +pub(super) fn write_textbox_drawing( + w: &mut Writer, + attr: &NodeAttr, + blocks: &[Block], + collector: &mut ExportCollector, +) { + let cx = kv_u64(attr, "cx_emu").to_string(); + let cy = kv_u64(attr, "cy_emu").to_string(); + let wrap = FloatWrap::read_or_class_default(attr).unwrap_or_default(); + let behind = if wrap.behind_text { "1" } else { "0" }; + + let _ = write_start(w, "w:r", &[]); + let _ = write_start(w, "w:drawing", &[]); + let _ = write_start( + w, + "wp:anchor", + &[ + ("distT", "0"), + ("distB", "0"), + ("distL", "0"), + ("distR", "0"), + ("simplePos", "0"), + ("relativeHeight", "0"), + ("behindDoc", behind), + ("locked", "0"), + ("layoutInCell", "1"), + ("allowOverlap", "1"), + ], + ); + let _ = write_empty(w, "wp:simplePos", &[("x", "0"), ("y", "0")]); + write_zero_position(w, "wp:positionH", "column"); + write_zero_position(w, "wp:positionV", "paragraph"); + let _ = write_empty(w, "wp:extent", &[("cx", &cx), ("cy", &cy)]); + write_wrap_element(w, wrap); + let _ = write_empty(w, "wp:docPr", &[("id", "1"), ("name", "TextBox")]); + + write_wsp(w, attr, blocks, collector); + + let _ = write_end(w, "wp:anchor"); + let _ = write_end(w, "w:drawing"); + let _ = write_end(w, "w:r"); +} + +/// Writes a `wp:positionH`/`positionV` at a zero offset relative to `rel`. +fn write_zero_position(w: &mut Writer, tag: &str, rel: &str) { + let _ = write_start(w, tag, &[("relativeFrom", rel)]); + let _ = write_start(w, "wp:posOffset", &[]); + let _ = w.write_event(Event::Text(BytesText::new("0"))); + let _ = write_end(w, "wp:posOffset"); + let _ = write_end(w, tag); +} + +/// Emits the `a:graphic`/`a:graphicData`/`wps:wsp` body: the shape properties +/// (fill + border) and the `w:txbxContent` interior blocks. +fn write_wsp( + w: &mut Writer, + attr: &NodeAttr, + blocks: &[Block], + collector: &mut ExportCollector, +) { + let _ = write_start(w, "a:graphic", &[("xmlns:a", NS_A)]); + let _ = write_start(w, "a:graphicData", &[("uri", NS_WPS)]); + let _ = write_start(w, "wps:wsp", &[("xmlns:wps", NS_WPS)]); + + let _ = write_start(w, "wps:spPr", &[]); + // Fill `a:srgbClr` must precede the `a:ln` border so the reader (which keys + // the first non-`ln` `srgbClr` as the fill) recovers each colour correctly. + if let Some(fill) = kv_str(attr, "textbox-fill") { + let _ = write_start(w, "a:solidFill", &[]); + let _ = write_empty(w, "a:srgbClr", &[("val", fill)]); + let _ = write_end(w, "a:solidFill"); + } + if let Some(line) = kv_str(attr, "textbox-line") { + // 1 pt border = 12700 EMU; matches the reader's `a:ln/@w` parse. + let _ = write_start(w, "a:ln", &[("w", "12700")]); + let _ = write_start(w, "a:solidFill", &[]); + let _ = write_empty(w, "a:srgbClr", &[("val", line)]); + let _ = write_end(w, "a:solidFill"); + let _ = write_end(w, "a:ln"); + } + let _ = write_end(w, "wps:spPr"); + + let _ = write_start(w, "wps:txbx", &[]); + let _ = write_start(w, "w:txbxContent", &[]); + write_blocks(w, blocks, collector, 0); + let _ = write_end(w, "w:txbxContent"); + let _ = write_end(w, "wps:txbx"); + + let _ = write_empty(w, "wps:bodyPr", &[]); + + let _ = write_end(w, "wps:wsp"); + let _ = write_end(w, "a:graphicData"); + let _ = write_end(w, "a:graphic"); +} diff --git a/loki-ooxml/src/docx/write/run_props.rs b/loki-ooxml/src/docx/write/run_props.rs index 8ab06e06..ce562997 100644 --- a/loki-ooxml/src/docx/write/run_props.rs +++ b/loki-ooxml/src/docx/write/run_props.rs @@ -15,7 +15,8 @@ use quick_xml::Writer; use loki_doc_model::style::props::char_props::{CharProps, HighlightColor}; use crate::docx::write::xml::{ - hex_color_val, pts_to_half_pts, pts_to_twips, write_empty, write_end, write_start, wval, + color_to_hex, hex_color_val, pts_to_half_pts, pts_to_twips, write_empty, write_end, + write_start, wval, }; /// Writes a `` element from [`CharProps`] (nothing if no field is set). @@ -29,7 +30,10 @@ pub(crate) fn write_char_props_elem(w: &mut Writer, cp: &C || cp.color.is_some() || cp.background_color.is_some() || cp.small_caps.is_some() - || cp.vertical_align.is_some(); + || cp.vertical_align.is_some() + || cp.emboss.is_some() + || cp.imprint.is_some() + || cp.character_border.is_some(); if !has_content { return; } @@ -74,6 +78,12 @@ pub(crate) fn emit_char_props(w: &mut Writer, cp: &CharPro if cp.shadow == Some(true) { let _ = write_empty(w, "w:shadow", &[]); } + if cp.emboss == Some(true) { + let _ = write_empty(w, "w:emboss", &[]); + } + if cp.imprint == Some(true) { + let _ = write_empty(w, "w:imprint", &[]); + } if let Some(ref ul) = cp.underline { use loki_doc_model::style::props::char_props::UnderlineStyle; let v = match ul { @@ -151,6 +161,9 @@ pub(crate) fn emit_char_props(w: &mut Writer, cp: &CharPro let v = if kern { "2" } else { "0" }; let _ = write_empty(w, "w:kern", &wval(v)); } + if let Some(ref bdr) = cp.character_border { + write_char_border(w, bdr); + } if cp.language.is_some() || cp.language_complex.is_some() || cp.language_east_asian.is_some() { let mut attrs: Vec<(&str, &str)> = Vec::new(); if let Some(ref l) = cp.language { @@ -166,6 +179,54 @@ pub(crate) fn emit_char_props(w: &mut Writer, cp: &CharPro } } +/// Writes a `w:bdr` character border (ECMA-376 §17.3.2.4) — the reverse of the +/// reader's `w:bdr` parse + `map_border_edge`: style → `w:val`, width in points → +/// `w:sz` (eighth-points, clamped to OOXML's 2..=96), spacing in points → +/// `w:space`, colour → hex (or `auto`). A `BorderStyle::None` border writes +/// `w:val="none"` (an explicit no-border, symmetric with the `"none"` the reader +/// maps back to `BorderStyle::None`). +fn write_char_border( + w: &mut Writer, + b: &loki_doc_model::style::props::border::Border, +) { + use loki_doc_model::style::props::border::BorderStyle; + let val = match b.style { + BorderStyle::None => "none", + BorderStyle::Dashed => "dashed", + BorderStyle::Dotted => "dotted", + BorderStyle::Double => "double", + BorderStyle::Inset => "inset", + BorderStyle::Outset => "outset", + BorderStyle::Wave => "wave", + // Groove/Ridge have no distinct `w:bdr` token; Solid and future variants + // map to `single`. + _ => "single", + }; + // Points → eighth-points, clamped to OOXML's valid 2..=96 `w:sz` range. + #[allow(clippy::cast_possible_truncation)] // clamped to 2..=96 above the cast + let sz = ((b.width.value() * 8.0).round().clamp(2.0, 96.0) as i32).to_string(); + // Spacing is stored in points; `w:space` is points (not twips). Absent → 0. + #[allow(clippy::cast_possible_truncation)] // bounded document measurement + let space = b + .spacing + .map_or(0, |s| s.value().round().clamp(0.0, 31.0) as i32) + .to_string(); + let color = b + .color + .as_ref() + .map_or_else(|| "auto".to_string(), color_to_hex); + let _ = write_empty( + w, + "w:bdr", + &[ + ("w:val", val), + ("w:sz", &sz), + ("w:space", &space), + ("w:color", &color), + ], + ); +} + /// Reverse of the import `map_highlight`: a [`HighlightColor`] to its OOXML /// `w:highlight @w:val` token. `None` (no highlight) yields `None`. fn highlight_val(h: HighlightColor) -> Option<&'static str> { diff --git a/loki-ooxml/src/docx/write/run_props_tests.rs b/loki-ooxml/src/docx/write/run_props_tests.rs index a9e73459..aed1169f 100644 --- a/loki-ooxml/src/docx/write/run_props_tests.rs +++ b/loki-ooxml/src/docx/write/run_props_tests.rs @@ -77,6 +77,38 @@ fn kerning_emits_threshold_and_disabled_zero() { assert!(emit(&off).contains(r#""#)); } +#[test] +fn emboss_and_imprint_are_emitted() { + let cp = CharProps { + emboss: Some(true), + imprint: Some(true), + ..Default::default() + }; + let xml = emit(&cp); + assert!(xml.contains(""), "xml = {xml}"); + assert!(xml.contains(""), "xml = {xml}"); +} + +#[test] +fn character_border_is_emitted() { + use loki_doc_model::style::props::border::{Border, BorderStyle}; + use loki_primitives::color::DocumentColor; + let cp = CharProps { + character_border: Some(Border { + style: BorderStyle::Solid, + width: Points::new(1.0), + color: Some(DocumentColor::from_hex("#C00000").expect("valid hex")), + spacing: Some(Points::new(1.0)), + }), + ..Default::default() + }; + let xml = emit(&cp); + assert!( + xml.contains(r#""#), + "xml = {xml}" + ); +} + #[test] fn complex_font_and_size_are_emitted() { let cp = CharProps { diff --git a/loki-ooxml/src/docx/write/xml.rs b/loki-ooxml/src/docx/write/xml.rs index a4421895..66e1fd4b 100644 --- a/loki-ooxml/src/docx/write/xml.rs +++ b/loki-ooxml/src/docx/write/xml.rs @@ -15,6 +15,7 @@ pub(super) const NS_WP: &str = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; pub(super) const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; pub(super) const NS_PIC: &str = "http://schemas.openxmlformats.org/drawingml/2006/picture"; +pub(super) const NS_WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"; pub(super) const REL_HYPERLINK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; diff --git a/loki-ooxml/src/lib.rs b/loki-ooxml/src/lib.rs index 49873554..a0433543 100644 --- a/loki-ooxml/src/lib.rs +++ b/loki-ooxml/src/lib.rs @@ -65,6 +65,8 @@ pub use docx::export::{DocxExport, DocxTemplateExport}; pub use docx::import::{DocxImport, DocxImportOptions, DocxImportResult}; #[cfg(feature = "docx")] pub use docx::mapper::{MapperError, map_document}; +#[cfg(feature = "docx")] +pub use docx::repair::{RepairFinding, RepairReport, analyze_docx, repair_docx}; #[cfg(feature = "xlsx")] pub use xlsx::export::XlsxExport; diff --git a/loki-ooxml/src/xml_util.rs b/loki-ooxml/src/xml_util.rs index 8e91eead..7e9a93df 100644 --- a/loki-ooxml/src/xml_util.rs +++ b/loki-ooxml/src/xml_util.rs @@ -175,56 +175,9 @@ pub fn hex_color(s: &str) -> Option { )) } -/// Resolves an OOXML `w:shd` shading element to an effective solid fill colour. -/// -/// `w:shd` (ECMA-376 §17.3.5) layers a pattern foreground (`@w:color`) over a -/// background (`@w:fill`) at a coverage implied by `@w:val`: -/// - `clear` / absent → the background `fill` shows through unchanged. -/// - `solid` → the foreground `color` fully covers the cell. -/// - `pctN` → `N`% of `color` blended over `fill` (e.g. `pct25` = 25 % color). -/// - `nil` / `none` → no shading at all. -/// -/// Texture patterns (`horzStripe`, `diagCross`, …) are approximated by their -/// background `fill` — the dominant colour — until per-pixel pattern fills are -/// supported. `auto` fill resolves to white and `auto` color to black, matching -/// Word's automatic-colour resolution for body shading. -/// -/// Returns `None` when the element contributes no visible fill (so the caller -/// leaves `background_color` unset rather than painting white over the page). -#[must_use] -pub fn resolve_shading( - fill: Option<&str>, - val: Option<&str>, - color: Option<&str>, -) -> Option { - let fill_rgb = fill.and_then(hex_color); - let color_rgb = color.and_then(hex_color); - match val.unwrap_or("clear") { - "nil" | "none" => None, - "solid" => color_rgb.or(fill_rgb), - v if v.starts_with("pct") => { - let pct: f32 = v[3..].parse().ok()?; - let frac = (pct / 100.0).clamp(0.0, 1.0); - // `auto` fill → white background; `auto` color → black foreground. - let bg = fill_rgb.unwrap_or_else(|| RgbColor::new(1.0, 1.0, 1.0)); - let fg = color_rgb.unwrap_or_else(|| RgbColor::new(0.0, 0.0, 0.0)); - Some(blend_rgb(bg, fg, frac)) - } - // `clear`, texture patterns, or unknown → background fill only. - _ => fill_rgb, - } -} - -/// Linearly blends `fg` over `bg` at coverage `t` in `[0, 1]`. -#[must_use] -fn blend_rgb(bg: RgbColor, fg: RgbColor, t: f32) -> RgbColor { - let mix = |a: f32, b: f32| a * (1.0 - t) + b * t; - RgbColor::new( - mix(bg.red(), fg.red()), - mix(bg.green(), fg.green()), - mix(bg.blue(), fg.blue()), - ) -} +#[path = "xml_util_shading.rs"] +mod shading; +pub use shading::{resolve_shading, resolve_shading_pattern}; #[cfg(test)] #[path = "xml_util_tests.rs"] diff --git a/loki-ooxml/src/xml_util_shading.rs b/loki-ooxml/src/xml_util_shading.rs new file mode 100644 index 00000000..e5ea8618 --- /dev/null +++ b/loki-ooxml/src/xml_util_shading.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `w:shd` shading resolution, split from `xml_util.rs` for the 300-line +//! ceiling. `resolve_shading` flattens a texture to a solid tint; +//! `resolve_shading_pattern` preserves the hatch geometry + colours so the +//! renderer can draw the actual lines. Both re-exported from `xml_util`. + +use appthere_color::RgbColor; + +use super::hex_color; + +/// Resolves an OOXML `w:shd` shading element to an effective solid fill colour. +/// +/// `w:shd` (ECMA-376 §17.3.5) layers a pattern foreground (`@w:color`) over a +/// background (`@w:fill`) at a coverage implied by `@w:val`: +/// - `clear` / absent → the background `fill` shows through unchanged. +/// - `solid` → the foreground `color` fully covers the cell. +/// - `pctN` → `N`% of `color` blended over `fill` (e.g. `pct25` = 25 % color). +/// - `nil` / `none` → no shading at all. +/// +/// Texture patterns (`horzStripe`, `diagCross`, …) are approximated by their +/// background `fill` — the dominant colour — until per-pixel pattern fills are +/// supported. `auto` fill resolves to white and `auto` color to black, matching +/// Word's automatic-colour resolution for body shading. +/// +/// Returns `None` when the element contributes no visible fill (so the caller +/// leaves `background_color` unset rather than painting white over the page). +#[must_use] +pub fn resolve_shading( + fill: Option<&str>, + val: Option<&str>, + color: Option<&str>, +) -> Option { + let fill_rgb = fill.and_then(hex_color); + let color_rgb = color.and_then(hex_color); + match val.unwrap_or("clear") { + "nil" | "none" => None, + "solid" => color_rgb.or(fill_rgb), + v if v.starts_with("pct") => { + let pct: f32 = v[3..].parse().ok()?; + let frac = (pct / 100.0).clamp(0.0, 1.0); + // `auto` fill → white background; `auto` color → black foreground. + let bg = fill_rgb.unwrap_or_else(|| RgbColor::new(1.0, 1.0, 1.0)); + let fg = color_rgb.unwrap_or_else(|| RgbColor::new(0.0, 0.0, 0.0)); + Some(blend_rgb(bg, fg, frac)) + } + // Line/cross texture patterns (`diagStripe`, `horzStripe`, `diagCross`, + // …): `resolve_shading` flattens the pattern to a tint of the foreground + // `@w:color` over `@w:fill` at the pattern's rough ink coverage — the + // fallback for consumers that cannot draw the actual hatch lines. + v if texture_coverage(v).is_some() => { + let frac = texture_coverage(v)?; + let bg = fill_rgb.unwrap_or_else(|| RgbColor::new(1.0, 1.0, 1.0)); + let fg = color_rgb.unwrap_or_else(|| RgbColor::new(0.0, 0.0, 0.0)); + Some(blend_rgb(bg, fg, frac)) + } + // `clear` or unknown → background fill only. + _ => fill_rgb, + } +} + +/// Approximate ink coverage (`0.0..1.0`) of a `w:shd` line/cross texture +/// pattern, used to flatten it to a solid tint. `None` for non-texture values +/// (`clear`, `solid`, `pctN`, unknown). Densities are eyeballed to Word: +/// `thin*` variants are lighter, cross hatches a touch denser than single +/// stripes. +fn texture_coverage(val: &str) -> Option { + let cov = match val { + "horzStripe" | "vertStripe" | "diagStripe" | "reverseDiagStripe" => 0.5, + "horzCross" | "diagCross" => 0.6, + "thinHorzStripe" | "thinVertStripe" | "thinDiagStripe" | "thinReverseDiagStripe" => 0.25, + "thinHorzCross" | "thinDiagCross" => 0.35, + _ => return None, + }; + Some(cov) +} + +/// Resolves a `w:shd` line/cross texture `@w:val` to a `ShadingPattern` +/// (hatch geometry + colours), or `None` for non-texture values (`clear`, +/// `solid`, `pctN`, unknown). +/// +/// The companion of [`resolve_shading`]: that flattens the texture to a solid +/// tint (the fallback stored on `background_color`); this preserves the pattern +/// so the renderer can draw the actual hatch lines. `@w:color` defaults to black +/// (the hatch line colour); `@w:fill` is carried as the background fill and left +/// `None` when absent or `auto`. +#[must_use] +pub fn resolve_shading_pattern( + fill: Option<&str>, + val: Option<&str>, + color: Option<&str>, +) -> Option { + use loki_doc_model::style::props::shading::{HatchPattern, ShadingPattern}; + use loki_primitives::color::DocumentColor; + + let v = val?; + let thin = v.starts_with("thin"); + // `strip_prefix("thin")` leaves the pattern stem capitalised + // (`thinHorzStripe` → `HorzStripe`); match both cases. + let base = v.strip_prefix("thin").unwrap_or(v); + let pattern = match base { + "horzStripe" | "HorzStripe" => HatchPattern::Horizontal, + "vertStripe" | "VertStripe" => HatchPattern::Vertical, + "diagStripe" | "DiagStripe" => HatchPattern::DiagUp, + "reverseDiagStripe" | "ReverseDiagStripe" => HatchPattern::DiagDown, + "horzCross" | "HorzCross" => HatchPattern::Cross, + "diagCross" | "DiagCross" => HatchPattern::DiagCross, + _ => return None, + }; + let fg = color + .and_then(hex_color) + .map_or(DocumentColor::Rgb(RgbColor::new(0.0, 0.0, 0.0)), |c| { + DocumentColor::Rgb(c) + }); + let bg = fill + .filter(|f| *f != "auto") + .and_then(hex_color) + .map(DocumentColor::Rgb); + Some(ShadingPattern { + pattern, + thin, + color: fg, + fill: bg, + }) +} + +/// Linearly blends `fg` over `bg` at coverage `t` in `[0, 1]`. +#[must_use] +fn blend_rgb(bg: RgbColor, fg: RgbColor, t: f32) -> RgbColor { + let mix = |a: f32, b: f32| a * (1.0 - t) + b * t; + RgbColor::new( + mix(bg.red(), fg.red()), + mix(bg.green(), fg.green()), + mix(bg.blue(), fg.blue()), + ) +} diff --git a/loki-ooxml/src/xml_util_tests.rs b/loki-ooxml/src/xml_util_tests.rs index dc28a53f..15213b02 100644 --- a/loki-ooxml/src/xml_util_tests.rs +++ b/loki-ooxml/src/xml_util_tests.rs @@ -102,11 +102,62 @@ fn shading_nil_is_none() { } #[test] -fn shading_unknown_texture_falls_back_to_fill() { - let c = resolve_shading(Some("97BC62"), Some("horzStripe"), Some("000000")).unwrap(); +fn shading_texture_pattern_blends_color_over_fill() { + // A line/cross texture is flattened to a tint of the foreground over fill. + // diagStripe ≈ 50 % of orange (ED7D31) over white (FFFFFF). + let c = resolve_shading(Some("FFFFFF"), Some("diagStripe"), Some("ED7D31")).unwrap(); + let expect = |fg: f32| 1.0 * 0.5 + fg * 0.5; + assert!((c.red() - expect(0xED as f32 / 255.0)).abs() < 1e-4); + assert!((c.green() - expect(0x7D as f32 / 255.0)).abs() < 1e-4); + // Lighter than the solid foreground (a visible-but-tinted stripe). + assert!(c.blue() > 0x31 as f32 / 255.0); +} + +#[test] +fn shading_thin_texture_is_lighter_than_bold() { + let thin = resolve_shading(Some("FFFFFF"), Some("thinDiagStripe"), Some("000000")).unwrap(); + let bold = resolve_shading(Some("FFFFFF"), Some("diagStripe"), Some("000000")).unwrap(); + // Less ink → closer to white → higher channel value. + assert!(thin.red() > bold.red()); +} + +#[test] +fn shading_unknown_value_falls_back_to_fill() { + let c = resolve_shading(Some("97BC62"), Some("someFutureVal"), Some("000000")).unwrap(); assert!((c.green() - 0xBC as f32 / 255.0).abs() < 1e-4); } +// ── resolve_shading_pattern ────────────────────────────────────────────────── + +#[test] +fn shading_pattern_preserves_geometry_and_colors() { + use loki_doc_model::style::props::shading::HatchPattern; + use loki_primitives::color::DocumentColor; + + let p = resolve_shading_pattern(Some("FFFFFF"), Some("diagStripe"), Some("ED7D31")) + .expect("diagStripe is a texture pattern"); + assert_eq!(p.pattern, HatchPattern::DiagUp); + assert!(!p.thin); + assert_eq!(p.color, DocumentColor::from_hex("#ED7D31").unwrap()); + assert_eq!(p.fill, Some(DocumentColor::from_hex("#FFFFFF").unwrap())); + + let thin = resolve_shading_pattern(Some("auto"), Some("thinHorzCross"), None) + .expect("thinHorzCross is a texture pattern"); + assert_eq!(thin.pattern, HatchPattern::Cross); + assert!(thin.thin); + // `@w:fill="auto"` leaves the background unpainted; missing `@w:color` → black. + assert_eq!(thin.fill, None); + assert_eq!(thin.color, DocumentColor::from_hex("#000000").unwrap()); +} + +#[test] +fn shading_pattern_none_for_non_texture_values() { + assert!(resolve_shading_pattern(Some("FFFFFF"), Some("clear"), None).is_none()); + assert!(resolve_shading_pattern(Some("FFFFFF"), Some("solid"), Some("FF0000")).is_none()); + assert!(resolve_shading_pattern(Some("FFFFFF"), Some("pct25"), Some("FF0000")).is_none()); + assert!(resolve_shading_pattern(Some("FFFFFF"), None, None).is_none()); +} + // ── XXE posture (audit-2026-06 S-5) ────────────────────────────────────────── /// A DOCTYPE-declared external entity must never be fetched or expanded. diff --git a/loki-ooxml/tests/conformance_round_trip.rs b/loki-ooxml/tests/conformance_round_trip.rs index ed63cdd9..27c60dd4 100644 --- a/loki-ooxml/tests/conformance_round_trip.rs +++ b/loki-ooxml/tests/conformance_round_trip.rs @@ -144,6 +144,148 @@ fn docx_round_trip_preserves_secondary_run_formatting() { } } +/// Emboss, imprint, and character-border (`w:bdr`) direct run formatting must +/// survive import-export-import. Each used to export with the field silently +/// dropped (import + render only), so a run carrying *only* one of these would +/// collapse to a plain run and merge with its neighbours — the same class of +/// loss the secondary-formatting regression above guards. Now `emit_char_props` +/// writes ``/``/`` symmetrically with the reader. +#[test] +fn docx_round_trip_preserves_emboss_imprint_and_char_border() { + use loki_doc_model::style::props::border::{Border, BorderStyle}; + use loki_primitives::color::DocumentColor; + + let embossed = CharProps { + emboss: Some(true), + ..Default::default() + }; + let imprinted = CharProps { + imprint: Some(true), + ..Default::default() + }; + let bordered = CharProps { + character_border: Some(Border { + style: BorderStyle::Solid, + width: Points::new(1.0), + color: Some(DocumentColor::from_hex("#C00000").expect("valid hex")), + spacing: Some(Points::new(1.0)), + }), + ..Default::default() + }; + + let seed = doc(vec![Block::Para(vec![ + styled_run("embossed", embossed), + Inline::Str(" ".to_string()), + styled_run("imprinted", imprinted), + Inline::Str(" ".to_string()), + styled_run("boxed", bordered), + Inline::Str(" plain tail.".to_string()), + ])]); + + if let Some(d) = round_trip_divergence(&seed) { + panic!( + "emboss/imprint/char-border diverged at `{}`:\n first import: {:?}\n re-import: {:?}", + d.path, d.left, d.right + ); + } +} + +/// Flattens a block's inline content, seeing through the `StyledPara` wrapper +/// the importer produces for a bare paragraph as well as plain `Para`/`Plain`. +fn block_inlines(b: &Block) -> Vec { + match b { + Block::Para(inl) | Block::Plain(inl) => inl.clone(), + Block::StyledPara(sp) => sp.inlines.clone(), + _ => vec![], + } +} + +/// A floating `wps` text box (`Inline::TextBox`) must survive +/// import-export-import: the export writes a `w:drawing`/`wp:anchor` whose +/// `wps:wsp` graphicData carries the shape fill/border and a `w:txbxContent` +/// body, and re-import reconstructs the same `TextBox` (geometry, fill, border, +/// interior paragraph text). Used to be import + render only — the box was +/// silently dropped on export. +#[test] +fn docx_round_trip_preserves_floating_text_box() { + use loki_doc_model::content::inline::Inline; + + // Build a TextBox exactly as the mapper does: geometry + fill/border on the + // attr, a floating (square) wrap class, one interior paragraph. + let mut attr = NodeAttr::default(); + attr.kv.push(("cx_emu".to_string(), "1828800".to_string())); + attr.kv.push(("cy_emu".to_string(), "731520".to_string())); + attr.kv + .push(("textbox-fill".to_string(), "FDF0E6".to_string())); + attr.kv + .push(("textbox-line".to_string(), "ED7D31".to_string())); + attr.classes.push("floating".to_string()); + let text_box = Inline::TextBox( + attr, + vec![Block::Para(vec![Inline::Str("Sidebar body.".to_string())])], + ); + + let seed = doc(vec![Block::Para(vec![ + text_box, + Inline::Str("Body copy beside the box.".to_string()), + ])]); + + let a = import(export(&seed)); + + // The re-imported model must still carry a TextBox with the fill/border and + // the interior text — assert directly (not only via divergence) so a silent + // downgrade to a plain image or dropped box fails loudly. + let found = a + .sections + .iter() + .flat_map(|s| s.blocks.iter()) + .flat_map(block_inlines) + .find_map(|i| match i { + Inline::TextBox(at, blocks) => Some((at, blocks)), + _ => None, + }); + let (at, blocks) = found.expect("re-imported model still has a TextBox"); + assert!( + at.kv + .iter() + .any(|(k, v)| k == "textbox-fill" && v == "FDF0E6"), + "fill survived: {:?}", + at.kv + ); + assert!( + at.kv + .iter() + .any(|(k, v)| k == "textbox-line" && v == "ED7D31"), + "border survived: {:?}", + at.kv + ); + assert!( + at.kv.iter().any(|(k, v)| k == "cx_emu" && v == "1828800"), + "geometry survived: {:?}", + at.kv + ); + let inner: String = blocks + .iter() + .flat_map(block_inlines) + .filter_map(|i| match i { + Inline::Str(s) => Some(s), + _ => None, + }) + .collect(); + assert!( + inner.contains("Sidebar body."), + "interior text survived: {inner:?}" + ); + + // And the whole thing must be import-export-import *stable*. + if let Some(d) = round_trip_divergence(&seed) { + panic!( + "text-box round-trip diverged at `{}`:\n first import: {:?}\n re-import: {:?}", + d.path, d.left, d.right + ); + } +} + /// The comprehensive reference fixture (headers, footnotes, hyperlinks, images, /// …) under the same import-export-import comparison. /// diff --git a/loki-ooxml/tests/repair.rs b/loki-ooxml/tests/repair.rs new file mode 100644 index 00000000..ae37ba63 --- /dev/null +++ b/loki-ooxml/tests/repair.rs @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! End-to-end tests for the public DOCX repair API on a real OPC package. + +use std::io::Cursor; + +use loki_doc_model::io::{DocumentExport, DocumentImport}; +use loki_ooxml::{DocxExport, DocxImport, analyze_docx, repair_docx}; +use loki_opc::Package; +use loki_opc::part::{PartData, PartName}; +use loki_opc::relationships::{Relationship, TargetMode}; + +const MT_DOC: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; +const REL_OFFICE_DOC: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"; + +/// Builds a minimal, valid `.docx` whose one paragraph has an **out-of-order** +/// `w:pPr` (`w:jc` before `w:spacing`) — the classic Word-rejecting corruption. +fn dirty_docx() -> Vec { + let xml = br#"Hi"#; + let mut pkg = Package::new(); + let doc = PartName::new("/word/document.xml").unwrap(); + pkg.set_part(doc.clone(), PartData::new(xml.to_vec(), MT_DOC)); + pkg.relationships_mut() + .add(Relationship { + id: "rId1".into(), + rel_type: REL_OFFICE_DOC.into(), + target: "word/document.xml".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + let ct = pkg.content_type_map_mut(); + ct.add_default( + "rels", + "application/vnd.openxmlformats-package.relationships+xml", + ); + ct.add_default("xml", "application/xml"); + ct.add_override(&doc, MT_DOC); + let mut buf = Cursor::new(Vec::new()); + pkg.write(&mut buf).unwrap(); + buf.into_inner() +} + +const MT_SETTINGS: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"; +const MT_FOOTNOTES: &str = + "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"; + +/// Builds a `.docx` whose `settings.xml` declares a `` referencing +/// separator notes, but the package has **no** `endnotes.xml` to back them +/// (`footnotePr` *is* backed by `footnotes.xml`). Word reports an error in the +/// Endnotes stream; Loki opens it. Exercises the cross-part note-separator check. +fn dangling_endnote_pr_docx() -> Vec { + let doc = br#"Hi"#; + let settings = br#""#; + let footnotes = br#""#; + + let mut pkg = Package::new(); + let mut add = |path: &str, bytes: &[u8], mt: &str| { + let pn = PartName::new(path).unwrap(); + pkg.set_part(pn.clone(), PartData::new(bytes.to_vec(), mt)); + pkg.content_type_map_mut().add_override(&pn, mt); + }; + add("/word/document.xml", doc, MT_DOC); + add("/word/settings.xml", settings, MT_SETTINGS); + add("/word/footnotes.xml", footnotes, MT_FOOTNOTES); + + pkg.relationships_mut() + .add(Relationship { + id: "rId1".into(), + rel_type: REL_OFFICE_DOC.into(), + target: "word/document.xml".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + let ct = pkg.content_type_map_mut(); + ct.add_default( + "rels", + "application/vnd.openxmlformats-package.relationships+xml", + ); + ct.add_default("xml", "application/xml"); + let mut buf = Cursor::new(Vec::new()); + pkg.write(&mut buf).unwrap(); + buf.into_inner() +} + +#[test] +fn analyze_detects_dangling_endnote_pr_but_not_the_backed_footnote_pr() { + let report = analyze_docx(&dangling_endnote_pr_docx()).expect("analyze"); + assert_eq!(report.findings.len(), 1, "only the endnotePr dangles"); + assert_eq!(report.findings[0].part, "word/settings.xml"); + assert_eq!(report.findings[0].container, "w:endnotePr"); + assert!(!report.repaired); +} + +#[test] +fn repair_removes_dangling_endnote_pr_refs_and_keeps_footnote_pr() { + let (fixed, report) = repair_docx(&dangling_endnote_pr_docx()).expect("repair"); + assert_eq!(report.findings.len(), 1); + assert!(report.repaired); + // Re-analysis is clean, and the footnotePr (backed by footnotes.xml) survived. + assert!(analyze_docx(&fixed).expect("re-analyze").is_clean()); + let pkg = Package::open(Cursor::new(&fixed)).expect("reopen"); + let settings = pkg + .part(&PartName::new("/word/settings.xml").unwrap()) + .expect("settings present"); + let settings = String::from_utf8_lossy(&settings.bytes); + assert!( + settings.contains("(pkg: &Package, writer: &mut W) -> O let mut ctm = pkg.content_type_map().clone(); ctm.add_default("rels", MEDIA_TYPE_RELATIONSHIPS); ctm.add_default("xml", "application/xml"); + // The core-properties part is an `.xml` part, so without an explicit + // Override it would resolve to the generic `application/xml` default — + // which makes Word reject the whole package. Register the required content + // type so `/docProps/core.xml` is typed correctly (ISO/IEC 29500-2 §8.2). + if let Some((name, _)) = &core_props_part_name { + ctm.add_override(name, MEDIA_TYPE_CORE_PROPERTIES); + } let ct_bytes = write_content_types(&ctm)?; zip.start_file("[Content_Types].xml", options)?; diff --git a/loki-opc/tests/package_tests.rs b/loki-opc/tests/package_tests.rs index 5a996187..e5ae72d6 100644 --- a/loki-opc/tests/package_tests.rs +++ b/loki-opc/tests/package_tests.rs @@ -41,6 +41,48 @@ fn test_package_round_trip() { ); } +/// The core-properties part is an `.xml` part, so it must carry an explicit +/// content-type Override — otherwise it resolves to the generic +/// `application/xml` default and Microsoft Word rejects the whole package as +/// unreadable (ISO/IEC 29500-2 §8.2). Regression lock for that write-path fix. +#[test] +fn core_properties_part_is_typed_correctly() { + const CORE_CT: &str = "application/vnd.openxmlformats-package.core-properties+xml"; + + let mut pkg = Package::new(); + pkg.set_part( + PartName::new("/word/document.xml").unwrap(), + PartData::xml(b"".to_vec()), + ); + pkg.core_properties_mut().title = Some("Typed Core".to_string()); + + let mut buffer = std::io::Cursor::new(Vec::new()); + pkg.write(&mut buffer).unwrap(); + + // The raw [Content_Types].xml must carry the override (this is exactly what + // Word inspects on open). + buffer.set_position(0); + let mut zip = zip::ZipArchive::new(&mut buffer).unwrap(); + let mut ct = String::new(); + zip.by_name("[Content_Types].xml") + .unwrap() + .read_to_string(&mut ct) + .unwrap(); + assert!( + ct.contains("/docProps/core.xml") && ct.contains(CORE_CT), + "[Content_Types].xml must override core.xml to the core-properties type:\n{ct}" + ); + + // And the reopened package must resolve that content type. + buffer.set_position(0); + let pkg_read = Package::open(&mut buffer).unwrap(); + assert_eq!( + pkg_read.content_type(&PartName::new("/docProps/core.xml").unwrap()), + Some(CORE_CT), + "core.xml must resolve to the core-properties content type, not the xml default", + ); +} + #[test] fn test_package_utf16_transcode() { use std::io::Cursor; diff --git a/loki-pdf/Cargo.toml b/loki-pdf/Cargo.toml index 13abacfe..ab114bf7 100644 --- a/loki-pdf/Cargo.toml +++ b/loki-pdf/Cargo.toml @@ -19,6 +19,10 @@ flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif"] } subsetter = "0.2.6" +[dev-dependencies] +# The bundled variable Arimo face, to verify PDF variable-font instancing. +loki-fonts = { path = "../loki-fonts" } + [dependencies.chrono] version = "0.4" default-features = false diff --git a/loki-pdf/src/fonts.rs b/loki-pdf/src/fonts.rs index 15acc07b..e68e3ae1 100644 --- a/loki-pdf/src/fonts.rs +++ b/loki-pdf/src/fonts.rs @@ -36,6 +36,11 @@ pub struct Face { pub resource: String, data: Arc>, font_index: u32, + /// Normalized variable-font coordinates (F2Dot14 i16, one per fvar axis) + /// for this instance — e.g. Arimo `wght=700` for bold Arial. Empty for a + /// static face or the default master. Runs with different coordinates are + /// distinct faces, each embedded as its own instanced subset. + coords: Vec, used_glyphs: BTreeSet, } @@ -57,7 +62,7 @@ pub struct FaceRefs { #[derive(Default)] pub struct FontBank { faces: Vec, - index_by_key: HashMap<(usize, u32), usize>, + index_by_key: HashMap<(usize, u32, Vec), usize>, } impl FontBank { @@ -67,19 +72,32 @@ impl FontBank { Self::default() } - /// Registers `data`/`font_index` (if new), records the glyph ids as used, - /// and returns the resource name to reference in the content stream. - pub fn use_face(&mut self, data: &Arc>, font_index: u32, glyphs: I) -> String + /// Registers `data`/`font_index`/`coords` (if new), records the glyph ids as + /// used, and returns the resource name to reference in the content stream. + /// A given `data`+`font_index` at different variable-font `coords` (e.g. + /// regular vs bold Arimo) registers as **separate** instanced faces. + pub fn use_face( + &mut self, + data: &Arc>, + font_index: u32, + coords: &[i16], + glyphs: I, + ) -> String where I: IntoIterator, { - let key = (Arc::as_ptr(data) as *const u8 as usize, font_index); + let key = ( + Arc::as_ptr(data) as *const u8 as usize, + font_index, + coords.to_vec(), + ); let idx = *self.index_by_key.entry(key).or_insert_with(|| { let resource = format!("F{}", self.faces.len()); self.faces.push(Face { resource, data: Arc::clone(data), font_index, + coords: coords.to_vec(), used_glyphs: BTreeSet::new(), }); self.faces.len() - 1 @@ -143,16 +161,24 @@ impl FontBank { fn embed_face(pdf: &mut Pdf, face: &Face, r: &FaceRefs) -> Result<(), PdfError> { // Metrics come from the *original* face — the subset program strips tables // (`cmap`, `OS/2`, …) the metrics readers rely on. - let ttf = ttf_parser::Face::parse(&face.data, face.font_index) + let mut ttf = ttf_parser::Face::parse(&face.data, face.font_index) .map_err(|e| PdfError::Font(format!("parse: {e:?}")))?; + // Instance the metrics face at this run's variable-font coordinates, so + // widths / bbox / ascent reflect e.g. the bold master, not the default. + let vars = subset::variation_coords(&ttf, &face.coords); + for &(tag, value) in &vars { + ttf.set_variation(tag, value); + } let upem = f32::from(ttf.units_per_em().max(1)); let scale = 1000.0 / upem; - // Subset to the used glyphs. On success the program is renumbered and the - // `BaseFont` gets the mandatory six-letter subset tag; on failure the full - // font is embedded verbatim under the un-tagged name (still valid PDF/X). + // Subset to the used glyphs, instancing the outlines at `vars` (so bold + // Arimo embeds bold glyphs, not the default master with bold advances). On + // success the program is renumbered and the `BaseFont` gets the mandatory + // six-letter subset tag; on failure the full font is embedded verbatim under + // the un-tagged name (still valid PDF/X). let (program, remapper) = - subset::subset_program(&face.data, face.font_index, &face.used_glyphs); + subset::subset_program(&face.data, face.font_index, &face.used_glyphs, &vars); let mut base_font_buf = Vec::with_capacity(20); if remapper.is_some() { base_font_buf.extend_from_slice(&subset::subset_tag(&face.used_glyphs)); diff --git a/loki-pdf/src/fonts_subset.rs b/loki-pdf/src/fonts_subset.rs index f5f8bec8..733380bd 100644 --- a/loki-pdf/src/fonts_subset.rs +++ b/loki-pdf/src/fonts_subset.rs @@ -12,22 +12,63 @@ use std::collections::BTreeSet; -use subsetter::GlyphRemapper; +use subsetter::{GlyphRemapper, Tag}; -/// Subsets `data` to the used glyph ids. Returns the reduced font program and -/// the [`GlyphRemapper`] (old gid → new gid). On any subsetting failure (e.g. a -/// CFF2 or malformed face the subsetter rejects) it falls back to the full font -/// program and `None`, so export still produces a valid, fully-embedded font. +/// Convert a face's normalized F2Dot14 coordinates (one per `fvar` axis, in +/// axis order) to user-space `(tag, value)` pairs via the font's `fvar`. Empty +/// when the face is static or at the default master. The inverse of +/// normalization (piecewise-linear, `avar` ignored) — exact at the axis +/// endpoints, which is where a bold instance (`wght` at its max) sits. +pub(super) fn variation_coords( + ttf: &ttf_parser::Face, + normalized: &[i16], +) -> Vec<(ttf_parser::Tag, f32)> { + if normalized.iter().all(|&c| c == 0) { + return Vec::new(); + } + ttf.variation_axes() + .into_iter() + .zip(normalized.iter().copied()) + .filter_map(|(axis, coord)| { + let n = f32::from(coord) / 16384.0; + (n != 0.0).then(|| { + let user = if n >= 0.0 { + axis.def_value + n * (axis.max_value - axis.def_value) + } else { + axis.def_value + n * (axis.def_value - axis.min_value) + }; + (axis.tag, user) + }) + }) + .collect() +} + +/// Subsets `data` to the used glyph ids, optionally **instancing** a variable +/// font at `variations` (user-space `(fvar tag, value)` pairs, e.g. `wght=700` +/// for bold Arimo). Returns the reduced program and the [`GlyphRemapper`] (old +/// gid → new gid). On any subsetting failure (e.g. a CFF2 or malformed face the +/// subsetter rejects) it falls back to the full font program and `None`, so +/// export still produces a valid, fully-embedded font. pub(super) fn subset_program( data: &[u8], index: u32, used: &BTreeSet, + variations: &[(ttf_parser::Tag, f32)], ) -> (Vec, Option) { let mut remapper = GlyphRemapper::new(); for &gid in used { remapper.remap(gid); } - match subsetter::subset(data, index, &remapper) { + let result = if variations.is_empty() { + subsetter::subset(data, index, &remapper) + } else { + let coords: Vec<(Tag, f32)> = variations + .iter() + .map(|(tag, value)| (Tag::new(&tag.0.to_be_bytes()), *value)) + .collect(); + subsetter::subset_with_variations(data, index, &coords, &remapper) + }; + match result { Ok(program) => (program, Some(remapper)), Err(_) => (data.to_vec(), None), } diff --git a/loki-pdf/src/fonts_subset_tests.rs b/loki-pdf/src/fonts_subset_tests.rs index 0a2e6201..b99e703c 100644 --- a/loki-pdf/src/fonts_subset_tests.rs +++ b/loki-pdf/src/fonts_subset_tests.rs @@ -5,10 +5,75 @@ //! The full subset round-trip on a real font is covered by the crate's //! integration test (`embeds_fonts_and_output_intent`). -use super::{cid_to_gid_map, subset_tag}; +use super::{cid_to_gid_map, subset_program, subset_tag}; use std::collections::BTreeSet; use subsetter::GlyphRemapper; +/// Sums every outline control-point coordinate — a cheap fingerprint of a +/// glyph's shape that changes when the outline moves (e.g. under instancing). +#[derive(Default)] +struct PointSum(f64); +impl ttf_parser::OutlineBuilder for PointSum { + fn move_to(&mut self, x: f32, y: f32) { + self.0 += f64::from(x) + f64::from(y); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0 += f64::from(x) + f64::from(y); + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.0 += f64::from(x1) + f64::from(y1) + f64::from(x) + f64::from(y); + } + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.0 += f64::from(x1) + f64::from(y1) + f64::from(x2) + f64::from(y2); + self.0 += f64::from(x) + f64::from(y); + } + fn close(&mut self) {} +} + +/// Instancing a variable font at a non-default weight must actually change the +/// embedded glyph outlines — the fix for bold Arial (Arimo `wght=700`) that +/// previously exported the default (regular) master with bold advances. +#[test] +fn variable_font_instancing_changes_glyph_outlines() { + let wght = ttf_parser::Tag::from_bytes(b"wght"); + // The first bundled face with a `wght` axis is Arimo (the Arial substitute). + let arimo = loki_fonts::fallback_font_blobs() + .iter() + .copied() + .find(|b| { + ttf_parser::Face::parse(b, 0) + .map(|f| f.variation_axes().into_iter().any(|a| a.tag == wght)) + .unwrap_or(false) + }) + .expect("a bundled variable font with a wght axis"); + + let face = ttf_parser::Face::parse(arimo, 0).unwrap(); + let gid = face.glyph_index('B').expect("Arimo has 'B'").0; + let used: BTreeSet = [0u16, gid].into_iter().collect(); + + let (regular, rmap) = subset_program(arimo, 0, &used, &[]); + let (bold, bmap) = subset_program(arimo, 0, &used, &[(wght, 700.0)]); + // Same used set ⇒ deterministic remap ⇒ 'B' is at the same new gid in both. + let new_gid = rmap.expect("regular subset ok").get(gid).unwrap(); + assert_eq!( + new_gid, + bmap.expect("bold subset ok").get(gid).unwrap(), + "both subsets renumber identically" + ); + + let outline_sum = |program: &[u8]| { + let f = ttf_parser::Face::parse(program, 0).expect("subset parses"); + let mut s = PointSum::default(); + f.outline_glyph(ttf_parser::GlyphId(new_gid), &mut s); + s.0 + }; + let (r, b) = (outline_sum(®ular), outline_sum(&bold)); + assert!( + (r - b).abs() > 1.0, + "wght=700 must move the 'B' outline vs the regular master; regular={r} bold={b}" + ); +} + #[test] fn subset_tag_is_six_uppercase_letters() { let used: BTreeSet = [5u16, 7, 42].into_iter().collect(); diff --git a/loki-pdf/src/page.rs b/loki-pdf/src/page.rs index b39e9499..7af3c9ce 100644 --- a/loki-pdf/src/page.rs +++ b/loki-pdf/src/page.rs @@ -11,7 +11,7 @@ use loki_layout::{ DecorationKind, GlyphEntry, LayoutPage, LayoutRect, PositionedDecoration, PositionedGlyphRun, - PositionedImage, PositionedItem, + PositionedHatch, PositionedImage, PositionedItem, }; use pdf_writer::{Content, Name, Str}; @@ -58,6 +58,7 @@ fn render_item( PositionedItem::FilledRect(r) | PositionedItem::HorizontalRule(r) => { fill_rect(&r.rect, layout_to_cmyk(r.color), page_h, ox, oy, content); } + PositionedItem::HatchRect(h) => render_hatch(h, page_h, ox, oy, content), PositionedItem::Decoration(d) => render_decoration(d, page_h, ox, oy, content), PositionedItem::BorderRect(b) => render_border(b, page_h, ox, oy, content), PositionedItem::Image(img) => draw_image(img, page_h, ox, oy, banks.images, content), @@ -155,7 +156,14 @@ fn render_run( if drawn.is_empty() { return; } - let resource = bank.use_face(&run.font_data, run.font_index, drawn.iter().map(|g| g.id)); + // The run's variable-font instance (e.g. Arimo `wght=700` for bold Arial) + // is embedded as its own instanced subset, so bold Arial exports bold. + let resource = bank.use_face( + &run.font_data, + run.font_index, + &run.normalized_coords, + drawn.iter().map(|g| g.id), + ); let cmyk: Cmyk = layout_to_cmyk(run.color); content.set_fill_cmyk(cmyk.c, cmyk.m, cmyk.y, cmyk.k); @@ -182,6 +190,42 @@ fn fill_rect(rect: &LayoutRect, color: Cmyk, page_h: f32, ox: f32, oy: f32, cont content.fill_nonzero(); } +/// Renders a `w:shd` hatch: the optional background fill, then each clipped +/// hatch line as a thin filled quad (the colour pipeline stays fill-only, so no +/// stroke colour space is needed — matching `render_decoration`/`render_border`). +fn render_hatch(h: &PositionedHatch, page_h: f32, ox: f32, oy: f32, content: &mut Content) { + if let Some(fill) = h.fill { + fill_rect(&h.rect, layout_to_cmyk(fill), page_h, ox, oy, content); + } + let cmyk = layout_to_cmyk(h.color); + let half = h.line_width() * 0.5; + content.set_fill_cmyk(cmyk.c, cmyk.m, cmyk.y, cmyk.k); + for s in h.segments() { + // Perpendicular offset of half the line width, so the segment becomes a + // thin quad; degenerate (zero-length) segments are skipped. + let (dx, dy) = (s.x1 - s.x0, s.y1 - s.y0); + let len = dx.hypot(dy); + if len <= f32::EPSILON { + continue; + } + let (px, py) = (-dy / len * half, dx / len * half); + // Layout → PDF space: x + ox, y flipped through the page height. + let fy = |y: f32| page_h - (oy + y); + let pts = [ + (ox + s.x0 + px, fy(s.y0 + py)), + (ox + s.x1 + px, fy(s.y1 + py)), + (ox + s.x1 - px, fy(s.y1 - py)), + (ox + s.x0 - px, fy(s.y0 - py)), + ]; + content.move_to(pts[0].0, pts[0].1); + content.line_to(pts[1].0, pts[1].1); + content.line_to(pts[2].0, pts[2].1); + content.line_to(pts[3].0, pts[3].1); + content.close_path(); + content.fill_nonzero(); + } +} + fn render_decoration( d: &PositionedDecoration, page_h: f32, diff --git a/loki-pdf/src/page_tests.rs b/loki-pdf/src/page_tests.rs index 3dafcd9b..2b92acd3 100644 --- a/loki-pdf/src/page_tests.rs +++ b/loki-pdf/src/page_tests.rs @@ -22,6 +22,7 @@ fn run_with(ids: &[u16]) -> PositionedGlyphRun { .collect(), color: LayoutColor::new(0.0, 0.0, 0.0, 1.0), synthesis: GlyphSynthesis::default(), + normalized_coords: Vec::new(), link_url: None, } } diff --git a/loki-render-cpu/Cargo.toml b/loki-render-cpu/Cargo.toml index 7cdae55f..e869e161 100644 --- a/loki-render-cpu/Cargo.toml +++ b/loki-render-cpu/Cargo.toml @@ -20,4 +20,6 @@ loki-doc-model = { path = "../loki-doc-model" } loki-fonts = { path = "../loki-fonts" } # Calibration example: import the ODF fixtures and diff against goldens. loki-odf = { path = "../loki-odf" } +# DOCX visual-golden test: import the OOXML corpus fixtures as candidates. +loki-ooxml = { path = "../loki-ooxml" } appthere-conformance = { path = "../appthere-conformance" } diff --git a/loki-render-cpu/src/paint.rs b/loki-render-cpu/src/paint.rs index 27924912..26ad5c8c 100644 --- a/loki-render-cpu/src/paint.rs +++ b/loki-render-cpu/src/paint.rs @@ -130,10 +130,11 @@ fn paint_glyph_run( }) .collect::>(); - // Variable fonts render at their default instance, matching the GPU - // path (its FontDataCache passes all-zero coords — the default master). + // Apply the run's variable-font instance (e.g. Arimo `wght=700` for bold + // Arial). Empty for static faces, where glifo uses the default master. ctx.glyph_run(resources, &font) .font_size(run.font_size * scale) + .normalized_coords(&run.normalized_coords) .hint(false) .fill_glyphs(glyphs.into_iter()); diff --git a/loki-render-cpu/tests/visual_golden_docx.rs b/loki-render-cpu/tests/visual_golden_docx.rs new file mode 100644 index 00000000..9493ef1a --- /dev/null +++ b/loki-render-cpu/tests/visual_golden_docx.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The DOCX visual-golden axis: compare Loki's render of each committed OOXML +//! corpus fixture against Microsoft Word's render at the calibrated tolerance. +//! +//! Word cannot be automated headlessly, so its goldens are captured manually +//! (open the fixture in Word, print to PDF, run +//! `scripts/generate-office-goldens.sh`) into +//! `appthere-conformance/goldens/docx//page-N.png`. Until those PNGs are +//! committed each fixture's golden dir holds only a `PENDING.txt`, so +//! [`golden_pages`] returns empty and the comparison is a **documented no-op** +//! that keeps the suite green — mirroring how the ODF golden axis began. The +//! moment real goldens land, these tests enforce the calibrated SSIM/ΔE +//! thresholds page-by-page with no further wiring. + +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +use appthere_conformance::CONFORMANCE_DPI; +use appthere_conformance::golden::{Tolerance, compare_pages, golden_pages, load_png}; +use image::RgbaImage; +use loki_doc_model::io::DocumentImport; +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; +use loki_ooxml::docx::import::{DocxImport, DocxImportOptions}; +use loki_render_cpu::render_document; + +fn conformance_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../appthere-conformance") +} + +/// Import the DOCX fixture and render every page through the pinned CPU +/// candidate path at [`CONFORMANCE_DPI`] — the exact geometry the goldens use. +fn render_candidate(stem: &str) -> Vec { + let fixture = conformance_root().join(format!("fixtures/docx/{stem}.docx")); + let bytes = std::fs::read(&fixture).expect("fixture exists (committed)"); + let doc = DocxImport::import(Cursor::new(bytes), DocxImportOptions::default()) + .expect("fixture imports"); + let mut resources = FontResources::new(); + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + let layout = match layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("expected paginated layout, got {other:?}"), + }; + render_document(&layout, CONFORMANCE_DPI).expect("candidate render") +} + +/// Compare a fixture against its committed goldens at the calibrated tolerance. +/// No-op (green) while the golden tree is empty. +fn compare_fixture(stem: &str) { + let golden_root = conformance_root().join("goldens/docx"); + let goldens = golden_pages(&golden_root, stem); + if goldens.is_empty() { + eprintln!( + "visual_golden_docx: no goldens committed for {stem} yet \ + (see goldens/docx/{stem}/PENDING.txt) — skipping" + ); + return; + } + + let candidate = render_candidate(stem); + assert!( + candidate.len() >= goldens.len(), + "{stem}: page-count drift — {} golden page(s) but Loki produced {}", + goldens.len(), + candidate.len() + ); + + for (i, golden_path) in goldens.iter().enumerate() { + let golden = load_png(golden_path).expect("golden decodes"); + let cand = &candidate[i]; + // ≤1 px DPI-rounding difference between the two pipelines; crop to the + // common area (as the ODF axis does). + let w = golden.width().min(cand.width()); + let h = golden.height().min(cand.height()); + let golden_c = image::imageops::crop_imm(&golden, 0, 0, w, h).to_image(); + let cand_c = image::imageops::crop_imm(cand, 0, 0, w, h).to_image(); + let report = compare_pages(&golden_c, &cand_c, Tolerance::calibrated()).expect("compare"); + let worst = report + .worst + .map(|r| { + format!( + "worst region {:?}: ssim={:.4} ΔE={:.3}", + r.region, r.ssim, r.delta_e + ) + }) + .unwrap_or_default(); + assert!( + report.passed, + "{stem} page {} must pass calibrated tolerance; {worst}", + i + 1 + ); + } +} + +#[test] +fn acid_docx_matches_its_golden() { + compare_fixture("acid-docx"); +} + +#[test] +fn iris_blueprint_matches_its_golden() { + compare_fixture("iris-blueprint"); +} + +#[test] +fn acid2_docx_matches_its_golden() { + compare_fixture("acid2-docx"); +} diff --git a/loki-renderer/src/doc_page_source.rs b/loki-renderer/src/doc_page_source.rs index eaafa675..20e05c90 100644 --- a/loki-renderer/src/doc_page_source.rs +++ b/loki-renderer/src/doc_page_source.rs @@ -218,6 +218,7 @@ impl DocPageSource { let options = LayoutOptions { preserve_for_editing: true, spell: crate::spell::active(), + revision_display: crate::revision::display(), ..Default::default() }; match loki_layout::layout_document( @@ -239,6 +240,7 @@ impl DocPageSource { let options = LayoutOptions { preserve_for_editing: true, spell: crate::spell::active(), + revision_display: crate::revision::display(), ..Default::default() }; let content_width = diff --git a/loki-renderer/src/lib.rs b/loki-renderer/src/lib.rs index e14d0510..7bad5a8f 100644 --- a/loki-renderer/src/lib.rs +++ b/loki-renderer/src/lib.rs @@ -27,6 +27,7 @@ mod view_types; pub(crate) mod reflow_view; pub mod render_layout; pub mod renderer_state; +pub mod revision; pub mod spell; pub(crate) mod vello_init; #[cfg(any(not(target_os = "android"), android_gpu))] diff --git a/loki-renderer/src/render_layout_tests.rs b/loki-renderer/src/render_layout_tests.rs index 869977c9..737b7d61 100644 --- a/loki-renderer/src/render_layout_tests.rs +++ b/loki-renderer/src/render_layout_tests.rs @@ -64,6 +64,9 @@ fn one_para_reflow(text: &str, origin: (f32, f32)) -> RenderLayout { font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, + character_border: None, link_url: None, math: None, scale: None, diff --git a/loki-renderer/src/revision.rs b/loki-renderer/src/revision.rs new file mode 100644 index 00000000..45c55bd4 --- /dev/null +++ b/loki-renderer/src/revision.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Active tracked-change **display mode** for the rendering layout path. +//! +//! Like the active spell state (`crate::spell`), the "Show Markup" view mode is +//! a per-view user setting rather than a document property: switching it must +//! not mutate the revision marks. Rather than thread it through every layout +//! call, the active [`RevisionDisplay`] is held here as app-internal ambient +//! state — the **Review** ribbon tab installs it via [`set_display`], and the +//! layout paths (`doc_page_source`, editor hit-testing) read it into +//! `LayoutOptions::revision_display`. +//! +//! The default is [`RevisionDisplay::AllMarkup`] (Word's "All Markup"), so +//! rendering is unchanged until the user picks Final / Original. + +use std::sync::{PoisonError, RwLock}; + +use loki_layout::RevisionDisplay; + +/// The active tracked-change display mode. Process-wide because it is one user +/// view setting shared by every open document and view. +static DISPLAY: RwLock = RwLock::new(RevisionDisplay::AllMarkup); + +/// Installs the active tracked-change display mode (Review tab → Show Markup). +pub fn set_display(mode: RevisionDisplay) { + *DISPLAY.write().unwrap_or_else(PoisonError::into_inner) = mode; +} + +/// The current tracked-change display mode for `LayoutOptions::revision_display`. +pub fn display() -> RevisionDisplay { + *DISPLAY.read().unwrap_or_else(PoisonError::into_inner) +} diff --git a/loki-text/src/editing/hit_test_tests.rs b/loki-text/src/editing/hit_test_tests.rs index e3c6372b..306729ab 100644 --- a/loki-text/src/editing/hit_test_tests.rs +++ b/loki-text/src/editing/hit_test_tests.rs @@ -34,6 +34,9 @@ fn make_test_layout() -> PaginatedLayout { font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, + character_border: None, link_url: None, math: None, scale: None, @@ -389,6 +392,9 @@ fn reflow_para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagr font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, + character_border: None, link_url: None, math: None, scale: None, diff --git a/loki-text/src/editing/navigation_tests.rs b/loki-text/src/editing/navigation_tests.rs index e1c409c8..db61a70e 100644 --- a/loki-text/src/editing/navigation_tests.rs +++ b/loki-text/src/editing/navigation_tests.rs @@ -36,6 +36,9 @@ fn layout_para( font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, + character_border: None, link_url: None, math: None, scale: None, @@ -208,6 +211,9 @@ fn make_two_para_layout(text0: &str, text1: &str) -> PaginatedLayout { font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, + character_border: None, link_url: None, math: None, scale: None, diff --git a/loki-text/src/editing/page_locate_tests.rs b/loki-text/src/editing/page_locate_tests.rs index 99fe7a5b..003cbdfa 100644 --- a/loki-text/src/editing/page_locate_tests.rs +++ b/loki-text/src/editing/page_locate_tests.rs @@ -37,6 +37,9 @@ fn para(text: &str, width: f32) -> Arc { word_spacing: None, font_variant: None, shadow: false, + emboss: false, + imprint: false, + character_border: None, link_url: None, math: None, scale: None, diff --git a/loki-text/src/editing/reflow_nav.rs b/loki-text/src/editing/reflow_nav.rs index 270e4c09..a476a31a 100644 --- a/loki-text/src/editing/reflow_nav.rs +++ b/loki-text/src/editing/reflow_nav.rs @@ -177,6 +177,9 @@ mod tests { font_variant: None, word_spacing: None, shadow: false, + emboss: false, + imprint: false, + character_border: None, link_url: None, math: None, scale: None, diff --git a/loki-text/src/editing/selection_handles_tests.rs b/loki-text/src/editing/selection_handles_tests.rs index eeb5f1de..47fe1278 100644 --- a/loki-text/src/editing/selection_handles_tests.rs +++ b/loki-text/src/editing/selection_handles_tests.rs @@ -37,6 +37,9 @@ fn para(text: &str) -> Arc { word_spacing: None, font_variant: None, shadow: false, + emboss: false, + imprint: false, + character_border: None, link_url: None, math: None, scale: None, diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index 70d7fa4b..2a2d032c 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -261,6 +261,8 @@ pub(super) fn EditorInner(path: String) -> Element { } }); + // Repair banner: self-contained hook (detection + state + mount). + let repair_banner = super::editor_repair_banner::use_repair_banner(path_signal, save_message); // ── Loro bridge: initialise CRDT once the document is loaded ───────────── // // The first paginated layout is a CPU-heavy pass (tens of ms on a multi-page @@ -689,9 +691,8 @@ pub(super) fn EditorInner(path: String) -> Element { )} } - // ── Save/export error banner (successes are the status chip) ───── + {repair_banner} {save_banner(save_message)} - // ── Ribbon (formatting controls) ────────────────────────────────── AtRibbon { // Core tabs + a Table contextual tab (appended by `use_ribbon_tabs` in a table). diff --git a/loki-text/src/routes/editor/editor_load.rs b/loki-text/src/routes/editor/editor_load.rs index 528a6920..a4867c4d 100644 --- a/loki-text/src/routes/editor/editor_load.rs +++ b/loki-text/src/routes/editor/editor_load.rs @@ -104,6 +104,31 @@ fn import_token(serialized: &str) -> Result { Ok(doc) } +/// Checks the opened DOCX file for schema **child-ordering** problems that stop +/// it opening in Microsoft Word (Loki's tolerant reader loads it regardless). +/// +/// Returns `Some(report)` only for a real, on-disk `.docx` whose bytes were +/// read and analysed; `None` for untitled/new documents, ODT, or any read +/// failure. A *clean* DOCX returns `Some` with an empty report — the caller +/// filters that out before showing the repair banner. Runs off the render +/// thread (called from a `spawn`ed effect) so it never blocks the open. +pub(super) fn analyze_open_docx(path: &str) -> Option { + use std::io::Read; + + // Untitled/new documents have no source file to inspect. + if crate::new_document::is_untitled(path) { + return None; + } + let token = FileAccessToken::deserialize(path).ok()?; + if !matches!(detect_format(&token), DocumentFormat::Docx) { + return None; // ODT/unsupported — the repair pass is DOCX-only. + } + let mut reader = token.open_read().ok()?; + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).ok()?; + loki_ooxml::analyze_docx(&bytes).ok() +} + /// Builds a bundled template document from its short `id` (see `loki-templates`). /// /// An unknown id degrades to a blank document so a stale path never fails to diff --git a/loki-text/src/routes/editor/editor_repair_banner.rs b/loki-text/src/routes/editor/editor_repair_banner.rs new file mode 100644 index 00000000..eb64c5f4 --- /dev/null +++ b/loki-text/src/routes/editor/editor_repair_banner.rs @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! "This document has problems that may stop it opening in Word — Repair?" +//! banner. +//! +//! Shown when a DOCX is opened whose OOXML child elements are out of the strict +//! schema order Microsoft Word enforces (Loki's tolerant reader opens it fine; +//! see `loki_ooxml::repair`). Offers a one-click lossless repair of the on-disk +//! file, or dismiss. Mirrors [`super::editor_font_warning::FontSubstitutionPanel`]: +//! amber `COLOR_CONTEXTUAL_TAB` accent (attention, not error), no `position: +//! fixed` / `box-shadow`, all strings via `fl!()`. Mounted at the panel boundary +//! (ADR-0013) so it owns its own hook scope. + +use appthere_ui::tokens; +use dioxus::prelude::*; +use loki_i18n::fl; + +use super::editor_state::SaveStatus; + +/// Detects Word-compatibility problems on open and returns the repair banner +/// element (empty when the document is clean or the banner was dismissed). +/// +/// A self-contained custom hook: it owns the detection state + effect and the +/// repair action, so `EditorInner` adds a single line. Must be called +/// unconditionally at the top of the component (Dioxus hook-order rule). +/// +/// `path_signal` is the route file token; `save_message` receives the +/// success/error status after a repair (surfaced by the existing save banner). +pub(super) fn use_repair_banner( + path_signal: Signal, + save_message: Signal>, +) -> Element { + let mut report = use_signal(|| Option::::None); + let mut dismissed = use_signal(|| false); + + // Inspect the opened DOCX bytes off the render thread (never blocks the + // open); resets for each newly-opened document. + use_effect(move || { + let p = path_signal(); + report.set(None); + dismissed.set(false); + spawn(async move { + if let Some(r) = super::editor_load::analyze_open_docx(&p) { + // Ignore a stale result if the user switched documents meanwhile. + if path_signal.peek().as_str() == p && !r.is_clean() { + report.set(Some(r)); + } + } + }); + }); + + let Some(r) = report() else { + return rsx! {}; + }; + let count = r.findings.len(); + rsx! { + RepairBanner { + count, + dismissed, + on_repair: move |_| repair_now(path_signal, report, save_message), + } + } +} + +/// Repairs the current file in place and reports the outcome via `save_message`. +fn repair_now( + path_signal: Signal, + mut report: Signal>, + mut save_message: Signal>, +) { + let path = path_signal.peek().clone(); + match super::editor_save::repair_document_file(&path) { + Ok(n) => { + report.set(None); + save_message.set(Some(SaveStatus::ok(fl!( + "editor-repair-done", + count = n as i64 + )))); + } + Err(e) => { + save_message.set(Some(SaveStatus::error(fl!( + "editor-repair-error", + reason = e.to_string() + )))); + } + } +} + +/// Inline repair banner. Renders nothing when there is nothing to repair +/// (`count == 0`) or the user dismissed it for this document. +/// +/// **Touch targets:** the Repair and Dismiss buttons meet the 44×44 logical-px +/// minimum (WCAG 2.5.8) through their padding plus the banner row height, +/// matching the ribbon-button convention. +#[component] +pub(super) fn RepairBanner( + count: usize, + dismissed: Signal, + on_repair: EventHandler<()>, +) -> Element { + let mut dismissed = dismissed; + if count == 0 || dismissed() { + return rsx! {}; + } + + let container = format!( + "display: flex; flex-direction: row; align-items: center; gap: {gap}px; \ + padding: {pv}px {ph}px; background: {bg}; border-top: 1px solid {border}; \ + border-bottom: 1px solid {border}; font-family: {ff}; color: {fg}; flex-shrink: 0;", + gap = tokens::SPACE_2, + pv = tokens::SPACE_2, + ph = tokens::SPACE_4, + bg = tokens::COLOR_SURFACE_2, + border = tokens::COLOR_CONTEXTUAL_TAB, + ff = tokens::FONT_FAMILY_UI, + fg = tokens::COLOR_TEXT_ON_CHROME, + ); + let repair_btn = format!( + "padding: {pv}px {ph}px; background: {bg}; border: 1px solid {bg}; \ + border-radius: {r}px; color: {fg}; font-size: {size}px; font-weight: {w}; \ + cursor: pointer; flex-shrink: 0;", + pv = tokens::SPACE_1, + ph = tokens::SPACE_3, + bg = tokens::COLOR_ACCENT_PRIMARY, + r = tokens::RADIUS_SM, + fg = tokens::COLOR_TEXT_ON_CHROME, + size = tokens::FONT_SIZE_LABEL, + w = tokens::FONT_WEIGHT_SEMIBOLD, + ); + let dismiss_btn = format!( + "padding: {pv}px {ph}px; background: {bg}; border: 1px solid {border}; \ + border-radius: {r}px; color: {fg}; font-size: {size}px; cursor: pointer; flex-shrink: 0;", + pv = tokens::SPACE_1, + ph = tokens::SPACE_2, + bg = tokens::COLOR_SURFACE_3, + border = tokens::COLOR_BORDER_CHROME, + r = tokens::RADIUS_SM, + fg = tokens::COLOR_TEXT_ON_CHROME, + size = tokens::FONT_SIZE_LABEL, + ); + + rsx! { + div { style: "{container}", + span { + style: format!("color: {}; font-weight: bold;", tokens::COLOR_CONTEXTUAL_TAB), + "⚠ {fl!(\"editor-repair-title\")}" + } + span { + style: format!( + "font-size: {}px; color: {};", + tokens::FONT_SIZE_LABEL, tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + ), + {fl!("editor-repair-message", count = count as i64)} + } + div { style: "flex: 1;" } + button { + style: "{repair_btn}", + onclick: move |_| on_repair.call(()), + {fl!("editor-repair-action")} + } + button { + style: "{dismiss_btn}", + onclick: move |_| dismissed.set(true), + {fl!("editor-repair-dismiss")} + } + } + } +} diff --git a/loki-text/src/routes/editor/editor_save.rs b/loki-text/src/routes/editor/editor_save.rs index f1f99188..b0ab4ca3 100644 --- a/loki-text/src/routes/editor/editor_save.rs +++ b/loki-text/src/routes/editor/editor_save.rs @@ -5,7 +5,7 @@ //! [`save_document_to_path`] serialises the live document from [`DocumentState`] //! and writes it back to the file identified by the route `path` token. -use std::io::{Cursor, Write}; +use std::io::{Cursor, Read, Write}; use std::sync::{Arc, Mutex}; use loki_doc_model::document::Document; @@ -125,6 +125,48 @@ pub(super) fn export_template_to_token( write_all_to_token(token, &buf.into_inner()) } +/// Repairs the on-disk `.docx` at `path` **losslessly**: reorders the OOXML +/// child elements into the schema sequence Microsoft Word requires, touching +/// nothing else (attributes, text, and constructs Loki does not model survive +/// verbatim). Returns the number of problems fixed (0 = already clean). +/// +/// This deliberately operates on the file bytes, not the in-memory document — +/// Loki's tolerant reader already imported a correct model, so a model +/// round-trip would only risk dropping what Loki cannot represent. Reuses the +/// same single-write path as save to avoid partial-write corruption. +pub(super) fn repair_document_file(path: &str) -> Result { + use super::editor_load::{DocumentFormat, detect_format}; + + if is_untitled(path) { + return Err(SaveError::UnsupportedFormat( + "untitled document — save it first".to_string(), + )); + } + let token = + FileAccessToken::deserialize(path).map_err(|e| SaveError::InvalidToken(e.to_string()))?; + if !matches!(detect_format(&token), DocumentFormat::Docx) { + return Err(SaveError::UnsupportedFormat( + "repair currently supports .docx only".to_string(), + )); + } + + let mut reader = token + .open_read() + .map_err(|e| SaveError::Io(e.to_string()))?; + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|e| SaveError::Io(e.to_string()))?; + + let (fixed, report) = + loki_ooxml::repair_docx(&bytes).map_err(|e| SaveError::Export(e.to_string()))?; + if report.is_clean() { + return Ok(0); + } + write_all_to_token(&token, &fixed)?; + Ok(report.findings.len()) +} + /// Clones the currently-loaded document out of `doc_state`. fn current_document(doc_state: &Arc>) -> Result, SaveError> { doc_state diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index fd0e8a5c..366518a0 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -38,6 +38,7 @@ mod editor_path_sync; mod editor_pointer; mod editor_pointer_touch; mod editor_publish; +mod editor_repair_banner; mod editor_responsive; mod editor_ribbon; mod editor_ribbon_color; diff --git a/loki-vello/src/band.rs b/loki-vello/src/band.rs index e379cd97..93f1c484 100644 --- a/loki-vello/src/band.rs +++ b/loki-vello/src/band.rs @@ -37,6 +37,7 @@ fn y_extent(item: &PositionedItem) -> (f32, f32) { PositionedItem::FilledRect(r) | PositionedItem::HorizontalRule(r) => { (r.rect.origin.y, r.rect.origin.y + r.rect.size.height) } + PositionedItem::HatchRect(h) => (h.rect.origin.y, h.rect.origin.y + h.rect.size.height), PositionedItem::BorderRect(r) => (r.rect.origin.y, r.rect.origin.y + r.rect.size.height), PositionedItem::Image(img) => (img.rect.origin.y, img.rect.origin.y + img.rect.size.height), PositionedItem::Decoration(d) => (d.y - d.thickness, d.y + d.thickness * 2.0), @@ -79,6 +80,7 @@ fn x_extent(item: &PositionedItem) -> f32 { PositionedItem::FilledRect(r) | PositionedItem::HorizontalRule(r) => { r.rect.origin.x + r.rect.size.width } + PositionedItem::HatchRect(h) => h.rect.origin.x + h.rect.size.width, PositionedItem::BorderRect(r) => r.rect.origin.x + r.rect.size.width, PositionedItem::Image(img) => img.rect.origin.x + img.rect.size.width, PositionedItem::Decoration(d) => d.x + d.width, diff --git a/loki-vello/src/glyph.rs b/loki-vello/src/glyph.rs index 8caee30f..ea8c14d3 100644 --- a/loki-vello/src/glyph.rs +++ b/loki-vello/src/glyph.rs @@ -58,15 +58,16 @@ pub fn paint_glyph_run( y: g.y * scale, }); - // Cached in F2Dot14 bit form, so no per-run conversion allocation. - let coords = font_cache.get_coords(&run.font_data, run.font_index); - + // The run's variable-font instance (F2Dot14 raw i16), as resolved by + // Parley — e.g. Arimo `wght=700` for bold Arial. Empty for static faces + // (skrifa then uses the default master). Rendering the default instead + // paints regular-weight glyphs with bold advances. scene .draw_glyphs(&font) .font_size(run.font_size * scale) .transform(transform) .glyph_transform(None) - .normalized_coords(coords) + .normalized_coords(&run.normalized_coords) .brush(&brush) .hint(false) .draw(peniko::Fill::NonZero, glyphs); diff --git a/loki-vello/src/rect.rs b/loki-vello/src/rect.rs index 94825eac..844e3a7a 100644 --- a/loki-vello/src/rect.rs +++ b/loki-vello/src/rect.rs @@ -6,7 +6,32 @@ //! Translates [`loki_layout::PositionedRect`] and //! [`loki_layout::PositionedBorderRect`] into Vello `fill` / `stroke` calls. -use loki_layout::{BorderEdge, LayoutRect, PositionedBorderRect, PositionedRect}; +use loki_layout::{BorderEdge, LayoutRect, PositionedBorderRect, PositionedHatch, PositionedRect}; + +/// Paint a `w:shd` hatch: the optional background fill, then each clipped hatch +/// line stroked at the pattern's line width. +pub fn paint_hatch(scene: &mut vello::Scene, item: &PositionedHatch, scale: f32) { + if let Some(fill) = item.fill { + paint_filled_rect( + scene, + &PositionedRect { + rect: item.rect, + color: fill, + }, + scale, + ); + } + let brush = crate::color::to_brush(&item.color); + let stroke = kurbo::Stroke::new(f64::from(item.line_width() * scale)); + let s = f64::from(scale); + for seg in item.segments() { + let line = kurbo::Line::new( + (f64::from(seg.x0) * s, f64::from(seg.y0) * s), + (f64::from(seg.x1) * s, f64::from(seg.y1) * s), + ); + scene.stroke(&stroke, kurbo::Affine::IDENTITY, &brush, None, &line); + } +} /// Paint a filled rectangle into the scene. pub fn paint_filled_rect(scene: &mut vello::Scene, item: &PositionedRect, scale: f32) { diff --git a/loki-vello/src/scene_items.rs b/loki-vello/src/scene_items.rs index f7cafc46..1477e2ae 100644 --- a/loki-vello/src/scene_items.rs +++ b/loki-vello/src/scene_items.rs @@ -128,6 +128,9 @@ pub(crate) fn paint_items( PositionedItem::FilledRect(r) => { crate::rect::paint_filled_rect(scene, r, scale); } + PositionedItem::HatchRect(h) => { + crate::rect::paint_hatch(scene, h, scale); + } PositionedItem::BorderRect(r) => { crate::rect::paint_border_rect(scene, r, scale); } @@ -210,6 +213,10 @@ pub(super) fn translate_item(item: &mut PositionedItem, dx: f32, dy: f32) { r.rect.origin.x += dx; r.rect.origin.y += dy; } + PositionedItem::HatchRect(h) => { + h.rect.origin.x += dx; + h.rect.origin.y += dy; + } PositionedItem::BorderRect(r) => { r.rect.origin.x += dx; r.rect.origin.y += dy; diff --git a/loki-vello/src/scene_tests.rs b/loki-vello/src/scene_tests.rs index f52c8410..43d7245a 100644 --- a/loki-vello/src/scene_tests.rs +++ b/loki-vello/src/scene_tests.rs @@ -128,6 +128,7 @@ fn test_translate_item_glyph_run() { glyphs: vec![], color: LayoutColor::BLACK, synthesis: GlyphSynthesis::default(), + normalized_coords: Vec::new(), link_url: None, }); translate_item(&mut item, 5.0, 3.0); diff --git a/scripts/generate-office-goldens.sh b/scripts/generate-office-goldens.sh new file mode 100755 index 00000000..c6a7a5a0 --- /dev/null +++ b/scripts/generate-office-goldens.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 AppThere Loki contributors +# +# Generate a Golden PNG render from a PDF *printed from an office suite* — the +# manual-print half of the visual conformance axis (Spec 02 §7.2 / M4): +# +# fixture .docx --Word: Export to PDF--> .pdf --pinned rasterizer--> page PNGs +# fixture .odt --LO: Export to PDF--> .pdf --pinned rasterizer--> page PNGs +# +# WHY THIS EXISTS ALONGSIDE generate-odf-goldens.sh +# ------------------------------------------------- +# `generate-odf-goldens.sh` drives `soffice --headless --convert-to pdf` +# itself, so the LibreOffice/ODF golden is fully scripted. Microsoft Office, +# however, cannot be automated headlessly on Linux/CI, so its OOXML goldens +# must be produced on a machine that has Word/Excel: open the committed +# fixture, Export/Print to PDF at default settings, and feed THAT PDF here. +# This script therefore INGESTS a pre-printed PDF rather than producing one. +# +# It reuses the one pinned rasterizer for every golden — appthere-conformance's +# `PdfRasterizer` (`pdftoppm` at CONFORMANCE_DPI = 144 dpi with pinned AA), the +# exact stage generate-odf-goldens.sh and the candidate side both use — so the +# golden and candidate PNGs differ only in the layout/render engine (Spec 02 +# D3), never in DPI, AA, or PNG encoder. That makes the resulting golden +# directly comparable to Loki's render via the same SSIM/ΔE differ the ODF +# goldens use. +# +# HOW TO PRINT THE REFERENCE PDF (do this once per fixture, on the ref app) +# Microsoft Word / Excel (OOXML): +# File → Export → Create PDF/XPS (or File → Save As → PDF), "Standard" +# quality, default page setup. Do NOT use "Minimum size". One PDF per +# fixture, page geometry as authored. +# LibreOffice (ODF, when capturing a manual print instead of the scripted +# path): File → Export As → Export as PDF, default settings. +# Record the exact application + version in --reference so the golden's +# provenance is data, not folklore (Spec 02 §7.4). +# +# Requirements: poppler-utils (`pdftoppm`) on PATH; the bundled +# metric-compatible fonts installed for the *reference* app so it shapes with +# the same faces Loki bundles (D4) — e.g. copy loki-fonts/fonts/*.ttf to the +# machine that runs Word/LibreOffice. +# +# Output: appthere-conformance/goldens///page-N.png plus a +# GENERATION.txt provenance record (reference app+version, rasterizer version, +# source-PDF sha256, optional fixture sha256, date, operator). +# +# Usage: +# scripts/generate-office-goldens.sh \ +# --format docx \ +# --stem tc-docx-001-line-spacing \ +# --pdf ~/prints/tc-docx-001.pdf \ +# --reference "Microsoft 365 Word (2508, Build 16.0.18827.20164)" \ +# [--fixture appthere-conformance/fixtures/docx/tc-docx-001-line-spacing.docx] \ +# [--goldens-root appthere-conformance/goldens] +# +# --fixture is optional but recommended: it copies the exact source document +# into the fixtures tree next to the golden and records its checksum, so the +# candidate (which imports the fixture) and the golden (Office's render of that +# same fixture) can never silently drift apart. + +set -euo pipefail +cd "$(dirname "$0")/.." + +FORMAT="" +STEM="" +PDF="" +REFERENCE="" +FIXTURE="" +GOLDENS_ROOT="appthere-conformance/goldens" + +usage() { + sed -n '2,59p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --format) FORMAT="${2:-}"; shift 2 ;; + --stem) STEM="${2:-}"; shift 2 ;; + --pdf) PDF="${2:-}"; shift 2 ;; + --reference) REFERENCE="${2:-}"; shift 2 ;; + --fixture) FIXTURE="${2:-}"; shift 2 ;; + --goldens-root) GOLDENS_ROOT="${2:-}"; shift 2 ;; + -h|--help) usage 0 ;; + *) echo "ERROR: unknown argument: $1" >&2; usage 2 ;; + esac +done + +die() { echo "ERROR: $*" >&2; exit 1; } + +# --- Validate inputs (fail loudly, never silently skip) ---------------------- +[[ -n "$FORMAT" ]] || die "--format is required (docx|xlsx|odt|ods|pptx)" +[[ -n "$STEM" ]] || die "--stem is required (the fixture identifier)" +[[ -n "$PDF" ]] || die "--pdf is required (a PDF printed from the reference app)" +[[ -n "$REFERENCE" ]] || die "--reference is required — record the exact app+version so provenance is data, not folklore" + +case "$FORMAT" in + docx|xlsx|odt|ods|pptx) ;; + *) die "unsupported --format '$FORMAT' (expected docx|xlsx|odt|ods|pptx)" ;; +esac + +[[ -f "$PDF" ]] || die "PDF not found: $PDF" +# Cheap sanity check that the input really is a PDF, so a mis-pass surfaces here +# rather than as an opaque pdftoppm failure. +head -c 5 "$PDF" | grep -q '^%PDF-' || die "not a PDF (no %PDF- header): $PDF" + +command -v pdftoppm >/dev/null || die "pdftoppm (poppler-utils) not found on PATH — install poppler-utils" + +if [[ -n "$FIXTURE" ]]; then + [[ -f "$FIXTURE" ]] || die "fixture not found: $FIXTURE" + fixture_ext="${FIXTURE##*.}" + [[ "$fixture_ext" == "$FORMAT" ]] \ + || die "fixture extension .$fixture_ext does not match --format $FORMAT" +fi + +# --- Rasterize through the ONE pinned stage --------------------------------- +OUT="$GOLDENS_ROOT/$FORMAT/$STEM" +rm -rf "$OUT" +mkdir -p "$OUT" + +echo "==> $FORMAT/$STEM (reference: $REFERENCE)" +# rasterize_pdf prints the rasterizer version on line 1, then the page PNGs. +RASTER_VERSION=$(cargo run -q -p appthere-conformance --example rasterize_pdf -- \ + "$PDF" "$OUT" page | head -1) + +PAGE_COUNT=$(find "$OUT" -maxdepth 1 -name 'page-*.png' | wc -l | tr -d ' ') +[[ "$PAGE_COUNT" -gt 0 ]] || die "rasterizer produced no page PNGs for $PDF" + +PDF_SHA=$(sha256sum "$PDF" | cut -d' ' -f1) + +# --- Record the source fixture alongside the golden (recommended) ----------- +FIXTURE_LINE="fixture-source: (not supplied — pass --fixture to lock candidate↔golden)" +if [[ -n "$FIXTURE" ]]; then + FIXTURES_DIR="appthere-conformance/fixtures/$FORMAT" + mkdir -p "$FIXTURES_DIR" + dest="$FIXTURES_DIR/$STEM.$FORMAT" + cp "$FIXTURE" "$dest" + FIXTURE_SHA=$(sha256sum "$dest" | cut -d' ' -f1) + FIXTURE_LINE="fixture-source: $dest (sha256 $FIXTURE_SHA)" + echo " copied fixture -> $dest" +fi + +# --- Provenance record (Spec 02 §7.2 / §7.4) -------------------------------- +{ + echo "fixture: $STEM.$FORMAT" + echo "reference: $REFERENCE (printed to PDF)" + echo "rasterizer: $RASTER_VERSION @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes" + echo "pages: $PAGE_COUNT" + echo "source-pdf: sha256 $PDF_SHA" + echo "$FIXTURE_LINE" + echo "generated: $(date -u +%Y-%m-%d)" + echo "operator: scripts/generate-office-goldens.sh (ingest printed PDF; Spec 02 §7.2)" +} > "$OUT/GENERATION.txt" + +echo "Golden written to $OUT ($PAGE_COUNT page(s))" diff --git a/scripts/suppressions-baseline.txt b/scripts/suppressions-baseline.txt index 49d21f81..cf3ec3eb 100644 --- a/scripts/suppressions-baseline.txt +++ b/scripts/suppressions-baseline.txt @@ -3,17 +3,18 @@ # scripts/check-suppressions.py: neither count may GROW; a file must be # removed once both reach 0. New files must start at 0/0. # Regenerate with: scripts/check-suppressions.py --update -# Totals: 461 `let _ =`, 172 `#[allow]` across 140 files. +# Totals: 504 `let _ =`, 179 `#[allow]` across 147 files. 66 1 loki-ooxml/src/docx/write/document_drawing.rs 47 0 loki-ooxml/src/docx/write/styles.rs 42 1 loki-ooxml/src/docx/write/document_table.rs +35 0 loki-ooxml/src/docx/write/document_textbox.rs 28 1 loki-ooxml/src/docx/write/numbering.rs 26 1 loki-ooxml/src/docx/write/document_inlines.rs 24 3 loki-ooxml/src/docx/write/document_para.rs +24 3 loki-ooxml/src/docx/write/run_props.rs 26 0 loki-ooxml/src/docx/write/document_blocks.rs 26 0 loki-ooxml/src/docx/write/footnotes.rs -21 1 loki-ooxml/src/docx/write/run_props.rs 16 0 loki-ooxml/src/docx/write/table_style.rs 14 1 loki-ooxml/src/docx/write/document.rs 15 0 loki-ooxml/src/pptx/write_slide.rs @@ -34,6 +35,7 @@ 1 3 loki-odf/src/odt/reader/styles.rs 0 4 loki-ooxml/src/docx/model/fields.rs 4 0 loki-ooxml/src/docx/omml/write.rs +4 0 loki-ooxml/src/docx/repair/dom.rs 4 0 loki-ooxml/src/docx/write/revision.rs 4 0 loki-ooxml/src/pptx/write_presentation.rs 0 4 loki-opc/src/compat/part_names.rs @@ -55,6 +57,7 @@ 0 2 loki-odf/src/odt/reader/document_frame.rs 0 2 loki-odf/src/odt/reader/styles_list_style.rs 0 2 loki-ooxml/src/docx/mapper/document.rs +1 1 loki-ooxml/src/docx/repair/mod.rs 0 2 loki-ooxml/src/docx/write/xml.rs 0 2 loki-ooxml/src/xml_util.rs 0 2 loki-opc/src/compat/zip_names.rs @@ -80,9 +83,13 @@ 1 0 loki-doc-model/src/loro_bridge/inlines.rs 0 1 loki-doc-model/src/style/table_banding.rs 0 1 loki-layout/src/flow_entry.rs +0 1 loki-layout/src/flow_float.rs 0 1 loki-layout/src/flow_headers.rs +0 1 loki-layout/src/flow_para_place.rs +0 1 loki-layout/src/flow_table_autofit.rs 0 1 loki-layout/src/flow_table_cells.rs 0 1 loki-layout/src/flow_table_paint.rs +0 1 loki-layout/src/hatch.rs 0 1 loki-layout/src/incremental.rs 0 1 loki-layout/src/para_drop_cap.rs 0 1 loki-layout/src/para_underlays.rs