From b5d233e33ff1848ac5c95235548e910ebe1a8c6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 05:25:11 +0000 Subject: [PATCH 01/35] test(loki-layout): pin list hanging-indent & table fixed-width geometry Add GPU-free layout-geometry assertions for two DOCX/ODT fidelity areas that the page-count + glyph-coverage canaries cannot see: - hanging_indent_tests.rs: assert wrapped list continuation lines align under the text start (one hanging indent past the marker), for both bullet and numbered lists. Locks the Word/LibreOffice contract against regression. Confirms the flow engine's continuation-line alignment is correct in the catalog-driven (imported) path. - table_tests.rs: characterize column-width resolution when explicit fixed widths over/underflow the table width (Loki rescales; documents the divergence from Word's fixed-layout honour-and-overflow). Adds an ignored target-spec test (fixed_columns_should_be_honored_like_word) that encodes the Word behaviour, unblocked once w:tblLayout is plumbed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- loki-layout/tests/hanging_indent_tests.rs | 238 ++++++++++++++++++++++ loki-layout/tests/table_tests.rs | 120 +++++++++++ 2 files changed, 358 insertions(+) create mode 100644 loki-layout/tests/hanging_indent_tests.rs diff --git a/loki-layout/tests/hanging_indent_tests.rs b/loki-layout/tests/hanging_indent_tests.rs new file mode 100644 index 00000000..478360fd --- /dev/null +++ b/loki-layout/tests/hanging_indent_tests.rs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Layout-geometry assertions for list hanging indent. +//! +//! These are GPU-free, deterministic tests that import a synthetic list +//! paragraph, run `flow_section`, and assert the *x-position* of wrapped +//! continuation lines. They guard the Word/LibreOffice contract that the +//! second and later lines of a wrapping list item align under the **text +//! start** (the hanging indent), not under the bullet/number marker. +//! +//! Symptom this pins: a regression where continuation lines collapse left +//! toward the marker instead of staying at `indent_start`. + +use loki_doc_model::content::attr::{ExtensionBag, NodeAttr}; +use loki_doc_model::content::block::{Block, StyledParagraph}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::layout::Section; +use loki_doc_model::layout::page::{PageLayout, PageMargins, PageSize}; +use loki_doc_model::style::catalog::StyleCatalog; +use loki_doc_model::style::list_style::{ + BulletChar, LabelAlignment, ListId, ListLevel, ListLevelKind, ListStyle, NumberingScheme, +}; +use loki_doc_model::style::props::para_props::ParaProps; +use loki_primitives::units::Points; + +use loki_layout::{ + FlowOutput, FontResources, LayoutMode, LayoutOptions, PositionedItem, flow_section, +}; + +const INDENT_START: f64 = 36.0; +const HANGING: f64 = 18.0; + +fn test_resources() -> FontResources { + let mut r = FontResources::new(); + for p in [ + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ] { + if let Ok(data) = std::fs::read(p) { + r.register_font(data); + } + } + r +} + +/// A catalog with one bullet list ("b") and one decimal list ("d"), each a +/// single level at `indent_start = 36pt`, `hanging = 18pt` — i.e. the marker +/// hangs at x≈18 and the text starts at x≈36. +fn list_catalog() -> StyleCatalog { + let mut catalog = StyleCatalog::new(); + catalog.list_styles.insert( + ListId::new("b"), + ListStyle { + id: ListId::new("b"), + display_name: None, + levels: vec![ListLevel { + level: 0, + kind: ListLevelKind::Bullet { + char: BulletChar::Char('•'), + font: None, + }, + indent_start: Points::new(INDENT_START), + hanging_indent: Points::new(HANGING), + label_alignment: LabelAlignment::Left, + tab_stop_after_label: None, + char_props: Default::default(), + }], + extensions: ExtensionBag::default(), + }, + ); + catalog.list_styles.insert( + ListId::new("d"), + ListStyle { + id: ListId::new("d"), + display_name: None, + levels: vec![ListLevel { + level: 0, + kind: ListLevelKind::Numbered { + scheme: NumberingScheme::Decimal, + start_value: 1, + format: "%1.".to_string(), + display_levels: 1, + }, + indent_start: Points::new(INDENT_START), + hanging_indent: Points::new(HANGING), + label_alignment: LabelAlignment::Left, + tab_stop_after_label: None, + char_props: Default::default(), + }], + extensions: ExtensionBag::default(), + }, + ); + catalog +} + +fn list_para(text: &str, list_id: &str) -> StyledParagraph { + StyledParagraph { + style_id: None, + direct_para_props: Some(Box::new(ParaProps { + list_id: Some(ListId::new(list_id)), + list_level: Some(0), + indent_start: Some(Points::new(INDENT_START)), + indent_hanging: Some(Points::new(HANGING)), + ..Default::default() + })), + direct_char_props: None, + inlines: vec![Inline::Str(text.into())], + attr: NodeAttr::default(), + } +} + +/// A narrow page (200×400pt, 10pt L/R margins → 180pt content) so that a +/// medium-length list item is guaranteed to wrap to several lines, leaving the +/// list's text column ~144pt wide. +fn narrow_layout() -> PageLayout { + PageLayout { + page_size: PageSize { + width: Points::new(200.0), + height: Points::new(400.0), + }, + margins: PageMargins { + top: Points::new(10.0), + bottom: Points::new(10.0), + left: Points::new(10.0), + right: Points::new(10.0), + ..PageMargins::default() + }, + ..PageLayout::default() + } +} + +/// (x, y) origin of every glyph run, in layout order. +fn glyph_origins(items: &[PositionedItem]) -> Vec<(f32, f32)> { + items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(run) => Some((run.origin.x, run.origin.y)), + _ => None, + }) + .collect() +} + +fn flow(catalog: &StyleCatalog, para: StyledParagraph) -> Vec { + let mut r = test_resources(); + let section = Section::with_layout_and_blocks(narrow_layout(), vec![Block::StyledPara(para)]); + match flow_section( + &mut r, + §ion, + catalog, + &LayoutMode::Pageless, + 1.0, + &LayoutOptions::default(), + &[], + ) { + FlowOutput::Canvas { items, .. } => items, + _ => panic!("expected Canvas output"), + } +} + +/// Split glyph-run origins into the first visual line and the continuation +/// lines, returning `(first_line_min_x, continuation_min_x)`. Panics if the +/// content did not wrap (so a non-wrapping fixture fails loudly rather than +/// silently passing). +fn first_and_continuation_x(origins: &[(f32, f32)]) -> (f32, f32) { + assert!(!origins.is_empty(), "expected glyph runs"); + let min_y = origins + .iter() + .map(|(_, y)| *y) + .fold(f32::INFINITY, f32::min); + // Runs on the first line share (approximately) the smallest baseline y. + let first_line_x = origins + .iter() + .filter(|(_, y)| (*y - min_y).abs() < 1.0) + .map(|(x, _)| *x) + .fold(f32::INFINITY, f32::min); + let continuation: Vec = origins + .iter() + .filter(|(_, y)| *y > min_y + 1.0) + .map(|(x, _)| *x) + .collect(); + assert!( + !continuation.is_empty(), + "fixture must wrap to >=2 lines for this assertion to be meaningful" + ); + let continuation_x = continuation.iter().cloned().fold(f32::INFINITY, f32::min); + (first_line_x, continuation_x) +} + +#[test] +fn bullet_continuation_aligns_with_hanging_text_start() { + let catalog = list_catalog(); + let items = flow( + &catalog, + list_para( + "This is a fairly long bullet item whose text must wrap across \ + several lines so we can check where the continuation lines start.", + "b", + ), + ); + let (first_line_x, continuation_x) = first_and_continuation_x(&glyph_origins(&items)); + + // The marker hangs to the left, so the first line starts further left than + // the continuation lines. + assert!( + continuation_x > first_line_x + 1.0, + "continuation lines ({continuation_x}) must be indented to the right of \ + the marker/first line ({first_line_x})" + ); + // Continuation lines should sit exactly one hanging-indent (18pt) to the + // right of the marker — i.e. under the text start, matching Word. + let delta = continuation_x - first_line_x; + assert!( + (delta - HANGING as f32).abs() < 2.0, + "continuation should indent one hanging ({HANGING}pt) past the marker; \ + got {delta}pt (first_line_x={first_line_x}, continuation_x={continuation_x})" + ); +} + +#[test] +fn numbered_continuation_aligns_with_hanging_text_start() { + let catalog = list_catalog(); + let items = flow( + &catalog, + list_para( + "This is a fairly long numbered item whose text must wrap across \ + several lines so we can check where the continuation lines start.", + "d", + ), + ); + let (first_line_x, continuation_x) = first_and_continuation_x(&glyph_origins(&items)); + let delta = continuation_x - first_line_x; + assert!( + (delta - HANGING as f32).abs() < 2.0, + "numbered continuation should indent one hanging ({HANGING}pt) past the \ + marker; got {delta}pt" + ); +} diff --git a/loki-layout/tests/table_tests.rs b/loki-layout/tests/table_tests.rs index bde5dd6e..c01ec6cc 100644 --- a/loki-layout/tests/table_tests.rs +++ b/loki-layout/tests/table_tests.rs @@ -348,6 +348,126 @@ fn test_table_non_uniform_columns() { ); } +/// Build an all-fixed-width table: one row, `widths.len()` columns, each a +/// `ColWidth::Fixed`, with an explicit `TableWidth::Fixed(table_width)`. +fn fixed_width_table(widths: &[f64], table_width: f32) -> Block { + use loki_doc_model::content::table::col::TableWidth; + let bg = Some(DocumentColor::Rgb(appthere_color::RgbColor::new( + 1.0, 0.0, 0.0, + ))); + let cells: Vec = widths + .iter() + .map(|_| make_cell_tall(vec!["x"], bg.clone(), 1)) + .collect(); + let col_specs: Vec = widths + .iter() + .map(|&w| ColSpec { + alignment: ColAlignment::Default, + width: ColWidth::Fixed(loki_primitives::units::Points::new(w)), + }) + .collect(); + Block::Table(Box::new(Table { + attr: loki_doc_model::content::attr::NodeAttr::default(), + caption: Default::default(), + width: Some(TableWidth::Fixed(table_width)), + col_specs, + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(cells)])], + foot: TableFoot::empty(), + })) +} + +fn cell_widths(items: &[PositionedItem]) -> Vec { + items + .iter() + .filter_map(|i| match i { + PositionedItem::FilledRect(rect) => Some(rect.rect.width()), + _ => None, + }) + .collect() +} + +/// CHARACTERIZATION — locks Loki's *current* behaviour, which **diverges from +/// Word**. When the sum of explicit fixed column widths exceeds the table +/// width, Loki scales every column down proportionally to fit. Microsoft Word +/// with `tblLayout w:type="fixed"` instead honours the fixed widths exactly and +/// lets the table overflow the page (clipping/overflowing content). +/// +/// This passes today; the `#[ignore]`d test below encodes the target Word +/// behaviour and is unblocked once `w:tblLayout` is parsed and carried into the +/// model. See `docs/fidelity-status.md` (Tables & Images) and the layout audit. +#[test] +fn fixed_columns_overflowing_table_width_are_scaled_down_current_behavior() { + let mut r = test_resources(); + // 3 columns × 200pt = 600pt of fixed width, but the table declares 300pt. + let table = fixed_width_table(&[200.0, 200.0, 200.0], 300.0); + let section = Section { + layout: PageLayout::default(), + blocks: vec![table], + extensions: ExtensionBag::default(), + }; + let (items, _) = flow_pageless(&mut r, §ion); + let widths = cell_widths(&items); + assert_eq!(widths.len(), 3, "expected 3 cell rects"); + for (i, w) in widths.iter().enumerate() { + assert!( + (w - 100.0).abs() < 1e-2, + "col {i}: current behaviour scales 200pt → 100pt (300/600); got {w}" + ); + } +} + +/// TARGET SPEC (Word fidelity) — fixed column widths must be honoured exactly; +/// the table is allowed to exceed the declared/table width rather than being +/// rescaled. Ignored until `w:tblLayout="fixed"` is parsed and threaded through +/// `loki-ooxml` → `loki-doc-model` → `loki-layout`. Remove `#[ignore]` once the +/// fixed-layout path exists. +#[test] +#[ignore = "needs w:tblLayout plumbing; see fidelity-status Tables & Images"] +fn fixed_columns_should_be_honored_like_word() { + let mut r = test_resources(); + let table = fixed_width_table(&[200.0, 200.0, 200.0], 300.0); + let section = Section { + layout: PageLayout::default(), + blocks: vec![table], + extensions: ExtensionBag::default(), + }; + let (items, _) = flow_pageless(&mut r, §ion); + let widths = cell_widths(&items); + assert_eq!(widths.len(), 3); + for (i, w) in widths.iter().enumerate() { + assert!( + (w - 200.0).abs() < 1e-2, + "col {i}: fixed 200pt must be honoured exactly (table overflows); got {w}" + ); + } +} + +/// CHARACTERIZATION — fixed widths that sum to *less* than the table width are +/// also currently scaled (up) to fill the table. Word's behaviour depends on +/// `tblLayout` (fixed: leave a gap; autofit: distribute), so this too is a +/// known divergence pending the `tblLayout` feature. +#[test] +fn fixed_columns_underflowing_table_width_are_scaled_up_current_behavior() { + let mut r = test_resources(); + // 2 columns × 50pt = 100pt fixed, table declares 300pt → scale ×3 → 150 each. + let table = fixed_width_table(&[50.0, 50.0], 300.0); + let section = Section { + layout: PageLayout::default(), + blocks: vec![table], + extensions: ExtensionBag::default(), + }; + let (items, _) = flow_pageless(&mut r, §ion); + let widths = cell_widths(&items); + assert_eq!(widths.len(), 2); + for (i, w) in widths.iter().enumerate() { + assert!( + (w - 150.0).abs() < 1e-2, + "col {i}: current behaviour scales 50pt → 150pt (300/100); got {w}" + ); + } +} + #[test] fn test_table_cell_vertical_alignment() { use loki_doc_model::content::table::row::CellVerticalAlign; From 674e12a3cde6949c4d30dcdf7f5f50901152e109 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 08:31:54 +0000 Subject: [PATCH 02/35] feat(import): parse drop caps & floating-image wrap (DOCX + ODT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture two previously-dropped constructs at import into typed model fields — the honest first layer before any layout/rendering work. Model (loki-doc-model): - DropCap / DropCapLength (style/props/drop_cap.rs) + ParaProps.drop_cap. - FloatWrap / TextWrap / WrapSide (content/float.rs) with a typed NodeAttr (de)serialization API (store/read) over reserved kv keys, consistent with the existing `floating` class convention. DOCX (loki-ooxml): - Parse w:framePr (dropCap/lines/hSpace) → DropCap; "drop" vs "margin". - Parse wp:wrapSquare/Tight/Through/TopAndBottom/None (+ wrapText side, anchor behindDoc) on drawings → FloatWrap on the image NodeAttr. ODT (loki-odf): - Parse style:drop-cap (lines/length/distance) in paragraph-properties → DropCap (length supports "word" or a char count). - Parse style:graphic-properties style:wrap/style:run-through on the frame's graphic style → FloatWrap on the floating figure. Neither is rendered yet: the initial is still laid out inline and the flow engine has no exclusion zones for floats (gap #12). Documented in docs/fidelity-status.md (new Drop Cap and Floating Images rows). Tests: model round-trips (float/drop_cap), DOCX mapper (frame_pr_*, anchor_drawing_carries_wrap_mode), ODT reader+mapper (read_stylesheet_drop_cap/graphic_wrap, drop_cap_*). All green; clippy --workspace -D warnings clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 + loki-doc-model/src/content/float.rs | 235 +++++++++++++++++++ loki-doc-model/src/content/mod.rs | 2 + loki-doc-model/src/style/props/drop_cap.rs | 85 +++++++ loki-doc-model/src/style/props/mod.rs | 2 + loki-doc-model/src/style/props/para_props.rs | 8 + loki-odf/src/odt/mapper/document.rs | 54 ++++- loki-odf/src/odt/mapper/props/paragraph.rs | 82 ++++++- loki-odf/src/odt/mapper/styles.rs | 3 + loki-odf/src/odt/model/styles.rs | 26 ++ loki-odf/src/odt/reader/styles.rs | 100 +++++++- loki-ooxml/src/docx/mapper/images.rs | 58 ++++- loki-ooxml/src/docx/mapper/props.rs | 68 +++++- loki-ooxml/src/docx/model/paragraph.rs | 19 ++ loki-ooxml/src/docx/reader/document.rs | 60 ++++- 15 files changed, 785 insertions(+), 19 deletions(-) create mode 100644 loki-doc-model/src/content/float.rs create mode 100644 loki-doc-model/src/style/props/drop_cap.rs diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 1997cae7..90cdda3a 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -63,6 +63,7 @@ This is the living source of truth documenting which document features, characte | **Named / Custom Paragraph Styles** | Yes | Yes | Yes | The full style catalog round-trips through DOCX `styles.xml`: custom styles created in the style editor, plus edits to built-in `Normal`/`Heading1`–`6`, persist across save/reload. Font family, weight (→ bold), size, alignment, indentation (incl. first-line), spacing, line height, `basedOn`, `next`-style, outline level, and the custom flag are all written and read back. In-session, the catalog round-trips through the Loro CRDT (`loro_bridge::styles`, a JSON snapshot like metadata), so style-editor edits are durable across rebuilds and **undoable** with Ctrl+Z/Ctrl+Y. | | **border_between** | Yes | No | No | Rules between adjacent same-styled paragraphs ignored. | | **bidi / RTL** | Yes | No | No | RTL paragraphs ignored due to Parley API limitations. | +| **Drop Cap** | Yes | No | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Not yet rendered** — the initial is still laid out inline at body size; multi-line spanning + wrap-around is the follow-up. Not yet re-exported, and not round-tripped through the Loro CRDT. Tested: `frame_pr_*` (DOCX mapper), `read_stylesheet_drop_cap` + `drop_cap_*` (ODT reader/mapper). ACID TC-DOCX-015 / TC-ODT-013. | --- @@ -76,6 +77,7 @@ This is the living source of truth documenting which document features, characte | **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | +| **Floating Images / Wrap Mode** | Yes | No | No | Anchored (floating) drawings are marked with the `floating` class and now 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` (on the frame's graphic style) both map. **Not yet laid out** — the flow engine has no exclusion zones, so text does not wrap around floats (gap #12); floating drawings are still not painted. Tested: `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | | **External Images** | Yes | No | No | Renders as gray placeholder rectangles. | --- diff --git a/loki-doc-model/src/content/float.rs b/loki-doc-model/src/content/float.rs new file mode 100644 index 00000000..8156a2ea --- /dev/null +++ b/loki-doc-model/src/content/float.rs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Floating-object text-wrap mode for anchored images and shapes. +//! +//! A floating (anchored) drawing is positioned outside the inline text flow, +//! and surrounding text wraps around it according to a *wrap mode*. OOXML +//! `wp:anchor` children (`wp:wrapSquare`/`wp:wrapTight`/`wp:wrapThrough`/ +//! `wp:wrapTopAndBottom`/`wp:wrapNone`); ODF `style:wrap` on the frame's +//! graphic style. +//! +//! Floating drawings are marked with the [`FLOATING_CLASS`] class on their +//! [`NodeAttr`]; the wrap detail is carried in `NodeAttr.kv` under reserved +//! keys (see [`FloatWrap::store`]/[`FloatWrap::read`]). This is the *import* +//! representation — the layout engine does not yet flow text around floats +//! (it currently treats all images as block prefixes), so capturing the wrap +//! mode is the prerequisite for that work. + +use crate::content::attr::NodeAttr; + +/// Class marking an image/shape as floating (anchored) rather than inline. +pub const FLOATING_CLASS: &str = "floating"; + +const KV_WRAP: &str = "float-wrap"; +const KV_SIDE: &str = "float-wrap-side"; +const KV_BEHIND: &str = "float-behind"; + +/// How body text wraps around a floating object. +/// +/// Neutral over OOXML wrap elements and ODF `style:wrap` values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum TextWrap { + /// Text wraps around the object's bounding box. + /// OOXML `wp:wrapSquare`; ODF `style:wrap="parallel"`. + #[default] + Square, + /// Text wraps to the object's tight contour (wrap polygon). + /// OOXML `wp:wrapTight`; ODF `style:wrap="parallel"` with a contour. + Tight, + /// Text flows through the object's transparent regions. + /// OOXML `wp:wrapThrough`; ODF `style:wrap="run-through"` (parallel form). + Through, + /// Text stops above and resumes below; no text beside the object. + /// OOXML `wp:wrapTopAndBottom`; ODF `style:wrap="none"`. + TopAndBottom, + /// No wrap: the object floats over or under the text. + /// OOXML `wp:wrapNone`; ODF `style:wrap="run-through"` (run-through form). + None, +} + +/// Which side(s) of the object text is allowed to occupy. +/// +/// OOXML `@wrapText` on the wrap element; ODF `style:wrap="left"/"right"/ +/// "dynamic"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum WrapSide { + /// Text on both sides. OOXML `bothSides`; ODF `parallel`. + #[default] + Both, + /// Text on the left only. OOXML `left`; ODF `left`. + Left, + /// Text on the right only. OOXML `right`; ODF `right`. + Right, + /// Text on the larger side only. OOXML `largest`; ODF `dynamic`. + Largest, +} + +/// Text-wrap configuration for a floating object. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FloatWrap { + /// The wrap mode. + pub wrap: TextWrap, + /// Which side(s) text may occupy (meaningful for `Square`/`Tight`/`Through`). + pub side: WrapSide, + /// `true` when the object sits behind the text (OOXML `wp:wrapNone` with + /// `behindDoc="1"`; ODF `style:run-through="background"`). + pub behind_text: bool, +} + +impl TextWrap { + fn as_kv(self) -> &'static str { + match self { + TextWrap::Square => "square", + TextWrap::Tight => "tight", + TextWrap::Through => "through", + TextWrap::TopAndBottom => "top-bottom", + TextWrap::None => "none", + } + } + + fn from_kv(s: &str) -> Option { + Some(match s { + "square" => TextWrap::Square, + "tight" => TextWrap::Tight, + "through" => TextWrap::Through, + "top-bottom" => TextWrap::TopAndBottom, + "none" => TextWrap::None, + _ => return None, + }) + } +} + +impl WrapSide { + fn as_kv(self) -> &'static str { + match self { + WrapSide::Both => "both", + WrapSide::Left => "left", + WrapSide::Right => "right", + WrapSide::Largest => "largest", + } + } + + fn from_kv(s: &str) -> Self { + match s { + "left" => WrapSide::Left, + "right" => WrapSide::Right, + "largest" => WrapSide::Largest, + _ => WrapSide::Both, + } + } +} + +impl FloatWrap { + /// Writes this wrap configuration into `attr`: ensures the [`FLOATING_CLASS`] + /// class is present and records the wrap mode/side/behind flag in `kv`. + /// Idempotent — any previously stored wrap keys are replaced. + pub fn store(&self, attr: &mut NodeAttr) { + if !attr.classes.iter().any(|c| c == FLOATING_CLASS) { + attr.classes.push(FLOATING_CLASS.to_string()); + } + attr.kv + .retain(|(k, _)| k != KV_WRAP && k != KV_SIDE && k != KV_BEHIND); + attr.kv + .push((KV_WRAP.to_string(), self.wrap.as_kv().to_string())); + attr.kv + .push((KV_SIDE.to_string(), self.side.as_kv().to_string())); + if self.behind_text { + attr.kv.push((KV_BEHIND.to_string(), "true".to_string())); + } + } + + /// Reads a wrap configuration previously stored on `attr`, if any. + /// Returns `None` when no wrap mode is recorded. + #[must_use] + pub fn read(attr: &NodeAttr) -> Option { + let wrap = attr + .kv + .iter() + .find(|(k, _)| k == KV_WRAP) + .and_then(|(_, v)| TextWrap::from_kv(v))?; + let side = attr + .kv + .iter() + .find(|(k, _)| k == KV_SIDE) + .map(|(_, v)| WrapSide::from_kv(v)) + .unwrap_or_default(); + let behind_text = attr.kv.iter().any(|(k, v)| k == KV_BEHIND && v == "true"); + Some(FloatWrap { + wrap, + side, + behind_text, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_then_read_round_trips() { + let mut attr = NodeAttr::default(); + let fw = FloatWrap { + wrap: TextWrap::Tight, + side: WrapSide::Left, + behind_text: false, + }; + fw.store(&mut attr); + assert!(attr.classes.iter().any(|c| c == FLOATING_CLASS)); + assert_eq!(FloatWrap::read(&attr), Some(fw)); + } + + #[test] + fn behind_text_round_trips() { + let mut attr = NodeAttr::default(); + let fw = FloatWrap { + wrap: TextWrap::None, + side: WrapSide::Both, + behind_text: true, + }; + fw.store(&mut attr); + assert_eq!(FloatWrap::read(&attr), Some(fw)); + } + + #[test] + fn store_is_idempotent() { + let mut attr = NodeAttr::default(); + FloatWrap { + wrap: TextWrap::Square, + side: WrapSide::Both, + behind_text: false, + } + .store(&mut attr); + FloatWrap { + wrap: TextWrap::Through, + side: WrapSide::Right, + behind_text: false, + } + .store(&mut attr); + // Only one floating class, one set of wrap keys. + assert_eq!( + attr.classes.iter().filter(|c| *c == FLOATING_CLASS).count(), + 1 + ); + assert_eq!(attr.kv.iter().filter(|(k, _)| k == KV_WRAP).count(), 1); + assert_eq!( + FloatWrap::read(&attr), + Some(FloatWrap { + wrap: TextWrap::Through, + side: WrapSide::Right, + behind_text: false, + }) + ); + } + + #[test] + fn read_none_when_absent() { + assert_eq!(FloatWrap::read(&NodeAttr::default()), None); + } +} diff --git a/loki-doc-model/src/content/mod.rs b/loki-doc-model/src/content/mod.rs index c76deca0..c04a31d1 100644 --- a/loki-doc-model/src/content/mod.rs +++ b/loki-doc-model/src/content/mod.rs @@ -12,9 +12,11 @@ pub mod annotation; pub mod attr; pub mod block; pub mod field; +pub mod float; pub mod inline; pub mod table; pub use attr::{ExtensionBag, ExtensionKey, NodeAttr}; pub use block::Block; +pub use float::{FloatWrap, TextWrap, WrapSide}; pub use inline::{Inline, NoteKind}; diff --git a/loki-doc-model/src/style/props/drop_cap.rs b/loki-doc-model/src/style/props/drop_cap.rs new file mode 100644 index 00000000..a2f525a3 --- /dev/null +++ b/loki-doc-model/src/style/props/drop_cap.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Drop-cap (dropped initial / raised cap) paragraph property. +//! +//! A drop cap enlarges the first character(s) of a paragraph to span several +//! lines, with the body text wrapping around it. ODF `style:drop-cap` (inside +//! `style:paragraph-properties`); OOXML `w:framePr` with `w:dropCap` on the +//! framed initial paragraph. +//! +//! This type captures the *import* representation only — the layout engine does +//! not yet render dropped initials (the glyph is currently shown inline at body +//! size). Carrying the property losslessly is the prerequisite for that work. + +use loki_primitives::units::Points; + +/// How many leading characters of the paragraph are enlarged. +/// +/// ODF `style:length` (`"word"` or an integer count); OOXML has no explicit +/// count — the framed paragraph's content is the dropped text, so importers +/// default to a single character. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum DropCapLength { + /// Enlarge the first `n` characters (`n >= 1`). + Chars(u8), + /// Enlarge the whole first word. ODF `style:length="word"`. + Word, +} + +impl Default for DropCapLength { + fn default() -> Self { + DropCapLength::Chars(1) + } +} + +/// A dropped/raised initial-capital specification. +/// +/// ODF `style:drop-cap`; OOXML `w:framePr` (`w:dropCap`, `w:lines`, `w:hSpace`). +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DropCap { + /// Number of text lines the cap spans (the "drop"). OOXML `w:lines`; + /// ODF `style:lines`. Always `>= 1`; `1` degenerates to an inline initial. + pub lines: u8, + /// How many leading characters are enlarged. + pub length: DropCapLength, + /// Distance between the cap and the wrapped body text. + /// OOXML `w:hSpace`; ODF `style:distance`. + pub distance: Points, + /// `true` when the cap sits in the page margin (OOXML `w:dropCap="margin"`), + /// `false` when it is dropped within the text body (`w:dropCap="drop"`, + /// the only ODF mode). + pub margin: bool, +} + +impl Default for DropCap { + fn default() -> Self { + DropCap { + lines: 1, + length: DropCapLength::default(), + distance: Points::new(0.0), + margin: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_single_char_in_text() { + let dc = DropCap::default(); + assert_eq!(dc.lines, 1); + assert_eq!(dc.length, DropCapLength::Chars(1)); + assert!(!dc.margin); + } + + #[test] + fn length_default_is_one_char() { + assert_eq!(DropCapLength::default(), DropCapLength::Chars(1)); + } +} diff --git a/loki-doc-model/src/style/props/mod.rs b/loki-doc-model/src/style/props/mod.rs index 643f23c3..24e8535c 100644 --- a/loki-doc-model/src/style/props/mod.rs +++ b/loki-doc-model/src/style/props/mod.rs @@ -13,10 +13,12 @@ pub mod border; pub mod char_props; +pub mod drop_cap; pub mod para_props; pub mod tab_stop; pub use border::{Border, BorderStyle}; pub use char_props::CharProps; +pub use drop_cap::{DropCap, DropCapLength}; pub use para_props::ParaProps; 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 a0dea1ba..aa891f1d 100644 --- a/loki-doc-model/src/style/props/para_props.rs +++ b/loki-doc-model/src/style/props/para_props.rs @@ -10,6 +10,7 @@ use crate::content::attr::ExtensionBag; use crate::style::list_style::ListId; use crate::style::props::border::Border; +use crate::style::props::drop_cap::DropCap; use crate::style::props::tab_stop::TabStop; use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; @@ -194,6 +195,12 @@ pub struct ParaProps { /// ODF `style:writing-mode`; OOXML `w:bidi`. pub bidi: Option, + // ── Drop cap ────────────────────────────────────────────────────────── + /// Dropped/raised initial capital. ODF `style:drop-cap`; OOXML `w:framePr` + /// with `w:dropCap`. Captured at import; not yet rendered (the initial is + /// currently shown inline at body size). + pub drop_cap: Option, + // ── Extensions ──────────────────────────────────────────────────────── /// Format-specific properties not representable in the above fields. pub extensions: ExtensionBag, @@ -242,6 +249,7 @@ impl ParaProps { inherit!(list_level); inherit!(outline_level); inherit!(bidi); + inherit!(drop_cap); self } } diff --git a/loki-odf/src/odt/mapper/document.rs b/loki-odf/src/odt/mapper/document.rs index f47a33f0..02b8d8e0 100644 --- a/loki-odf/src/odt/mapper/document.rs +++ b/loki-odf/src/odt/mapper/document.rs @@ -25,6 +25,7 @@ use loki_doc_model::content::block::{ TableOfContentsBlock, }; use loki_doc_model::content::field::types::{CrossRefFormat, Field, FieldKind}; +use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; use loki_doc_model::content::inline::{ BookmarkKind, Inline, LinkTarget, MathType, NoteKind, StyledRun, }; @@ -56,7 +57,7 @@ use crate::odt::model::fields::OdfField; use crate::odt::model::frames::{OdfFrame, OdfFrameKind}; use crate::odt::model::notes::{OdfNote, OdfNoteClass}; use crate::odt::model::paragraph::{OdfHyperlink, OdfParagraph, OdfParagraphChild, OdfSpan}; -use crate::odt::model::styles::{OdfCellProps, OdfStyle, OdfStylesheet}; +use crate::odt::model::styles::{OdfCellProps, OdfGraphicWrap, OdfStyle, OdfStylesheet}; use crate::odt::model::tables::OdfTable; use crate::xml_util::parse_length; @@ -86,6 +87,9 @@ pub(crate) struct OdfMappingContext<'a> { /// Cell properties from `style:table-cell-properties`: style name → props. /// Pre-built from the ODF stylesheet before the mapping pass. pub cell_style_props: &'a HashMap, + /// Frame text-wrap from `style:graphic-properties`: graphic-style name → + /// wrap config. Pre-built from the ODF stylesheet before the mapping pass. + pub frame_wraps: &'a HashMap, /// Non-fatal issues accumulated during mapping. pub warnings: Vec, /// Floating frames (images and text boxes that are not `as-char` anchored) @@ -138,6 +142,14 @@ pub(crate) fn map_document( .filter_map(|s| Some((s.name.clone(), s.cell_props.clone()?))) .collect(); + // ── 2c. Pre-build frame-wrap lookup from graphic styles ────────────────── + let frame_wraps: HashMap = stylesheet + .named_styles + .iter() + .chain(stylesheet.auto_styles.iter()) + .filter_map(|s| Some((s.name.clone(), map_graphic_wrap(s.graphic_wrap.as_ref()?)?))) + .collect(); + // ── 3. Build style lookup for master page resolution ───────────────────── let all_styles: HashMap<&str, &OdfStyle> = stylesheet .named_styles @@ -163,6 +175,7 @@ pub(crate) fn map_document( options, col_style_widths: &col_style_widths, cell_style_props: &cell_style_props, + frame_wraps: &frame_wraps, warnings: Vec::new(), pending_figures: Vec::new(), comments: Vec::new(), @@ -433,6 +446,36 @@ fn map_field(odf: &OdfField) -> Field { // ── Frames ───────────────────────────────────────────────────────────────────── +/// Convert an [`OdfGraphicWrap`] (raw `style:wrap`/`style:run-through`) to the +/// neutral [`FloatWrap`]. Returns `None` when no `style:wrap` is specified. +fn map_graphic_wrap(gw: &OdfGraphicWrap) -> Option { + let wrap_str = gw.wrap.as_deref()?; + let (wrap, side) = match wrap_str { + "none" => (TextWrap::TopAndBottom, WrapSide::Both), + "parallel" => (TextWrap::Square, WrapSide::Both), + "left" => (TextWrap::Square, WrapSide::Left), + "right" => (TextWrap::Square, WrapSide::Right), + "dynamic" | "biggest" => (TextWrap::Square, WrapSide::Largest), + "run-through" => (TextWrap::None, WrapSide::Both), + _ => return None, + }; + let behind_text = gw.run_through.as_deref() == Some("background"); + Some(FloatWrap { + wrap, + side, + behind_text, + }) +} + +/// Resolve the text-wrap for a frame from its graphic style (`draw:style-name`). +fn frame_wrap(frame: &OdfFrame, ctx: &OdfMappingContext<'_>) -> Option { + frame + .style_name + .as_deref() + .and_then(|name| ctx.frame_wraps.get(name)) + .copied() +} + /// Map an ODF drawing frame to an inline element. /// /// For `as-char`-anchored frames, the mapped element is returned directly. @@ -469,8 +512,14 @@ fn map_frame(frame: &OdfFrame, ctx: &mut OdfMappingContext<'_>) -> Option ParaProps { } } + // ── Drop cap ─────────────────────────────────────────────────────────── + if let Some(dc) = props.drop_cap.as_ref() { + out.drop_cap = Some(map_drop_cap(dc)); + } + out } +/// Convert an ODF `style:drop-cap` to the neutral [`DropCap`]. ODF drop caps are +/// always dropped within the text body (never in the margin). +fn map_drop_cap(dc: &OdfDropCap) -> DropCap { + let lines = dc + .lines + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(1) + .max(1); + let length = match dc.length.as_deref() { + Some("word") => DropCapLength::Word, + Some(n) => n + .parse::() + .ok() + .map_or(DropCapLength::Chars(1), |c| DropCapLength::Chars(c.max(1))), + None => DropCapLength::Chars(1), + }; + let distance = dc + .distance + .as_deref() + .and_then(parse_length) + .unwrap_or(Points::new(0.0)); + DropCap { + lines, + length, + distance, + margin: false, + } +} + /// Parse an ODF CSS-like border shorthand `"width style color"` into a /// [`Border`]. /// @@ -209,3 +245,47 @@ pub(super) fn parse_odf_border(s: &str) -> Option { spacing: None, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::odt::model::styles::OdfDropCap; + + #[test] + fn drop_cap_maps_lines_length_distance() { + let props = OdfParaProps { + drop_cap: Some(OdfDropCap { + lines: Some("3".into()), + length: Some("2".into()), + distance: Some("0.2cm".into()), + }), + ..Default::default() + }; + let dc = map_para_props(&props).drop_cap.expect("drop cap mapped"); + assert_eq!(dc.lines, 3); + assert_eq!(dc.length, DropCapLength::Chars(2)); + assert!(!dc.margin); + // 0.2cm ≈ 5.67pt. + assert!((dc.distance.value() - 5.669).abs() < 0.1); + } + + #[test] + fn drop_cap_word_length() { + let props = OdfParaProps { + drop_cap: Some(OdfDropCap { + lines: Some("2".into()), + length: Some("word".into()), + distance: None, + }), + ..Default::default() + }; + let dc = map_para_props(&props).drop_cap.expect("drop cap"); + assert_eq!(dc.length, DropCapLength::Word); + assert_eq!(dc.lines, 2); + } + + #[test] + fn no_drop_cap_when_absent() { + assert!(map_para_props(&OdfParaProps::default()).drop_cap.is_none()); + } +} diff --git a/loki-odf/src/odt/mapper/styles.rs b/loki-odf/src/odt/mapper/styles.rs index bbb637b5..914cd1d9 100644 --- a/loki-odf/src/odt/mapper/styles.rs +++ b/loki-odf/src/odt/mapper/styles.rs @@ -138,6 +138,7 @@ mod tests { text_props: None, col_width: None, cell_props: None, + graphic_wrap: None, is_automatic: is_auto, master_page_name: None, } @@ -157,6 +158,7 @@ mod tests { }), col_width: None, cell_props: None, + graphic_wrap: None, is_automatic: false, master_page_name: None, } @@ -254,6 +256,7 @@ mod tests { text_props: None, col_width: None, cell_props: None, + graphic_wrap: None, is_automatic: false, master_page_name: None, }], diff --git a/loki-odf/src/odt/model/styles.rs b/loki-odf/src/odt/model/styles.rs index 4beb33ef..8b5f2e31 100644 --- a/loki-odf/src/odt/model/styles.rs +++ b/loki-odf/src/odt/model/styles.rs @@ -75,6 +75,9 @@ pub(crate) struct OdfStyle { pub col_width: Option, /// Properties for `style:family="table-cell"` styles. pub cell_props: Option, + /// `style:wrap` / `style:run-through` from `style:graphic-properties`, if + /// present. Only set for `style:family="graphic"` styles applied to frames. + pub graphic_wrap: Option, /// `true` for styles from `office:automatic-styles`. pub is_automatic: bool, /// `style:master-page-name` — for paragraph styles, the master page this @@ -87,6 +90,16 @@ pub(crate) struct OdfStyle { pub master_page_name: Option, } +/// `style:graphic-properties` wrap attributes (ODF 1.3 §20.x). Raw strings. +#[derive(Debug, Clone, Default)] +pub(crate) struct OdfGraphicWrap { + /// `style:wrap` — `"none"`, `"parallel"`, `"run-through"`, `"left"`, + /// `"right"`, `"dynamic"`, `"biggest"`. + pub wrap: Option, + /// `style:run-through` — `"foreground"` or `"background"` (behind text). + pub run_through: Option, +} + /// The family of ODF elements a style applies to. /// /// ODF 1.3 §19.480 `style:family`. @@ -176,6 +189,19 @@ pub(crate) struct OdfParaProps { pub tab_stops: Vec, /// `style:writing-mode` — text direction, e.g. `"lr-tb"`, `"rl-tb"`. pub writing_mode: Option, + /// `style:drop-cap` child element, if present. ODF 1.3 §20.342. + pub drop_cap: Option, +} + +/// `style:drop-cap` element (ODF 1.3 §20.342). Raw attribute strings. +#[derive(Debug, Clone, Default)] +pub(crate) struct OdfDropCap { + /// `style:lines` — number of lines the cap spans. + pub lines: Option, + /// `style:length` — `"word"` or an integer character count. + pub length: Option, + /// `style:distance` — gap between cap and body text (length). + pub distance: Option, } /// A single tab stop within a paragraph style. diff --git a/loki-odf/src/odt/reader/styles.rs b/loki-odf/src/odt/reader/styles.rs index 6b058ae1..e600cfa2 100644 --- a/loki-odf/src/odt/reader/styles.rs +++ b/loki-odf/src/odt/reader/styles.rs @@ -18,8 +18,8 @@ use crate::odt::model::document::{OdfHeaderFooterProps, OdfMasterPage, OdfPageLa use crate::odt::model::list_styles::{OdfListLevel, OdfListLevelKind, OdfListStyle}; use crate::odt::model::paragraph::OdfParagraph; use crate::odt::model::styles::{ - OdfCellProps, OdfDefaultStyle, OdfParaProps, OdfStyle, OdfStyleFamily, OdfStylesheet, - OdfTabStop, OdfTextProps, + OdfCellProps, OdfDefaultStyle, OdfDropCap, OdfGraphicWrap, OdfParaProps, OdfStyle, + OdfStyleFamily, OdfStylesheet, OdfTabStop, OdfTextProps, }; use crate::odt::reader::columns::parse_plp_columns; use crate::odt::reader::document::read_paragraph; @@ -87,7 +87,7 @@ pub(crate) fn read_stylesheet(xml: &[u8], is_automatic: bool) -> OdfResult OdfResult OdfResult OdfResult, Option, Option, + Option, )> { let mut buf = Vec::new(); let mut para_props: Option = None; let mut text_props: Option = None; let mut col_width: Option = None; let mut cell_props: Option = None; + let mut graphic_wrap: Option = None; loop { buf.clear(); @@ -292,6 +296,11 @@ fn parse_style_props( drop(e); skip_element(reader, b"table-cell-properties")?; } + b"graphic-properties" => { + graphic_wrap = Some(parse_graphic_wrap_element(e)); + drop(e); + skip_element(reader, b"graphic-properties")?; + } _ => { let local = local.clone(); drop(e); @@ -314,6 +323,9 @@ fn parse_style_props( b"table-cell-properties" => { cell_props = Some(parse_cell_props_element(e)); } + b"graphic-properties" => { + graphic_wrap = Some(parse_graphic_wrap_element(e)); + } _ => {} } } @@ -333,7 +345,15 @@ fn parse_style_props( } } - Ok((para_props, text_props, col_width, cell_props)) + Ok((para_props, text_props, col_width, cell_props, graphic_wrap)) +} + +/// Build an [`OdfGraphicWrap`] from a `style:graphic-properties` element. +fn parse_graphic_wrap_element(e: &quick_xml::events::BytesStart<'_>) -> OdfGraphicWrap { + OdfGraphicWrap { + wrap: local_attr_val(e, b"wrap"), + run_through: local_attr_val(e, b"run-through"), + } } /// Build an [`OdfCellProps`] from the attributes of a @@ -419,6 +439,7 @@ fn parse_para_props_element(e: &quick_xml::events::BytesStart<'_>) -> OdfParaPro background_color: local_attr_val(e, b"background-color"), tab_stops: Vec::new(), writing_mode: local_attr_val(e, b"writing-mode"), + drop_cap: None, } } @@ -443,8 +464,8 @@ fn parse_para_props_with_children( skip_element(reader, &local)?; } } - Ok(Event::Empty(ref e)) => { - if e.local_name().into_inner() == b"tab-stop" { + Ok(Event::Empty(ref e)) => match e.local_name().into_inner() { + b"tab-stop" => { let position = local_attr_val(e, b"position").unwrap_or_default(); let tab_type = local_attr_val(e, b"type"); let leader_style = local_attr_val(e, b"leader-style"); @@ -454,7 +475,15 @@ fn parse_para_props_with_children( leader_style, }); } - } + b"drop-cap" => { + pp.drop_cap = Some(OdfDropCap { + lines: local_attr_val(e, b"lines"), + length: local_attr_val(e, b"length"), + distance: local_attr_val(e, b"distance"), + }); + } + _ => {} + }, Ok(Event::End(ref e)) => { if e.local_name().into_inner() == b"paragraph-properties" { break; @@ -1150,7 +1179,7 @@ pub(crate) fn read_auto_styles(xml: &[u8]) -> OdfResult> { let list_style_name = local_attr_val(e, b"list-style-name"); let master_page_name = local_attr_val(e, b"master-page-name"); drop(e); - let (para_props, text_props, col_width, cell_props) = + let (para_props, text_props, col_width, cell_props, graphic_wrap) = parse_style_props(&mut reader, b"style")?; styles.push(OdfStyle { name, @@ -1162,6 +1191,7 @@ pub(crate) fn read_auto_styles(xml: &[u8]) -> OdfResult> { text_props, col_width, cell_props, + graphic_wrap, is_automatic: true, master_page_name, }); @@ -1188,6 +1218,7 @@ pub(crate) fn read_auto_styles(xml: &[u8]) -> OdfResult> { text_props: None, col_width: None, cell_props: None, + graphic_wrap: None, is_automatic: true, master_page_name, }); @@ -1297,6 +1328,57 @@ mod tests { assert_eq!(tp.font_weight.as_deref(), Some("bold")); } + #[test] + fn read_stylesheet_drop_cap() { + let xml = br#" + + + + + + + + +"#; + + let sheet = read_stylesheet(xml, false).unwrap(); + let dc = sheet.named_styles[0] + .para_props + .as_ref() + .unwrap() + .drop_cap + .as_ref() + .expect("drop cap parsed"); + assert_eq!(dc.lines.as_deref(), Some("3")); + assert_eq!(dc.length.as_deref(), Some("2")); + assert_eq!(dc.distance.as_deref(), Some("0.2cm")); + } + + #[test] + fn read_stylesheet_graphic_wrap() { + let xml = br#" + + + + + + +"#; + + let sheet = read_stylesheet(xml, false).unwrap(); + let gw = sheet.auto_styles[0] + .graphic_wrap + .as_ref() + .expect("graphic wrap parsed"); + assert_eq!(gw.wrap.as_deref(), Some("left")); + assert_eq!(gw.run_through.as_deref(), Some("foreground")); + } + #[test] fn read_stylesheet_list_style_bullet_level() { let xml = br#" diff --git a/loki-ooxml/src/docx/mapper/images.rs b/loki-ooxml/src/docx/mapper/images.rs index 06c2da43..2acdc0ad 100644 --- a/loki-ooxml/src/docx/mapper/images.rs +++ b/loki-ooxml/src/docx/mapper/images.rs @@ -4,6 +4,7 @@ //! Image/drawing mapper: `w:drawing` → [`Inline::Image`]. use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::float::FLOATING_CLASS; use loki_doc_model::content::inline::{Inline, LinkTarget}; use crate::docx::model::paragraph::DocxDrawing; @@ -46,8 +47,11 @@ pub(crate) fn map_drawing(drawing: &DocxDrawing, ctx: &mut MappingContext<'_>) - if let Some(cy) = drawing.cy { attr.kv.push(("cy_emu".to_string(), cy.to_string())); } - if drawing.is_anchor { - attr.classes.push("floating".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()); } let alt = match &drawing.descr { @@ -108,6 +112,7 @@ mod tests { descr: None, name: None, is_anchor: false, + wrap: None, }; let result = map_drawing(&drawing, &mut ctx); assert!(result.is_none()); @@ -135,6 +140,7 @@ mod tests { descr: None, name: None, is_anchor: false, + wrap: None, }; let result = map_drawing(&drawing, &mut ctx); assert!(result.is_none()); @@ -166,6 +172,7 @@ mod tests { 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 { @@ -202,6 +209,7 @@ mod tests { descr: None, name: None, is_anchor: true, + wrap: None, }; let result = map_drawing(&drawing, &mut ctx).unwrap(); if let Inline::Image(attr, _, _) = result { @@ -210,4 +218,50 @@ mod tests { 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"); + } + } } diff --git a/loki-ooxml/src/docx/mapper/props.rs b/loki-ooxml/src/docx/mapper/props.rs index 23b3a377..3253ddc4 100644 --- a/loki-ooxml/src/docx/mapper/props.rs +++ b/loki-ooxml/src/docx/mapper/props.rs @@ -13,6 +13,7 @@ use loki_doc_model::style::props::border::{Border, BorderStyle}; use loki_doc_model::style::props::char_props::{ CharProps, HighlightColor, StrikethroughStyle, UnderlineStyle, VerticalAlign, }; +use loki_doc_model::style::props::drop_cap::{DropCap, DropCapLength}; use loki_doc_model::style::props::para_props::{ LineHeight, ParaProps, ParagraphAlignment, Spacing, }; @@ -20,11 +21,28 @@ use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; -use crate::docx::model::paragraph::{DocxBorderEdge, DocxPPr, DocxRPr}; +use crate::docx::model::paragraph::{DocxBorderEdge, DocxFramePr, DocxPPr, DocxRPr}; use crate::xml_util::hex_color; // ── Internal conversion helpers ─────────────────────────────────────────────── +/// Maps `w:framePr` to a [`DropCap`], or `None` when no drop cap is requested +/// (`w:dropCap` absent, `"none"`, or `"default"`). OOXML carries no explicit +/// character count, so the length defaults to a single character. +fn map_frame_pr(fp: &DocxFramePr) -> Option { + let margin = match fp.drop_cap.as_deref()? { + "drop" => false, + "margin" => true, + _ => return None, + }; + Some(DropCap { + lines: fp.lines.unwrap_or(1).max(1), + length: DropCapLength::Chars(1), + distance: twips_to_pt(fp.h_space.unwrap_or(0)), + margin, + }) +} + /// Converts a twips integer to [`Points`] (1 pt = 20 twips). fn twips_to_pt(twips: i32) -> Points { Points::new(f64::from(twips) / 20.0) @@ -154,6 +172,11 @@ pub(crate) fn map_ppr(ppr: &DocxPPr) -> ParaProps { props.page_break_before = ppr.page_break_before; props.bidi = ppr.bidi; + // Drop cap (w:framePr w:dropCap). Only "drop"/"margin" produce a drop cap. + if let Some(ref fp) = ppr.frame_pr { + props.drop_cap = map_frame_pr(fp); + } + // OOXML outline_lvl is 0-indexed; model is 1-indexed (None = body text). props.outline_level = ppr.outline_lvl.map(|l| l + 1); @@ -337,6 +360,49 @@ mod tests { } } + #[test] + fn frame_pr_drop_maps_to_drop_cap() { + let ppr = DocxPPr { + frame_pr: Some(DocxFramePr { + drop_cap: Some("drop".into()), + lines: Some(3), + h_space: Some(40), // twips → 2pt + }), + ..Default::default() + }; + let dc = map_ppr(&ppr).drop_cap.expect("drop cap present"); + assert_eq!(dc.lines, 3); + assert!(!dc.margin); + assert_eq!(dc.distance.value(), 2.0); + assert_eq!(dc.length, DropCapLength::Chars(1)); + } + + #[test] + fn frame_pr_margin_is_in_margin() { + let ppr = DocxPPr { + frame_pr: Some(DocxFramePr { + drop_cap: Some("margin".into()), + lines: Some(2), + h_space: None, + }), + ..Default::default() + }; + assert!(map_ppr(&ppr).drop_cap.expect("drop cap").margin); + } + + #[test] + fn frame_pr_none_is_not_a_drop_cap() { + let ppr = DocxPPr { + frame_pr: Some(DocxFramePr { + drop_cap: Some("none".into()), + lines: None, + h_space: None, + }), + ..Default::default() + }; + assert!(map_ppr(&ppr).drop_cap.is_none()); + } + // ── map_ppr ────────────────────────────────────────────────────────────── #[test] diff --git a/loki-ooxml/src/docx/model/paragraph.rs b/loki-ooxml/src/docx/model/paragraph.rs index 1e3bdac9..5f9f55c3 100644 --- a/loki-ooxml/src/docx/model/paragraph.rs +++ b/loki-ooxml/src/docx/model/paragraph.rs @@ -98,6 +98,22 @@ pub struct DocxPPr { /// Carries formatting that applies to the paragraph mark itself (e.g. a /// font override that affects the default spacing of an empty paragraph). pub ppr_rpr: Option, + /// Text-frame properties from `w:framePr` — carries drop-cap settings. + pub frame_pr: Option, +} + +/// `w:framePr` text-frame properties (ECMA-376 §17.3.1.11). +/// +/// Only the drop-cap-relevant attributes are captured; the full text-frame +/// positioning model is not yet imported. +#[derive(Debug, Clone, Default)] +pub struct DocxFramePr { + /// `@w:dropCap` — `"drop"`, `"margin"`, `"none"`, or `"default"`. + pub drop_cap: Option, + /// `@w:lines` — number of lines the dropped cap spans. + pub lines: Option, + /// `@w:hSpace` — horizontal distance from the surrounding text, in twips. + pub h_space: Option, } /// `w:ind` indentation attributes (ECMA-376 §17.3.1.12). @@ -295,4 +311,7 @@ pub struct DocxDrawing { pub name: Option, /// Whether this is an anchor (floating) rather than inline drawing. pub is_anchor: bool, + /// Text-wrap configuration for a floating (anchored) drawing. + /// `None` for inline drawings or anchored drawings without a wrap element. + pub wrap: Option, } diff --git a/loki-ooxml/src/docx/reader/document.rs b/loki-ooxml/src/docx/reader/document.rs index 7f47bc60..9a010157 100644 --- a/loki-ooxml/src/docx/reader/document.rs +++ b/loki-ooxml/src/docx/reader/document.rs @@ -11,9 +11,9 @@ use quick_xml::events::Event; use crate::docx::model::document::{DocxBodyChild, DocxDocument}; use crate::docx::model::paragraph::{ - DocxBorderEdge, DocxCols, DocxDrawing, DocxHdrFtrRef, DocxHyperlink, DocxInd, DocxNumPr, - DocxPBdr, DocxPPr, DocxParaChild, DocxParagraph, DocxPgMar, DocxPgSz, DocxRFonts, DocxRPr, - DocxRun, DocxRunChild, DocxSectPr, DocxSpacing, DocxTab, + DocxBorderEdge, DocxCols, DocxDrawing, DocxFramePr, DocxHdrFtrRef, DocxHyperlink, DocxInd, + DocxNumPr, DocxPBdr, DocxPPr, DocxParaChild, DocxParagraph, DocxPgMar, DocxPgSz, DocxRFonts, + DocxRPr, DocxRun, DocxRunChild, DocxSectPr, DocxSpacing, DocxTab, }; use crate::docx::model::styles::{ DocxCellMargins, DocxTableCell, DocxTableModel, DocxTableRow, DocxTblPr, DocxTblWidth, @@ -22,6 +22,7 @@ use crate::docx::model::styles::{ use crate::docx::reader::runs::{parse_fld_simple_runs, parse_hyperlink_runs, parse_tracked_runs}; use crate::docx::reader::util::{attr_val, local_name, parse_emu, toggle_prop}; use crate::error::{OoxmlError, OoxmlResult}; +use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; /// Parses `word/document.xml` bytes into a [`DocxDocument`]. pub fn parse_document(xml: &[u8]) -> OoxmlResult { @@ -321,6 +322,17 @@ fn apply_ppr_attr(name: &[u8], e: &quick_xml::events::BytesStart<'_>, ppr: &mut b"bidi" => ppr.bidi = Some(toggle_prop(attr_val(e, b"val").as_deref())), b"widowControl" => ppr.widow_control = Some(toggle_prop(attr_val(e, b"val").as_deref())), b"shd" => ppr.shd_fill = attr_val(e, b"fill"), + b"framePr" => { + ppr.frame_pr = Some(DocxFramePr { + drop_cap: attr_val(e, b"dropCap"), + lines: attr_val(e, b"lines") + .as_deref() + .and_then(|v| v.parse().ok()), + h_space: attr_val(e, b"hSpace") + .as_deref() + .and_then(|v| v.parse().ok()), + }); + } _ => {} } } @@ -696,13 +708,22 @@ fn parse_drawing(reader: &mut Reader<&[u8]>) -> OoxmlResult { descr: None, name: None, is_anchor: false, + wrap: None, }; + // Wrap mode/side are carried on a `wp:wrap*` child; `behindDoc` lives on the + // `wp:anchor` element. Collect both, then assemble the `FloatWrap` at the end. + let mut wrap_mode: Option = None; + let mut wrap_side = WrapSide::Both; + let mut behind_doc = false; let mut buf = Vec::new(); loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(ref e) | Event::Empty(ref e)) => { match local_name(e.local_name().as_ref()) { - b"anchor" => drawing.is_anchor = true, + b"anchor" => { + drawing.is_anchor = true; + behind_doc = attr_val(e, b"behindDoc").as_deref() == Some("1"); + } b"extent" => { drawing.cx = attr_val(e, b"cx").as_deref().and_then(parse_emu); drawing.cy = attr_val(e, b"cy").as_deref().and_then(parse_emu); @@ -714,6 +735,20 @@ fn parse_drawing(reader: &mut Reader<&[u8]>) -> OoxmlResult { b"blip" => { drawing.rel_id = attr_val(e, b"embed"); } + b"wrapSquare" => { + wrap_mode = Some(TextWrap::Square); + wrap_side = parse_wrap_text(e); + } + b"wrapTight" => { + wrap_mode = Some(TextWrap::Tight); + wrap_side = parse_wrap_text(e); + } + b"wrapThrough" => { + wrap_mode = Some(TextWrap::Through); + wrap_side = parse_wrap_text(e); + } + b"wrapTopAndBottom" => wrap_mode = Some(TextWrap::TopAndBottom), + b"wrapNone" => wrap_mode = Some(TextWrap::None), _ => {} } } @@ -731,9 +766,26 @@ fn parse_drawing(reader: &mut Reader<&[u8]>) -> OoxmlResult { } buf.clear(); } + if let Some(wrap) = wrap_mode { + drawing.wrap = Some(FloatWrap { + wrap, + side: wrap_side, + behind_text: behind_doc, + }); + } Ok(drawing) } +/// 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() { + Some("left") => WrapSide::Left, + Some("right") => WrapSide::Right, + Some("largest") => WrapSide::Largest, + _ => WrapSide::Both, + } +} + /// Parses a `w:tbl` element. Called after Start("tbl") is consumed. fn parse_table(reader: &mut Reader<&[u8]>) -> OoxmlResult { let mut tbl = DocxTableModel::default(); From 8b305bb06883a73f6f5cf07b2c85959bd5b767b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 09:07:09 +0000 Subject: [PATCH 03/35] test(loki-acid): add render_acid_pdf example for Word-vs-Loki comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a DOCX fixture through Loki's own pipeline (import → loki-layout paginate → loki-pdf export) to a PDF, so its output can be diffed against a canonical Word/LibreOffice PDF render. GPU-free; reuses the same layout engine as the editor. Used to evaluate acid_docx fidelity against a Word-for-Android reference render. cargo run -p loki-acid --example render_acid_pdf -- Adds loki-pdf as a dev-dependency of loki-acid (examples only). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- Cargo.lock | 1 + loki-acid/Cargo.toml | 2 ++ loki-acid/examples/render_acid_pdf.rs | 34 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 loki-acid/examples/render_acid_pdf.rs diff --git a/Cargo.lock b/Cargo.lock index 47062b50..d73cb65d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3819,6 +3819,7 @@ dependencies = [ "loki-layout", "loki-odf", "loki-ooxml", + "loki-pdf", "loki-presentation-model", "loki-primitives", "loki-sheet-model", diff --git a/loki-acid/Cargo.toml b/loki-acid/Cargo.toml index 5b20213d..2ae52007 100644 --- a/loki-acid/Cargo.toml +++ b/loki-acid/Cargo.toml @@ -25,3 +25,5 @@ loki-doc-model = { path = "../loki-doc-model" } loki-graphics = { path = "../loki-graphics", features = ["serde"] } 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" } diff --git a/loki-acid/examples/render_acid_pdf.rs b/loki-acid/examples/render_acid_pdf.rs new file mode 100644 index 00000000..f7170516 --- /dev/null +++ b/loki-acid/examples/render_acid_pdf.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Render an ACID fixture through Loki's own pipeline to a PDF, for visual +//! comparison against a canonical (Word / LibreOffice) render. +//! +//! Usage: +//! cargo run -p loki-acid --example render_acid_pdf -- +//! +//! Reuses `loki-layout` for pagination (the same engine the editor/GPU renderer +//! use), so the PDF geometry matches Loki's on-screen layout. GPU-free. + +use std::io::Cursor; + +use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() != 3 { + eprintln!("usage: render_acid_pdf "); + std::process::exit(2); + } + let bytes = std::fs::read(&args[1]).expect("read input docx"); + let result = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(bytes)) + .expect("import docx"); + let doc = result.document; + + let mut out = Vec::new(); + loki_pdf::export_document(&doc, &loki_pdf::PdfXOptions::default(), &mut out) + .expect("export pdf"); + std::fs::write(&args[2], &out).expect("write pdf"); + eprintln!("wrote {} ({} bytes)", args[2], out.len()); +} From a9927ecff6c0dc5d37682d8883b020c84f596ec6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 09:29:15 +0000 Subject: [PATCH 04/35] fix(loki-pdf): skip .notdef glyphs on export, matching loki-vello MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PDF exporter drew every glyph including id 0 (.notdef), so any glyph the layout shapes as .notdef rendered as a visible tofu box — most conspicuously the glyph Parley shapes for tab characters (\t), which appeared in tab stops, tab leaders, and list markers (whose synthesized text is "\t"). The on-screen loki-vello renderer already filters id 0 (glyph.rs); loki-pdf did not, so the two renderers disagreed. render_run now filters id 0 before registering the face and drawing, mirroring loki-vello exactly. The tab's advance is unaffected (it comes from the layout's tab-stop inline box, not the .notdef glyph), so only the spurious ink is removed. Verified against the Word-for-Android acid_docx reference: tofu boxes are gone from the tab-stop and list pages. This unmasks two pre-existing, separate bugs (documented, not fixed here): list-marker numbers are shaped in a font without digit coverage (so they were tofu, now absent), and tab alignment types (right/center/decimal) + leaders are unimplemented. Tests: notdef_only_run_emits_nothing, notdef_is_filtered_from_real_run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 1 + loki-pdf/src/fonts.rs | 6 +++ loki-pdf/src/page.rs | 82 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 90cdda3a..bbba17f2 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -172,6 +172,7 @@ OCF (ZIP) container. | **CMYK colour + OutputIntent** | `loki-pdf` | Yes | All text/graphics emitted in DeviceCMYK with a PDF/X `OutputIntent`. Default printing condition is FOGRA39. An ICC `DestOutputProfile` is embedded only when supplied via `OutputIntent::icc_profile`; otherwise the registered condition identifier is referenced (supply a licensed profile for full certification). | | **XMP + Info + trailer ID + Trapped** | `loki-pdf` | Yes | XMP metadata packet, Document Info dictionary, trailer `/ID`, and `Trapped` flag all written (PDF/X requirements). | | **Text decorations / rules / borders / fills** | `loki-pdf` | Yes | Underline/strikethrough/overline, horizontal rules, table borders and cell fills emitted as CMYK fills. | +| **`.notdef` glyph filtering** | `loki-pdf` | Yes | Glyph id 0 (`.notdef`) is skipped on export, matching the on-screen `loki-vello` renderer (`glyph.rs`). Without this the PDF showed tofu boxes where the layout emits `.notdef` — notably the glyph Parley shapes for tab characters (`\t`), whose advance is supplied separately by the tab-stop inline box. Tested by `notdef_only_run_emits_nothing` / `notdef_is_filtered_from_real_run`. Note: this only removes spurious *ink* — it does not fix list-marker numbers shaped in a digit-less font, nor tab *alignment* types (right/center/decimal) and leaders, which remain separate gaps. | | **Images in PDF** | `loki-pdf` | Yes | `data:` URI images are decoded, converted to **DeviceCMYK**, Flate-compressed, and embedded as image XObjects; transparency is preserved via a DeviceGray soft mask. CMYK conversion is the naive transform (no ICC); subsetting/recompression of already-CMYK sources is not yet optimised. | | **Clipping / rotation in PDF** | `loki-pdf` | Partial | `ClippedGroup` renders children without the clip mask; `RotatedGroup` renders at the group origin without rotation (over-paint preferred to omission). | | **EPUB 3.3 container** | `loki-epub` | Yes | OCF ZIP with `mimetype` stored first, `META-INF/container.xml`, package document, navigation document, one XHTML content document, a stylesheet, and packaged image resources. | diff --git a/loki-pdf/src/fonts.rs b/loki-pdf/src/fonts.rs index 94fb4db2..40ff747f 100644 --- a/loki-pdf/src/fonts.rs +++ b/loki-pdf/src/fonts.rs @@ -92,6 +92,12 @@ impl FontBank { self.faces.is_empty() } + /// The glyph ids recorded as used for the face at `idx` (test introspection). + #[cfg(test)] + pub(crate) fn used_glyph_ids(&self, idx: usize) -> Vec { + self.faces[idx].used_glyphs.iter().copied().collect() + } + /// Allocates the five indirect references each face needs, advancing /// `next`. Returns them aligned with [`Self::faces`]. pub fn allocate_refs(&self, next: &mut i32) -> Vec { diff --git a/loki-pdf/src/page.rs b/loki-pdf/src/page.rs index 2a8363fd..6697b82a 100644 --- a/loki-pdf/src/page.rs +++ b/loki-pdf/src/page.rs @@ -10,7 +10,7 @@ //! the embedded `Identity-H` fonts collected in the [`FontBank`]. use loki_layout::{ - DecorationKind, LayoutPage, LayoutRect, PositionedDecoration, PositionedGlyphRun, + DecorationKind, GlyphEntry, LayoutPage, LayoutRect, PositionedDecoration, PositionedGlyphRun, PositionedImage, PositionedItem, }; use pdf_writer::{Content, Name, Str}; @@ -114,17 +114,23 @@ fn render_run( if run.glyphs.is_empty() { return; } - let resource = bank.use_face( - &run.font_data, - run.font_index, - run.glyphs.iter().map(|g| g.id), - ); + // Glyph id 0 is the .notdef glyph (rendered as a tofu box by most fonts). + // Skip it so characters with no font coverage are invisible, matching Word + // and the on-screen `loki-vello` renderer (which filters id 0 identically). + // Notably this drops the `.notdef` glyph that Parley shapes for tab + // characters (`\t`); the tab's advance is already provided by the layout's + // tab-stop inline box, so only the spurious tofu ink is removed. + let drawn: Vec<&GlyphEntry> = run.glyphs.iter().filter(|g| g.id != 0).collect(); + if drawn.is_empty() { + return; + } + let resource = bank.use_face(&run.font_data, run.font_index, 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); content.begin_text(); content.set_font(pdf_writer::Name(resource.as_bytes()), run.font_size); - for glyph in &run.glyphs { + for glyph in drawn { let x = ox + run.origin.x + glyph.x; let baseline = oy + run.origin.y + glyph.y; let y = page_h - baseline; @@ -195,3 +201,65 @@ fn render_border( ); } } + +#[cfg(test)] +mod tests { + use super::*; + use loki_layout::{GlyphSynthesis, LayoutColor, LayoutPoint}; + use std::sync::Arc; + + fn run_with(ids: &[u16]) -> PositionedGlyphRun { + PositionedGlyphRun { + origin: LayoutPoint { x: 0.0, y: 0.0 }, + font_data: Arc::new(vec![0u8; 4]), + font_index: 0, + font_size: 12.0, + glyphs: ids + .iter() + .map(|&id| GlyphEntry { + id, + x: 0.0, + y: 0.0, + advance: 6.0, + }) + .collect(), + color: LayoutColor::new(0.0, 0.0, 0.0, 1.0), + synthesis: GlyphSynthesis::default(), + link_url: None, + } + } + + // A run made only of .notdef (id 0) glyphs — e.g. the glyph Parley shapes + // for a tab character — must not register a face or emit any glyph, matching + // the on-screen `loki-vello` renderer. Previously these rendered as tofu. + #[test] + fn notdef_only_run_emits_nothing() { + let mut bank = FontBank::new(); + let mut content = Content::new(); + render_run(&run_with(&[0, 0]), 100.0, 0.0, 0.0, &mut bank, &mut content); + assert!( + bank.is_empty(), + "a .notdef-only run must not register a face" + ); + } + + // A run mixing .notdef with real glyphs registers the face but excludes the + // .notdef id from the subset (and never draws it). + #[test] + fn notdef_is_filtered_from_real_run() { + let mut bank = FontBank::new(); + let mut content = Content::new(); + render_run( + &run_with(&[0, 5, 0, 7]), + 100.0, + 0.0, + 0.0, + &mut bank, + &mut content, + ); + assert_eq!(bank.faces().len(), 1); + let ids = bank.used_glyph_ids(0); + assert!(!ids.contains(&0), "the .notdef glyph must be filtered out"); + assert!(ids.contains(&5) && ids.contains(&7), "real glyphs kept"); + } +} From 133ca3ae3f0a56432449382fc38575503b19c041 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 09:46:52 +0000 Subject: [PATCH 05/35] fix(loki-layout): register metric-compatible fallback fonts on all platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List-item markers (and any Calibri/Arial/Times/Cambria text) rendered with missing glyphs — numbers came out as .notdef — because the metric-compatible fallback faces (Carlito/Caladea/Arimo/Cousine/Tinos) were registered only on `target_os = "android"`. On desktop the engine relied on the installer placing them under `assets/fonts/`, which is absent in headless/CI/PDF-export. So `resolve_font_name("Calibri")` found no Carlito, returned "Calibri", and Parley fell back to a wider font that lacked digit glyphs. `FontResources` now registers the embedded fallback faces *lazily* — only when a substitute family is requested but not already present in the collection — so a fully-installed desktop never pays for them, while headless export, CI, the ACID harness, and Android resolve substitutes correctly. `loki-fonts` exposes the raw face bytes (`fallback_font_blobs`) on all targets; the `@font-face` CSS builder stays Android-CPU-only. Verified against the Word acid_docx reference: list markers now render 1./1.1/2.0.1 exactly like Word. (The Wingdings bullet glyphs remain blank — no open-source Symbol/Wingdings substitute exists.) Note: default-font *body* text still imports with font_name=None because `loki-ooxml` does not yet apply `w:docDefaults`; that residual page-drift (TC-DOCX-027) is documented and tracked separately. Tests: substituted_family_is_actually_available, fallback_font_blobs_embedded_on_all_targets. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 4 +++ loki-fonts/src/lib.rs | 45 +++++++++++++++--------- loki-layout/Cargo.toml | 14 ++++---- loki-layout/src/font.rs | 77 +++++++++++++++++++++++++++++++++-------- 4 files changed, 103 insertions(+), 37 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index bbba17f2..bdd9fd3c 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -113,6 +113,10 @@ When a document requests a font that is unavailable locally, Loki automatically - **Calibri** → **Carlito** - **Cambria** → **Caladea** +The substitute faces are **embedded in the layout engine and registered lazily** (`loki-layout` `FontResources::resolve_font_name`) the first time a substitute is requested but not already in the font collection. This guarantees the substitution works in headless contexts — **PDF/EPUB export, CI, the ACID harness, and fresh installs before the desktop installer places the fonts system-wide** — not just on a fully-installed desktop. Previously the bundled faces were registered only on Android, so on desktop/headless a requested "Calibri" resolved to itself, was not found, and Parley fell back to a wider font lacking some glyphs (digits rendered as `.notdef`, and list markers / Calibri text reflowed). Tested by `substituted_family_is_actually_available` and `fallback_font_blobs_embedded_on_all_targets` (`loki-layout` `font::tests`). + +**Known remaining gap:** a paragraph that requests *no* font inherits the document default (`w:docDefaults`) in Word, but `loki-ooxml` does not yet fold `docDefaults` into the resolved base `CharProps` — so default-font body text imports with `font_name = None` and uses the engine's default face rather than the document's Calibri. This is the residual driver of the ACID page-count drift (TC-DOCX-027) and is tracked separately from marker/substitution resolution. + ### Font Substitution Alerts When any font substitutions or missing fonts (such as **Aptos**, which has no open-source metric-compatible fallback) are detected in a document, Loki: 1. Flags the substitution/missing font status in the document's layout context. diff --git a/loki-fonts/src/lib.rs b/loki-fonts/src/lib.rs index d3fb104f..6a446780 100644 --- a/loki-fonts/src/lib.rs +++ b/loki-fonts/src/lib.rs @@ -20,8 +20,11 @@ //! document::Style { r#type: "text/css", "{loki_fonts::face_css()}" } //! ``` //! -//! [`face_css`] returns `""` on desktop and Android GPU builds (no-op). -//! Font bytes are only embedded on `target_os = "android"`. +//! The raw face bytes ([`fallback_font_blobs`]) are embedded on **all** platforms +//! — the layout engine registers them lazily for metric-compatible substitution in +//! headless/CI/PDF-export contexts as well as Android. Only the `@font-face` CSS +//! generation ([`face_css`], used by the Android-CPU HTML fallback) is gated to +//! that target and returns `""` elsewhere. #![forbid(unsafe_code)] @@ -96,13 +99,12 @@ pub fn face_css() -> &'static str { /// Arimo, Cousine, Tinos), for direct registration into the document layout /// engine's font collection. /// -/// Android-only: on desktop the layout engine discovers these fonts from the -/// executable-relative `assets/fonts/` directory, but that path does not resolve -/// on Android, so the bytes must be registered directly (otherwise a document's -/// Calibri/Arial/Times text falls back to an Android system font with different -/// metrics — e.g. wider digit advances). Returns `&[]` would be the desktop -/// shape, but the function is simply not compiled there. -#[cfg(target_os = "android")] +/// Available on **all** platforms. The layout engine registers these lazily, only +/// when a substitute family (e.g. Carlito for Calibri) is requested but not found +/// in the collection — so a properly-installed desktop never pays for them, while +/// headless export, CI, and Android (where the executable-relative `assets/fonts/` +/// directory does not resolve) still resolve Calibri/Arial/Times to a +/// metric-compatible face instead of a wider system fallback. pub fn fallback_font_blobs() -> &'static [&'static [u8]] { imp::fallback_font_blobs() } @@ -120,14 +122,21 @@ mod tests { } } -// ── Android-only implementation ─────────────────────────────────────────────── -// The entire font-data and CSS-generation block is compiled only on Android so -// that desktop binaries do not embed ~7 MB of font bytes. +// ── Bundled font-face implementation ────────────────────────────────────────── +// The raw font bytes (`FACES` / `fallback_font_blobs`) are compiled on every +// platform so the layout engine can register metric-compatible substitutes in +// headless/CI/PDF-export contexts, not just Android. The ~7 MB embed is the cost +// of guaranteed Word-fidelity substitution. The `@font-face` CSS generation +// (`face_css_impl`/`build_css`) — used only by the Android-CPU HTML fallback — +// remains gated to that target. -#[cfg(target_os = "android")] mod imp { + #[cfg(all(target_os = "android", not(android_gpu)))] use std::sync::OnceLock; + // `family`/`weight`/`style` are read only by the Android CSS builder; on other + // targets only `bytes` is used (by `fallback_font_blobs`). + #[cfg_attr(not(all(target_os = "android", not(android_gpu))), allow(dead_code))] struct Face { family: &'static str, weight: &'static str, // "100 900" for variable fonts, "400"/"700" for static @@ -251,21 +260,25 @@ mod imp { }, ]; + #[cfg(all(target_os = "android", not(android_gpu)))] static FACE_CSS: OnceLock = OnceLock::new(); + #[cfg(all(target_os = "android", not(android_gpu)))] pub(super) fn face_css_impl() -> &'static str { FACE_CSS.get_or_init(build_css) } /// Raw bytes of every bundled fallback face, for direct registration into a - /// font collection (e.g. Parley's). Used by the document layout engine on - /// Android, where the executable-relative asset path that loads these on - /// desktop is unavailable. + /// font collection (e.g. Parley's). Compiled on every platform; the layout + /// engine registers them lazily when a metric-compatible substitute is + /// requested but not already present in the collection. pub(super) fn fallback_font_blobs() -> &'static [&'static [u8]] { + use std::sync::OnceLock; static BLOBS: OnceLock> = OnceLock::new(); BLOBS.get_or_init(|| FACES.iter().map(|f| f.bytes).collect()) } + #[cfg(all(target_os = "android", not(android_gpu)))] fn build_css() -> String { use std::fmt::Write as _; diff --git a/loki-layout/Cargo.toml b/loki-layout/Cargo.toml index 08f553c7..933af6b2 100644 --- a/loki-layout/Cargo.toml +++ b/loki-layout/Cargo.toml @@ -21,13 +21,13 @@ parley = "0.10" fontique = { version = "0.10", features = ["fontconfig-dlopen"] } thiserror = "2" tracing = "0.1" - -# Android only: the bundled metric-compatible fallback fonts (Carlito/Caladea/ -# Arimo/Cousine/Tinos). On desktop these are discovered from the executable's -# `assets/fonts/` directory, but that path does not resolve on Android, so the -# bytes are registered directly in `FontResources::new`. Gated to the Android -# target so desktop builds neither depend on loki-fonts nor embed the ~7 MB. -[target.'cfg(target_os = "android")'.dependencies] +# Bundled metric-compatible fallback fonts (Carlito/Caladea/Arimo/Cousine/Tinos) +# for Calibri/Cambria/Arial/Courier New/Times New Roman. Registered *lazily* by +# `FontResources::resolve_font_name` only when the requested substitute family is +# not already available (e.g. installed system-wide on desktop). This guarantees +# metric-compatible substitution in headless/CI/PDF-export contexts and on Android +# (where the `assets/fonts/` directory does not resolve), at the cost of embedding +# the ~7 MB of faces in every build. loki-fonts.workspace = true [dev-dependencies] diff --git a/loki-layout/src/font.rs b/loki-layout/src/font.rs index 45ebb908..2c598157 100644 --- a/loki-layout/src/font.rs +++ b/loki-layout/src/font.rs @@ -40,6 +40,10 @@ pub struct FontResources { /// did not change between layout passes (the common case on a keystroke, /// where only one paragraph differs). See [`ParaCache`]. pub(crate) para_cache: ParaCache, + /// Whether the embedded metric-compatible fallback faces (Carlito/Caladea/ + /// Arimo/Cousine/Tinos) have been registered. Done lazily, at most once, the + /// first time a substitute family is requested but found missing. + fallbacks_registered: bool, } impl FontResources { @@ -60,19 +64,12 @@ impl FontResources { } } - // Android: the executable-relative `assets/fonts/` path above does not - // resolve, so register the bundled metric-compatible fallbacks - // (Carlito/Caladea/Arimo/Cousine/Tinos) directly from embedded bytes. - // Without this a document's Calibri/Arial/Times text — which - // `resolve_font_name` substitutes to those families — would not be found - // in the collection and would fall back to an Android system font with - // different glyph metrics (e.g. wider digit advances). - #[cfg(target_os = "android")] - for blob in loki_fonts::fallback_font_blobs() { - font_cx - .collection - .register_fonts(parley::fontique::Blob::from(blob.to_vec()), None); - } + // The bundled metric-compatible fallback faces (Carlito/Caladea/Arimo/ + // Cousine/Tinos) are registered lazily by `resolve_font_name` only when a + // substitute family is requested but missing — so a properly-installed + // desktop (where they are found above, or system-wide) never pays the + // registration, while headless/CI/PDF-export and Android still resolve + // Calibri/Arial/Times correctly. See `ensure_fallback_fonts_registered`. tracing::info!( target: "loki_text::open", elapsed_ms = started.elapsed().as_secs_f64() * 1000.0, @@ -85,6 +82,25 @@ impl FontResources { font_data_cache: HashMap::new(), substitutions: HashMap::new(), para_cache: ParaCache::default(), + fallbacks_registered: false, + } + } + + /// Registers the embedded metric-compatible fallback faces into the Fontique + /// collection, at most once. Called lazily when a substitute family (e.g. + /// Carlito for Calibri) is requested but not already present, so that + /// substitution works even when the fonts are not installed system-wide + /// (headless export, CI, fresh desktop installs, Android). + fn ensure_fallback_fonts_registered(&mut self) { + if self.fallbacks_registered { + return; + } + self.fallbacks_registered = true; + for blob in loki_fonts::fallback_font_blobs() { + let bytes: Vec = blob.to_vec(); + self.font_cx + .collection + .register_fonts(parley::fontique::Blob::from(bytes), None); } } @@ -151,7 +167,12 @@ impl FontResources { }; if let Some(sub_name) = substitute { - // Check if the substitute is available. + // If the substitute is not already in the collection (e.g. not + // installed system-wide), lazily register the embedded faces so the + // metric-compatible substitution still works. + if self.font_cx.collection.family_id(sub_name).is_none() { + self.ensure_fallback_fonts_registered(); + } if self.font_cx.collection.family_id(sub_name).is_some() { self.substitutions .insert(name.to_string(), Some(sub_name.to_string())); @@ -211,4 +232,32 @@ mod tests { assert_eq!(r.substitutions.get("calibri"), Some(&None)); } } + + // Regression guard: the embedded metric-compatible faces must be available on + // every platform (not gated to Android), so headless/CI/PDF-export builds can + // register them. Re-gating to `target_os = "android"` would fail this here. + #[test] + fn fallback_font_blobs_embedded_on_all_targets() { + assert!( + !loki_fonts::fallback_font_blobs().is_empty(), + "metric-compatible fallback faces must be embedded on this target" + ); + } + + // Regression guard for the actual rendering bug: resolving a font with a known + // metric-compatible substitute must yield a family that is *actually present* + // in the collection. Before lazy fallback registration, "Calibri" resolved to + // itself but Carlito was absent on desktop → Parley fell back to a digit-less + // font, so list markers and Calibri text rendered `.notdef`. + #[test] + fn substituted_family_is_actually_available() { + let mut r = FontResources::new(); + for requested in ["Calibri", "Arial", "Times New Roman", "Cambria"] { + let resolved = r.resolve_font_name(requested); + assert!( + r.font_cx.collection.family_id(resolved.as_str()).is_some(), + "{requested:?} resolved to {resolved:?}, which is not available in the collection", + ); + } + } } From 5354d44bebeea3ec113d9e96b89a7d9f82c8317b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 10:33:32 +0000 Subject: [PATCH 06/35] fix(import): resolve bare paragraphs through the default paragraph style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paragraph with no explicit style (`w:pStyle`) resolved to `CharProps`/ `ParaProps::default()`, bypassing the `__DocDefault` → `Normal` chain that carries the document base font (`w:docDefaults`). So default-font body text imported with `font_name = None` and rendered in the engine's default face — a wide fallback rather than the document's Calibri (→ Carlito) — diverging from Word and inflating pagination. - `StyleCatalog` gains `default_paragraph_style` + `effective_paragraph_style`: the style a bare paragraph inherits from. The DOCX mapper sets it to the `w:default="1"` paragraph style (else the canonical/synthesized `Normal`), which roots at `__DocDefault`. - `loki-layout` `resolve_para_props` / `flatten_paragraph` use `effective_paragraph_style` so a `style_id = None` paragraph resolves the document default for both paragraph and character properties. Verified against the Word acid_docx render: body text now lays out in Carlito (metric-compatible Calibri) instead of the wide DejaVu fallback. (ODT's `style:default-style` is not yet wired into this field — separate follow-up.) Tests: default_paragraph_style_resolves_doc_default_font / explicit_default_paragraph_style_is_preferred (loki-ooxml), effective_paragraph_style_falls_back_to_default (loki-doc-model). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-doc-model/src/style/catalog.rs | 24 +++++++++ loki-doc-model/src/style/catalog_tests.rs | 20 ++++++++ loki-layout/src/resolve.rs | 10 ++-- loki-ooxml/src/docx/mapper/styles.rs | 17 +++++++ loki-ooxml/src/docx/mapper/styles_tests.rs | 57 ++++++++++++++++++++++ 6 files changed, 123 insertions(+), 7 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index bdd9fd3c..63d47f27 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -115,7 +115,7 @@ When a document requests a font that is unavailable locally, Loki automatically The substitute faces are **embedded in the layout engine and registered lazily** (`loki-layout` `FontResources::resolve_font_name`) the first time a substitute is requested but not already in the font collection. This guarantees the substitution works in headless contexts — **PDF/EPUB export, CI, the ACID harness, and fresh installs before the desktop installer places the fonts system-wide** — not just on a fully-installed desktop. Previously the bundled faces were registered only on Android, so on desktop/headless a requested "Calibri" resolved to itself, was not found, and Parley fell back to a wider font lacking some glyphs (digits rendered as `.notdef`, and list markers / Calibri text reflowed). Tested by `substituted_family_is_actually_available` and `fallback_font_blobs_embedded_on_all_targets` (`loki-layout` `font::tests`). -**Known remaining gap:** a paragraph that requests *no* font inherits the document default (`w:docDefaults`) in Word, but `loki-ooxml` does not yet fold `docDefaults` into the resolved base `CharProps` — so default-font body text imports with `font_name = None` and uses the engine's default face rather than the document's Calibri. This is the residual driver of the ACID page-count drift (TC-DOCX-027) and is tracked separately from marker/substitution resolution. +**Default-style inheritance:** a paragraph with no explicit style (`w:pStyle`) now resolves through the document's **default paragraph style** (`StyleCatalog::default_paragraph_style`, set by the DOCX mapper to the `w:default="1"` paragraph style — typically `Normal`, rooted at `w:docDefaults`). Both the paragraph and character resolution paths (`loki-layout` `resolve_para_props` / `flatten_paragraph`) honour it via `effective_paragraph_style`. This means default-font body text inherits the document base font (e.g. Calibri → Carlito) instead of importing with `font_name = None` and rendering in the engine's default face. Tested by `default_paragraph_style_resolves_doc_default_font` / `explicit_default_paragraph_style_is_preferred` (`loki-ooxml`) and `effective_paragraph_style_falls_back_to_default` (`loki-doc-model`). (ODT's `style:default-style` is not yet wired into this field — a separate follow-up.) ### Font Substitution Alerts When any font substitutions or missing fonts (such as **Aptos**, which has no open-source metric-compatible fallback) are detected in a document, Loki: diff --git a/loki-doc-model/src/style/catalog.rs b/loki-doc-model/src/style/catalog.rs index 162b17b4..c0da4a2c 100644 --- a/loki-doc-model/src/style/catalog.rs +++ b/loki-doc-model/src/style/catalog.rs @@ -89,6 +89,17 @@ pub struct StyleCatalog { /// Named list styles. ODF `text:list-style`; /// OOXML `w:abstractNum`. pub list_styles: IndexMap, + /// The id of the document's **default paragraph style** — the style a + /// paragraph with no explicit style reference inherits from. OOXML: the + /// paragraph style with `w:default="1"` (typically `Normal`, rooted at + /// `w:docDefaults`); ODF: the `style:default-style` for paragraphs. `None` + /// means "no document default" (a bare paragraph resolves to engine defaults). + /// + /// Without this, default-font body text (no `w:pStyle`) would bypass the + /// `docDefaults` chain and lose the document's base font, causing wrong-font + /// rendering and pagination drift. + #[cfg_attr(feature = "serde", serde(default))] + pub default_paragraph_style: Option, } impl StyleCatalog { @@ -98,6 +109,19 @@ impl StyleCatalog { Self::default() } + /// Returns the style id to resolve for a paragraph, given its (possibly + /// absent) explicit style reference: the explicit id if present, otherwise + /// the document's [`default_paragraph_style`](Self::default_paragraph_style). + /// Mirrors OOXML/ODF semantics where a paragraph with no style still + /// inherits the default paragraph style (and through it, `docDefaults`). + #[must_use] + pub fn effective_paragraph_style<'a>( + &'a self, + explicit: Option<&'a StyleId>, + ) -> Option<&'a StyleId> { + explicit.or(self.default_paragraph_style.as_ref()) + } + /// Resolves the paragraph properties for a style by walking the parent /// chain and merging properties (child wins over parent). ADR-0003. /// diff --git a/loki-doc-model/src/style/catalog_tests.rs b/loki-doc-model/src/style/catalog_tests.rs index 3308d613..f2fb14e8 100644 --- a/loki-doc-model/src/style/catalog_tests.rs +++ b/loki-doc-model/src/style/catalog_tests.rs @@ -183,3 +183,23 @@ fn resolve_deep_legitimate_chain_still_inherits() { let resolved = catalog.resolve_char(&StyleId::new("S9")).unwrap(); assert_eq!(resolved.italic, Some(true), "root value must inherit down"); } + +#[test] +fn effective_paragraph_style_falls_back_to_default() { + let mut catalog = StyleCatalog::new(); + // No explicit style → None when no document default is set. + assert_eq!(catalog.effective_paragraph_style(None), None); + + catalog.default_paragraph_style = Some(StyleId::new("Normal")); + // No explicit style → the document default. + assert_eq!( + catalog.effective_paragraph_style(None), + Some(&StyleId::new("Normal")) + ); + // An explicit style always wins over the default. + let explicit = StyleId::new("Heading1"); + assert_eq!( + catalog.effective_paragraph_style(Some(&explicit)), + Some(&explicit) + ); +} diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index cbdad785..0e9b35f4 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -168,9 +168,8 @@ pub struct CollectedNote { /// 1. Named style chain via [`StyleCatalog::resolve_para`]. /// 2. Direct paragraph formatting on the paragraph itself. pub fn resolve_para_props(block: &StyledParagraph, catalog: &StyleCatalog) -> ResolvedParaProps { - let mut base: ParaProps = block - .style_id - .as_ref() + let mut base: ParaProps = catalog + .effective_paragraph_style(block.style_id.as_ref()) .and_then(|id| catalog.resolve_para(id)) .unwrap_or_default(); if let Some(direct) = &block.direct_para_props { @@ -219,9 +218,8 @@ pub fn flatten_paragraph( Vec, Vec, ) { - let base: CharProps = block - .style_id - .as_ref() + let base: CharProps = catalog + .effective_paragraph_style(block.style_id.as_ref()) .and_then(|id| catalog.resolve_char(id)) .unwrap_or_default(); let base = match &block.direct_char_props { diff --git a/loki-ooxml/src/docx/mapper/styles.rs b/loki-ooxml/src/docx/mapper/styles.rs index 736e6449..7bdfb97e 100644 --- a/loki-ooxml/src/docx/mapper/styles.rs +++ b/loki-ooxml/src/docx/mapper/styles.rs @@ -134,6 +134,23 @@ pub(crate) fn map_styles(styles: &DocxStyles) -> StyleCatalog { .insert(StyleId::new("Normal"), normal_style); } + // Record the document's default paragraph style — the one a bare paragraph + // (no `w:pStyle`) inherits from. Prefer the explicit `w:default="1"` + // paragraph style; fall back to the canonical/synthesized `Normal`. Through + // its parent chain this reaches `__DocDefault` (w:docDefaults), so default- + // font body text picks up the document base font instead of engine defaults. + catalog.default_paragraph_style = styles + .styles + .iter() + .find(|s| matches!(s.style_type, DocxStyleType::Paragraph) && s.is_default) + .map(|s| StyleId::new(&s.style_id)) + .or_else(|| { + catalog + .paragraph_styles + .contains_key(&StyleId::new("Normal")) + .then(|| StyleId::new("Normal")) + }); + catalog } diff --git a/loki-ooxml/src/docx/mapper/styles_tests.rs b/loki-ooxml/src/docx/mapper/styles_tests.rs index e919944e..8606093b 100644 --- a/loki-ooxml/src/docx/mapper/styles_tests.rs +++ b/loki-ooxml/src/docx/mapper/styles_tests.rs @@ -113,6 +113,63 @@ fn doc_defaults_create_synthetic_root() { assert_eq!(root.char_props.bold, Some(true)); } +#[test] +fn default_paragraph_style_resolves_doc_default_font() { + use crate::docx::model::paragraph::{DocxRFonts, DocxRPr}; + // docDefaults font Calibri, no explicit pStyle → Normal synthesized. + let styles = DocxStyles { + default_rpr: Some(DocxRPr { + fonts: Some(DocxRFonts { + ascii: Some("Calibri".into()), + ..Default::default() + }), + ..Default::default() + }), + default_ppr: None, + styles: vec![], + }; + let catalog = map_styles(&styles); + + // A bare paragraph (no w:pStyle) must resolve through the recorded default + // paragraph style, which inherits the docDefaults font. + let def = catalog + .default_paragraph_style + .clone() + .expect("default paragraph style recorded"); + assert_eq!(def, StyleId::new("Normal")); + let resolved = catalog + .effective_paragraph_style(None) + .and_then(|id| catalog.resolve_char(id)) + .expect("bare paragraph resolves the default style"); + assert_eq!(resolved.font_name.as_deref(), Some("Calibri")); +} + +#[test] +fn explicit_default_paragraph_style_is_preferred() { + // A paragraph style flagged w:default="1" wins over the synthesized Normal. + let styles = DocxStyles { + default_rpr: None, + default_ppr: None, + styles: vec![DocxStyle { + style_type: DocxStyleType::Paragraph, + style_id: "MyBody".into(), + is_default: true, + is_custom: false, + name: Some("My Body".into()), + based_on: None, + next: None, + link: None, + ppr: None, + rpr: None, + }], + }; + let catalog = map_styles(&styles); + assert_eq!( + catalog.default_paragraph_style, + Some(StyleId::new("MyBody")) + ); +} + #[test] fn duplicate_style_ids_last_definition_wins() { use crate::docx::model::paragraph::DocxRPr; From 999fb24341511369c3e1c3f14bdf6017534f5173 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 10:53:48 +0000 Subject: [PATCH 07/35] feat(loki-layout): tab-stop alignment (right/center/decimal) + leaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab stops only ever advanced content to the next stop (left-align), and `ResolvedTabStop` dropped the stop's alignment and leader during resolution. So right-aligned tabs pushed content off the line (it wrapped), decimal tabs left numbers un-aligned, and dot/dash leaders were never drawn — TOC lines and number columns diverged badly from Word. - `ResolvedTabStop` now carries `alignment` + `leader` (populated in resolve.rs). - The two-pass tab expansion measures the content following each tab using zero-width probe inline boxes — plus a decimal-marker box per tab and an end-of-text sentinel — then sizes the final box so the content lands per the stop's alignment: Left advances to the stop, Right ends at it, Center centres on it, Decimal puts the first `.` at it. Tabs are processed left-to-right, accumulating the shift so later stops resolve against the shifted pen. - Leaders (dot/dash/underscore/heavy/middle-dot) are drawn across the gap as renderer-agnostic FilledRects. Verified against the Word-for-Windows acid_docx reference: the TC-008/009 page now matches — "Chapter One ....... 12" right-aligned with a dot leader, and the 1234.5 / 7.89 / 42.0 column aligned on the decimal point. Tests: tab_stops_tests.rs (right/center/decimal geometry, dot-leader fill, and a left-tab regression guard). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-layout/src/para.rs | 258 ++++++++++++++++++++++++--- loki-layout/src/resolve.rs | 2 + loki-layout/tests/tab_stops_tests.rs | 183 +++++++++++++++++++ 4 files changed, 421 insertions(+), 24 deletions(-) create mode 100644 loki-layout/tests/tab_stops_tests.rs diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 63d47f27..bbc801cc 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -58,7 +58,7 @@ This is the living source of truth documenting which document features, characte | **Spacing (Before/After)** | Yes | Yes | Yes | Margins and paragraph offsets respected. | | **Line Height** | Yes | Yes | Yes | Supports exact, relative (multipliers), and at-least rules. | | **Borders** | Yes | Yes | Yes | Top, bottom, left, right borders supported. | -| **Tab Stops** | Yes | Yes | Yes | Position-sorted tab stops supported. | +| **Tab Stops** | Yes | Yes | Yes | Position-sorted tab stops with **alignment and leaders**. The two-pass tab expansion (`para.rs`) measures the content following each tab via zero-width probe inline boxes (plus a decimal-marker box per tab and an end-of-text sentinel), then sizes the final inline box so the content lands per the stop's alignment: **Left** advances to the stop, **Right** ends the content at the stop, **Center** centres it, **Decimal** places the first `.` at the stop. **Leaders** (dot/dash/underscore/heavy/middle-dot) are drawn across the gap as renderer-agnostic fills. Tabs are processed left-to-right, accumulating the shift so later stops resolve against the shifted pen. Tested by `tab_stops_tests.rs` (right/center/decimal geometry + dot leader). Limits: content that wraps mid-tab-sequence falls back to left-align for the wrapped tab; `bar` tabs (vertical rule) map to left; leader dots are square fills, not period glyphs. | | **Background Color** | Yes | Yes | Yes | Paragraph background shading supported. | | **Named / Custom Paragraph Styles** | Yes | Yes | Yes | The full style catalog round-trips through DOCX `styles.xml`: custom styles created in the style editor, plus edits to built-in `Normal`/`Heading1`–`6`, persist across save/reload. Font family, weight (→ bold), size, alignment, indentation (incl. first-line), spacing, line height, `basedOn`, `next`-style, outline level, and the custom flag are all written and read back. In-session, the catalog round-trips through the Loro CRDT (`loro_bridge::styles`, a JSON snapshot like metadata), so style-editor edits are durable across rebuilds and **undoable** with Ctrl+Z/Ctrl+Y. | | **border_between** | Yes | No | No | Rules between adjacent same-styled paragraphs ignored. | diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index 655ffba6..26d6b106 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use loki_doc_model::style::list_style::{ BulletChar, ListId, ListLevel, ListLevelKind, NumberingScheme, }; +use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader}; use parley::{ Alignment, AlignmentOptions, Cursor, FontFamily, FontStyle, FontWeight, InlineBox, InlineBoxKind, LineHeight, PositionedLayoutItem, RangedBuilder, Selection, StyleProperty, @@ -112,6 +113,10 @@ pub struct ResolvedListMarker { pub struct ResolvedTabStop { /// Tab stop position from the content-area start edge, in points. pub position: f32, + /// How text following the tab is aligned relative to [`Self::position`]. + pub alignment: TabAlignment, + /// Leader character drawn across the tab gap (dots/dashes/…), if any. + pub leader: TabLeader, } /// Resolved line-height specification for a paragraph. @@ -571,19 +576,158 @@ impl ParagraphLayout { /// Default tab stop interval: 0.5 inch = 36 pt = 720 twips (Word default). const DEFAULT_TAB_INTERVAL: f32 = 36.0; -/// Return the next tab stop position strictly greater than `x`. -/// -/// Searches `stops` (sorted ascending) first; falls back to the default -/// 36 pt grid when no explicit stop is defined beyond `x`. -fn next_tab_stop(stops: &[ResolvedTabStop], x: f32, indent_hanging: f32) -> f32 { +/// Return the tab stop a tab at pen position `x` advances to: the first +/// explicit stop strictly greater than `x`, else a synthesized default-grid +/// stop (36 pt, left-aligned, no leader). A hanging indent acts as an implicit +/// first stop. +fn next_tab_stop_resolved( + stops: &[ResolvedTabStop], + x: f32, + indent_hanging: f32, +) -> ResolvedTabStop { if indent_hanging > 0.0 && x < indent_hanging - 0.5 { - return indent_hanging; + return ResolvedTabStop { + position: indent_hanging, + alignment: TabAlignment::Left, + leader: TabLeader::None, + }; } if let Some(s) = stops.iter().find(|s| s.position > x + 0.5) { - s.position + *s } else { - ((x / DEFAULT_TAB_INTERVAL).floor() + 1.0) * DEFAULT_TAB_INTERVAL + ResolvedTabStop { + position: ((x / DEFAULT_TAB_INTERVAL).floor() + 1.0) * DEFAULT_TAB_INTERVAL, + alignment: TabAlignment::Left, + leader: TabLeader::None, + } + } +} + +/// Emit the leader fill for a tab gap `[x0, x1]` at `baseline`, as +/// renderer-agnostic [`PositionedItem::FilledRect`]s. Dotted leaders are a row +/// of small squares; dashed are short bars; underscore/heavy are a solid rule +/// just below the baseline (like an underline). A `None` leader emits nothing. +fn emit_tab_leader( + items: &mut Vec, + leader: TabLeader, + x0: f32, + x1: f32, + baseline: f32, +) { + let width = x1 - x0; + if width < 1.0 || leader == TabLeader::None { + return; + } + let color = LayoutColor::BLACK; + let mut dots = |size: f32, pitch: f32, y: f32| { + let mut x = x0 + (pitch - size) * 0.5; + while x + size <= x1 { + items.push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(x, y, size, size), + color, + })); + x += pitch; + } + }; + match leader { + TabLeader::Dot | TabLeader::MiddleDot => dots(0.9, 3.6, baseline - 1.6), + TabLeader::Dash => { + let (dash, pitch, th, y) = (2.4, 4.2, 0.8, baseline - 1.9); + let mut x = x0 + 1.0; + while x + dash <= x1 { + items.push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(x, y, dash, th), + color, + })); + x += pitch; + } + } + TabLeader::Underscore => items.push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(x0, baseline + 1.0, width, 0.8), + color, + })), + TabLeader::Heavy => items.push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(x0, baseline + 1.0, width, 1.4), + color, + })), + // `None` is handled by the early return; `_` covers the non-exhaustive + // enum's future variants. + _ => {} + } +} + +/// The planned expansion of one tab character: the inline-box width to insert +/// so the following text lands at its stop, plus the leader to draw across it. +#[derive(Debug, Clone, Copy)] +struct TabPlan { + /// Width of the inline box that advances the pen to the aligned position. + width: f32, + /// Leader to fill the gap (drawn across `[tab_x, tab_x + width]`). + leader: TabLeader, +} + +/// Compute each tab's expansion width and leader from probe measurements. +/// +/// Processes tabs left-to-right, accumulating the shift each expansion adds, so +/// a later tab's stop is found relative to its *shifted* position. Alignment +/// positions the content that follows the tab (up to the next tab / line end): +/// Left advances to the stop; Right ends the content at the stop; Center +/// centres it; Decimal places the first `.` at the stop. Content widths come +/// from the zero-width probe boxes (natural, unshifted layout). +#[allow(clippy::too_many_arguments)] +fn compute_tab_plans( + stops: &[ResolvedTabStop], + indent_hanging: f32, + x_tab: &[f32], + line_tab: &[usize], + x_dec: &[f32], + x_end: f32, + line_end: usize, +) -> Vec { + let n = x_tab.len(); + let mut plans = Vec::with_capacity(n); + let mut shift = 0.0f32; + for i in 0..n { + let final_tab_x = x_tab[i] + shift; + let stop = next_tab_stop_resolved(stops, final_tab_x, indent_hanging); + + // Natural boundary of the content following this tab: the next tab, or + // the end-of-text sentinel for the last tab. + let (boundary_x, boundary_line) = if i + 1 < n { + (x_tab[i + 1], line_tab[i + 1]) + } else { + (x_end, line_end) + }; + // Content width is only meaningful when the boundary is on the same + // visual line; otherwise the content wrapped — fall back to left-align. + let content_w = if boundary_line == line_tab[i] && boundary_x >= x_tab[i] { + boundary_x - x_tab[i] + } else { + 0.0 + }; + + let offset = match stop.alignment { + TabAlignment::Right => content_w, + TabAlignment::Center => content_w / 2.0, + TabAlignment::Decimal => { + if x_dec[i].is_nan() { + content_w // no decimal separator → behave like right-align + } else { + (x_dec[i] - x_tab[i]).max(0.0) + } + } + // Left / Clear (filtered earlier) / non-exhaustive → advance to stop. + _ => 0.0, + }; + + let width = (stop.position - offset - final_tab_x).max(0.0); + plans.push(TabPlan { + width, + leader: stop.leader, + }); + shift += width; } + plans } /// Push paragraph-level defaults and per-span character styles onto `builder`. @@ -765,6 +909,14 @@ fn clean_text_and_spans( /// (which count up from 0) so the two can coexist in one paragraph. const MATH_ID_BASE: u64 = 1 << 40; +/// Probe-only inline-box id base for decimal-separator markers (one per tab), +/// used to measure where the first `.` after a tab sits for decimal alignment. +const DEC_ID_BASE: u64 = 1 << 20; + +/// Probe-only inline-box id for the end-of-text sentinel, used to measure the +/// trailing edge of the content following the last tab. +const END_ID: u64 = 1 << 30; + /// Lay out a single paragraph using Parley. /// /// `text_content` is the flattened text from all inline runs. `style_spans` @@ -925,7 +1077,24 @@ fn layout_paragraph_uncached( .map(|(i, _)| i) .collect(); - let tab_inline_widths: Vec = if !tab_char_positions.is_empty() { + // Byte offset of the first decimal separator after each tab (before the + // next tab / end), for Decimal-aligned stops. + let decimal_positions: Vec> = tab_char_positions + .iter() + .enumerate() + .map(|(i, &t)| { + let end = tab_char_positions + .get(i + 1) + .copied() + .unwrap_or(clean_text.len()); + clean_text[t + 1..end].find('.').map(|rel| t + 1 + rel) + }) + .collect(); + + let tab_plans: Vec = if tab_char_positions.is_empty() { + vec![] + } else { + let n = tab_char_positions.len(); let mut probe = resources.layout_cx.ranged_builder( &mut resources.font_cx, &clean_text, @@ -941,30 +1110,60 @@ fn layout_paragraph_uncached( width: 0.0, height: 0.0, }); + if let Some(dpos) = decimal_positions[idx] { + probe.push_inline_box(InlineBox { + id: DEC_ID_BASE + idx as u64, + kind: InlineBoxKind::InFlow, + index: dpos, + width: 0.0, + height: 0.0, + }); + } } + probe.push_inline_box(InlineBox { + id: END_ID, + kind: InlineBoxKind::InFlow, + index: clean_text.len(), + width: 0.0, + height: 0.0, + }); push_math_inline_boxes(&mut probe, &math_boxes); let mut probe_layout = probe.build(&clean_text); probe_layout.break_all_lines(Some(line_w)); - let mut x_positions = vec![0.0f32; tab_char_positions.len()]; - for line in probe_layout.lines() { + let mut x_tab = vec![0.0f32; n]; + let mut line_tab = vec![usize::MAX; n]; + let mut x_dec = vec![f32::NAN; n]; + let mut x_end = 0.0f32; + let mut line_end = usize::MAX; + for (li, line) in probe_layout.lines().enumerate() { for item in line.items() { if let PositionedLayoutItem::InlineBox(pib) = item { - let idx = pib.id as usize; - if idx < x_positions.len() { - x_positions[idx] = pib.x; + let id = pib.id; + if (id as usize) < n { + x_tab[id as usize] = pib.x; + line_tab[id as usize] = li; + } else if (DEC_ID_BASE..END_ID).contains(&id) { + let i = (id - DEC_ID_BASE) as usize; + if i < n { + x_dec[i] = pib.x; + } + } else if id == END_ID { + x_end = pib.x; + line_end = li; } } } } - x_positions - .iter() - .map(|&x| { - (next_tab_stop(¶_props.tab_stops, x, para_props.indent_hanging) - x).max(0.0) - }) - .collect() - } else { - vec![] + compute_tab_plans( + ¶_props.tab_stops, + para_props.indent_hanging, + &x_tab, + &line_tab, + &x_dec, + x_end, + line_end, + ) }; // ── Main (final) layout pass ────────────────────────────────────────────── @@ -976,7 +1175,7 @@ fn layout_paragraph_uncached( ); push_para_styles(&mut builder, para_props, &clean_spans); for (idx, &pos) in tab_char_positions.iter().enumerate() { - let width = tab_inline_widths.get(idx).copied().unwrap_or(0.0); + let width = tab_plans.get(idx).map(|p| p.width).unwrap_or(0.0); builder.push_inline_box(InlineBox { id: idx as u64, kind: InlineBoxKind::InFlow, @@ -1024,6 +1223,7 @@ fn layout_paragraph_uncached( } else { para_props.indent_start }; + let line_baseline = line.metrics().baseline; for item in line.items() { // Math inline box: emit the typeset equation's draw items, offset to // the box's resolved position on the line. @@ -1040,6 +1240,18 @@ fn layout_paragraph_uncached( // `pib.y + ascent`; the descent hangs below that. 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) { + emit_tab_leader( + &mut items, + plan.leader, + pib.x + indent_x, + pib.x + indent_x + pib.width, + line_baseline, + ); + } } continue; } diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index 0e9b35f4..7fcbe686 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -888,6 +888,8 @@ fn map_para_props(p: &ParaProps) -> ResolvedParaProps { .filter(|s| s.alignment != TabAlignment::Clear) .map(|s| ResolvedTabStop { position: pts_to_f32(s.position), + alignment: s.alignment, + leader: s.leader, }) .collect(); stops.sort_by(|a, b| { diff --git a/loki-layout/tests/tab_stops_tests.rs b/loki-layout/tests/tab_stops_tests.rs new file mode 100644 index 00000000..cc52b95a --- /dev/null +++ b/loki-layout/tests/tab_stops_tests.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Layout-geometry assertions for tab-stop alignment and leaders. +//! +//! GPU-free tests that lay out a paragraph containing a tab and a single +//! explicit tab stop, then assert where the post-tab content lands relative to +//! the stop (left/right/center/decimal) and that leaders are drawn. Guards the +//! Word contract for TOC dot-leaders and decimal-aligned number columns. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::{Block, StyledParagraph}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::layout::Section; +use loki_doc_model::layout::page::{PageLayout, PageMargins, PageSize}; +use loki_doc_model::style::catalog::StyleCatalog; +use loki_doc_model::style::props::para_props::ParaProps; +use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; +use loki_primitives::units::Points; + +use loki_layout::{ + FlowOutput, FontResources, LayoutMode, LayoutOptions, PositionedItem, flow_section, +}; + +const STOP: f64 = 300.0; + +fn test_resources() -> FontResources { + let mut r = FontResources::new(); + for p in [ + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ] { + if let Ok(data) = std::fs::read(p) { + r.register_font(data); + } + } + r +} + +fn wide_layout() -> PageLayout { + PageLayout { + page_size: PageSize { + width: Points::new(420.0), + height: Points::new(400.0), + }, + margins: PageMargins { + top: Points::new(10.0), + bottom: Points::new(10.0), + left: Points::new(0.0), + right: Points::new(10.0), + ..PageMargins::default() + }, + ..PageLayout::default() + } +} + +fn tab_para(text: &str, alignment: TabAlignment, leader: TabLeader) -> StyledParagraph { + StyledParagraph { + style_id: None, + direct_para_props: Some(Box::new(ParaProps { + tab_stops: Some(vec![TabStop { + position: Points::new(STOP), + alignment, + leader, + }]), + ..Default::default() + })), + direct_char_props: None, + inlines: vec![Inline::Str(text.into())], + attr: NodeAttr::default(), + } +} + +fn layout(para: StyledParagraph) -> Vec { + let mut r = test_resources(); + let section = Section::with_layout_and_blocks(wide_layout(), vec![Block::StyledPara(para)]); + match flow_section( + &mut r, + §ion, + &StyleCatalog::new(), + &LayoutMode::Pageless, + 1.0, + &LayoutOptions::default(), + &[], + ) { + FlowOutput::Canvas { items, .. } => items, + _ => panic!("expected Canvas output"), + } +} + +/// `(origin_x, right_edge)` of the rightmost glyph run — the content after the +/// tab. `right_edge = origin.x + sum(glyph advances)`. +fn post_tab_run(items: &[PositionedItem]) -> (f32, f32) { + items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(run) => { + let adv: f32 = run.glyphs.iter().map(|g| g.advance).sum(); + Some((run.origin.x, run.origin.x + adv)) + } + _ => None, + }) + .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) + .expect("a post-tab glyph run") +} + +#[test] +fn right_tab_aligns_content_right_edge_to_stop() { + let items = layout(tab_para("Ch\t12", TabAlignment::Right, TabLeader::None)); + let (origin, right) = post_tab_run(&items); + assert!( + (right - STOP as f32).abs() < 2.0, + "right-tab content must end at the stop ({STOP}); right edge = {right}" + ); + assert!(origin < STOP as f32, "content must start left of the stop"); +} + +#[test] +fn center_tab_centers_content_on_stop() { + let items = layout(tab_para("Ch\t12", TabAlignment::Center, TabLeader::None)); + let (origin, right) = post_tab_run(&items); + let center = (origin + right) / 2.0; + assert!( + (center - STOP as f32).abs() < 2.0, + "center-tab content must be centered on the stop ({STOP}); center = {center}" + ); +} + +#[test] +fn decimal_tab_aligns_numbers_on_the_decimal_point() { + // Two numbers with the same fractional suffix (".5"): a decimal stop puts + // the '.' at the stop, so both right edges land at stop + width(".5") and + // are therefore equal AND strictly right of the stop (distinguishing decimal + // from right-alignment, which would end exactly at the stop). + let (_, right_short) = post_tab_run(&layout(tab_para( + "x\t1.5", + TabAlignment::Decimal, + TabLeader::None, + ))); + let (_, right_long) = post_tab_run(&layout(tab_para( + "x\t1234.5", + TabAlignment::Decimal, + TabLeader::None, + ))); + assert!( + (right_short - right_long).abs() < 1.0, + "decimal-aligned numbers sharing a '.5' suffix must share a right edge: \ + {right_short} vs {right_long}" + ); + assert!( + right_short > STOP as f32 + 0.5, + "the fractional part must extend right of the decimal stop ({STOP})" + ); +} + +#[test] +fn dot_leader_fills_the_tab_gap() { + let items = layout(tab_para("Ch\t12", TabAlignment::Right, TabLeader::Dot)); + let dots: Vec<_> = items + .iter() + .filter_map(|i| match i { + PositionedItem::FilledRect(r) => Some(r.rect.x()), + _ => None, + }) + .filter(|&x| x > 20.0 && x < STOP as f32) + .collect(); + assert!( + dots.len() >= 5, + "a dot leader must place multiple dots in the gap before the stop; got {}", + dots.len() + ); +} + +#[test] +fn left_tab_unchanged_advances_to_stop() { + // Regression: a plain left tab still advances content to begin at the stop. + let items = layout(tab_para("Ch\tX", TabAlignment::Left, TabLeader::None)); + let (origin, _) = post_tab_run(&items); + assert!( + (origin - STOP as f32).abs() < 2.0, + "left-tab content must begin at the stop ({STOP}); origin = {origin}" + ); +} From c4a46aebe0fb5655c2c9ddbf254605b781c48dca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 11:16:07 +0000 Subject: [PATCH 08/35] fix(loki-layout): wrap over-long words within table cells (TC-DOCX-006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long unbreakable word in a narrow fixed-width cell overflowed horizontally, spilling its text across into the neighbouring cell, because cell paragraphs used the default no-break wrapping. Word breaks such a word to the column width (the row grows tall instead). Cell content now lays out with CSS `overflow-wrap: anywhere`: `ResolvedParaProps` gains `break_long_words`, threaded from a new `FlowState::break_long_words` that the flow engine sets while flowing cell blocks (and the cell-height measuring / rotated-cell paths). `push_para_styles` pushes `OverflowWrap::Anywhere` only when the flag is set, so normal body paragraphs are unaffected. The cache key already folds in `ResolvedParaProps`, so cell and body layouts stay distinct. Verified against the Word acid_docx reference: the TC-006 narrow column now character-wraps the long word exactly like Word (Narrowc/olumnsh/ouldnot/…), the row grows tall, and nothing spills into the wide cell. Tests: long_word_wraps_within_narrow_cell. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-layout/src/flow.rs | 14 +++++++ loki-layout/src/flow_para.rs | 3 ++ loki-layout/src/para.rs | 15 ++++++- loki-layout/src/resolve.rs | 2 + loki-layout/tests/table_tests.rs | 72 ++++++++++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 2 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index bbc801cc..6fdef018 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -71,7 +71,7 @@ This is the living source of truth documenting which document features, characte | Feature | Import | Layout/Render | Export | Notes | | :--- | :---: | :---: | :---: | :--- | -| **Column Widths** | Yes | Yes | Yes | Mapped using `ColWidth` and `TableWidth` specs. | +| **Column Widths** | Yes | Yes | Yes | Mapped using `ColWidth` and `TableWidth` specs. **Cell content that does not fit a column wraps within it** — an over-long unbreakable word breaks to the column width (CSS `overflow-wrap: anywhere`, set on cell paragraphs via `FlowState::break_long_words`), so the row grows tall like Word instead of the word overflowing horizontally into the next cell (TC-DOCX-006). Tested by `long_word_wraps_within_narrow_cell`. Known gap: `w:tblLayout` (fixed vs autofit) is still not parsed, so when explicit fixed widths sum to more/less than the table width Loki rescales them rather than honouring them exactly (characterised by `fixed_columns_*_current_behavior`); cell content is not yet clipped to the cell box (relies on wrapping to avoid overflow). | | **Row Heights** | Yes | Yes | Yes | Evaluated dynamically based on cell content. | | **Column Spanning** | Yes | Yes | Yes | Supports horizontal cell merging. | | **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). | diff --git a/loki-layout/src/flow.rs b/loki-layout/src/flow.rs index c9f194b3..a337239c 100644 --- a/loki-layout/src/flow.rs +++ b/loki-layout/src/flow.rs @@ -185,6 +185,11 @@ pub(super) struct FlowState<'a> { /// Comment anchors (`id`, content-local `y`) recorded on the current page, /// consumed by [`finish_page`] to lay out the gutter comment panel. pub(super) pending_comment_anchors: Vec<(String, f32)>, + /// When `true`, paragraphs laid out in this state break over-long words to + /// the available width (CSS `overflow-wrap: anywhere`). Set while flowing + /// table-cell content so a long word wraps to the column width instead of + /// overflowing into the neighbouring cell. + pub(super) break_long_words: bool, } impl FlowState<'_> { @@ -293,6 +298,7 @@ fn new_flow_state<'a>( column_para_start: 0, comments, pending_comment_anchors: Vec::new(), + break_long_words: false, } } @@ -1179,6 +1185,8 @@ fn measure_cell_height( // Cells never render the comment gutter panel. comments: &[], pending_comment_anchors: Vec::new(), + // Cell content: break over-long words to the column width (Word). + break_long_words: true, }; for block in &cell.blocks { @@ -1305,6 +1313,8 @@ fn flow_cell_blocks( // Cells never render the comment gutter panel. comments: &[], pending_comment_anchors: Vec::new(), + // Cell content: break over-long words to the column width (Word). + break_long_words: true, }; for block in blocks { @@ -1503,10 +1513,14 @@ fn flow_table( } else { state.current_indent = cell_x + pad_left; state.content_width = cell_content_width; + // Cell content breaks over-long words to the column width (Word). + let old_break = state.break_long_words; + state.break_long_words = true; for block in &cell.blocks { flow_block(state, block, idx); } + state.break_long_words = old_break; // If it fits on a single page, apply vertical alignment let cell_page_start = cell_starts[c_idx].0; diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index 6318c0db..af436548 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -70,6 +70,9 @@ const CHAIN_LIMIT: usize = 5; /// 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); + // Inherit the cell-content word-breaking flag from the flow state so a long + // unbreakable word wraps to the column width instead of overflowing. + resolved.break_long_words = state.break_long_words; // ── List level indentation fallback ───────────────────────────────────── // OOXML defines indentation on both the paragraph and its numbering level. diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index 26d6b106..e8fb14cc 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -18,7 +18,8 @@ use loki_doc_model::style::list_style::{ use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader}; use parley::{ Alignment, AlignmentOptions, Cursor, FontFamily, FontStyle, FontWeight, InlineBox, - InlineBoxKind, LineHeight, PositionedLayoutItem, RangedBuilder, Selection, StyleProperty, + InlineBoxKind, LineHeight, OverflowWrap, PositionedLayoutItem, RangedBuilder, Selection, + StyleProperty, }; use crate::color::LayoutColor; @@ -258,6 +259,12 @@ pub struct ResolvedParaProps { /// Explicit tab stops, sorted ascending by position. Empty = use the /// default 36 pt (0.5 inch) grid. Gap #7. pub tab_stops: Vec, + /// Break an over-long word that does not fit the available width by + /// allowing a break at any character (CSS `overflow-wrap: anywhere`). + /// Set for table-cell content so a long unbreakable word wraps to the + /// fixed column width (matching Word) instead of overflowing into the + /// neighbouring cell. Normal body paragraphs leave this `false`. + pub break_long_words: bool, } impl Default for ResolvedParaProps { @@ -283,6 +290,7 @@ impl Default for ResolvedParaProps { indent_hanging: 0.0, list_marker: None, tab_stops: Vec::new(), + break_long_words: false, } } } @@ -765,6 +773,11 @@ fn push_para_styles( ) { builder.push_default(StyleProperty::Brush(LayoutColor::BLACK)); builder.push_default(StyleProperty::FontSize(12.0)); + // Table cells break over-long words to the column width (CSS + // `overflow-wrap: anywhere`); body paragraphs keep words intact. + if para_props.break_long_words { + builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::Anywhere)); + } match para_props.line_height { // MetricsRelative(1.0) is Parley's default — single-spacing from natural // font metrics. Correct for OOXML lineRule="auto" w:line="240". diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index 7fcbe686..f8e1b22d 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -899,6 +899,8 @@ fn map_para_props(p: &ParaProps) -> ResolvedParaProps { }); stops }, + // Set by the flow engine for table-cell content; see ResolvedParaProps. + break_long_words: false, } } diff --git a/loki-layout/tests/table_tests.rs b/loki-layout/tests/table_tests.rs index c01ec6cc..f4cf19e9 100644 --- a/loki-layout/tests/table_tests.rs +++ b/loki-layout/tests/table_tests.rs @@ -468,6 +468,78 @@ fn fixed_columns_underflowing_table_width_are_scaled_up_current_behavior() { } } +/// A long unbreakable word in a narrow fixed-width 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. +#[test] +fn long_word_wraps_within_narrow_cell() { + use loki_doc_model::content::table::col::TableWidth; + let mut r = test_resources(); + let cell = make_cell_tall( + vec!["Narrowcolumnshouldnotgrowtofitthislongunbrokenword"], + None, + 1, + ); + let table = Block::Table(Box::new(Table { + attr: loki_doc_model::content::attr::NodeAttr::default(), + caption: Default::default(), + width: Some(TableWidth::Fixed(60.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 { + layout: PageLayout::default(), + blocks: vec![table], + extensions: ExtensionBag::default(), + }; + let (items, height) = flow_pageless(&mut r, §ion); + + // Each wrapped line's width must fit the 60pt column (small tolerance), i.e. + // no line overflows horizontally into a neighbouring cell. (Content sits at + // the page's left-margin offset, so width — not absolute x — is the check.) + let max_line_width = items + .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!( + max_line_width < 62.0, + "each wrapped line must fit the 60pt column; widest line = {max_line_width}" + ); + // The word wrapped across several lines, so the row is much taller than one. + assert!( + height > 40.0, + "the wrapped word must make the row tall; table height = {height}" + ); + // Multiple glyph runs on distinct baselines confirm the word actually wrapped. + let distinct_lines = { + let mut ys: Vec = items + .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!( + distinct_lines >= 4, + "expected the long word to wrap to several lines; got {distinct_lines}" + ); +} + #[test] fn test_table_cell_vertical_alignment() { use loki_doc_model::content::table::row::CellVerticalAlign; From 40ff5878a5795170574f31f5e5fd2c79df10199f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 11:28:26 +0000 Subject: [PATCH 09/35] fix(loki-layout): place cells correctly across vMerge-covered columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combined vertical-merge + gridSpan tables (L-merges, TC-DOCX-005) rendered wrong: a cell in a row whose leading column was occupied by a `row_span` (vMerge) cell from above was placed too far left, overlapping the merged cell, because every table pass recomputed each cell's grid column independently from column 0 — ignoring coverage from rows above. `flow_table` now computes the cell→grid-column assignment once via `assign_cell_columns`, which walks the grid top-to-bottom marking the columns a `row_span > 1` cell covers in the rows it spans; later cells skip covered columns. All four passes (height measure, span distribution, content flow, background/border) use this shared `(col_start, col_end)` instead of a local per-row counter. Verified against the Word acid_docx reference: the TC-003/004/005 table now matches — "A merged" spans rows 2-3 and the "B3+C3 gridSpan=2" cell sits under columns B-C (not at the left edge). Tests: vmerge_gridspan_l_merge_places_cells_correctly; updated test_table_row_span_distribution to assert the spanned-column placement. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 4 +- loki-layout/src/flow.rs | 71 ++++++++++++++++-------- loki-layout/tests/table_tests.rs | 92 +++++++++++++++++++++++++++++++- 3 files changed, 142 insertions(+), 25 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 6fdef018..b58368a3 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -73,8 +73,8 @@ This is the living source of truth documenting which document features, characte | :--- | :---: | :---: | :---: | :--- | | **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`. Known gap: `w:tblLayout` (fixed vs autofit) is still not parsed, so when explicit fixed widths sum to more/less than the table width Loki rescales them rather than honouring them exactly (characterised by `fixed_columns_*_current_behavior`); cell content is not yet clipped to the cell box (relies on wrapping to avoid overflow). | | **Row Heights** | Yes | Yes | Yes | Evaluated dynamically based on cell content. | -| **Column Spanning** | Yes | Yes | Yes | Supports horizontal cell merging. | -| **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). | +| **Column Spanning** | Yes | Yes | Yes | Supports horizontal cell merging (`w:gridSpan`). | +| **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). **Combined vMerge + gridSpan (L-merges) place correctly:** the layout assigns each cell its grid columns via a shared coverage grid (`assign_cell_columns`), so a cell in a row whose leading column is occupied by a vertical merge above is shifted into the next free column instead of overlapping the merged cell (TC-DOCX-005). Tested by `vmerge_gridspan_l_merge_places_cells_correctly` and `test_table_row_span_distribution`. | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | | **Floating Images / Wrap Mode** | Yes | No | No | Anchored (floating) drawings are marked with the `floating` class and now 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` (on the frame's graphic style) both map. **Not yet laid out** — the flow engine has no exclusion zones, so text does not wrap around floats (gap #12); floating drawings are still not painted. Tested: `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | diff --git a/loki-layout/src/flow.rs b/loki-layout/src/flow.rs index a337239c..d2c91cc0 100644 --- a/loki-layout/src/flow.rs +++ b/loki-layout/src/flow.rs @@ -1324,6 +1324,42 @@ fn flow_cell_blocks( temp_state.current_items } +/// Assign each cell its grid column span `(col_start, col_end)`, accounting for +/// columns occupied by a `row_span` (vMerge) cell from an earlier row. +/// +/// Walks rows top-to-bottom, left-to-right: each cell takes the next column not +/// already covered by a vertical merge from above, then occupies `col_span` +/// columns. A cell with `row_span > 1` marks its columns covered in the rows it +/// spans, so cells there skip those columns. Mirrors the OOXML/HTML table grid. +fn assign_cell_columns( + rows: &[&loki_doc_model::content::table::row::Row], + col_count: usize, +) -> Vec> { + let mut covered = vec![vec![false; col_count]; rows.len()]; + let mut cell_cols: Vec> = Vec::with_capacity(rows.len()); + for (row_idx, row) in rows.iter().enumerate() { + let mut col = 0usize; + let mut row_cols = Vec::with_capacity(row.cells.len()); + for cell in &row.cells { + while col < col_count && covered[row_idx][col] { + col += 1; + } + let col_start = col.min(col_count); + let col_end = (col_start + cell.col_span as usize).min(col_count); + row_cols.push((col_start, col_end)); + if cell.row_span > 1 { + let last = (row_idx + cell.row_span as usize).min(rows.len()); + for cov_row in covered.iter_mut().take(last).skip(row_idx + 1) { + cov_row[col_start..col_end].fill(true); + } + } + col = col_end; + } + cell_cols.push(row_cols); + } + cell_cols +} + fn flow_table( state: &mut FlowState, tbl: &loki_doc_model::content::table::core::Table, @@ -1341,13 +1377,19 @@ fn flow_table( } rows.extend(&tbl.foot.rows); + // Assign each cell its grid columns, accounting for columns covered by a + // `row_span` (vMerge) cell from an earlier row. `cell_cols[row][cell] = + // (col_start, col_end)`. Without this, a cell in a row whose leading + // column is occupied by a vertical merge above would be placed too far + // left (overlapping the merged cell) — the TC-DOCX-005 L-merge bug. + let cell_cols = assign_cell_columns(&rows, col_widths.len()); + let mut row_heights = vec![0.0f32; rows.len()]; // Pass 1: Measure all cells with row_span == 1 for (row_idx, row) in rows.iter().enumerate() { - let mut col_start = 0; - for cell in &row.cells { - let col_end = (col_start + cell.col_span as usize).min(col_widths.len()); + for (c_idx, cell) in row.cells.iter().enumerate() { + let (col_start, col_end) = cell_cols[row_idx][c_idx]; if cell.row_span == 1 { let pad_left = cell.props.padding_left.map(pts_to_f32).unwrap_or(0.0); let pad_right = cell.props.padding_right.map(pts_to_f32).unwrap_or(0.0); @@ -1364,16 +1406,14 @@ fn flow_table( ); row_heights[row_idx] = row_heights[row_idx].max(h); } - col_start = col_end; } row_heights[row_idx] = row_heights[row_idx].max(crate::MIN_ROW_HEIGHT); } // Pass 2: Distribute spanning cell heights across spanned rows for (row_idx, row) in rows.iter().enumerate() { - let mut col_start = 0; - for cell in &row.cells { - let col_end = (col_start + cell.col_span as usize).min(col_widths.len()); + for (c_idx, cell) in row.cells.iter().enumerate() { + let (col_start, col_end) = cell_cols[row_idx][c_idx]; if cell.row_span > 1 { let span = cell.row_span as usize; let spanned_height: f32 = row_heights @@ -1399,7 +1439,6 @@ fn flow_table( row_heights[last] += extra; } } - col_start = col_end; } } @@ -1425,9 +1464,8 @@ fn flow_table( let mut cell_starts = Vec::new(); // Pass 3a: Flow cell content blocks - let mut col_start = 0; for (c_idx, cell) in row.cells.iter().enumerate() { - let col_end = (col_start + cell.col_span as usize).min(col_widths.len()); + let (col_start, col_end) = cell_cols[row_idx][c_idx]; let old_indent = state.current_indent; let old_width = state.content_width; @@ -1550,7 +1588,6 @@ fn flow_table( state.current_indent = old_indent; state.content_width = old_width; - col_start = col_end; } let row_page_end = state.page_number; @@ -1601,15 +1638,6 @@ fn flow_table( // Pass 3b: Emit background and border decorations for this row's cells for p in original_row_page..=row_page_end { - let mut col_start_map = Vec::new(); - { - let mut curr_col = 0; - for cell in &row.cells { - col_start_map.push(curr_col); - curr_col = (curr_col + cell.col_span as usize).min(col_widths.len()); - } - } - for (c_idx, cell) in row.cells.iter().enumerate().rev() { let cell_page_start = cell_starts[c_idx].0; let cell_item_start = cell_starts[c_idx].1; @@ -1633,8 +1661,7 @@ fn flow_table( } let y = get_cell_y_on_page(p); - let col_start = col_start_map[c_idx]; - let col_end = (col_start + cell.col_span as usize).min(col_widths.len()); + let (col_start, col_end) = cell_cols[row_idx][c_idx]; let cell_w: f32 = col_widths[col_start..col_end].iter().sum(); let cell_x = table_indent + col_widths[0..col_start].iter().sum::(); let cell_rect = LayoutRect { diff --git a/loki-layout/tests/table_tests.rs b/loki-layout/tests/table_tests.rs index f4cf19e9..68313a02 100644 --- a/loki-layout/tests/table_tests.rs +++ b/loki-layout/tests/table_tests.rs @@ -193,10 +193,16 @@ fn test_table_row_span_distribution() { .iter() .find(|r| r.rect.x() > 100.0 && r.rect.y() < 1.0) .unwrap(); + // c11 is in row 1, where column 0 is covered by c00's vertical span, so it + // must be placed in column 1 (x > 100) — not overlapping the merged cell. let rect_c11 = bg_rects .iter() - .find(|r| r.rect.x() < 100.0 && r.rect.y() > 1.0) + .find(|r| r.rect.x() > 100.0 && r.rect.y() > 1.0) .unwrap(); + assert!( + (rect_c11.rect.x() - rect_c01.rect.x()).abs() < 1e-3, + "c11 must sit in the same column as c01 (column 1), not under the vMerge cell" + ); let h_c00 = rect_c00.rect.height(); let h_c01 = rect_c01.rect.height(); @@ -468,6 +474,90 @@ fn fixed_columns_underflowing_table_width_are_scaled_up_current_behavior() { } } +/// TC-DOCX-003/004/005 L-merge: row 1 has a `vMerge`-restart cell spanning two +/// rows in column 0; the row below has a `gridSpan=2` cell that must land in +/// columns 1–2 (the covered column 0 is skipped), and the merged cell must +/// extend down beside it. Pins the covered-column grid fix. +#[test] +fn vmerge_gridspan_l_merge_places_cells_correctly() { + use loki_doc_model::content::table::col::TableWidth; + use loki_primitives::units::Points; + let mut r = test_resources(); + let bg = Some(DocumentColor::Rgb(appthere_color::RgbColor::new( + 0.5, 0.5, 0.5, + ))); + let mut header = make_cell_tall(vec!["Header"], bg.clone(), 1); + header.col_span = 3; + let a = make_cell_tall(vec!["A"], bg.clone(), 2); // vMerge restart, spans 2 rows + let b2 = make_cell_tall(vec!["B2"], bg.clone(), 1); + let c2 = make_cell_tall(vec!["C2"], bg.clone(), 1); + let mut bc = make_cell_tall(vec!["B3C3"], bg.clone(), 1); // gridSpan=2; continue cell dropped on import + bc.col_span = 2; + + let table = Block::Table(Box::new(Table { + attr: Default::default(), + caption: Default::default(), + width: Some(TableWidth::Fixed(300.0)), + col_specs: (0..3) + .map(|_| ColSpec { + alignment: ColAlignment::Default, + width: ColWidth::Fixed(Points::new(100.0)), + }) + .collect(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![ + Row::new(vec![header]), + Row::new(vec![a, b2, c2]), + Row::new(vec![bc]), + ])], + foot: TableFoot::empty(), + })); + let section = Section { + layout: PageLayout::default(), + blocks: vec![table], + extensions: ExtensionBag::default(), + }; + let (items, _) = flow_pageless(&mut r, §ion); + let rects: Vec<_> = items + .iter() + .filter_map(|i| match i { + PositionedItem::FilledRect(rect) => Some(rect.rect), + _ => None, + }) + .collect(); + assert_eq!(rects.len(), 5, "5 cell backgrounds (continue cell dropped)"); + + let header = rects + .iter() + .max_by(|a, b| a.width().partial_cmp(&b.width()).unwrap()) + .unwrap(); + let b3c3 = rects + .iter() + .max_by(|a, b| a.y().partial_cmp(&b.y()).unwrap()) + .unwrap(); + // The gridSpan cell skips the vMerge-covered column 0 → starts at column 1. + assert!( + (b3c3.x() - header.x() - 100.0).abs() < 1.0, + "B3C3 must start at column 1 (header.x + 100); got x={} vs header.x={}", + b3c3.x(), + header.x() + ); + assert!( + (b3c3.width() - 200.0).abs() < 1.0, + "B3C3 spans columns 1-2 (200pt); got {}", + b3c3.width() + ); + // The merged cell (column 0, below the header) extends down beside B3C3. + let a = rects + .iter() + .find(|r| (r.x() - header.x()).abs() < 1.0 && r.y() > header.y() + 1.0) + .expect("merged cell A in column 0"); + assert!( + (a.y() + a.height() - (b3c3.y() + b3c3.height())).abs() < 1.0, + "the vMerge cell must extend to the bottom of the spanned rows" + ); +} + /// A long unbreakable word in a narrow fixed-width 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 From 3b83f849683f735e207dc4874410fe4d1e564ba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 11:44:53 +0000 Subject: [PATCH 10/35] feat(tables): honour w:tblLayout="fixed" grid column widths Word's fixed table layout keeps grid (gridCol) column widths exactly, letting the table over/underflow the declared width, whereas autofit rescales them to fit. Loki previously always rescaled. - Parse `w:tblLayout @w:type` into `DocxTblPr::layout` (reader). - Carry "fixed" into the model as the `table-fixed-layout` class (`TABLE_FIXED_LAYOUT_CLASS`) on the table's `NodeAttr` (mapper). - `resolve_column_widths` skips the autofit rescale when the class is present, honouring fixed grid widths exactly (layout). Verified end-to-end on ACID TC-DOCX-006: the narrow fixed column stays narrow (its long unbreakable word char-wraps within it) instead of growing to fit. Tests: fixed_columns_should_be_honored_like_word, tbl_layout_fixed_marks_table_class, tbl_layout_autofit_has_no_fixed_class. Also fixes a pre-existing clippy field_reassign_with_default in loki-doc-model section.rs test code that blocked the --all-targets gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-doc-model/src/content/table/core.rs | 7 ++ loki-doc-model/src/layout/section.rs | 12 ++-- loki-layout/src/flow.rs | 16 ++++- loki-layout/tests/table_tests.rs | 9 ++- loki-ooxml/src/docx/mapper/table.rs | 14 +++- loki-ooxml/src/docx/mapper/table_tests.rs | 78 +++++++++++++++++++++++ loki-ooxml/src/docx/model/styles.rs | 2 + loki-ooxml/src/docx/reader/document.rs | 3 + 9 files changed, 132 insertions(+), 11 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index b58368a3..3db60703 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -71,7 +71,7 @@ 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`. Known gap: `w:tblLayout` (fixed vs autofit) is still not parsed, so when explicit fixed widths sum to more/less than the table width Loki rescales them rather than honouring them exactly (characterised by `fixed_columns_*_current_behavior`); cell content is not yet clipped to the cell box (relies on wrapping to avoid overflow). | +| **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`. Known gap: cell content is not yet hard-clipped to the cell box (relies on wrapping to avoid overflow). | | **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`. | diff --git a/loki-doc-model/src/content/table/core.rs b/loki-doc-model/src/content/table/core.rs index 37e50ae8..bc8c789d 100644 --- a/loki-doc-model/src/content/table/core.rs +++ b/loki-doc-model/src/content/table/core.rs @@ -12,6 +12,13 @@ use crate::content::inline::Inline; use crate::content::table::col::{ColSpec, TableWidth}; use crate::content::table::row::Row; +/// Class on a [`Table`]'s [`NodeAttr`] marking it as **fixed layout** — column +/// widths come from the grid (`w:tblGrid`/gridCol) and are honoured exactly, +/// even when they sum to more or less than the table width (the table then +/// overflows or underfills). OOXML `w:tblLayout w:type="fixed"`. Absent ⇒ +/// autofit: columns are resized to fit the table width. +pub const TABLE_FIXED_LAYOUT_CLASS: &str = "table-fixed-layout"; + /// The caption of a table. /// /// Modelled on pandoc's `Caption = (Maybe ShortCaption, [Block])`. diff --git a/loki-doc-model/src/layout/section.rs b/loki-doc-model/src/layout/section.rs index 8a5b3177..b157aa22 100644 --- a/loki-doc-model/src/layout/section.rs +++ b/loki-doc-model/src/layout/section.rs @@ -90,10 +90,14 @@ mod tests { #[test] fn two_sections_with_different_page_sizes() { - let mut layout_a = PageLayout::default(); - layout_a.page_size = PageSize::a4(); - let mut layout_b = PageLayout::default(); - layout_b.page_size = PageSize::letter(); + let layout_a = PageLayout { + page_size: PageSize::a4(), + ..Default::default() + }; + let layout_b = PageLayout { + page_size: PageSize::letter(), + ..Default::default() + }; let s1 = Section::with_layout_and_blocks(layout_a, vec![]); let s2 = Section::with_layout_and_blocks(layout_b, vec![]); diff --git a/loki-layout/src/flow.rs b/loki-layout/src/flow.rs index d2c91cc0..b7c1f279 100644 --- a/loki-layout/src/flow.rs +++ b/loki-layout/src/flow.rs @@ -1255,9 +1255,19 @@ fn resolve_column_widths( } } } else if total_fixed_width > 0.0 { - let scale = table_width / total_fixed_width; - for w in &mut resolved_widths { - *w *= scale; + // Fixed-layout tables (`w:tblLayout="fixed"`) honour the grid widths + // exactly — the table overflows or underfills rather than rescaling. + // Autofit tables scale the fixed widths to fill the table width. + let fixed_layout = tbl + .attr + .classes + .iter() + .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; + } } } else { let uniform_w = table_width / col_count as f32; diff --git a/loki-layout/tests/table_tests.rs b/loki-layout/tests/table_tests.rs index 68313a02..228cfd08 100644 --- a/loki-layout/tests/table_tests.rs +++ b/loki-layout/tests/table_tests.rs @@ -429,10 +429,15 @@ fn fixed_columns_overflowing_table_width_are_scaled_down_current_behavior() { /// `loki-ooxml` → `loki-doc-model` → `loki-layout`. Remove `#[ignore]` once the /// fixed-layout path exists. #[test] -#[ignore = "needs w:tblLayout plumbing; see fidelity-status Tables & Images"] fn fixed_columns_should_be_honored_like_word() { let mut r = test_resources(); - let table = fixed_width_table(&[200.0, 200.0, 200.0], 300.0); + let mut table = fixed_width_table(&[200.0, 200.0, 200.0], 300.0); + // Mark the table as fixed-layout (OOXML `w:tblLayout w:type="fixed"`). + 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 { layout: PageLayout::default(), blocks: vec![table], diff --git a/loki-ooxml/src/docx/mapper/table.rs b/loki-ooxml/src/docx/mapper/table.rs index 2bca6c11..2d7ea7c9 100644 --- a/loki-ooxml/src/docx/mapper/table.rs +++ b/loki-ooxml/src/docx/mapper/table.rs @@ -85,8 +85,20 @@ pub(crate) fn map_table(t: &DocxTableModel, ctx: &mut MappingContext<'_>) -> Blo let width = map_tbl_width(t); + // OOXML `w:tblLayout w:type="fixed"` → honour grid column widths exactly + // (no autofit rescale). Mark the table so loki-layout skips its rescale. + let mut attr = NodeAttr::default(); + if t.tbl_pr + .as_ref() + .and_then(|p| p.layout.as_deref()) + .is_some_and(|l| l == "fixed") + { + attr.classes + .push(loki_doc_model::content::table::core::TABLE_FIXED_LAYOUT_CLASS.to_string()); + } + let table = Table { - attr: NodeAttr::default(), + attr, caption: TableCaption::default(), width, col_specs, diff --git a/loki-ooxml/src/docx/mapper/table_tests.rs b/loki-ooxml/src/docx/mapper/table_tests.rs index 03e437ef..d9acb9ce 100644 --- a/loki-ooxml/src/docx/mapper/table_tests.rs +++ b/loki-ooxml/src/docx/mapper/table_tests.rs @@ -108,6 +108,84 @@ fn two_by_two_table() { } } +#[test] +fn tbl_layout_fixed_marks_table_class() { + use crate::docx::model::styles::DocxTblPr; + use loki_doc_model::content::table::core::TABLE_FIXED_LAYOUT_CLASS; + + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let t = DocxTableModel { + tbl_pr: Some(DocxTblPr { + layout: Some("fixed".to_string()), + ..Default::default() + }), + col_widths: vec![1440, 1440], + rows: vec![simple_row(vec![simple_cell( + vec![DocxParagraph::default()], + )])], + }; + let block = map_table(&t, &mut ctx); + if let Block::Table(tbl) = block { + assert!( + tbl.attr + .classes + .iter() + .any(|c| c == TABLE_FIXED_LAYOUT_CLASS), + "fixed tblLayout must add the fixed-layout class" + ); + } else { + panic!("expected Table"); + } +} + +#[test] +fn tbl_layout_autofit_has_no_fixed_class() { + use crate::docx::model::styles::DocxTblPr; + use loki_doc_model::content::table::core::TABLE_FIXED_LAYOUT_CLASS; + + let styles = StyleCatalog::default(); + let (fn_m, en_m, hl_m, img_m) = ( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + let opts = DocxImportOptions::default(); + let mut ctx = make_ctx(&styles, &fn_m, &en_m, &hl_m, &img_m, &opts); + + let t = DocxTableModel { + tbl_pr: Some(DocxTblPr { + layout: Some("autofit".to_string()), + ..Default::default() + }), + col_widths: vec![1440], + rows: vec![simple_row(vec![simple_cell( + vec![DocxParagraph::default()], + )])], + }; + let block = map_table(&t, &mut ctx); + if let Block::Table(tbl) = block { + assert!( + !tbl.attr + .classes + .iter() + .any(|c| c == TABLE_FIXED_LAYOUT_CLASS), + "autofit tblLayout must NOT add the fixed-layout class" + ); + } else { + panic!("expected Table"); + } +} + #[test] fn header_row_goes_to_head() { let styles = StyleCatalog::default(); diff --git a/loki-ooxml/src/docx/model/styles.rs b/loki-ooxml/src/docx/model/styles.rs index 1c05621e..cf5dd0ec 100644 --- a/loki-ooxml/src/docx/model/styles.rs +++ b/loki-ooxml/src/docx/model/styles.rs @@ -79,6 +79,8 @@ pub struct DocxTblPr { pub style_id: Option, /// Table width from `w:tblW`. pub width: Option, + /// `w:tblLayout @w:type` — `"fixed"` or `"autofit"` (the default). + pub layout: Option, } /// Table width specification from `w:tblW` (ECMA-376 §17.4.63). diff --git a/loki-ooxml/src/docx/reader/document.rs b/loki-ooxml/src/docx/reader/document.rs index 9a010157..16ec45a8 100644 --- a/loki-ooxml/src/docx/reader/document.rs +++ b/loki-ooxml/src/docx/reader/document.rs @@ -844,6 +844,9 @@ fn parse_tbl_pr(reader: &mut Reader<&[u8]>) -> OoxmlResult { b"tblStyle" => { pr.style_id = attr_val(e, b"val"); } + b"tblLayout" => { + pr.layout = attr_val(e, b"type"); + } _ => {} }, Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"tblPr" => { From 6996c5c14fc67ba2d5eca5ca2ced5485add57792 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 11:58:57 +0000 Subject: [PATCH 11/35] feat(tables): clip cell content to the cell box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word clips a table cell's content to the cell boundary; over-wide content (a wide image, or an unbreakable token that exceeds a fixed column) is masked rather than bleeding into the neighbouring cell. Loki previously over-painted — char-wrapping kept ordinary text in bounds, but nothing constrained the rest. - Layout: each single-page cell's flowed content is wrapped in a `PositionedItem::ClippedGroup` whose rect is the cell box (`flow.rs`). Backgrounds/borders are emitted outside the clip so they paint fully. - PDF: `ClippedGroup` now emits a real clip path (`re W n` inside `q`/`Q`) instead of the previous over-paint TODO (`page.rs`). `loki-vello` already pushed a Vello clip layer for these groups. Continuation pages of a cell that spills across a page boundary are not yet clipped (single-page cells only); documented as a known gap. Tests: cell_content_is_clipped_to_cell_box (layout), clipped_group_emits_clip_operators (PDF). Existing table/flow tests that scanned top-level glyph runs now recurse into clip/rotation groups via shared `flatten`/`any_glyph_run` helpers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-layout/src/flow.rs | 33 ++++++++++++ loki-layout/src/flow_tests.rs | 18 +++++-- loki-layout/tests/table_tests.rs | 90 ++++++++++++++++++++++++++++++-- loki-pdf/src/page.rs | 63 ++++++++++++++++++++-- 5 files changed, 195 insertions(+), 11 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 3db60703..80d1ec7a 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -71,7 +71,7 @@ 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`. Known gap: cell content is not yet hard-clipped to the cell box (relies on wrapping to avoid overflow). | +| **Column Widths** | Yes | Yes | Yes | Mapped using `ColWidth` and `TableWidth` specs. **Cell content that does not fit a column wraps within it** — an over-long unbreakable word breaks to the column width (CSS `overflow-wrap: anywhere`, set on cell paragraphs via `FlowState::break_long_words`), so the row grows tall like Word instead of the word overflowing horizontally into the next cell (TC-DOCX-006). Tested by `long_word_wraps_within_narrow_cell`. **Fixed vs autofit layout is honoured:** `w:tblLayout w:type="fixed"` is parsed (`DocxTblPr::layout`) and carried into the model as the `table-fixed-layout` class (`TABLE_FIXED_LAYOUT_CLASS`); for such tables `resolve_column_widths` (`flow.rs`) honours the grid (`gridCol`) widths exactly — the table over/underflows rather than rescaling — matching Word (TC-DOCX-006). Autofit tables (the default) still rescale fixed widths proportionally to the table width. Tested by `fixed_columns_should_be_honored_like_word` (layout) and `tbl_layout_fixed_marks_table_class` / `tbl_layout_autofit_has_no_fixed_class` (DOCX mapper); the prior rescale behaviour for autofit is locked by `fixed_columns_*_current_behavior`. **Cell content is hard-clipped to the cell box:** each cell's flowed content is wrapped in a `PositionedItem::ClippedGroup` whose rect is the cell box (`flow.rs`), so over-wide content (a wide image, an unbreakable token exceeding a fixed column) is masked at the cell boundary rather than bleeding into neighbours — matching Word. Both renderers honour the clip: `loki-vello` pushes a Vello clip layer; `loki-pdf` emits `re W n` inside `q`/`Q`. Cell backgrounds/borders stay outside the clip so they paint fully. Tested by `cell_content_is_clipped_to_cell_box` (layout) and `clipped_group_emits_clip_operators` (PDF). Known gap: a cell whose content spills onto a later page is not clipped on the continuation page (single-page cells only — per-page rects deferred). | | **Row Heights** | Yes | Yes | Yes | Evaluated dynamically based on cell content. | | **Column Spanning** | Yes | Yes | Yes | Supports horizontal cell merging (`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`. | diff --git a/loki-layout/src/flow.rs b/loki-layout/src/flow.rs index b7c1f279..8fc616ec 100644 --- a/loki-layout/src/flow.rs +++ b/loki-layout/src/flow.rs @@ -1587,6 +1587,39 @@ fn flow_table( item.translate(0.0, y_offset); } } + + // Clip the cell's content to its box so over-wide content + // (a wide image, or an unbreakable token exceeding the + // column) cannot bleed into neighbouring cells — Word clips + // cell content to the cell boundary. Char-wrapping already + // keeps ordinary text inside the column; this is the safety + // net for the rest. Only single-page cells are clipped here; + // a cell that spilled onto a later page keeps its items + // unwrapped (per-page clipping would need a rect per page — + // see fidelity-status Tables & Images). + if state.current_items.len() > cell_item_start { + let cell_top_y = if state.page_number == original_row_page { + original_row_y_start + } else { + 0.0 + }; + let clip_rect = LayoutRect { + origin: LayoutPoint { + x: cell_x, + y: cell_top_y, + }, + size: LayoutSize { + width: cell_w, + height: cell_height, + }, + }; + let inner: Vec = + state.current_items.drain(cell_item_start..).collect(); + state.current_items.push(PositionedItem::ClippedGroup { + clip_rect, + items: inner, + }); + } } Vec::new() diff --git a/loki-layout/src/flow_tests.rs b/loki-layout/src/flow_tests.rs index 53ae987c..f07a42b1 100644 --- a/loki-layout/src/flow_tests.rs +++ b/loki-layout/src/flow_tests.rs @@ -453,6 +453,19 @@ fn has_clipped_group(items: &[PositionedItem]) -> bool { .any(|i| matches!(i, PositionedItem::ClippedGroup { .. })) } +/// Recursively checks for any glyph run, descending into clip/rotation groups. +/// Table cell content is wrapped in a per-cell `ClippedGroup`, so a flat scan +/// would miss it. +fn any_glyph_run(items: &[PositionedItem]) -> bool { + items.iter().any(|i| match i { + PositionedItem::GlyphRun(_) => true, + PositionedItem::ClippedGroup { items, .. } | PositionedItem::RotatedGroup { items, .. } => { + any_glyph_run(items) + } + _ => false, + }) +} + // ── Paragraph splitting tests ───────────────────────────────────────────────── #[test] @@ -1080,10 +1093,7 @@ fn table_2x2_renders_on_one_page() { "2×2 table should fit on one page, got {}", pages.len() ); - let has_runs = pages[0] - .content_items - .iter() - .any(|i| matches!(i, PositionedItem::GlyphRun(_))); + let has_runs = any_glyph_run(&pages[0].content_items); assert!(has_runs, "table cells must produce glyph runs"); } diff --git a/loki-layout/tests/table_tests.rs b/loki-layout/tests/table_tests.rs index 228cfd08..ec53f3ee 100644 --- a/loki-layout/tests/table_tests.rs +++ b/loki-layout/tests/table_tests.rs @@ -16,6 +16,20 @@ use loki_layout::{ FlowOutput, FontResources, LayoutMode, LayoutOptions, PositionedItem, flow_section, }; +/// Flatten positioned items, descending into `ClippedGroup`/`RotatedGroup` so +/// nested cell content is visible to assertions. Table cell content is wrapped +/// in a per-cell `ClippedGroup` (cell-box clip), so a flat scan of the +/// top-level items would otherwise miss every glyph run inside a cell. +fn flatten<'a>(items: &'a [PositionedItem], out: &mut Vec<&'a PositionedItem>) { + for i in items { + match i { + PositionedItem::ClippedGroup { items, .. } + | PositionedItem::RotatedGroup { items, .. } => flatten(items, out), + other => out.push(other), + } + } +} + fn test_resources() -> FontResources { let mut r = FontResources::new(); for p in ["/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"] { @@ -454,6 +468,72 @@ fn fixed_columns_should_be_honored_like_word() { } } +/// Cell content must be wrapped in a [`PositionedItem::ClippedGroup`] whose +/// clip rect is the cell's box, so over-wide content cannot bleed into a +/// neighbouring cell (Word clips cell content to the cell boundary). The cell +/// background/border stay *outside* the clip (top-level), so they still paint +/// fully. +#[test] +fn cell_content_is_clipped_to_cell_box() { + use loki_doc_model::content::table::col::TableWidth; + use loki_primitives::units::Points; + let mut r = test_resources(); + let cell = make_cell_tall(vec!["Hello"], None, 1); + let table = Block::Table(Box::new(Table { + attr: loki_doc_model::content::attr::NodeAttr::default(), + caption: Default::default(), + width: Some(TableWidth::Fixed(120.0)), + col_specs: vec![ColSpec { + alignment: ColAlignment::Default, + width: ColWidth::Fixed(Points::new(120.0)), + }], + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![cell])])], + foot: TableFoot::empty(), + })); + let section = Section { + layout: PageLayout::default(), + blocks: vec![table], + extensions: ExtensionBag::default(), + }; + let (items, _) = flow_pageless(&mut r, §ion); + + // Exactly one ClippedGroup wraps the cell's content, and it contains the + // glyph run(s) — no glyph run leaks to the top level. + let clip = items + .iter() + .find_map(|i| match i { + PositionedItem::ClippedGroup { clip_rect, items } => Some((clip_rect, items)), + _ => None, + }) + .expect("cell content must be wrapped in a ClippedGroup"); + assert!( + items + .iter() + .all(|i| !matches!(i, PositionedItem::GlyphRun(_))), + "no glyph run may sit at the top level outside the cell clip" + ); + let (clip_rect, clipped) = clip; + assert!( + clipped + .iter() + .any(|i| matches!(i, PositionedItem::GlyphRun(_))), + "the cell's glyph run must be inside the ClippedGroup" + ); + // The clip rect spans the 120pt column (width), positioned at the left + // margin (72pt) in pageless layout. + assert!( + (clip_rect.size.width - 120.0).abs() < 1.0, + "clip width should match the 120pt cell; got {}", + clip_rect.size.width + ); + assert!( + clip_rect.size.height > 0.0, + "clip height must be the row height; got {}", + clip_rect.size.height + ); +} + /// CHARACTERIZATION — fixed widths that sum to *less* than the table width are /// also currently scaled (up) to fill the table. Word's behaviour depends on /// `tblLayout` (fixed: leave a gap; autofit: distribute), so this too is a @@ -594,11 +674,13 @@ fn long_word_wraps_within_narrow_cell() { extensions: ExtensionBag::default(), }; let (items, height) = flow_pageless(&mut r, §ion); + let mut flat = Vec::new(); + flatten(&items, &mut flat); // Each wrapped line's width must fit the 60pt column (small tolerance), i.e. // no line overflows horizontally into a neighbouring cell. (Content sits at // the page's left-margin offset, so width — not absolute x — is the check.) - let max_line_width = items + let max_line_width = flat .iter() .filter_map(|i| match i { PositionedItem::GlyphRun(run) => { @@ -618,7 +700,7 @@ fn long_word_wraps_within_narrow_cell() { ); // Multiple glyph runs on distinct baselines confirm the word actually wrapped. let distinct_lines = { - let mut ys: Vec = items + let mut ys: Vec = flat .iter() .filter_map(|i| match i { PositionedItem::GlyphRun(run) => Some((run.origin.y * 4.0).round()), @@ -684,8 +766,10 @@ fn test_table_cell_vertical_alignment() { }; let (items, _) = flow_pageless(&mut r, §ion); + let mut flat = Vec::new(); + flatten(&items, &mut flat); - let glyph_runs: Vec<_> = items + let glyph_runs: Vec<_> = flat .iter() .filter_map(|i| match i { PositionedItem::GlyphRun(run) => Some(run), diff --git a/loki-pdf/src/page.rs b/loki-pdf/src/page.rs index 6697b82a..f660c3c1 100644 --- a/loki-pdf/src/page.rs +++ b/loki-pdf/src/page.rs @@ -61,12 +61,23 @@ fn render_item( 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), - PositionedItem::ClippedGroup { items, .. } => { - // TODO(pdf-clip): clipping is not yet emitted; render children so - // no content is dropped (over-paint is preferable to omission). + PositionedItem::ClippedGroup { clip_rect, items } => { + // Clip children to `clip_rect` (page-content-local coords). Used for + // page-fragment masks and table cell boxes so over-wide content does + // not bleed past its region — matching Word and the loki-vello + // on-screen renderer. PDF clips with `re W n`: define the rect, set + // it as the clip path, then end the path without painting it. + let x = ox + clip_rect.origin.x; + // PDF y-axis is bottom-up; flip the rect's top-left to bottom-left. + let y = page_h - (oy + clip_rect.origin.y + clip_rect.size.height); + content.save_state(); + content.rect(x, y, clip_rect.size.width, clip_rect.size.height); + content.clip_nonzero(); + content.end_path(); for child in items { render_item(child, page_h, ox, oy, banks, content); } + content.restore_state(); } PositionedItem::RotatedGroup { origin, items, .. } => { // TODO(pdf-rotate): rotation transform is not yet emitted; render @@ -243,6 +254,52 @@ mod tests { ); } + // A ClippedGroup must emit the PDF clip operators (`re` rect, `W` clip, + // `n` end-path) wrapped in save/restore, so table cell content is masked to + // the cell box rather than over-painting neighbours. + #[test] + fn clipped_group_emits_clip_operators() { + use loki_layout::{LayoutRect, LayoutSize, PositionedRect}; + let mut fonts = FontBank::new(); + let mut images = ImageBank::new(); + let mut banks = PageBanks { + fonts: &mut fonts, + images: &mut images, + }; + let mut content = Content::new(); + let child = PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect { + origin: LayoutPoint { x: 10.0, y: 10.0 }, + size: LayoutSize { + width: 5.0, + height: 5.0, + }, + }, + color: LayoutColor::new(0.0, 0.0, 0.0, 1.0), + }); + let group = PositionedItem::ClippedGroup { + clip_rect: LayoutRect { + origin: LayoutPoint { x: 0.0, y: 0.0 }, + size: LayoutSize { + width: 20.0, + height: 20.0, + }, + }, + items: vec![child], + }; + render_item(&group, 100.0, 0.0, 0.0, &mut banks, &mut content); + let bytes = content.finish().to_vec(); + let stream = String::from_utf8_lossy(&bytes); + // `re` (rect) + `W` (clip-nonzero) + `n` (end-path) define the clip path; + // `q`/`Q` bracket it so the clip is popped after the children paint. + assert!(stream.contains("re"), "clip rect operator `re` missing"); + assert!(stream.contains('W'), "clip operator `W` missing"); + assert!( + stream.contains('q') && stream.contains('Q'), + "save/restore (`q`/`Q`) missing" + ); + } + // A run mixing .notdef with real glyphs registers the face but excludes the // .notdef id from the subset (and never draws it). #[test] From 383cbdd7afb1feb968fc79e065fe7bb03f011603 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 12:59:15 +0000 Subject: [PATCH 12/35] feat(layout): render dropped initials (drop caps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop caps were imported but rendered inline at body size. They now render on the read-only/paint path (PDF/print/canvas): the leading initial is enlarged to span `lines` rows and the body text wraps to its right, matching Word (ACID TC-DOCX-015). - `para_drop_cap.rs` (new): extract the initial, shape it enlarged so its ascent spans the line band (coloured/weighted from the initial's run), and report the body inset + cap glyph items. - `layout_paragraph` (para.rs): when a paragraph carries a drop cap and qualifies (read-only, no tabs/math), trim the initial from the body, re-break the body at the narrowed width, shift the first `n_lines` lines into the cap band, and emit the enlarged initial. - `drop_cap_merge.rs` (new, DOCX import): Word encodes a drop cap as a `w:framePr` frame paragraph (just the initial) followed by the body paragraph. This pass folds the frame into the body so the single-paragraph (ODF-style) renderer applies to both formats. Limitations (documented in fidelity-status): - Editor path (`preserve_for_editing`) keeps the initial inline so hit-test indices stay aligned — follow-up. - APPROX(drop-cap-width): Parley exposes no public per-line max-advance (the setter is on its private breaker state), so body lines below the cap wrap at the narrowed width rather than reclaiming full measure. Tests: cap_byte_len_*, trim/merge unit tests, and layout integration tests (drop_cap_enlarges_initial_and_shifts_first_lines, drop_cap_inline_when_preserving_for_editing). Verified visually against the Word reference on ACID page 9. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-layout/src/lib.rs | 1 + loki-layout/src/para.rs | 101 ++++++- loki-layout/src/para_drop_cap.rs | 274 ++++++++++++++++++ loki-layout/src/para_drop_cap_tests.rs | 49 ++++ loki-layout/src/para_tests.rs | 111 ++++++- loki-layout/src/resolve.rs | 3 + loki-ooxml/src/docx/mapper/document.rs | 6 +- loki-ooxml/src/docx/mapper/drop_cap_merge.rs | 68 +++++ .../src/docx/mapper/drop_cap_merge_tests.rs | 100 +++++++ loki-ooxml/src/docx/mapper/mod.rs | 1 + 11 files changed, 710 insertions(+), 6 deletions(-) create mode 100644 loki-layout/src/para_drop_cap.rs create mode 100644 loki-layout/src/para_drop_cap_tests.rs create mode 100644 loki-ooxml/src/docx/mapper/drop_cap_merge.rs create mode 100644 loki-ooxml/src/docx/mapper/drop_cap_merge_tests.rs diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 80d1ec7a..aaee65d2 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -63,7 +63,7 @@ This is the living source of truth documenting which document features, characte | **Named / Custom Paragraph Styles** | Yes | Yes | Yes | The full style catalog round-trips through DOCX `styles.xml`: custom styles created in the style editor, plus edits to built-in `Normal`/`Heading1`–`6`, persist across save/reload. Font family, weight (→ bold), size, alignment, indentation (incl. first-line), spacing, line height, `basedOn`, `next`-style, outline level, and the custom flag are all written and read back. In-session, the catalog round-trips through the Loro CRDT (`loro_bridge::styles`, a JSON snapshot like metadata), so style-editor edits are durable across rebuilds and **undoable** with Ctrl+Z/Ctrl+Y. | | **border_between** | Yes | No | No | Rules between adjacent same-styled paragraphs ignored. | | **bidi / RTL** | Yes | No | No | RTL paragraphs ignored due to Parley API limitations. | -| **Drop Cap** | Yes | No | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Not yet rendered** — the initial is still laid out inline at body size; multi-line spanning + wrap-around is the follow-up. Not yet re-exported, and not round-tripped through the Loro CRDT. Tested: `frame_pr_*` (DOCX mapper), `read_stylesheet_drop_cap` + `drop_cap_*` (ODT reader/mapper). ACID TC-DOCX-015 / TC-ODT-013. | +| **Drop Cap** | Yes | Yes (paint) | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Rendered** on the read-only/paint path (PDF/print/canvas): the leading initial is enlarged to span `lines` rows and the body text wraps to its right (`para_drop_cap.rs` + `layout_paragraph`). Word encodes a drop cap as a `w:framePr` frame paragraph holding just the initial followed by the body paragraph; a DOCX import pass (`drop_cap_merge.rs`) folds the frame into the body so the single-paragraph (ODF-style) renderer applies. The cap is sized so its ascent spans the line band, coloured/weighted from the initial's run, positioned with its top at the first line and baseline near the last spanned line; `margin` mode hangs it in the left margin. **Limitations:** in the live editor (`preserve_for_editing`) the initial stays inline so hit-test indices remain aligned (follow-up); `APPROX(drop-cap-width)` — Parley exposes no public per-line max-advance, so body lines *below* the cap wrap at the narrowed width (ragged-right slightly early) rather than reclaiming full width. Not re-exported, not round-tripped through Loro. Tested: `frame_pr_*` + `drop_cap_merge` (DOCX), `read_stylesheet_drop_cap`/`drop_cap_*` (ODT), `cap_byte_len_*` + `drop_cap_enlarges_initial_and_shifts_first_lines` / `drop_cap_inline_when_preserving_for_editing` (layout). ACID TC-DOCX-015 / TC-ODT-013. | --- diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index 258ca7e2..b13b3e21 100644 --- a/loki-layout/src/lib.rs +++ b/loki-layout/src/lib.rs @@ -34,6 +34,7 @@ mod math; pub mod mode; pub mod para; mod para_cache; +mod para_drop_cap; pub mod resolve; pub mod result; diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index e8fb14cc..2b463f59 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -265,6 +265,12 @@ pub struct ResolvedParaProps { /// fixed column width (matching Word) instead of overflowing into the /// neighbouring cell. Normal body paragraphs leave this `false`. pub break_long_words: bool, + /// Dropped-initial specification, or `None`. When set (and the paragraph + /// qualifies — see [`layout_paragraph`]), the leading character(s) are + /// enlarged to span `lines` text rows with the body text flowing beside + /// them. Imported from OOXML `w:framePr`/`w:dropCap` and ODF + /// `style:drop-cap`. + pub drop_cap: Option, } impl Default for ResolvedParaProps { @@ -291,6 +297,7 @@ impl Default for ResolvedParaProps { list_marker: None, tab_stops: Vec::new(), break_long_words: false, + drop_cap: None, } } } @@ -1179,6 +1186,38 @@ fn layout_paragraph_uncached( ) }; + // ── Drop-cap preparation (read-only paint path only) ────────────────────── + // The dropped initial spans several lines, so it is removed from the body + // flow and rendered separately; the first `n_lines` body lines are narrowed + // and shifted to clear it. Gated to `!preserve_for_editing` because trimming + // the cap from the body text would desync editor hit-test indices; in the + // editor the initial keeps its inline form (follow-up). Tabs / inline math + // disqualify a paragraph (the cap's manual breaking is incompatible). + let drop_state: Option<( + loki_doc_model::style::props::drop_cap::DropCap, + String, + StyleSpan, + )> = para_props + .drop_cap + .filter(|_| !preserve_for_editing && tab_char_positions.is_empty() && math_boxes.is_empty()) + .and_then(|dc| { + let k = crate::para_drop_cap::cap_byte_len(&clean_text, dc.length); + if k == 0 || k >= clean_text.len() { + return None; // no initial, or no body text would remain + } + let base = clean_spans + .iter() + .find(|s| s.range.start == 0 && s.range.end > 0) + .or_else(|| clean_spans.first()) + .cloned()?; + let cap_text = clean_text[..k].to_string(); + let (body, body_spans) = + crate::para_drop_cap::trim_leading(&clean_text, &clean_spans, k); + clean_text = body; + clean_spans = body_spans; + Some((dc, cap_text, base)) + }); + // ── Main (final) layout pass ────────────────────────────────────────────── let mut builder = resources.layout_cx.ranged_builder( &mut resources.font_cx, @@ -1200,7 +1239,47 @@ fn layout_paragraph_uncached( push_math_inline_boxes(&mut builder, &math_boxes); let mut layout = builder.build(&clean_text); - layout.break_all_lines(Some(line_w)); + // For a drop-cap paragraph, plan the cap from the body's first-line metrics, + // then re-break narrowing the first `n_lines` lines by the cap region. + let drop_plan = if let Some((dc, cap_text, base)) = &drop_state { + layout.break_all_lines(Some(line_w)); // Pass A: read body line metrics. + let (lh, asc, bl) = layout + .lines() + .next() + .map(|l| { + let m = l.metrics(); + (m.line_height, m.ascent, m.baseline) + }) + .unwrap_or((0.0, 0.0, 0.0)); + let plan = crate::para_drop_cap::plan_drop_cap( + resources, + cap_text, + base, + dc, + lh, + bl, + asc, + display_scale, + ); + if let Some(p) = &plan { + // Re-break the whole body at the narrowed width so every line clears + // the cap band. The first `n_lines` are then shifted right into the + // band during emission; later lines stay at the left margin. + // + // APPROX(drop-cap-width): Parley's public API exposes no per-line + // max-advance (the setter is on the private breaker state), so lines + // *below* the cap also wrap at the narrowed width and lose + // `body_inset` of measure — they ragged-right slightly early rather + // than reclaiming full width as Word does. Text still stays within + // the margins. A true per-line solution needs upstream Parley API. + let narrow = (line_w - p.body_inset).max(1.0); + layout.break_all_lines(Some(narrow)); + } + plan + } else { + layout.break_all_lines(Some(line_w)); + None + }; layout.align(para_props.alignment, AlignmentOptions::default()); let total_height = layout.height(); @@ -1231,11 +1310,17 @@ fn layout_paragraph_uncached( for line in layout.lines() { // Hanging indent: the first line shifts left so the marker is visible to // the left of `indent_start`. Subsequent lines use the full `indent_start`. - let indent_x = if line_index == 0 && para_props.indent_hanging > 0.0 { + let mut indent_x = if line_index == 0 && para_props.indent_hanging > 0.0 { para_props.indent_start - para_props.indent_hanging } else { para_props.indent_start }; + // Drop cap: the first `n_lines` body lines are inset to clear the cap. + if let Some(p) = &drop_plan + && line_index < p.n_lines + { + indent_x += p.body_inset; + } let line_baseline = line.metrics().baseline; for item in line.items() { // Math inline box: emit the typeset equation's draw items, offset to @@ -1406,6 +1491,18 @@ fn layout_paragraph_uncached( line_index += 1; } + // Drop cap: emit the enlarged initial, shifted to the paragraph's left edge + // (the same `indent_start` the body glyphs use). Grow the paragraph height + // if the cap hangs below a short body. + if let Some(p) = &drop_plan { + for it in &p.items { + let mut it = it.clone(); + it.translate(para_props.indent_start, 0.0); + items.push(it); + } + content_bottom = content_bottom.max(p.bottom); + } + // Prepend border (below background so it renders on top). let has_border = para_props.border_top.is_some() || para_props.border_right.is_some() diff --git a/loki-layout/src/para_drop_cap.rs b/loki-layout/src/para_drop_cap.rs new file mode 100644 index 00000000..2f468908 --- /dev/null +++ b/loki-layout/src/para_drop_cap.rs @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Dropped-initial (drop cap) layout. +//! +//! A drop cap enlarges the first character(s) of a paragraph so the initial +//! spans several body lines, with the body text flowing beside it. This module +//! sizes and positions the enlarged initial and reports the horizontal region +//! the first lines of body text must avoid. The caller (`layout_paragraph`) +//! drives Parley's per-line breaker to narrow + shift those lines and appends +//! the cap glyph(s) produced here. +//! +//! OOXML `w:framePr`/`w:dropCap`; ODF `style:drop-cap`. See +//! [`loki_doc_model::style::props::drop_cap::DropCap`]. + +use parley::{ + AlignmentOptions, FontFamily, FontStyle, FontWeight, PositionedLayoutItem, StyleProperty, +}; + +use loki_doc_model::style::props::drop_cap::{DropCap, DropCapLength}; + +use crate::font::FontResources; +use crate::geometry::LayoutPoint; +use crate::items::{GlyphEntry, GlyphSynthesis, PositionedGlyphRun, PositionedItem}; +use crate::para::StyleSpan; + +/// The result of planning a dropped initial for one paragraph. +pub(crate) struct DropCapPlan { + /// Number of body lines the cap spans (`>= 1`). + pub n_lines: usize, + /// Horizontal inset (cap advance + distance), in points, that the first + /// `n_lines` body lines must leave clear on the left. `0.0` in margin mode + /// (the cap hangs in the margin and the body is not inset). + pub body_inset: f32, + /// Cap glyph draw items in paragraph-local space (the body's `indent_start` + /// is added by the caller, as for body glyph runs). + pub items: Vec, + /// Lowest `y` reached by the cap ink, for paragraph-height growth. + pub bottom: f32, +} + +/// Returns the byte length of the leading initial to enlarge, per `length`. +/// +/// `Chars(n)` takes the first `n` Unicode scalar values; `Word` takes up to the +/// first whitespace. Returns `0` when the paragraph has no usable initial. +pub(crate) fn cap_byte_len(text: &str, length: DropCapLength) -> usize { + let trimmed = text.trim_start(); + if trimmed.is_empty() { + return 0; + } + // Account for any leading whitespace skipped by `trim_start`. + let lead_ws = text.len() - trimmed.len(); + match length { + DropCapLength::Word => { + let word_len = trimmed + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(i, _)| i) + .unwrap_or(trimmed.len()); + lead_ws + word_len + } + DropCapLength::Chars(n) => { + let n = n.max(1) as usize; + let end = trimmed + .char_indices() + .nth(n) + .map(|(i, _)| i) + .unwrap_or(trimmed.len()); + lead_ws + end + } + // Unknown future length kinds degenerate to a single character. + _ => lead_ws + trimmed.chars().next().map(char::len_utf8).unwrap_or(0), + } +} + +/// Removes the leading `k` bytes (the extracted initial) from `text` and shifts +/// `spans` to match, dropping spans that lay wholly within the removed prefix +/// and clamping any that straddle it. `k` must be a char boundary. +/// +/// Only used on the read-only paint path, where the original byte indices are +/// not needed for hit-testing (the Parley layout is not retained), so trimming +/// the body text is lossless for rendering purposes. +pub(crate) fn trim_leading(text: &str, spans: &[StyleSpan], k: usize) -> (String, Vec) { + let body = text[k..].to_string(); + let body_len = body.len(); + let spans = spans + .iter() + .filter_map(|s| { + if s.range.end <= k { + return None; // entirely within the removed initial + } + let start = s.range.start.saturating_sub(k).min(body_len); + let end = s.range.end.saturating_sub(k).min(body_len); + let mut s2 = s.clone(); + s2.range = start..end; + Some(s2) + }) + .collect(); + (body, spans) +} + +/// Plans the dropped initial: sizes the cap to span `dc.lines` rows, positions +/// it against the first body line, and returns its glyph items plus the body +/// inset. Returns `None` if the cap cannot be shaped (empty or zero advance). +/// +/// `body_line_height` is the body's line pitch; `first_baseline`/`first_ascent` +/// come from the body layout's first line. `cap_text` is the already-extracted +/// initial. `base` supplies the cap's font family / weight / style / colour. +#[allow(clippy::too_many_arguments)] +pub(crate) fn plan_drop_cap( + resources: &mut FontResources, + cap_text: &str, + base: &StyleSpan, + dc: &DropCap, + body_line_height: f32, + first_baseline: f32, + first_ascent: f32, + display_scale: f32, +) -> Option { + let n_lines = (dc.lines as usize).max(1); + let cap_text = cap_text.trim(); + if cap_text.is_empty() || body_line_height <= 0.0 { + return None; + } + + // Probe at a one-line size to measure the font's ascent ratio, then scale so + // the cap's ascent spans `n_lines` rows (Word sizes the initial to the line + // band it occupies). + let probe_size = body_line_height.max(1.0); + let probe = shape_cap(resources, cap_text, base, probe_size, display_scale)?; + if probe.ascent <= 0.0 { + return None; + } + let target_ascent = n_lines as f32 * body_line_height; + let cap_size = (probe_size * target_ascent / probe.ascent).max(1.0); + + let shaped = shape_cap(resources, cap_text, base, cap_size, display_scale)?; + if shaped.advance <= 0.0 { + return None; + } + + let distance = pts(dc.distance); + // Align the cap's top with the top of the first body line; its baseline then + // sits `cap_ascent` below that. (line0 top = first_baseline − first_ascent.) + let line0_top = first_baseline - first_ascent; + let cap_baseline = line0_top + shaped.ascent; + + // Margin mode: the cap hangs in the left margin and the body is not inset. + // Drop (in-text) mode: the body's first `n_lines` lines clear the cap. + let (cap_x, body_inset) = if dc.margin { + (-(shaped.advance + distance), 0.0) + } else { + (0.0, shaped.advance + distance) + }; + + let mut items = shaped.items; + for item in &mut items { + item.translate(cap_x, cap_baseline); + } + let bottom = cap_baseline + shaped.descent; + + Some(DropCapPlan { + n_lines, + body_inset, + items, + bottom, + }) +} + +/// A shaped cap: glyph items (baseline-relative, at x origin 0) plus metrics. +struct ShapedCap { + items: Vec, + advance: f32, + ascent: f32, + descent: f32, +} + +/// Shapes `cap_text` at `font_size` using `base`'s family/weight/style/colour, +/// returning glyph runs whose origin is the baseline at `(0, 0)`. +fn shape_cap( + resources: &mut FontResources, + cap_text: &str, + base: &StyleSpan, + font_size: f32, + display_scale: f32, +) -> Option { + let mut builder = + resources + .layout_cx + .ranged_builder(&mut resources.font_cx, cap_text, display_scale, true); + builder.push_default(StyleProperty::Brush(base.color)); + builder.push_default(StyleProperty::FontSize(font_size)); + if base.weight != 400 { + builder.push_default(StyleProperty::FontWeight(FontWeight::new( + base.weight as f32, + ))); + } + if base.italic { + builder.push_default(StyleProperty::FontStyle(FontStyle::Italic)); + } + if let Some(name) = &base.font_name { + builder.push_default(StyleProperty::FontFamily(FontFamily::named(name.as_str()))); + } + let mut layout = builder.build(cap_text); + layout.break_all_lines(None); + layout.align(parley::Alignment::Start, AlignmentOptions::default()); + + let line = layout.lines().next()?; + let ascent = line.metrics().ascent; + let descent = line.metrics().descent; + + let mut items = Vec::new(); + let mut advance = 0.0f32; + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + let run = glyph_run.run(); + let run_offset = glyph_run.offset(); + let run_baseline = glyph_run.baseline(); + advance += glyph_run.advance(); + + let raw: &[u8] = run.font().data.data(); + let font_data = resources + .font_data_cache + .entry(raw.as_ptr() as u64) + .or_insert_with(|| std::sync::Arc::new(raw.to_vec())) + .clone(); + let synthesis = run.synthesis(); + let glyphs: Vec = glyph_run + .positioned_glyphs() + .map(|g| GlyphEntry { + id: g.id as u16, + x: g.x - run_offset, + y: g.y - run_baseline, + advance: g.advance, + }) + .collect(); + // Baseline-relative: the run's baseline sits at local y = 0 and its + // start at x = 0; `plan_drop_cap` then translates the whole cap to its + // target `(cap_x, cap_baseline)`. (Glyph x/y are already made relative + // to `run_offset`/`run_baseline` below.) + items.push(PositionedItem::GlyphRun(PositionedGlyphRun { + origin: LayoutPoint { x: 0.0, y: 0.0 }, + font_data, + font_index: run.font().index, + font_size: run.font_size(), + glyphs, + color: base.color, + synthesis: GlyphSynthesis { + bold: synthesis.embolden(), + italic: synthesis.skew().is_some(), + }, + link_url: None, + })); + } + if items.is_empty() { + return None; + } + Some(ShapedCap { + items, + advance, + ascent, + descent, + }) +} + +fn pts(p: loki_primitives::units::Points) -> f32 { + p.value() as f32 +} + +#[cfg(test)] +#[path = "para_drop_cap_tests.rs"] +mod tests; diff --git a/loki-layout/src/para_drop_cap_tests.rs b/loki-layout/src/para_drop_cap_tests.rs new file mode 100644 index 00000000..376a84ae --- /dev/null +++ b/loki-layout/src/para_drop_cap_tests.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for [`crate::para_drop_cap`]. + +use super::*; + +#[test] +fn cap_byte_len_single_char() { + assert_eq!(cap_byte_len("Hello world", DropCapLength::Chars(1)), 1); +} + +#[test] +fn cap_byte_len_multi_char() { + assert_eq!(cap_byte_len("Hello world", DropCapLength::Chars(3)), 3); +} + +#[test] +fn cap_byte_len_word_stops_at_space() { + assert_eq!(cap_byte_len("Hello world", DropCapLength::Word), 5); +} + +#[test] +fn cap_byte_len_word_whole_when_no_space() { + assert_eq!(cap_byte_len("Hello", DropCapLength::Word), 5); +} + +#[test] +fn cap_byte_len_skips_leading_whitespace() { + // Two leading spaces + 'H' → byte length covers the spaces and the char. + assert_eq!(cap_byte_len(" Hello", DropCapLength::Chars(1)), 3); +} + +#[test] +fn cap_byte_len_multibyte_initial() { + // 'É' is two bytes in UTF-8; one char must yield two bytes. + assert_eq!(cap_byte_len("École", DropCapLength::Chars(1)), 2); +} + +#[test] +fn cap_byte_len_empty_is_zero() { + assert_eq!(cap_byte_len("", DropCapLength::Chars(1)), 0); + assert_eq!(cap_byte_len(" ", DropCapLength::Word), 0); +} + +#[test] +fn cap_byte_len_chars_zero_treated_as_one() { + assert_eq!(cap_byte_len("Hello", DropCapLength::Chars(0)), 1); +} diff --git a/loki-layout/src/para_tests.rs b/loki-layout/src/para_tests.rs index 443a3d8c..b431494f 100644 --- a/loki-layout/src/para_tests.rs +++ b/loki-layout/src/para_tests.rs @@ -4,7 +4,7 @@ //! Unit tests for [`crate::para`]. use super::*; -use crate::items::{BorderStyle, PositionedItem}; +use crate::items::{BorderStyle, PositionedGlyphRun, PositionedItem}; use loki_doc_model::style::list_style::{ BulletChar, LabelAlignment, ListLevel, ListLevelKind, NumberingScheme, }; @@ -896,6 +896,115 @@ fn line_end_offset_excludes_trailing_newline() { ); } +#[test] +fn drop_cap_enlarges_initial_and_shifts_first_lines() { + use loki_doc_model::style::props::drop_cap::{DropCap, DropCapLength}; + + let mut r = test_resources(); + let text = "Hello world this is a longer paragraph that wraps across several lines so \ + we can exercise the dropped-initial rendering path with enough body text \ + to produce a number of distinct wrapped lines below the cap band."; + let spans = [single_span(text, 12.0)]; + let props = ResolvedParaProps { + drop_cap: Some(DropCap { + lines: 3, + length: DropCapLength::Chars(1), + distance: DocPoints::new(2.0), + margin: false, + }), + ..ResolvedParaProps::default() + }; + // Read-only (paint) path: drop caps render dropped only when !preserve. + let result = layout_paragraph(&mut r, text, &spans, &props, 300.0, 1.0, false); + + let runs: Vec<&PositionedGlyphRun> = result + .items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(g) => Some(g), + _ => None, + }) + .collect(); + assert!(!runs.is_empty(), "expected glyph runs"); + + // The cap is sized to span ~3 lines → far larger than the 12 pt body. + let max_size = runs.iter().map(|g| g.font_size).fold(0.0_f32, f32::max); + assert!( + max_size > 24.0, + "cap glyph should be enlarged to span 3 lines; max font_size = {max_size}" + ); + // Body text is retained at the original 12 pt. + assert!( + runs.iter().any(|g| (g.font_size - 12.0).abs() < 0.5), + "body text should remain at 12 pt" + ); + + // Body (12 pt) runs only. The first body line (smallest y) must be shifted + // right to clear the cap; a later line must sit back at the left margin. + let mut body: Vec<&PositionedGlyphRun> = runs + .iter() + .copied() + .filter(|g| (g.font_size - 12.0).abs() < 0.5) + .collect(); + body.sort_by(|a, b| a.origin.y.partial_cmp(&b.origin.y).unwrap()); + let first_y = body.first().unwrap().origin.y; + let last_y = body.last().unwrap().origin.y; + assert!(last_y > first_y, "body must wrap to multiple lines"); + + let first_line_min_x = body + .iter() + .filter(|g| (g.origin.y - first_y).abs() < 0.5) + .map(|g| g.origin.x) + .fold(f32::INFINITY, f32::min); + let last_line_min_x = body + .iter() + .filter(|g| (g.origin.y - last_y).abs() < 0.5) + .map(|g| g.origin.x) + .fold(f32::INFINITY, f32::min); + assert!( + first_line_min_x > 10.0, + "first body line must clear the cap band; min x = {first_line_min_x}" + ); + assert!( + last_line_min_x < first_line_min_x - 5.0, + "a line below the cap must return toward the left margin; \ + first = {first_line_min_x}, last = {last_line_min_x}" + ); +} + +#[test] +fn drop_cap_inline_when_preserving_for_editing() { + use loki_doc_model::style::props::drop_cap::{DropCap, DropCapLength}; + + let mut r = test_resources(); + let text = "Hello world this is body text that should stay inline in the editor."; + let spans = [single_span(text, 12.0)]; + let props = ResolvedParaProps { + drop_cap: Some(DropCap { + lines: 3, + length: DropCapLength::Chars(1), + distance: DocPoints::new(2.0), + margin: false, + }), + ..ResolvedParaProps::default() + }; + // Editing path: the cap stays inline (no enlarged glyph) so hit-test + // indices remain aligned with the source text. + let result = layout_paragraph(&mut r, text, &spans, &props, 300.0, 1.0, true); + let max_size = result + .items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(g) => Some(g.font_size), + _ => None, + }) + .fold(0.0_f32, f32::max); + assert!( + (max_size - 12.0).abs() < 0.5, + "editor path must keep the initial inline at body size; max = {max_size}" + ); +} + #[test] fn line_end_offset_read_only_returns_none() { let mut r = test_resources(); diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index f8e1b22d..59958bb7 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -901,6 +901,9 @@ fn map_para_props(p: &ParaProps) -> ResolvedParaProps { }, // Set by the flow engine for table-cell content; see ResolvedParaProps. break_long_words: false, + // Dropped initial (rendered in the read-only/paint path); see + // `layout_paragraph`. Forwarded straight from the imported model. + drop_cap: p.drop_cap, } } diff --git a/loki-ooxml/src/docx/mapper/document.rs b/loki-ooxml/src/docx/mapper/document.rs index 1f537f29..13e588a0 100644 --- a/loki-ooxml/src/docx/mapper/document.rs +++ b/loki-ooxml/src/docx/mapper/document.rs @@ -350,7 +350,9 @@ pub(crate) fn map_document( ); sections.push(Section { layout, - blocks: std::mem::take(&mut current_blocks), + blocks: super::drop_cap_merge::merge_drop_cap_frames(std::mem::take( + &mut current_blocks, + )), extensions: ExtensionBag::default(), }); } @@ -373,7 +375,7 @@ pub(crate) fn map_document( ); sections.push(Section { layout: final_layout, - blocks: current_blocks, + blocks: super::drop_cap_merge::merge_drop_cap_frames(current_blocks), extensions: ExtensionBag::default(), }); diff --git a/loki-ooxml/src/docx/mapper/drop_cap_merge.rs b/loki-ooxml/src/docx/mapper/drop_cap_merge.rs new file mode 100644 index 00000000..774eec65 --- /dev/null +++ b/loki-ooxml/src/docx/mapper/drop_cap_merge.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Drop-cap frame merging. +//! +//! Word encodes a dropped initial as **two** paragraphs: a framed paragraph +//! (`w:framePr w:dropCap`) containing just the enlarged initial, immediately +//! followed by the body paragraph, which wraps around the floated frame. +//! +//! Loki's layout models a drop cap as a single paragraph whose leading +//! character is enlarged (the ODF `style:drop-cap` model). This pass bridges +//! the two: it folds a drop-cap frame paragraph into the following body +//! paragraph — prepending the initial run(s) and moving the drop-cap property +//! onto the body — so the renderer's single-paragraph path applies. + +use loki_doc_model::content::block::Block; +use loki_doc_model::style::props::para_props::ParaProps; + +/// Merges drop-cap frame paragraphs into their following body paragraph. +/// +/// A block qualifies as a frame when it is a [`Block::StyledPara`] carrying a +/// `drop_cap` in its direct paragraph properties. When the next block is also a +/// styled paragraph, the two are merged; otherwise the frame is left untouched +/// (it then renders as a normal — if large — initial). +pub(crate) fn merge_drop_cap_frames(blocks: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(blocks.len()); + let mut iter = blocks.into_iter().peekable(); + + while let Some(block) = iter.next() { + let frame_dc = match &block { + Block::StyledPara(p) => p.direct_para_props.as_ref().and_then(|pp| pp.drop_cap), + _ => None, + }; + + if let Some(dc) = frame_dc { + // Only merge when a styled body paragraph follows the frame. + if matches!(iter.peek(), Some(Block::StyledPara(_))) { + let Block::StyledPara(frame) = block else { + unreachable!("frame_dc is Some only for StyledPara"); + }; + let Some(Block::StyledPara(mut body)) = iter.next() else { + unreachable!("peeked a StyledPara"); + }; + + // Prepend the frame's initial run(s) to the body text. + let mut inlines = frame.inlines; + inlines.append(&mut body.inlines); + body.inlines = inlines; + + // Move the drop-cap property onto the body paragraph. + let mut pp: ParaProps = body.direct_para_props.map(|b| *b).unwrap_or_default(); + pp.drop_cap = Some(dc); + body.direct_para_props = Some(Box::new(pp)); + + out.push(Block::StyledPara(body)); + continue; + } + } + + out.push(block); + } + + out +} + +#[cfg(test)] +#[path = "drop_cap_merge_tests.rs"] +mod tests; diff --git a/loki-ooxml/src/docx/mapper/drop_cap_merge_tests.rs b/loki-ooxml/src/docx/mapper/drop_cap_merge_tests.rs new file mode 100644 index 00000000..bf31ec77 --- /dev/null +++ b/loki-ooxml/src/docx/mapper/drop_cap_merge_tests.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for [`super::merge_drop_cap_frames`]. + +use super::*; +use loki_doc_model::content::block::StyledParagraph; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::style::props::drop_cap::{DropCap, DropCapLength}; + +fn para(text: &str, drop_cap: Option) -> Block { + let direct_para_props = drop_cap.map(|dc| { + Box::new(ParaProps { + drop_cap: Some(dc), + ..ParaProps::default() + }) + }); + Block::StyledPara(StyledParagraph { + style_id: None, + direct_para_props, + direct_char_props: None, + inlines: vec![Inline::Str(text.into())], + attr: Default::default(), + }) +} + +fn drop_cap() -> DropCap { + DropCap { + lines: 3, + length: DropCapLength::Chars(1), + distance: loki_primitives::units::Points::new(0.0), + margin: false, + } +} + +fn para_text(block: &Block) -> String { + let Block::StyledPara(p) = block else { + panic!("expected StyledPara"); + }; + p.inlines + .iter() + .map(|i| match i { + Inline::Str(s) => s.as_str(), + _ => "", + }) + .collect() +} + +#[test] +fn frame_merges_into_following_body() { + let blocks = vec![para("D", Some(drop_cap())), para("ropped initial.", None)]; + let out = merge_drop_cap_frames(blocks); + assert_eq!(out.len(), 1, "frame + body collapse into one paragraph"); + assert_eq!(para_text(&out[0]), "Dropped initial."); + // The drop cap moved onto the merged body paragraph. + let Block::StyledPara(p) = &out[0] else { + panic!("expected StyledPara"); + }; + assert_eq!( + p.direct_para_props.as_ref().and_then(|pp| pp.drop_cap), + Some(drop_cap()) + ); +} + +#[test] +fn frame_without_following_paragraph_is_kept() { + let blocks = vec![para("D", Some(drop_cap()))]; + let out = merge_drop_cap_frames(blocks); + assert_eq!(out.len(), 1, "lone frame is preserved"); + assert_eq!(para_text(&out[0]), "D"); +} + +#[test] +fn non_drop_cap_paragraphs_are_unchanged() { + let blocks = vec![para("First.", None), para("Second.", None)]; + let out = merge_drop_cap_frames(blocks); + assert_eq!(out.len(), 2); + assert_eq!(para_text(&out[0]), "First."); + assert_eq!(para_text(&out[1]), "Second."); +} + +#[test] +fn body_retains_its_own_paragraph_props() { + // Body has its own direct props (no drop cap); after merge it keeps them + // plus the injected drop cap. + let body = Block::StyledPara(StyledParagraph { + style_id: Some(loki_doc_model::style::catalog::StyleId::new("BodyStyle")), + direct_para_props: None, + direct_char_props: None, + inlines: vec![Inline::Str("ropped.".into())], + attr: Default::default(), + }); + let blocks = vec![para("D", Some(drop_cap())), body]; + let out = merge_drop_cap_frames(blocks); + let Block::StyledPara(p) = &out[0] else { + panic!("expected StyledPara"); + }; + assert_eq!(p.style_id.as_ref().map(|s| s.as_str()), Some("BodyStyle")); + assert!(p.direct_para_props.as_ref().unwrap().drop_cap.is_some()); +} diff --git a/loki-ooxml/src/docx/mapper/mod.rs b/loki-ooxml/src/docx/mapper/mod.rs index f801ca28..46ff8b9b 100644 --- a/loki-ooxml/src/docx/mapper/mod.rs +++ b/loki-ooxml/src/docx/mapper/mod.rs @@ -198,6 +198,7 @@ pub mod error; pub use error::MapperError; pub(crate) mod document; +pub(crate) mod drop_cap_merge; pub(crate) mod fields; pub(crate) mod images; pub(crate) mod inline; From fec7474f805b8dd2c815ed959a07f84c0271515c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 13:11:44 +0000 Subject: [PATCH 13/35] feat(layout): wrap text around floating images (gap #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floating (anchored) drawings were imported with their wrap mode but never laid out — all images stacked as full-width blocks above the text, and floats weren't placed beside text. They now wrap: text flows beside the float and the image is painted in the reserved band, matching the Word reference on ACID TC-DOCX-023. - `flow_float.rs` (new): plan a side band for a Square/Tight/Through (and non-behind None) float — float on the left when text occupies the right and vice-versa; Both/Largest default to a left float. Reserves the band via paragraph start/end indent and emits the image item. - `flow_para.rs`: plan the float before paragraph layout, widen the indent so the text wraps, remove it from the block-stacked set, then paint it and reserve its height so the next paragraph clears it. - `CollectedImage` now carries the `FloatWrap` read from the image's NodeAttr (`resolve.rs`). A margin-anchored `wrapNone` image that is not behind the text is treated as a side wrap because Word reserves its space (the ACID reference flows text beside, not under, it). Limitations (documented in fidelity-status, gap #12): v1 wraps only the float's anchoring paragraph and narrows every line of it uniformly (same Parley per-line-width limit as drop caps); cross-paragraph wrap, the tight contour, behindDoc overlap, and absolute anchor positioning are not yet done. TopAndBottom / behind-text floats still stack as blocks. Tests: flow_float unit tests (side selection, oversized skip, behind/ top-bottom skip, wrapNone-front wrap). Verified visually on ACID page 10. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-layout/src/flow.rs | 2 + loki-layout/src/flow_float.rs | 110 ++++++++++++++++++++++++ loki-layout/src/flow_float_tests.rs | 124 ++++++++++++++++++++++++++++ loki-layout/src/flow_para.rs | 21 ++++- loki-layout/src/resolve.rs | 5 ++ 6 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 loki-layout/src/flow_float.rs create mode 100644 loki-layout/src/flow_float_tests.rs diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index aaee65d2..a7eed0ec 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -77,7 +77,7 @@ This is the living source of truth documenting which document features, characte | **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). **Combined vMerge + gridSpan (L-merges) place correctly:** the layout assigns each cell its grid columns via a shared coverage grid (`assign_cell_columns`), so a cell in a row whose leading column is occupied by a vertical merge above is shifted into the next free column instead of overlapping the merged cell (TC-DOCX-005). Tested by `vmerge_gridspan_l_merge_places_cells_correctly` and `test_table_row_span_distribution`. | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | -| **Floating Images / Wrap Mode** | Yes | No | No | Anchored (floating) drawings are marked with the `floating` class and now 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` (on the frame's graphic style) both map. **Not yet laid out** — the flow engine has no exclusion zones, so text does not wrap around floats (gap #12); floating drawings are still not painted. Tested: `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | +| **Floating Images / Wrap Mode** | Yes | Partial | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Limitations (gap #12):** v1 wraps only the float's **anchoring paragraph**, and (per the same Parley per-line-width limit as drop caps) narrows *every* line of it uniformly rather than only the lines beside the float — a float shorter than its paragraph leaves the lines below it narrowed; cross-paragraph wrap, the tight wrap **contour** (approximated by the bounding box), true `behindDoc` overlap, and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. Tested: `flow_float` unit tests + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | | **External Images** | Yes | No | No | Renders as gray placeholder rectangles. | --- diff --git a/loki-layout/src/flow.rs b/loki-layout/src/flow.rs index 8fc616ec..47c01118 100644 --- a/loki-layout/src/flow.rs +++ b/loki-layout/src/flow.rs @@ -15,6 +15,8 @@ mod columns_impl; #[path = "flow_comments.rs"] mod comments_impl; +#[path = "flow_float.rs"] +mod float_impl; #[path = "flow_para.rs"] mod para_impl; diff --git a/loki-layout/src/flow_float.rs b/loki-layout/src/flow_float.rs new file mode 100644 index 00000000..c3fb575a --- /dev/null +++ b/loki-layout/src/flow_float.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Floating-image text wrap (gap #12). +//! +//! An anchored (floating) drawing sits beside the body text, which wraps around +//! it. This module plans the placement of such a float for the paragraph it is +//! anchored in: it chooses a side, reserves the horizontal band the text must +//! avoid (returned as left/right indent deltas), and produces the float's image +//! item. +//! +//! **Scope (v1).** Wrapping is applied to the *anchoring paragraph* only, using +//! a uniform indent (every line of that paragraph clears the float, matching the +//! same Parley per-line-width limitation documented for drop caps). `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. A float taller than its paragraph reserves its full height so following +//! paragraphs clear it, but they do not themselves wrap. OOXML `wp:anchor` wrap +//! children; ODF `style:wrap`. + +use loki_doc_model::content::float::{TextWrap, WrapSide}; + +use crate::geometry::LayoutRect; +use crate::items::{PositionedImage, PositionedItem}; +use crate::resolve::{CollectedImage, emu_to_pt}; + +/// Default gap between a float and the wrapped text, in points (~0.13"). +const FLOAT_WRAP_GAP: f32 = 9.0; + +/// A planned float placement for one paragraph. +pub(crate) struct FloatPlacement { + /// Extra left indent (points) — non-zero when the float sits on the left. + pub indent_start_delta: f32, + /// Extra right indent (points) — non-zero when the float sits on the right. + pub indent_end_delta: f32, + /// The float's image item in paragraph-content-local coordinates (x measured + /// from the content-area left edge, y from the paragraph top). + pub item: PositionedItem, + /// Float height in points; the paragraph reserves at least this much so the + /// following paragraph clears the float. + pub height: f32, +} + +/// Plans wrapping for the first side-wrapping float in `images`. +/// +/// Returns the float's index in `images` (so the caller can remove it from the +/// block-stacked set) and its [`FloatPlacement`]. Returns `None` when no image +/// is a side-wrapping float (`Square`/`Tight`/`Through`/non-behind `None`), or +/// when the float would leave too little usable text width. +pub(crate) fn plan_float( + images: &[CollectedImage], + content_width: f32, +) -> Option<(usize, FloatPlacement)> { + let (idx, img, fw) = images.iter().enumerate().find_map(|(i, img)| { + 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. + let side_wraps = matches!( + f.wrap, + TextWrap::Square | TextWrap::Tight | TextWrap::Through | TextWrap::None + ); + (side_wraps && !f.behind_text).then_some((i, img, f)) + })?; + + let w = emu_to_pt(img.cx_emu); + let h = emu_to_pt(img.cy_emu); + if w <= 0.0 || h <= 0.0 { + return None; + } + let band = w + FLOAT_WRAP_GAP; + // Leave at least a quarter of the column for text; otherwise skip wrapping. + if band >= content_width * 0.75 { + return None; + } + + // WrapSide names the side TEXT occupies, so the float sits opposite: + // side=Right → text right → float LEFT; side=Left → text left → float RIGHT. + // Both/Largest → default to a left float (text flows to its right). + let float_left = !matches!(fw.side, WrapSide::Left); + + let (indent_start_delta, indent_end_delta, x) = if float_left { + (band, 0.0, 0.0) + } else { + (0.0, band, content_width - w) + }; + + let item = PositionedItem::Image(PositionedImage { + rect: LayoutRect::new(x, 0.0, w, h), + src: img.src.clone(), + alt: img.alt.clone(), + }); + + Some(( + idx, + FloatPlacement { + indent_start_delta, + indent_end_delta, + item, + height: h, + }, + )) +} + +#[cfg(test)] +#[path = "flow_float_tests.rs"] +mod tests; diff --git a/loki-layout/src/flow_float_tests.rs b/loki-layout/src/flow_float_tests.rs new file mode 100644 index 00000000..ade16e3a --- /dev/null +++ b/loki-layout/src/flow_float_tests.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for [`crate::flow::float_impl`]. + +use super::*; +use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; + +/// One inch = 914400 EMU; build an image of `w_in` × `h_in` inches. +fn img(w_in: f64, h_in: f64, float: Option) -> CollectedImage { + CollectedImage { + src: "data:image/png;base64,AAAA".into(), + alt: None, + cx_emu: (w_in * 914_400.0) as u64, + cy_emu: (h_in * 914_400.0) as u64, + float, + } +} + +fn square(side: WrapSide) -> FloatWrap { + FloatWrap { + wrap: TextWrap::Square, + side, + behind_text: false, + } +} + +#[test] +fn inline_image_is_not_planned() { + let images = vec![img(1.0, 1.0, None)]; + assert!(plan_float(&images, 468.0).is_none()); +} + +#[test] +fn top_and_bottom_float_is_not_side_wrapped() { + let images = vec![img( + 1.0, + 1.0, + Some(FloatWrap { + wrap: TextWrap::TopAndBottom, + side: WrapSide::Both, + behind_text: false, + }), + )]; + assert!(plan_float(&images, 468.0).is_none()); +} + +#[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"); +} + +#[test] +fn behind_text_float_is_not_side_wrapped() { + let images = vec![img( + 1.0, + 1.0, + Some(FloatWrap { + wrap: TextWrap::Square, + side: WrapSide::Both, + behind_text: true, + }), + )]; + assert!(plan_float(&images, 468.0).is_none()); +} + +#[test] +fn side_left_text_puts_float_on_the_right() { + // WrapSide::Left = text on the left → float on the RIGHT. + let images = vec![img(1.0, 1.0, Some(square(WrapSide::Left)))]; + let (idx, p) = plan_float(&images, 468.0).expect("planned"); + assert_eq!(idx, 0); + assert_eq!(p.indent_start_delta, 0.0); + assert!(p.indent_end_delta > 72.0, "right band ≥ image width + gap"); + if let PositionedItem::Image(im) = &p.item { + // Right float sits near the right edge. + assert!(im.rect.origin.x > 468.0 - 80.0); + } else { + panic!("expected image item"); + } +} + +#[test] +fn side_right_text_puts_float_on_the_left() { + // WrapSide::Right = text on the right → float on the LEFT. + let images = vec![img(1.0, 1.0, Some(square(WrapSide::Right)))]; + let (_, p) = plan_float(&images, 468.0).expect("planned"); + assert!(p.indent_start_delta > 72.0, "left band ≥ image width + gap"); + assert_eq!(p.indent_end_delta, 0.0); + if let PositionedItem::Image(im) = &p.item { + assert_eq!(im.rect.origin.x, 0.0, "left float at content origin"); + assert!((im.rect.size.height - 72.0).abs() < 0.5, "1in = 72pt tall"); + } else { + panic!("expected image item"); + } +} + +#[test] +fn both_sides_default_to_a_left_float() { + let images = vec![img(1.0, 1.0, Some(square(WrapSide::Both)))]; + let (_, p) = plan_float(&images, 468.0).expect("planned"); + assert!(p.indent_start_delta > 0.0, "Both → float left, text right"); + assert_eq!(p.indent_end_delta, 0.0); +} + +#[test] +fn oversized_float_is_skipped() { + // A float wider than 75% of the column leaves too little text width. + let images = vec![img(6.0, 1.0, Some(square(WrapSide::Both)))]; + assert!(plan_float(&images, 468.0).is_none()); +} diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index af436548..67ffe82d 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -138,10 +138,22 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc let effective_para: &StyledParagraph = owned_para.as_ref().unwrap_or(para); // ──────────────────────────────────────────────────────────────────────── - let (text, spans, images, notes) = + let (text, spans, mut images, notes) = flatten_paragraph(effective_para, state.catalog, &mut state.note_counter); state.pending_footnotes.extend(notes); + // ── Floating image wrap (gap #12) ──────────────────────────────────────── + // Reserve a side band so the paragraph's text wraps beside a square/tight/ + // through float; the float image itself is emitted after text layout. The + // floated image is removed from the inline/block image set so it is not also + // stacked above the text. + let float_plan = super::float_impl::plan_float(&images, state.content_width); + if let Some((idx, placement)) = &float_plan { + resolved.indent_start += placement.indent_start_delta; + resolved.indent_end += placement.indent_end_delta; + images.remove(*idx); + } + state.cursor_y += resolved.space_before; if resolved.page_break_before && state.mode.is_paginated() { @@ -196,6 +208,13 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc para_layout.items = image_items; } + // Emit the floating image beside the wrapped text and reserve its height so + // the following paragraph clears it. + if let Some((_, placement)) = float_plan { + para_layout.items.push(placement.item); + para_layout.height = para_layout.height.max(placement.height); + } + place_paragraph_layout(state, &resolved, para_layout, block_index); if resolved.page_break_after && state.mode.is_paginated() { diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index 59958bb7..7993b5d4 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -145,6 +145,10 @@ pub struct CollectedImage { pub cx_emu: u64, /// Height in English Metric Units. pub cy_emu: u64, + /// Float wrap configuration when the drawing is anchored (floating), or + /// `None` for an inline drawing. Read from the image's `NodeAttr` (see + /// [`loki_doc_model::content::float::FloatWrap`]). + pub float: Option, } /// A footnote or endnote body collected during paragraph flattening. @@ -690,6 +694,7 @@ fn walk_inlines( alt, cx_emu, cy_emu, + float: loki_doc_model::content::float::FloatWrap::read(attr), }); } } From 024bac99d8dfc490edafe633bcfb783686e0a2ba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 13:29:28 +0000 Subject: [PATCH 14/35] refactor(layout): extract emit_glyph_run into a shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the per-run glyph emission (highlight underlay, hard-shadow copy, main glyph run, underline/strikethrough) out of layout_paragraph's main loop into `para_emit::emit_glyph_run`, and make the span lookup helpers pub(crate). No behaviour change — the main loop now calls the helper. This prepares the banded (drop-cap / float) layout path to reuse the exact same emission for its two sub-layouts (per-line precision), instead of duplicating it. para.rs shrinks; the helper is 171 lines. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- loki-layout/src/lib.rs | 1 + loki-layout/src/para.rs | 159 ++++--------------------------- loki-layout/src/para_emit.rs | 171 ++++++++++++++++++++++++++++++++++ loki-layout/src/para_tests.rs | 2 +- 4 files changed, 192 insertions(+), 141 deletions(-) create mode 100644 loki-layout/src/para_emit.rs diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index b13b3e21..486f03ba 100644 --- a/loki-layout/src/lib.rs +++ b/loki-layout/src/lib.rs @@ -35,6 +35,7 @@ pub mod mode; pub mod para; mod para_cache; mod para_drop_cap; +mod para_emit; pub mod resolve; pub mod result; diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index 2b463f59..5a1c2ac4 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -24,11 +24,8 @@ use parley::{ use crate::color::LayoutColor; use crate::font::FontResources; -use crate::geometry::{LayoutInsets, LayoutPoint, LayoutRect}; -use crate::items::{ - BorderEdge, DecorationKind, GlyphEntry, GlyphSynthesis, PositionedBorderRect, - PositionedDecoration, PositionedGlyphRun, PositionedItem, PositionedRect, -}; +use crate::geometry::{LayoutInsets, LayoutRect}; +use crate::items::{BorderEdge, PositionedBorderRect, PositionedItem, PositionedRect}; /// Vertical text position for superscript / subscript runs. /// @@ -1356,137 +1353,13 @@ fn layout_paragraph_uncached( let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue; }; - let run = glyph_run.run(); - let style = glyph_run.style(); - let run_offset = glyph_run.offset(); - let run_baseline = glyph_run.baseline(); - - // Intern the font data bytes by pointer identity so all glyph - // runs using the same Parley-internal font share the same Arc. - // Without this, every run would clone the full font file bytes - // (potentially hundreds of KB) producing unique Arc pointers that - // defeat the FontDataCache in loki-vello. - let raw_bytes: &[u8] = run.font().data.data(); - let font_data = resources - .font_data_cache - .entry(raw_bytes.as_ptr() as u64) - .or_insert_with(|| Arc::new(raw_bytes.to_vec())) - .clone(); - let synthesis = run.synthesis(); - let glyphs: Vec = glyph_run - .positioned_glyphs() - .map(|g| GlyphEntry { - id: g.id as u16, - x: g.x - run_offset, - y: g.y - run_baseline, - advance: g.advance, - }) - .collect(); - - let text_range = run.text_range(); - - let link_url = span_link_url_for_range(&clean_spans, text_range.clone()); - - // ── Vertical offset for super/subscript (gap #3) ─────────────────── - // Parley does not expose baseline-shift, so font size is reduced to - // 58 % in push_para_styles. We manually shift the run origin here so - // the text actually appears above/below the baseline. - // Superscript: raise by 35 % of the original (pre-reduction) font size. - // Subscript: lower by 20 % of the original font size. - let va_offset = span_vertical_align_for_range(&clean_spans, text_range.clone()) - .map(|(va, orig_size)| match va { - VerticalAlign::Superscript => -orig_size * 0.35, - VerticalAlign::Subscript => orig_size * 0.20, - }) - .unwrap_or(0.0); - - // ── Highlight colour (gap #10) ────────────────────────────────────── - // Emit a filled rect sized to the run's ink extent BEFORE the glyph - // run so the background renders below the text. - if let Some(hl_color) = span_highlight_for_range(&clean_spans, text_range.clone()) { - let m = run.metrics(); - items.push(PositionedItem::FilledRect(PositionedRect { - rect: LayoutRect::new( - run_offset + indent_x, - run_baseline - m.ascent + va_offset, - glyph_run.advance(), - m.ascent + m.descent, - ), - color: hl_color, - })); - } - - // ── 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 span_has_shadow(&clean_spans, text_range.clone()) { - items.push(PositionedItem::GlyphRun(PositionedGlyphRun { - origin: LayoutPoint { - x: run_offset + indent_x + 0.5, - y: run_baseline + va_offset + 0.5, - }, - 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 - })); - } - - // ── 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, - })); - - // Underline decoration. - if let Some(deco) = &style.underline { - let m = run.metrics(); - // COMPAT(parley-0.6): RunMetrics offsets follow OpenType / skrifa - // Y-up convention (negative = below baseline). Negate to convert - // to screen Y-down (positive = below baseline). - items.push(PositionedItem::Decoration(PositionedDecoration { - x: run_offset + indent_x, - y: run_baseline - deco.offset.unwrap_or(m.underline_offset), - width: glyph_run.advance(), - thickness: deco.size.unwrap_or(m.underline_size), - kind: DecorationKind::Underline, - color: deco.brush, - })); - } - - // Strikethrough decoration. - if let Some(deco) = &style.strikethrough { - let m = run.metrics(); - // COMPAT(parley-0.6): same Y-up → Y-down negation as underline. - items.push(PositionedItem::Decoration(PositionedDecoration { - x: run_offset + indent_x, - y: run_baseline - deco.offset.unwrap_or(m.strikethrough_offset), - width: glyph_run.advance(), - thickness: deco.size.unwrap_or(m.strikethrough_size), - kind: DecorationKind::Strikethrough, - color: deco.brush, - })); - } + crate::para_emit::emit_glyph_run( + &glyph_run, + indent_x, + &clean_spans, + resources, + &mut items, + ); } line_index += 1; } @@ -1701,7 +1574,10 @@ fn ordinal_suffix(n: u32) -> &'static str { /// Returns the highlight colour for the first span fully containing /// `text_range`, or `None` if no such span has a highlight. -fn span_highlight_for_range(spans: &[StyleSpan], text_range: Range) -> Option { +pub(crate) fn span_highlight_for_range( + spans: &[StyleSpan], + text_range: Range, +) -> Option { spans .iter() .find(|s| s.range.start <= text_range.start && s.range.end >= text_range.end) @@ -1710,7 +1586,10 @@ fn span_highlight_for_range(spans: &[StyleSpan], text_range: Range) -> Op /// Returns the link URL for the first span fully containing `text_range`, /// or `None` if no span in that range carries a link URL. -fn span_link_url_for_range(spans: &[StyleSpan], text_range: Range) -> Option { +pub(crate) fn span_link_url_for_range( + spans: &[StyleSpan], + text_range: Range, +) -> Option { spans .iter() .find(|s| s.range.start <= text_range.start && s.range.end >= text_range.end) @@ -1719,7 +1598,7 @@ fn span_link_url_for_range(spans: &[StyleSpan], text_range: Range) -> Opt /// Returns `true` if the first span fully containing `text_range` has /// `shadow = true`. -fn span_has_shadow(spans: &[StyleSpan], text_range: Range) -> bool { +pub(crate) fn span_has_shadow(spans: &[StyleSpan], text_range: Range) -> bool { spans .iter() .find(|s| s.range.start <= text_range.start && s.range.end >= text_range.end) @@ -1729,7 +1608,7 @@ fn span_has_shadow(spans: &[StyleSpan], text_range: Range) -> bool { /// Returns the vertical alignment and original (pre-reduction) font size for /// the first span fully containing `text_range`, or `None` if no vertical /// alignment is set on that span. -fn span_vertical_align_for_range( +pub(crate) fn span_vertical_align_for_range( spans: &[StyleSpan], text_range: Range, ) -> Option<(VerticalAlign, f32)> { diff --git a/loki-layout/src/para_emit.rs b/loki-layout/src/para_emit.rs new file mode 100644 index 00000000..269d4620 --- /dev/null +++ b/loki-layout/src/para_emit.rs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Glyph-run emission shared by the main paragraph layout loop +//! ([`crate::para`]) and the banded (drop-cap / float wrap) layout path +//! ([`crate::para_band`]). +//! +//! Given one Parley [`parley::GlyphRun`] and the horizontal offset of its line, +//! this emits the run's highlight underlay, hard-shadow copy, main glyph run, +//! and underline/strikethrough decorations as renderer-agnostic +//! [`PositionedItem`]s. The y coordinates are the run's native layout-space +//! values; callers that stack a second sub-layout translate the emitted items +//! vertically afterwards. + +use std::sync::Arc; + +use crate::color::LayoutColor; +use crate::font::FontResources; +use crate::geometry::{LayoutPoint, LayoutRect}; +use crate::items::{ + DecorationKind, GlyphEntry, GlyphSynthesis, PositionedDecoration, PositionedGlyphRun, + PositionedItem, PositionedRect, +}; +use crate::para::{ + StyleSpan, VerticalAlign, span_has_shadow, span_highlight_for_range, span_link_url_for_range, + span_vertical_align_for_range, +}; + +/// Emits one shaped glyph run at horizontal offset `indent_x`, appending the +/// highlight, shadow, glyph, and decoration items to `items`. +/// +/// `spans` supplies per-range character styling (highlight, link, shadow, +/// super/subscript) looked up by the run's text range. +pub(crate) fn emit_glyph_run( + glyph_run: &parley::GlyphRun<'_, LayoutColor>, + indent_x: f32, + spans: &[StyleSpan], + resources: &mut FontResources, + items: &mut Vec, +) { + let run = glyph_run.run(); + let style = glyph_run.style(); + let run_offset = glyph_run.offset(); + let run_baseline = glyph_run.baseline(); + + // Intern the font data bytes by pointer identity so all glyph runs using the + // same Parley-internal font share the same Arc. Without this, every run + // would clone the full font file bytes (potentially hundreds of KB) + // producing unique Arc pointers that defeat the FontDataCache in loki-vello. + let raw_bytes: &[u8] = run.font().data.data(); + let font_data = resources + .font_data_cache + .entry(raw_bytes.as_ptr() as u64) + .or_insert_with(|| Arc::new(raw_bytes.to_vec())) + .clone(); + let synthesis = run.synthesis(); + let glyphs: Vec = glyph_run + .positioned_glyphs() + .map(|g| GlyphEntry { + id: g.id as u16, + x: g.x - run_offset, + y: g.y - run_baseline, + advance: g.advance, + }) + .collect(); + + let text_range = run.text_range(); + + let link_url = span_link_url_for_range(spans, text_range.clone()); + + // ── Vertical offset for super/subscript (gap #3) ────────────────────────── + // Parley does not expose baseline-shift, so font size is reduced to 58 % in + // push_para_styles. We manually shift the run origin here so the text + // actually appears above/below the baseline. + // Superscript: raise by 35 % of the original (pre-reduction) font size. + // Subscript: lower by 20 % of the original font size. + let va_offset = span_vertical_align_for_range(spans, text_range.clone()) + .map(|(va, orig_size)| match va { + VerticalAlign::Superscript => -orig_size * 0.35, + VerticalAlign::Subscript => orig_size * 0.20, + }) + .unwrap_or(0.0); + + // ── Highlight colour (gap #10) ──────────────────────────────────────────── + // Emit a filled rect sized to the run's ink extent BEFORE the glyph run so + // the background renders below the text. + if let Some(hl_color) = span_highlight_for_range(spans, text_range.clone()) { + let m = run.metrics(); + items.push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new( + run_offset + indent_x, + run_baseline - m.ascent + va_offset, + glyph_run.advance(), + m.ascent + m.descent, + ), + color: hl_color, + })); + } + + // ── 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 span_has_shadow(spans, text_range.clone()) { + items.push(PositionedItem::GlyphRun(PositionedGlyphRun { + origin: LayoutPoint { + x: run_offset + indent_x + 0.5, + y: run_baseline + va_offset + 0.5, + }, + 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 + })); + } + + // ── 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, + })); + + // Underline decoration. + if let Some(deco) = &style.underline { + let m = run.metrics(); + // COMPAT(parley-0.6): RunMetrics offsets follow OpenType / skrifa Y-up + // convention (negative = below baseline). Negate to convert to screen + // Y-down (positive = below baseline). + items.push(PositionedItem::Decoration(PositionedDecoration { + x: run_offset + indent_x, + y: run_baseline - deco.offset.unwrap_or(m.underline_offset), + width: glyph_run.advance(), + thickness: deco.size.unwrap_or(m.underline_size), + kind: DecorationKind::Underline, + color: deco.brush, + })); + } + + // Strikethrough decoration. + if let Some(deco) = &style.strikethrough { + let m = run.metrics(); + // COMPAT(parley-0.6): same Y-up → Y-down negation as underline. + items.push(PositionedItem::Decoration(PositionedDecoration { + x: run_offset + indent_x, + y: run_baseline - deco.offset.unwrap_or(m.strikethrough_offset), + width: glyph_run.advance(), + thickness: deco.size.unwrap_or(m.strikethrough_size), + kind: DecorationKind::Strikethrough, + color: deco.brush, + })); + } +} diff --git a/loki-layout/src/para_tests.rs b/loki-layout/src/para_tests.rs index b431494f..fda5542e 100644 --- a/loki-layout/src/para_tests.rs +++ b/loki-layout/src/para_tests.rs @@ -4,7 +4,7 @@ //! Unit tests for [`crate::para`]. use super::*; -use crate::items::{BorderStyle, PositionedGlyphRun, PositionedItem}; +use crate::items::{BorderStyle, DecorationKind, PositionedGlyphRun, PositionedItem}; use loki_doc_model::style::list_style::{ BulletChar, LabelAlignment, ListLevel, ListLevelKind, NumberingScheme, }; From 3250ad5b8e1312e2507de71b20f6a88758c7b100 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 13:44:37 +0000 Subject: [PATCH 15/35] feat(layout): per-line precision for drop caps and float wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both features previously narrowed *every* line of the affected paragraph because Parley exposes no public per-line max-advance (the setter is on its private breaker state). Lines below the cap/float stayed narrowed instead of reclaiming the full column as Word does. New `para_band` lays the body out in two passes: the lines overlapping the band (cap / float height) are taken from a narrow layout (shifted right for a left-side object), and the remaining text is re-flowed at full width and stacked below. Glyph emission is shared with the main loop via `para_emit::emit_glyph_run`. - `ResolvedParaProps::wrap_band` carries a float band from the flow engine (`flow_para` sets it instead of adjusting the indent). - `layout_paragraph` builds a unified band from the drop cap or the float and routes plain-text read-only paragraphs through the split; the editor / tab / math paths keep the uniform-narrow fallback (and their Parley layout for hit-testing — the split never runs there). - `prepend_para_box` factors the shared border/background emission. Verified against the Word reference: ACID page 9 (drop cap) — the 4th line below the 3-line cap reclaims full width; ACID page 10 (float) — text below the image returns to the left margin at full width. Tests: para_band unit tests (left/right band, short body), strengthened drop_cap_enlarges_initial_and_shifts_first_lines to assert the tail reclaims x ≈ 0. All layout/ooxml/pdf/vello suites green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 4 +- loki-layout/src/flow_float.rs | 7 +- loki-layout/src/flow_para.rs | 9 +- loki-layout/src/lib.rs | 1 + loki-layout/src/para.rs | 212 ++++++++++++++++-------- loki-layout/src/para_band.rs | 252 +++++++++++++++++++++++++++++ loki-layout/src/para_band_tests.rs | 142 ++++++++++++++++ loki-layout/src/para_tests.rs | 6 +- loki-layout/src/resolve.rs | 2 + 9 files changed, 556 insertions(+), 79 deletions(-) create mode 100644 loki-layout/src/para_band.rs create mode 100644 loki-layout/src/para_band_tests.rs diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index a7eed0ec..58071f70 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -63,7 +63,7 @@ This is the living source of truth documenting which document features, characte | **Named / Custom Paragraph Styles** | Yes | Yes | Yes | The full style catalog round-trips through DOCX `styles.xml`: custom styles created in the style editor, plus edits to built-in `Normal`/`Heading1`–`6`, persist across save/reload. Font family, weight (→ bold), size, alignment, indentation (incl. first-line), spacing, line height, `basedOn`, `next`-style, outline level, and the custom flag are all written and read back. In-session, the catalog round-trips through the Loro CRDT (`loro_bridge::styles`, a JSON snapshot like metadata), so style-editor edits are durable across rebuilds and **undoable** with Ctrl+Z/Ctrl+Y. | | **border_between** | Yes | No | No | Rules between adjacent same-styled paragraphs ignored. | | **bidi / RTL** | Yes | No | No | RTL paragraphs ignored due to Parley API limitations. | -| **Drop Cap** | Yes | Yes (paint) | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Rendered** on the read-only/paint path (PDF/print/canvas): the leading initial is enlarged to span `lines` rows and the body text wraps to its right (`para_drop_cap.rs` + `layout_paragraph`). Word encodes a drop cap as a `w:framePr` frame paragraph holding just the initial followed by the body paragraph; a DOCX import pass (`drop_cap_merge.rs`) folds the frame into the body so the single-paragraph (ODF-style) renderer applies. The cap is sized so its ascent spans the line band, coloured/weighted from the initial's run, positioned with its top at the first line and baseline near the last spanned line; `margin` mode hangs it in the left margin. **Limitations:** in the live editor (`preserve_for_editing`) the initial stays inline so hit-test indices remain aligned (follow-up); `APPROX(drop-cap-width)` — Parley exposes no public per-line max-advance, so body lines *below* the cap wrap at the narrowed width (ragged-right slightly early) rather than reclaiming full width. Not re-exported, not round-tripped through Loro. Tested: `frame_pr_*` + `drop_cap_merge` (DOCX), `read_stylesheet_drop_cap`/`drop_cap_*` (ODT), `cap_byte_len_*` + `drop_cap_enlarges_initial_and_shifts_first_lines` / `drop_cap_inline_when_preserving_for_editing` (layout). ACID TC-DOCX-015 / TC-ODT-013. | +| **Drop Cap** | Yes | Yes (paint) | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Rendered** on the read-only/paint path (PDF/print/canvas): the leading initial is enlarged to span `lines` rows and the body text wraps to its right (`para_drop_cap.rs` + `layout_paragraph`). Word encodes a drop cap as a `w:framePr` frame paragraph holding just the initial followed by the body paragraph; a DOCX import pass (`drop_cap_merge.rs`) folds the frame into the body so the single-paragraph (ODF-style) renderer applies. The cap is sized so its ascent spans the line band, coloured/weighted from the initial's run, positioned with its top at the first line and baseline near the last spanned line; `margin` mode hangs it in the left margin. **Per-line precision:** body lines *beside* the cap are narrowed while lines *below* it reclaim the full column width — Parley exposes no public per-line max-advance, so the body is laid out in two passes (`para_band`: narrow band lines + full-width reflow of the tail, stacked). **Limitations:** in the live editor (`preserve_for_editing`) the initial stays inline so hit-test indices remain aligned, and that path keeps the older uniform-narrow fallback (follow-up). Not re-exported, not round-tripped through Loro. Tested: `frame_pr_*` + `drop_cap_merge` (DOCX), `read_stylesheet_drop_cap`/`drop_cap_*` (ODT), `cap_byte_len_*` + `drop_cap_enlarges_initial_and_shifts_first_lines` / `drop_cap_inline_when_preserving_for_editing` (layout). ACID TC-DOCX-015 / TC-ODT-013. | --- @@ -77,7 +77,7 @@ This is the living source of truth documenting which document features, characte | **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). **Combined vMerge + gridSpan (L-merges) place correctly:** the layout assigns each cell its grid columns via a shared coverage grid (`assign_cell_columns`), so a cell in a row whose leading column is occupied by a vertical merge above is shifted into the next free column instead of overlapping the merged cell (TC-DOCX-005). Tested by `vmerge_gridspan_l_merge_places_cells_correctly` and `test_table_row_span_distribution`. | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | -| **Floating Images / Wrap Mode** | Yes | Partial | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Limitations (gap #12):** v1 wraps only the float's **anchoring paragraph**, and (per the same Parley per-line-width limit as drop caps) narrows *every* line of it uniformly rather than only the lines beside the float — a float shorter than its paragraph leaves the lines below it narrowed; cross-paragraph wrap, the tight wrap **contour** (approximated by the bounding box), true `behindDoc` overlap, and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. Tested: `flow_float` unit tests + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | +| **Floating Images / Wrap Mode** | Yes | Partial | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Limitations (gap #12):** v1 wraps only the float's **anchoring paragraph**; the live-editor path keeps the older uniform-narrow fallback; cross-paragraph wrap, the tight wrap **contour** (approximated by the bounding box), true `behindDoc` overlap, and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. Tested: `flow_float` unit tests + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | | **External Images** | Yes | No | No | Renders as gray placeholder rectangles. | --- diff --git a/loki-layout/src/flow_float.rs b/loki-layout/src/flow_float.rs index c3fb575a..0f24163e 100644 --- a/loki-layout/src/flow_float.rs +++ b/loki-layout/src/flow_float.rs @@ -9,9 +9,10 @@ //! avoid (returned as left/right indent deltas), and produces the float's image //! item. //! -//! **Scope (v1).** Wrapping is applied to the *anchoring paragraph* only, using -//! a uniform indent (every line of that paragraph clears the float, matching the -//! same Parley per-line-width limitation documented for drop caps). `Square`, +//! **Scope (v1).** Wrapping is applied to the *anchoring paragraph* only. The +//! band is passed to the layout as a [`crate::para::WrapBand`]; on the paint +//! path the body is laid out in two passes (`para_band`) so lines beside the +//! float are narrowed while lines below it reclaim the full column. `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). diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index 67ffe82d..1eea0b8b 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -149,8 +149,13 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc // stacked above the text. let float_plan = super::float_impl::plan_float(&images, state.content_width); if let Some((idx, placement)) = &float_plan { - resolved.indent_start += placement.indent_start_delta; - resolved.indent_end += placement.indent_end_delta; + // 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: placement.indent_start_delta + placement.indent_end_delta, + cover_height: placement.height, + shift_text: placement.indent_start_delta > 0.0, + }); images.remove(*idx); } diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index 486f03ba..eecb744f 100644 --- a/loki-layout/src/lib.rs +++ b/loki-layout/src/lib.rs @@ -33,6 +33,7 @@ pub mod items; mod math; pub mod mode; pub mod para; +mod para_band; mod para_cache; mod para_drop_cap; mod para_emit; diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index 5a1c2ac4..fb351b55 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -268,6 +268,26 @@ pub struct ResolvedParaProps { /// them. Imported from OOXML `w:framePr`/`w:dropCap` and ODF /// `style:drop-cap`. pub drop_cap: Option, + /// A leading side band the first lines of this paragraph must clear (a + /// floating image the text wraps around). Set by the flow engine; the + /// banded layout path narrows the lines beside it and reflows the rest at + /// full width. `None` for normal paragraphs. + pub wrap_band: Option, +} + +/// A side band (a floating object) the first lines of a paragraph wrap around. +/// +/// Set on [`ResolvedParaProps::wrap_band`] by the flow engine; consumed by the +/// banded layout path. Drop caps build their band internally instead. +#[derive(Debug, Clone, Copy)] +pub struct WrapBand { + /// Horizontal width (points) to clear, including the gap to the text. + pub inset: f32, + /// Vertical extent (points) the band covers from the paragraph top. + pub cover_height: f32, + /// `true` when the object is on the left (text shifts right); `false` when + /// on the right (text narrows but does not shift). + pub shift_text: bool, } impl Default for ResolvedParaProps { @@ -295,6 +315,7 @@ impl Default for ResolvedParaProps { tab_stops: Vec::new(), break_long_words: false, drop_cap: None, + wrap_band: None, } } } @@ -770,7 +791,7 @@ fn push_math_inline_boxes( /// Extracted so the same styles can be applied in both the probe pass (pass 1) /// and the final pass (pass 2) of the two-pass tab stop expansion. -fn push_para_styles( +pub(crate) fn push_para_styles( builder: &mut RangedBuilder<'_, LayoutColor>, para_props: &ResolvedParaProps, style_spans: &[StyleSpan], @@ -984,6 +1005,43 @@ pub fn layout_paragraph( result } +/// 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. +fn prepend_para_box( + items: &mut Vec, + para_props: &ResolvedParaProps, + width: f32, + height: f32, +) { + let bw = width + para_props.indent_start + para_props.indent_end; + let has_border = para_props.border_top.is_some() + || para_props.border_right.is_some() + || para_props.border_bottom.is_some() + || para_props.border_left.is_some(); + if has_border { + items.insert( + 0, + PositionedItem::BorderRect(PositionedBorderRect { + rect: LayoutRect::new(0.0, 0.0, bw, height), + top: para_props.border_top, + right: para_props.border_right, + bottom: para_props.border_bottom, + left: para_props.border_left, + }), + ); + } + 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, + }), + ); + } +} + /// Lays out a single paragraph using Parley, without consulting or populating /// the shaping cache. [`layout_paragraph`] wraps this with memoisation. fn layout_paragraph_uncached( @@ -1236,10 +1294,10 @@ fn layout_paragraph_uncached( push_math_inline_boxes(&mut builder, &math_boxes); let mut layout = builder.build(&clean_text); - // For a drop-cap paragraph, plan the cap from the body's first-line metrics, - // then re-break narrowing the first `n_lines` lines by the cap region. + // Plan the drop cap (its enlarged glyph + band geometry) from the body's + // first-line metrics. `drop_plan` keeps the line height for `cover_height`. let drop_plan = if let Some((dc, cap_text, base)) = &drop_state { - layout.break_all_lines(Some(line_w)); // Pass A: read body line metrics. + layout.break_all_lines(Some(line_w)); // metrics only let (lh, asc, bl) = layout .lines() .next() @@ -1248,7 +1306,7 @@ fn layout_paragraph_uncached( (m.line_height, m.ascent, m.baseline) }) .unwrap_or((0.0, 0.0, 0.0)); - let plan = crate::para_drop_cap::plan_drop_cap( + crate::para_drop_cap::plan_drop_cap( resources, cap_text, base, @@ -1257,26 +1315,83 @@ fn layout_paragraph_uncached( bl, asc, display_scale, - ); - if let Some(p) = &plan { - // Re-break the whole body at the narrowed width so every line clears - // the cap band. The first `n_lines` are then shifted right into the - // band during emission; later lines stay at the left margin. - // - // APPROX(drop-cap-width): Parley's public API exposes no per-line - // max-advance (the setter is on the private breaker state), so lines - // *below* the cap also wrap at the narrowed width and lose - // `body_inset` of measure — they ragged-right slightly early rather - // than reclaiming full width as Word does. Text still stays within - // the margins. A true per-line solution needs upstream Parley API. - let narrow = (line_w - p.body_inset).max(1.0); - layout.break_all_lines(Some(narrow)); - } - plan + ) + .map(|p| (p, lh)) } else { - layout.break_all_lines(Some(line_w)); None }; + + // Unified leading band: a drop cap (object on the left) or a float band set + // by the flow engine. The band's first lines are narrowed; lines below it + // reclaim full width (`para_band` lays the body out in two passes). + let band: Option = if let Some((p, lh)) = &drop_plan { + Some(crate::para_band::Band { + inset: p.body_inset, + cover_height: p.n_lines as f32 * lh, + // In-text drop shifts the text right; margin drop has inset 0. + shift_text: p.body_inset > 0.0, + }) + } else { + para_props.wrap_band.map(|w| crate::para_band::Band { + inset: w.inset, + cover_height: w.cover_height, + shift_text: w.shift_text, + }) + }; + + // Precise per-line band split runs on the read-only paint path for plain + // text; the editor / tab / math paths fall back to a uniform narrow below. + let can_split = band.is_some() + && !preserve_for_editing + && tab_char_positions.is_empty() + && math_boxes.is_empty(); + + if can_split { + let body = crate::para_band::layout_band_body( + resources, + &clean_text, + &clean_spans, + para_props, + line_w, + display_scale, + band.expect("can_split implies band"), + ); + let mut items = body.items; + let mut content_bottom = body.height; + if let Some((p, _)) = &drop_plan { + // Emit the enlarged initial at the paragraph's left edge. + for it in &p.items { + let mut it = it.clone(); + it.translate(para_props.indent_start, 0.0); + items.push(it); + } + content_bottom = content_bottom.max(p.bottom); + } + prepend_para_box(&mut items, para_props, body.width, body.height); + return ParagraphLayout { + height: content_bottom, + width: body.width, + items, + first_baseline: body.first_baseline, + last_baseline: body.last_baseline, + line_boundaries: body.line_boundaries, + parley_layout: None, + orig_to_clean, + clean_to_orig, + indent_start: para_props.indent_start, + indent_hanging: para_props.indent_hanging, + }; + } + + // Fallback / normal path: break at the (possibly band-narrowed) width. A + // band here means the float could not be split (editor, tabs, or math) — it + // uniformly narrows every line (APPROX, as documented for `para_band`). + let band_inset = band.as_ref().map(|b| b.inset).unwrap_or(0.0); + let band_shift = band + .as_ref() + .map(|b| if b.shift_text { b.inset } else { 0.0 }) + .unwrap_or(0.0); + layout.break_all_lines(Some((line_w - band_inset).max(1.0))); layout.align(para_props.alignment, AlignmentOptions::default()); let total_height = layout.height(); @@ -1312,12 +1427,8 @@ fn layout_paragraph_uncached( } else { para_props.indent_start }; - // Drop cap: the first `n_lines` body lines are inset to clear the cap. - if let Some(p) = &drop_plan - && line_index < p.n_lines - { - indent_x += p.body_inset; - } + // Float-wrap fallback (uniform narrow): every line clears the band. + indent_x += band_shift; let line_baseline = line.metrics().baseline; for item in line.items() { // Math inline box: emit the typeset equation's draw items, offset to @@ -1364,48 +1475,9 @@ fn layout_paragraph_uncached( line_index += 1; } - // Drop cap: emit the enlarged initial, shifted to the paragraph's left edge - // (the same `indent_start` the body glyphs use). Grow the paragraph height - // if the cap hangs below a short body. - if let Some(p) = &drop_plan { - for it in &p.items { - let mut it = it.clone(); - it.translate(para_props.indent_start, 0.0); - items.push(it); - } - content_bottom = content_bottom.max(p.bottom); - } - - // Prepend border (below background so it renders on top). - let has_border = para_props.border_top.is_some() - || para_props.border_right.is_some() - || para_props.border_bottom.is_some() - || para_props.border_left.is_some(); - if has_border { - let bw = total_width + para_props.indent_start + para_props.indent_end; - items.insert( - 0, - PositionedItem::BorderRect(PositionedBorderRect { - rect: LayoutRect::new(0.0, 0.0, bw, total_height), - top: para_props.border_top, - right: para_props.border_right, - bottom: para_props.border_bottom, - left: para_props.border_left, - }), - ); - } - - // Prepend background fill. - if let Some(bg) = para_props.background_color { - let bw = total_width + para_props.indent_start + para_props.indent_end; - items.insert( - 0, - PositionedItem::FilledRect(PositionedRect { - rect: LayoutRect::new(0.0, 0.0, bw, total_height), - color: bg, - }), - ); - } + // `drop_plan` is always handled by the band-split path above (drop caps + // never reach this fallback), so only the box decorations remain here. + prepend_para_box(&mut items, para_props, total_width, total_height); let parley_layout = if preserve_for_editing { Some(Arc::new(layout)) diff --git a/loki-layout/src/para_band.rs b/loki-layout/src/para_band.rs new file mode 100644 index 00000000..bc47ef6a --- /dev/null +++ b/loki-layout/src/para_band.rs @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Banded paragraph layout — per-line precision for drop caps and floats. +//! +//! A *band* is a rectangular region on one side of the first part of a +//! paragraph that the body text must avoid: the dropped initial of a drop cap, +//! or a floating image the text wraps around. Parley's public API exposes no +//! per-line max-advance (the setter lives on its private breaker state), so the +//! lines beside the band and the lines below it cannot be broken at two widths +//! in one layout. This module instead lays out the body **twice**: the lines +//! overlapping the band are taken from a narrow layout, and the remaining text +//! is re-flowed at full width and stacked below — so text below the band +//! reclaims the full column, matching Word. +//! +//! Glyph emission is shared with the main paragraph path via +//! [`crate::para_emit::emit_glyph_run`]. The banded path is only used for +//! plain text (no tabs / inline math), which a drop-cap / float paragraph is. + +use parley::{AlignmentOptions, PositionedLayoutItem}; + +use crate::color::LayoutColor; +use crate::font::FontResources; +use crate::items::PositionedItem; +use crate::para::{ResolvedParaProps, StyleSpan, push_para_styles}; +use crate::para_emit::emit_glyph_run; + +/// A side band the first lines of a paragraph must clear. +pub(crate) struct Band { + /// Horizontal width (points) the band occupies, including any gap. + pub inset: f32, + /// Vertical extent (points) the band covers from the paragraph top; lines + /// whose top is above this are narrowed, lines below reclaim full width. + pub cover_height: f32, + /// `true` when the band is on the **left** (object on the left, text shifted + /// right); `false` when on the right (text narrowed but not shifted). + pub shift_text: bool, +} + +/// The laid-out body of a banded paragraph. +pub(crate) struct BandBody { + pub items: Vec, + pub height: f32, + pub width: f32, + pub first_baseline: f32, + pub last_baseline: f32, + pub line_boundaries: Vec<(f32, f32)>, +} + +/// Lays out `text` with a leading side band: the lines overlapping the band are +/// broken at the narrowed width (and shifted right when the band is on the +/// left), and the remaining text is re-flowed at full width below. +pub(crate) fn layout_band_body( + resources: &mut FontResources, + text: &str, + spans: &[StyleSpan], + para_props: &ResolvedParaProps, + line_w: f32, + display_scale: f32, + band: Band, +) -> BandBody { + let indent_start = para_props.indent_start; + let x_shift = if band.shift_text { band.inset } else { 0.0 }; + let narrow_w = (line_w - band.inset).max(1.0); + + let narrow = build_layout(resources, text, spans, para_props, narrow_w, display_scale); + + // How many leading lines overlap the band vertically. + let total = narrow.lines().count(); + let n_lines = narrow + .lines() + .take_while(|l| l.metrics().block_min_coord < band.cover_height) + .count() + .min(total); + + let mut items = Vec::new(); + + // Whole body fits within the band → one narrow segment. + if n_lines >= total { + emit_lines( + &narrow, + spans, + resources, + indent_start + x_shift, + 0, + usize::MAX, + &mut items, + ); + let (first_baseline, last_baseline) = baselines(&narrow, 0.0); + return BandBody { + items, + height: narrow.height(), + width: narrow.width() + x_shift, + first_baseline, + last_baseline, + line_boundaries: line_boundaries(&narrow, 0.0, 0, usize::MAX), + }; + } + + // Split: emit band lines [0, n_lines) narrow (+shift); reflow the tail full. + let split_byte = narrow + .lines() + .nth(n_lines) + .map(|l| l.text_range().start) + .unwrap_or(text.len()); + let band_height = narrow + .lines() + .nth(n_lines) + .map(|l| l.metrics().block_min_coord) + .unwrap_or_else(|| narrow.height()); + + emit_lines( + &narrow, + spans, + resources, + indent_start + x_shift, + 0, + n_lines, + &mut items, + ); + let mut boundaries = line_boundaries(&narrow, 0.0, 0, n_lines); + let first_baseline = narrow + .lines() + .next() + .map(|l| l.metrics().baseline) + .unwrap_or(0.0); + let narrow_width = narrow.width() + x_shift; + drop(narrow); + + let (suffix_text, suffix_spans) = crate::para_drop_cap::trim_leading(text, spans, split_byte); + let full = build_layout( + resources, + &suffix_text, + &suffix_spans, + para_props, + line_w, + display_scale, + ); + + let tail_start = items.len(); + emit_lines( + &full, + &suffix_spans, + resources, + indent_start, + 0, + usize::MAX, + &mut items, + ); + for it in &mut items[tail_start..] { + it.translate(0.0, band_height); + } + boundaries.extend(line_boundaries(&full, band_height, 0, usize::MAX)); + let last_baseline = full + .lines() + .last() + .map(|l| l.metrics().baseline) + .unwrap_or(0.0) + + band_height; + + BandBody { + items, + height: band_height + full.height(), + width: narrow_width.max(full.width()), + first_baseline, + last_baseline, + line_boundaries: boundaries, + } +} + +/// Builds and breaks a plain-text layout at `max_w`, applying the paragraph +/// alignment. +fn build_layout( + resources: &mut FontResources, + text: &str, + spans: &[StyleSpan], + para_props: &ResolvedParaProps, + max_w: f32, + display_scale: f32, +) -> parley::Layout { + let mut builder = + resources + .layout_cx + .ranged_builder(&mut resources.font_cx, text, display_scale, true); + push_para_styles(&mut builder, para_props, spans); + let mut layout = builder.build(text); + layout.break_all_lines(Some(max_w)); + layout.align(para_props.alignment, AlignmentOptions::default()); + layout +} + +/// Emits glyph runs for lines `[lo, hi)` of `layout` at horizontal offset +/// `indent_x`. +fn emit_lines( + layout: &parley::Layout, + spans: &[StyleSpan], + resources: &mut FontResources, + indent_x: f32, + lo: usize, + hi: usize, + items: &mut Vec, +) { + for (i, line) in layout.lines().enumerate() { + if i < lo || i >= hi { + continue; + } + for item in line.items() { + if let PositionedLayoutItem::GlyphRun(glyph_run) = item { + emit_glyph_run(&glyph_run, indent_x, spans, resources, items); + } + } + } +} + +/// Per-line `(min, max)` block coordinates for lines `[lo, hi)`, offset by `dy`. +fn line_boundaries( + layout: &parley::Layout, + dy: f32, + lo: usize, + hi: usize, +) -> Vec<(f32, f32)> { + layout + .lines() + .enumerate() + .filter(|(i, _)| *i >= lo && *i < hi) + .map(|(_, l)| { + let m = l.metrics(); + (m.block_min_coord + dy, m.block_max_coord + dy) + }) + .collect() +} + +/// First and last line baselines, offset by `dy`. +fn baselines(layout: &parley::Layout, dy: f32) -> (f32, f32) { + let first = layout + .lines() + .next() + .map(|l| l.metrics().baseline) + .unwrap_or(0.0) + + dy; + let last = layout + .lines() + .last() + .map(|l| l.metrics().baseline) + .unwrap_or(0.0) + + dy; + (first, last) +} + +#[cfg(test)] +#[path = "para_band_tests.rs"] +mod tests; diff --git a/loki-layout/src/para_band_tests.rs b/loki-layout/src/para_band_tests.rs new file mode 100644 index 00000000..33f57baa --- /dev/null +++ b/loki-layout/src/para_band_tests.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for [`crate::para_band`]. + +use super::*; +use crate::para::ResolvedParaProps; + +fn test_resources() -> FontResources { + let mut r = FontResources::new(); + for p in ["/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"] { + if let Ok(data) = std::fs::read(p) { + r.register_font(data); + } + } + r +} + +fn span(text: &str) -> StyleSpan { + StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + link_url: None, + math: None, + } +} + +/// Collect glyph-run `(y, x)` origins, sorted by y. +fn run_origins(body: &BandBody) -> Vec<(f32, f32)> { + let mut v: Vec<(f32, f32)> = body + .items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(g) => Some((g.origin.y, g.origin.x)), + _ => None, + }) + .collect(); + v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + v +} + +#[test] +fn left_band_shifts_first_lines_and_tail_reclaims_full_width() { + let mut r = test_resources(); + let text = "The quick brown fox jumps over the lazy dog. The quick brown fox jumps \ + over the lazy dog. The quick brown fox jumps over the lazy dog again \ + and again to fill several wrapped lines below the band region."; + let spans = [span(text)]; + let props = ResolvedParaProps::default(); + // Left band: 60 pt wide, covering ~2 lines (cover_height 30 pt ≈ 2 × ~14 pt). + let band = Band { + inset: 60.0, + cover_height: 30.0, + shift_text: true, + }; + let body = layout_band_body(&mut r, text, &spans, &props, 360.0, 1.0, band); + + let origins = run_origins(&body); + assert!(origins.len() >= 4, "expected several wrapped lines"); + let first_y = origins.first().unwrap().0; + let last_y = origins.last().unwrap().0; + + // Lines in the band (smallest y) are shifted right by the inset. + let first_x = origins + .iter() + .filter(|(y, _)| (*y - first_y).abs() < 0.5) + .map(|(_, x)| *x) + .fold(f32::INFINITY, f32::min); + assert!( + (first_x - 60.0).abs() < 2.0, + "band line should be shifted by the 60 pt inset; got {first_x}" + ); + + // Lines below the band reclaim the full column (x ≈ 0). + let last_x = origins + .iter() + .filter(|(y, _)| (*y - last_y).abs() < 0.5) + .map(|(_, x)| *x) + .fold(f32::INFINITY, f32::min); + assert!( + last_x < 2.0, + "tail line should reclaim the full left margin; got {last_x}" + ); +} + +#[test] +fn right_band_narrows_without_shifting() { + let mut r = test_resources(); + let text = "The quick brown fox jumps over the lazy dog repeatedly to fill several \ + lines of wrapped body text beside and below the right-hand band region."; + let spans = [span(text)]; + let props = ResolvedParaProps::default(); + // Right band: text stays at the left edge (no shift), just narrowed. + let band = Band { + inset: 60.0, + cover_height: 30.0, + shift_text: false, + }; + let body = layout_band_body(&mut r, text, &spans, &props, 360.0, 1.0, band); + let origins = run_origins(&body); + // All lines start at the left edge (x ≈ 0) regardless of the band. + for (_, x) in &origins { + assert!(*x < 2.0, "right band must not shift text; got x = {x}"); + } +} + +#[test] +fn short_body_within_band_stays_single_segment() { + let mut r = test_resources(); + let text = "Short text."; + let spans = [span(text)]; + let props = ResolvedParaProps::default(); + let band = Band { + inset: 40.0, + cover_height: 200.0, // taller than the single line + shift_text: true, + }; + let body = layout_band_body(&mut r, text, &spans, &props, 360.0, 1.0, band); + let origins = run_origins(&body); + assert!(!origins.is_empty()); + // The single line is in the band → shifted right by the inset. + for (_, x) in &origins { + assert!( + (*x - 40.0).abs() < 2.0, + "in-band line should be shifted; got x = {x}" + ); + } +} diff --git a/loki-layout/src/para_tests.rs b/loki-layout/src/para_tests.rs index fda5542e..39b46d5c 100644 --- a/loki-layout/src/para_tests.rs +++ b/loki-layout/src/para_tests.rs @@ -965,9 +965,11 @@ fn drop_cap_enlarges_initial_and_shifts_first_lines() { first_line_min_x > 10.0, "first body line must clear the cap band; min x = {first_line_min_x}" ); + // Per-line precision: the line below the 3-line cap reclaims the full column + // (back to the paragraph's left edge, x ≈ indent_start = 0), not the cap band. assert!( - last_line_min_x < first_line_min_x - 5.0, - "a line below the cap must return toward the left margin; \ + last_line_min_x < 2.0, + "a line below the cap must reclaim the full left margin; \ first = {first_line_min_x}, last = {last_line_min_x}" ); } diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index 7993b5d4..6f2c2645 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -909,6 +909,8 @@ fn map_para_props(p: &ParaProps) -> ResolvedParaProps { // Dropped initial (rendered in the read-only/paint path); see // `layout_paragraph`. Forwarded straight from the imported model. drop_cap: p.drop_cap, + // Float wrap band is injected by the flow engine, not the model. + wrap_band: None, } } From 163e6d4310ce8ea370c2d1007cec7518a7faba3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:00:39 +0000 Subject: [PATCH 16/35] feat(layout): cross-paragraph float wrap A float taller than its anchoring paragraph now keeps wrapping the *following* paragraphs beside its remaining vertical extent, instead of those paragraphs starting below the float. This matches Word, where a tall image flows the next paragraphs around it until its bottom edge. - `flow::float_impl::ActiveFloat` records a float's page-relative bottom, band inset, and side on `FlowState::active_float` when the float overhangs its anchor. - `flow_paragraph` applies the remaining band as a `WrapBand` on each following paragraph that starts above the float bottom (`para_band` then narrows just the overlapping lines), and clears the float once a paragraph reaches its bottom. The old `max(height, float_height)` reservation is dropped so the next paragraph can start within the band. - `reserve_active_float` reserves any unused float tail (advances the cursor to the float bottom) when the wrap can't continue: a non-paragraph block (table/list/rule), the end of the block list, or a page boundary. Wrap is bounded to a single page. Verified: new flow test `tall_float_wraps_following_paragraph` (short anchor + 1-inch float + long follower) asserts the follower's lines beside the float are shifted and the lines below reclaim full width; ACID page 10 (float taller-than-text anchor) is byte-for-byte unchanged. All layout/ooxml/pdf suites green; clippy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-layout/src/flow.rs | 24 ++++++++++ loki-layout/src/flow_float.rs | 62 +++++++++++++++++++------ loki-layout/src/flow_para.rs | 62 ++++++++++++++++++++++--- loki-layout/src/flow_tests.rs | 87 +++++++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 21 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 58071f70..266b8a04 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -77,7 +77,7 @@ This is the living source of truth documenting which document features, characte | **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). **Combined vMerge + gridSpan (L-merges) place correctly:** the layout assigns each cell its grid columns via a shared coverage grid (`assign_cell_columns`), so a cell in a row whose leading column is occupied by a vertical merge above is shifted into the next free column instead of overlapping the merged cell (TC-DOCX-005). Tested by `vmerge_gridspan_l_merge_places_cells_correctly` and `test_table_row_span_distribution`. | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | -| **Floating Images / Wrap Mode** | Yes | Partial | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Limitations (gap #12):** v1 wraps only the float's **anchoring paragraph**; the live-editor path keeps the older uniform-narrow fallback; cross-paragraph wrap, the tight wrap **contour** (approximated by the bounding box), true `behindDoc` overlap, and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. Tested: `flow_float` unit tests + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | +| **Floating Images / Wrap Mode** | Yes | Partial | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Cross-paragraph wrap:** a float taller than its anchoring paragraph keeps wrapping the *following* paragraphs (tracked as `flow::float_impl::ActiveFloat`) until its bottom is cleared; bounded to a single page and to consecutive plain paragraphs (a table/list/rule or page break reserves the float's remaining height instead). **Limitations (gap #12):** the live-editor path keeps the uniform-narrow fallback; the tight wrap **contour** is approximated by the bounding box; true `behindDoc` overlap and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. Tested: `flow_float` unit tests + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | | **External Images** | Yes | No | No | Renders as gray placeholder rectangles. | --- diff --git a/loki-layout/src/flow.rs b/loki-layout/src/flow.rs index 47c01118..c1f48c00 100644 --- a/loki-layout/src/flow.rs +++ b/loki-layout/src/flow.rs @@ -192,6 +192,11 @@ pub(super) struct FlowState<'a> { /// table-cell content so a long word wraps to the column width instead of /// overflowing into the neighbouring cell. pub(super) break_long_words: bool, + /// A float taller than its anchoring paragraph whose remaining vertical + /// extent the following paragraphs on this page keep wrapping beside. Set by + /// [`para_impl::flow_paragraph`]; reserved/cleared by + /// [`float_impl::reserve_active_float`] and on every page boundary. + pub(super) active_float: Option, } impl FlowState<'_> { @@ -301,6 +306,7 @@ fn new_flow_state<'a>( comments, pending_comment_anchors: Vec::new(), break_long_words: false, + active_float: None, } } @@ -345,6 +351,9 @@ fn run_paginated_loop( flow_block(state, block, i); i += 1; } + // Reserve any float left active by the final block so the section's height + // accounts for it. + float_impl::reserve_active_float(state); None } @@ -437,6 +446,8 @@ pub fn flow_section( for (idx, block) in section.blocks.iter().enumerate() { flow_block(&mut state, block, idx); } + // Reserve any float left active by the final block (continuous mode). + float_impl::reserve_active_float(&mut state); } flow_footnotes(&mut state); @@ -471,6 +482,15 @@ pub fn flow_section( // ── Block dispatch ──────────────────────────────────────────────────────────── fn flow_block(state: &mut FlowState, block: &Block, idx: usize) { + // Only consecutive plain paragraphs continue a cross-paragraph float wrap; + // any other block clears the float (reserving its remaining height) so it + // does not overlap the image. + if !matches!( + block, + Block::StyledPara(_) | Block::Para(_) | Block::Plain(_) | Block::Heading(..) + ) { + float_impl::reserve_active_float(state); + } match block { Block::StyledPara(p) => flow_paragraph(state, p, idx), Block::Para(i) | Block::Plain(i) => { @@ -598,6 +618,8 @@ pub(super) fn finish_page(state: &mut FlowState) { state.page_number += 1; state.current_paragraphs.clear(); state.cursor_y = 0.0; + // Cross-paragraph float wrap does not continue onto the next page. + state.active_float = None; } // ── Header / footer layout helpers ─────────────────────────────────────────── @@ -1189,6 +1211,7 @@ fn measure_cell_height( pending_comment_anchors: Vec::new(), // Cell content: break over-long words to the column width (Word). break_long_words: true, + active_float: None, }; for block in &cell.blocks { @@ -1327,6 +1350,7 @@ fn flow_cell_blocks( pending_comment_anchors: Vec::new(), // Cell content: break over-long words to the column width (Word). break_long_words: true, + active_float: None, }; for block in blocks { diff --git a/loki-layout/src/flow_float.rs b/loki-layout/src/flow_float.rs index 0f24163e..d9555bc6 100644 --- a/loki-layout/src/flow_float.rs +++ b/loki-layout/src/flow_float.rs @@ -9,17 +9,21 @@ //! avoid (returned as left/right indent deltas), and produces the float's image //! item. //! -//! **Scope (v1).** Wrapping is applied to the *anchoring paragraph* only. The -//! band is passed to the layout as a [`crate::para::WrapBand`]; on the paint -//! path the body is laid out in two passes (`para_band`) so lines beside the -//! float are narrowed while lines below it reclaim the full column. `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. A float taller than its paragraph reserves its full height so following -//! paragraphs clear it, but they do not themselves wrap. OOXML `wp:anchor` wrap -//! children; ODF `style:wrap`. +//! **Scope.** The float is planned for the *anchoring paragraph*; the band is +//! passed to the layout as a [`crate::para::WrapBand`] and, on the paint path, +//! the body is laid out in two passes (`para_band`) so lines beside the float +//! are narrowed while lines below it reclaim the full column. When the float is +//! 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 +//! 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`. use loki_doc_model::content::float::{TextWrap, WrapSide}; @@ -27,6 +31,37 @@ use crate::geometry::LayoutRect; use crate::items::{PositionedImage, PositionedItem}; use crate::resolve::{CollectedImage, emu_to_pt}; +/// A float whose vertical extent reaches past its anchoring paragraph, so the +/// paragraphs that follow on the same page keep wrapping beside it. +/// +/// Tracked on [`super::FlowState::active_float`]. Coordinates are +/// page-content-relative (the same space as [`super::FlowState::cursor_y`]). +pub(crate) struct ActiveFloat { + /// Page-content-relative y of the float's bottom edge. Paragraphs starting + /// above this are narrowed to clear the band; the first one at/below it ends + /// the wrap. + pub bottom_y: f32, + /// Horizontal band width (points) following paragraphs must clear. + pub inset: f32, + /// `true` when the float is on the left (text shifts right); `false` when on + /// the right (text narrows but does not shift). + pub shift_text: bool, +} + +/// Reserves any remaining vertical extent of an active float and clears it. +/// +/// Called when the float can no longer be wrapped by following text — a +/// non-paragraph block (table/list/rule), the end of the block list, or a page +/// boundary. Advancing `cursor_y` to the float bottom keeps later content from +/// overlapping the image. +pub(crate) fn reserve_active_float(state: &mut super::FlowState) { + if let Some(af) = state.active_float.take() + && state.cursor_y < af.bottom_y + { + state.cursor_y = af.bottom_y; + } +} + /// Default gap between a float and the wrapped text, in points (~0.13"). const FLOAT_WRAP_GAP: f32 = 9.0; @@ -39,8 +74,9 @@ pub(crate) struct FloatPlacement { /// The float's image item in paragraph-content-local coordinates (x measured /// from the content-area left edge, y from the paragraph top). pub item: PositionedItem, - /// Float height in points; the paragraph reserves at least this much so the - /// following paragraph clears the float. + /// Float height in points. When it exceeds the anchoring paragraph's text, + /// the overhang becomes an [`ActiveFloat`] so following paragraphs wrap + /// beside it (and any unused tail is reserved before the next block). pub height: f32, } diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index 1eea0b8b..e1f57123 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -148,13 +148,21 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc // floated image is removed from the inline/block image set so it is not also // stacked above the text. let float_plan = super::float_impl::plan_float(&images, state.content_width); - if let Some((idx, placement)) = &float_plan { + // 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: placement.indent_start_delta + placement.indent_end_delta, - cover_height: placement.height, - shift_text: placement.indent_start_delta > 0.0, + inset, + cover_height: height, + shift_text, }); images.remove(*idx); } @@ -165,6 +173,20 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc finish_page(state); } + // Cross-paragraph wrap: when this paragraph has no float of its own but an + // earlier float still extends below the cursor, narrow it to clear the + // remaining band (the part of the float still above the paragraph top). + if own_float.is_none() + && let Some(af) = &state.active_float + && state.cursor_y < af.bottom_y - 0.5 + { + resolved.wrap_band = Some(crate::para::WrapBand { + inset: af.inset, + cover_height: af.bottom_y - state.cursor_y, + shift_text: af.shift_text, + }); + } + // Record comment start anchors at the paragraph's top (on the final page, // after any page break above) for the gutter comment panel. super::comments_impl::record_comment_anchors(state, &effective_para.inlines); @@ -213,15 +235,41 @@ pub(super) fn flow_paragraph(state: &mut FlowState, para: &StyledParagraph, bloc para_layout.items = image_items; } - // Emit the floating image beside the wrapped text and reserve its height so - // the following paragraph clears it. + // Emit the floating image beside the wrapped text. Its height is NOT forced + // onto the paragraph: a float taller than its text becomes an `ActiveFloat` + // (below) so the *following* paragraphs wrap beside its remaining extent + // rather than starting below it. if let Some((_, placement)) = float_plan { para_layout.items.push(placement.item); - para_layout.height = para_layout.height.max(placement.height); } + // 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); + // Maintain the cross-paragraph float band. + if state.page_number != page_before { + // The paragraph crossed a page; wrap does not span pages. + state.active_float = None; + } else if let Some((inset, height, shift_text)) = own_float { + // A float taller than its anchoring paragraph keeps wrapping below. + let bottom_y = para_top + height; + state.active_float = + (bottom_y > state.cursor_y + 0.5).then_some(super::float_impl::ActiveFloat { + bottom_y, + inset, + shift_text, + }); + } else if let Some(af) = &state.active_float { + // Inherited float: drop it once this paragraph reaches its bottom. + if state.cursor_y >= af.bottom_y - 0.5 { + state.active_float = None; + } + } + if resolved.page_break_after && state.mode.is_paginated() { finish_page(state); } diff --git a/loki-layout/src/flow_tests.rs b/loki-layout/src/flow_tests.rs index f07a42b1..4ca781be 100644 --- a/loki-layout/src/flow_tests.rs +++ b/loki-layout/src/flow_tests.rs @@ -1300,4 +1300,91 @@ mod page_fields { let f2 = format!("{:?}", pages[1].footer_items); assert_eq!(f1, f2, "static footers should be identical on every page"); } + + /// A floating image taller than its short anchoring paragraph keeps wrapping + /// the *following* paragraph beside it (cross-paragraph wrap), and text below + /// the float reclaims the full column width. + #[test] + fn tall_float_wraps_following_paragraph() { + use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; + use loki_doc_model::content::inline::LinkTarget; + + fn floating_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())); + // side = Right (text on the right) → float on the LEFT, text shifts right. + FloatWrap { + wrap: TextWrap::Square, + side: WrapSide::Right, + 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 float (72 × 72 pt) anchored in a one-word paragraph. + let anchor = StyledParagraph { + style_id: None, + direct_para_props: None, + direct_char_props: None, + inlines: vec![floating_image(914_400, 914_400), Inline::Str("Hi.".into())], + attr: NodeAttr::default(), + }; + // A long follower that wraps for many lines — past the float bottom. + let follower_text = "The quick brown fox jumps over the lazy dog. ".repeat(14); + let follower = make_para(&follower_text); + let section = section_of(vec![anchor, follower], PageLayout::default()); + + let (items, _h, _w) = flow_pageless(&mut r, §ion); + + // The float image is a tall item at the left edge. + let img = items + .iter() + .find_map(|i| match i { + PositionedItem::Image(im) => Some(im), + _ => None, + }) + .expect("float image emitted"); + // In Canvas/Pageless output, content x carries the left-margin offset, so + // a left float sits at ≈ the left margin rather than literal 0. + let left_edge = img.rect.origin.x; + assert!((img.rect.size.height - 72.0).abs() < 1.0, "1 in tall float"); + + // Glyph-run origins by y. + let glyphs: Vec<(f32, f32)> = items + .iter() + .filter_map(|i| match i { + PositionedItem::GlyphRun(g) => Some((g.origin.y, g.origin.x)), + _ => None, + }) + .collect(); + + // The FOLLOWER's first lines (below the one-line anchor, still within the + // 72 pt float extent: y ∈ 20..60) are shifted right to clear the band. + let beside_min_x = glyphs + .iter() + .filter(|(y, _)| *y > 20.0 && *y < 60.0) + .map(|(_, x)| *x) + .fold(f32::INFINITY, f32::min); + assert!( + beside_min_x > left_edge + 70.0, + "follower lines beside the float must clear the band; \ + min x = {beside_min_x}, float left = {left_edge}" + ); + + // Lines below the float (y > 90) reclaim the full column (back to the + // float's own left edge, i.e. the column start). + let below_min_x = glyphs + .iter() + .filter(|(y, _)| *y > 90.0) + .map(|(_, x)| *x) + .fold(f32::INFINITY, f32::min); + assert!( + below_min_x < left_edge + 5.0, + "text below the float must reclaim full width; \ + min x = {below_min_x}, float left = {left_edge}" + ); + } } From 98f8f2051cb5dc30862588577ad6f7e4dfa6d152 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:43:11 +0000 Subject: [PATCH 17/35] feat(layout): render drop caps in the live editor with hit-testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the dropped initial only rendered on the read-only paint path; in the editor (`preserve_for_editing`) it stayed inline at body size, because trimming the cap from the body text would desync hit-test indices. The editor now shows the same enlarged initial as print. The read-only path keeps the precise two-pass band split (`para_band`, no single hit-test layout). The editor instead lays the body out as ONE uniform-narrow Parley layout it can hit-test against: - The cap bytes are trimmed from `clean_text` and the orig↔clean maps are rebased past them, so body-layout offsets map back to the correct original bytes for cursor/click/selection. - `ParagraphLayout` gains `drop_lines` / `drop_shift`; `line_indent` adds the shift to the leading lines beside the cap so the caret, hit-test, and selection geometry line up with the rendered glyphs. - The fallback path emits the enlarged initial and shifts only the first `n_lines` (replacing the prior uniform float-band shift, which now also shifts just the lines beside the band — more correct for both). Limitations (documented): the editor body uses the uniform-narrow fallback (lines below the cap are slightly narrow vs. the read-only per-line split), and cursor granularity around the cap is coarse — the cap collapses to the body-start caret, so it is deletable (Backspace from body start) but not independently selectable. Tests: `drop_cap_enlarged_and_hit_testable_in_editor` replaces the old inline-only test (asserts enlarged glyph + retained layout + first-line caret shifted + body click maps past the cap). Read-only ACID page 9 is byte-for-byte unchanged; all layout/ooxml/odf/pdf/vello suites green; workspace clippy gate clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-layout/src/flow_para.rs | 2 + loki-layout/src/para.rs | 95 +++++++++++++++++++++++++++++------ loki-layout/src/para_tests.rs | 44 +++++++++++++--- 4 files changed, 122 insertions(+), 21 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 266b8a04..7c8f0f68 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -63,7 +63,7 @@ This is the living source of truth documenting which document features, characte | **Named / Custom Paragraph Styles** | Yes | Yes | Yes | The full style catalog round-trips through DOCX `styles.xml`: custom styles created in the style editor, plus edits to built-in `Normal`/`Heading1`–`6`, persist across save/reload. Font family, weight (→ bold), size, alignment, indentation (incl. first-line), spacing, line height, `basedOn`, `next`-style, outline level, and the custom flag are all written and read back. In-session, the catalog round-trips through the Loro CRDT (`loro_bridge::styles`, a JSON snapshot like metadata), so style-editor edits are durable across rebuilds and **undoable** with Ctrl+Z/Ctrl+Y. | | **border_between** | Yes | No | No | Rules between adjacent same-styled paragraphs ignored. | | **bidi / RTL** | Yes | No | No | RTL paragraphs ignored due to Parley API limitations. | -| **Drop Cap** | Yes | Yes (paint) | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Rendered** on the read-only/paint path (PDF/print/canvas): the leading initial is enlarged to span `lines` rows and the body text wraps to its right (`para_drop_cap.rs` + `layout_paragraph`). Word encodes a drop cap as a `w:framePr` frame paragraph holding just the initial followed by the body paragraph; a DOCX import pass (`drop_cap_merge.rs`) folds the frame into the body so the single-paragraph (ODF-style) renderer applies. The cap is sized so its ascent spans the line band, coloured/weighted from the initial's run, positioned with its top at the first line and baseline near the last spanned line; `margin` mode hangs it in the left margin. **Per-line precision:** body lines *beside* the cap are narrowed while lines *below* it reclaim the full column width — Parley exposes no public per-line max-advance, so the body is laid out in two passes (`para_band`: narrow band lines + full-width reflow of the tail, stacked). **Limitations:** in the live editor (`preserve_for_editing`) the initial stays inline so hit-test indices remain aligned, and that path keeps the older uniform-narrow fallback (follow-up). Not re-exported, not round-tripped through Loro. Tested: `frame_pr_*` + `drop_cap_merge` (DOCX), `read_stylesheet_drop_cap`/`drop_cap_*` (ODT), `cap_byte_len_*` + `drop_cap_enlarges_initial_and_shifts_first_lines` / `drop_cap_inline_when_preserving_for_editing` (layout). ACID TC-DOCX-015 / TC-ODT-013. | +| **Drop Cap** | Yes | Yes | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Rendered** on the read-only/paint path (PDF/print/canvas): the leading initial is enlarged to span `lines` rows and the body text wraps to its right (`para_drop_cap.rs` + `layout_paragraph`). Word encodes a drop cap as a `w:framePr` frame paragraph holding just the initial followed by the body paragraph; a DOCX import pass (`drop_cap_merge.rs`) folds the frame into the body so the single-paragraph (ODF-style) renderer applies. The cap is sized so its ascent spans the line band, coloured/weighted from the initial's run, positioned with its top at the first line and baseline near the last spanned line; `margin` mode hangs it in the left margin. **Per-line precision (read-only):** body lines *beside* the cap are narrowed while lines *below* it reclaim the full column width — Parley exposes no public per-line max-advance, so the body is laid out in two passes (`para_band`: narrow band lines + full-width reflow of the tail, stacked). **Live editor (`preserve_for_editing`):** the enlarged initial is now rendered in the editor too, with the body laid out as a single hit-testable Parley layout (the cap bytes are trimmed and the orig↔clean maps rebased past them, and `ParagraphLayout::drop_lines`/`drop_shift` keep the caret, hit-test, and selection geometry aligned with the shifted lines). **Limitations:** the editor body uses the uniform-narrow fallback (lines below the cap are slightly narrow), and cursor granularity *around* the cap is coarse — the cap collapses to the body-start caret position, so it is deletable (Backspace from the body start) but not independently selectable. Not re-exported, not round-tripped through Loro. Tested: `frame_pr_*` + `drop_cap_merge` (DOCX), `read_stylesheet_drop_cap`/`drop_cap_*` (ODT), `cap_byte_len_*` + `drop_cap_enlarges_initial_and_shifts_first_lines` / `drop_cap_enlarged_and_hit_testable_in_editor` (layout). ACID TC-DOCX-015 / TC-ODT-013. | --- diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index e1f57123..1262dbdb 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -487,6 +487,8 @@ fn build_chain_layouts<'s>( clean_to_orig: vec![0], indent_start: 0.0, indent_hanging: 0.0, + drop_lines: 0, + drop_shift: 0.0, }, ) } diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index fb351b55..94d805d3 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -407,6 +407,16 @@ pub struct ParagraphLayout { /// Hanging indent in points (first line starts this far left of /// `indent_start`). `0.0` = no hanging. pub indent_hanging: f32, + /// Number of leading lines shifted right by [`Self::drop_shift`] to clear a + /// dropped initial (or a float band in the editor fallback). `0` = none. + /// + /// Editing geometry ([`Self::line_indent`]) adds the shift to these lines so + /// the caret, hit-test, and selection coordinates line up with the rendered + /// glyphs (the Parley layout is built in an un-shifted coordinate space). + pub drop_lines: usize, + /// Horizontal shift in points applied to the first [`Self::drop_lines`] + /// lines. `0.0` = none. + pub drop_shift: f32, } impl std::fmt::Debug for ParagraphLayout { @@ -594,10 +604,16 @@ impl ParagraphLayout { /// left of `indent_start`. Editing geometry adds this so cursor, hit-test, /// and selection coordinates line up with the rendered text. fn line_indent(&self, line_index: usize) -> f32 { - if line_index == 0 && self.indent_hanging > 0.0 { + let base = if line_index == 0 && self.indent_hanging > 0.0 { self.indent_start - self.indent_hanging } else { self.indent_start + }; + // Leading lines beside a dropped initial / float band are shifted right. + if line_index < self.drop_lines { + base + self.drop_shift + } else { + base } } } @@ -1053,7 +1069,7 @@ fn layout_paragraph_uncached( display_scale: f32, preserve_for_editing: bool, ) -> ParagraphLayout { - let (mut clean_text, mut clean_spans, orig_to_clean, clean_to_orig) = + let (mut clean_text, mut clean_spans, mut orig_to_clean, mut clean_to_orig) = clean_text_and_spans(text_content, style_spans); for span in &mut clean_spans { @@ -1101,6 +1117,8 @@ fn layout_paragraph_uncached( 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 @@ -1132,6 +1150,8 @@ fn layout_paragraph_uncached( clean_to_orig, indent_start: para_props.indent_start, indent_hanging: para_props.indent_hanging, + drop_lines: 0, + drop_shift: 0.0, }; } @@ -1241,12 +1261,15 @@ fn layout_paragraph_uncached( ) }; - // ── Drop-cap preparation (read-only paint path only) ────────────────────── + // ── Drop-cap preparation ────────────────────────────────────────────────── // The dropped initial spans several lines, so it is removed from the body // flow and rendered separately; the first `n_lines` body lines are narrowed - // and shifted to clear it. Gated to `!preserve_for_editing` because trimming - // the cap from the body text would desync editor hit-test indices; in the - // editor the initial keeps its inline form (follow-up). Tabs / inline math + // and shifted to clear it. The cap bytes are trimmed from `clean_text`, so + // the orig↔clean maps are rebased past them below to keep editor hit-testing + // aligned. Read-only paint uses the precise two-pass band split; the editor + // (`preserve_for_editing`) renders the same enlarged cap but lays the body + // out as a single uniform-narrow layout it can hit-test against (the lines + // below the cap are slightly narrow, as documented). Tabs / inline math // disqualify a paragraph (the cap's manual breaking is incompatible). let drop_state: Option<( loki_doc_model::style::props::drop_cap::DropCap, @@ -1254,7 +1277,7 @@ fn layout_paragraph_uncached( StyleSpan, )> = para_props .drop_cap - .filter(|_| !preserve_for_editing && tab_char_positions.is_empty() && math_boxes.is_empty()) + .filter(|_| tab_char_positions.is_empty() && math_boxes.is_empty()) .and_then(|dc| { let k = crate::para_drop_cap::cap_byte_len(&clean_text, dc.length); if k == 0 || k >= clean_text.len() { @@ -1273,6 +1296,22 @@ fn layout_paragraph_uncached( Some((dc, cap_text, base)) }); + // Rebase the orig↔clean maps past the trimmed cap so the body layout's + // offsets (which start after the cap) map back to the right original bytes + // for editor hit-testing. The cap bytes [0, k) collapse to body offset 0; + // body byte j corresponds to clean byte j + k. + let drop_cap_bytes = drop_state + .as_ref() + .map(|(_, cap, _)| cap.len()) + .unwrap_or(0); + if drop_cap_bytes > 0 { + for v in orig_to_clean.iter_mut() { + *v = v.saturating_sub(drop_cap_bytes); + } + let drain_to = drop_cap_bytes.min(clean_to_orig.len()); + clean_to_orig.drain(0..drain_to); + } + // ── Main (final) layout pass ────────────────────────────────────────────── let mut builder = resources.layout_cx.ranged_builder( &mut resources.font_cx, @@ -1380,20 +1419,33 @@ fn layout_paragraph_uncached( clean_to_orig, indent_start: para_props.indent_start, indent_hanging: para_props.indent_hanging, + drop_lines: 0, + drop_shift: 0.0, }; } // Fallback / normal path: break at the (possibly band-narrowed) width. A - // band here means the float could not be split (editor, tabs, or math) — it - // uniformly narrows every line (APPROX, as documented for `para_band`). + // band here is a drop cap in the editor, or a float that could not be split + // (editor, tabs, or math). Every line wraps at the narrowed width (APPROX, + // as documented for `para_band`); only the leading lines beside the object + // are shifted right to clear it. let band_inset = band.as_ref().map(|b| b.inset).unwrap_or(0.0); - let band_shift = band + let drop_shift = band .as_ref() .map(|b| if b.shift_text { b.inset } else { 0.0 }) .unwrap_or(0.0); layout.break_all_lines(Some((line_w - band_inset).max(1.0))); layout.align(para_props.alignment, AlignmentOptions::default()); + // Leading lines whose top is within the band's vertical extent are shifted. + let drop_lines = match &band { + Some(b) => layout + .lines() + .take_while(|l| l.metrics().block_min_coord < b.cover_height) + .count(), + None => 0, + }; + let total_height = layout.height(); let total_width = layout.width(); let first_baseline = layout @@ -1427,8 +1479,11 @@ fn layout_paragraph_uncached( } else { para_props.indent_start }; - // Float-wrap fallback (uniform narrow): every line clears the band. - indent_x += band_shift; + // Leading lines beside a drop cap / float band are shifted right to + // clear it; lines below it return to the paragraph's left edge. + if line_index < drop_lines { + indent_x += drop_shift; + } let line_baseline = line.metrics().baseline; for item in line.items() { // Math inline box: emit the typeset equation's draw items, offset to @@ -1475,8 +1530,18 @@ fn layout_paragraph_uncached( line_index += 1; } - // `drop_plan` is always handled by the band-split path above (drop caps - // never reach this fallback), so only the box decorations remain here. + // A drop cap reaches this fallback only in the editor (`preserve_for_editing`), + // where the body is one hit-testable layout; emit its enlarged initial at the + // paragraph's left edge, above the shifted body lines. + if let Some((p, _)) = &drop_plan { + for it in &p.items { + let mut it = it.clone(); + it.translate(para_props.indent_start, 0.0); + items.push(it); + } + content_bottom = content_bottom.max(p.bottom); + } + prepend_para_box(&mut items, para_props, total_width, total_height); let parley_layout = if preserve_for_editing { @@ -1499,6 +1564,8 @@ fn layout_paragraph_uncached( clean_to_orig, indent_start: para_props.indent_start, indent_hanging: para_props.indent_hanging, + drop_lines, + drop_shift, } } diff --git a/loki-layout/src/para_tests.rs b/loki-layout/src/para_tests.rs index 39b46d5c..6fe49e3d 100644 --- a/loki-layout/src/para_tests.rs +++ b/loki-layout/src/para_tests.rs @@ -975,11 +975,11 @@ fn drop_cap_enlarges_initial_and_shifts_first_lines() { } #[test] -fn drop_cap_inline_when_preserving_for_editing() { +fn drop_cap_enlarged_and_hit_testable_in_editor() { use loki_doc_model::style::props::drop_cap::{DropCap, DropCapLength}; let mut r = test_resources(); - let text = "Hello world this is body text that should stay inline in the editor."; + let text = "Hello world this is body text that wraps beside the cap in the editor."; let spans = [single_span(text, 12.0)]; let props = ResolvedParaProps { drop_cap: Some(DropCap { @@ -990,9 +990,11 @@ fn drop_cap_inline_when_preserving_for_editing() { }), ..ResolvedParaProps::default() }; - // Editing path: the cap stays inline (no enlarged glyph) so hit-test - // indices remain aligned with the source text. + // Editor path: the initial is enlarged (matching print) AND a hit-testable + // Parley layout is retained for the body. let result = layout_paragraph(&mut r, text, &spans, &props, 300.0, 1.0, true); + + // The dropped initial is rendered enlarged (≈ 3 line-heights tall). let max_size = result .items .iter() @@ -1002,8 +1004,38 @@ fn drop_cap_inline_when_preserving_for_editing() { }) .fold(0.0_f32, f32::max); assert!( - (max_size - 12.0).abs() < 0.5, - "editor path must keep the initial inline at body size; max = {max_size}" + max_size > 24.0, + "editor must render the enlarged initial; max glyph size = {max_size}" + ); + + // Hit-testing is available (body layout retained). + let caret = result + .cursor_rect(1) + .expect("editor drop-cap paragraph must retain a hit-testable layout"); + // The caret just after the cap (start of the body's first line) sits shifted + // right into the band, clearing the dropped initial. + assert!( + caret.x > 20.0, + "first body line caret must clear the cap band; x = {}", + caret.x + ); + + // A click in the body maps back to a sensible original offset past the cap. + let body_glyph = result + .items + .iter() + .find_map(|i| match i { + PositionedItem::GlyphRun(g) if g.font_size < 16.0 => Some(g.origin), + _ => None, + }) + .expect("body glyphs present"); + let hit = result + .hit_test_point(body_glyph.x + 2.0, body_glyph.y) + .expect("hit-test available in editor"); + assert!( + hit.byte_offset >= 1, + "a click in the body must map past the cap byte; offset = {}", + hit.byte_offset ); } From d66b91751a63c4182a61ed689d57ea6bd8106ec2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:50:45 +0000 Subject: [PATCH 18/35] test(acid): render ODT fixtures + verify ODT drop-cap path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the render_acid_pdf example to dispatch on file extension so it can render .odt fixtures (via OdtImport) as well as .docx, enabling visual spot-checks of the ODT layout path. Spot-check findings (docs/fidelity-status.md updated): - TC-ODT-013 (drop cap): renders identically to the DOCX path through the shared para_band renderer — the enlarged initial spans three lines, the body wraps to its right, and the tail lines reclaim the full column width (item-#1 per-line precision confirmed on ODT). - TC-ODT-007 (frames + wrap): the acid_odt.odt fixture contains no draw:frame/draw:image/style:wrap content, so the ODT import→float wiring is unit-tested but cannot be visually verified through ACID; the float layout itself is renderer-shared with the verified DOCX path (TC-DOCX-023). No production code change — example tooling + docs only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 4 ++-- loki-acid/examples/render_acid_pdf.rs | 21 ++++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 7c8f0f68..d0c1424b 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -63,7 +63,7 @@ This is the living source of truth documenting which document features, characte | **Named / Custom Paragraph Styles** | Yes | Yes | Yes | The full style catalog round-trips through DOCX `styles.xml`: custom styles created in the style editor, plus edits to built-in `Normal`/`Heading1`–`6`, persist across save/reload. Font family, weight (→ bold), size, alignment, indentation (incl. first-line), spacing, line height, `basedOn`, `next`-style, outline level, and the custom flag are all written and read back. In-session, the catalog round-trips through the Loro CRDT (`loro_bridge::styles`, a JSON snapshot like metadata), so style-editor edits are durable across rebuilds and **undoable** with Ctrl+Z/Ctrl+Y. | | **border_between** | Yes | No | No | Rules between adjacent same-styled paragraphs ignored. | | **bidi / RTL** | Yes | No | No | RTL paragraphs ignored due to Parley API limitations. | -| **Drop Cap** | Yes | Yes | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Rendered** on the read-only/paint path (PDF/print/canvas): the leading initial is enlarged to span `lines` rows and the body text wraps to its right (`para_drop_cap.rs` + `layout_paragraph`). Word encodes a drop cap as a `w:framePr` frame paragraph holding just the initial followed by the body paragraph; a DOCX import pass (`drop_cap_merge.rs`) folds the frame into the body so the single-paragraph (ODF-style) renderer applies. The cap is sized so its ascent spans the line band, coloured/weighted from the initial's run, positioned with its top at the first line and baseline near the last spanned line; `margin` mode hangs it in the left margin. **Per-line precision (read-only):** body lines *beside* the cap are narrowed while lines *below* it reclaim the full column width — Parley exposes no public per-line max-advance, so the body is laid out in two passes (`para_band`: narrow band lines + full-width reflow of the tail, stacked). **Live editor (`preserve_for_editing`):** the enlarged initial is now rendered in the editor too, with the body laid out as a single hit-testable Parley layout (the cap bytes are trimmed and the orig↔clean maps rebased past them, and `ParagraphLayout::drop_lines`/`drop_shift` keep the caret, hit-test, and selection geometry aligned with the shifted lines). **Limitations:** the editor body uses the uniform-narrow fallback (lines below the cap are slightly narrow), and cursor granularity *around* the cap is coarse — the cap collapses to the body-start caret position, so it is deletable (Backspace from the body start) but not independently selectable. Not re-exported, not round-tripped through Loro. Tested: `frame_pr_*` + `drop_cap_merge` (DOCX), `read_stylesheet_drop_cap`/`drop_cap_*` (ODT), `cap_byte_len_*` + `drop_cap_enlarges_initial_and_shifts_first_lines` / `drop_cap_enlarged_and_hit_testable_in_editor` (layout). ACID TC-DOCX-015 / TC-ODT-013. | +| **Drop Cap** | Yes | Yes | No | Imported into `ParaProps.drop_cap` (`DropCap`: lines spanned, length in chars or whole word, distance, in-margin vs in-text). DOCX `w:framePr` (`w:dropCap`/`w:lines`/`w:hSpace`) and ODF `style:drop-cap` (`style:lines`/`style:length`/`style:distance`) both map. **Rendered** on the read-only/paint path (PDF/print/canvas): the leading initial is enlarged to span `lines` rows and the body text wraps to its right (`para_drop_cap.rs` + `layout_paragraph`). Word encodes a drop cap as a `w:framePr` frame paragraph holding just the initial followed by the body paragraph; a DOCX import pass (`drop_cap_merge.rs`) folds the frame into the body so the single-paragraph (ODF-style) renderer applies. The cap is sized so its ascent spans the line band, coloured/weighted from the initial's run, positioned with its top at the first line and baseline near the last spanned line; `margin` mode hangs it in the left margin. **Per-line precision (read-only):** body lines *beside* the cap are narrowed while lines *below* it reclaim the full column width — Parley exposes no public per-line max-advance, so the body is laid out in two passes (`para_band`: narrow band lines + full-width reflow of the tail, stacked). **Live editor (`preserve_for_editing`):** the enlarged initial is now rendered in the editor too, with the body laid out as a single hit-testable Parley layout (the cap bytes are trimmed and the orig↔clean maps rebased past them, and `ParagraphLayout::drop_lines`/`drop_shift` keep the caret, hit-test, and selection geometry aligned with the shifted lines). **Limitations:** the editor body uses the uniform-narrow fallback (lines below the cap are slightly narrow), and cursor granularity *around* the cap is coarse — the cap collapses to the body-start caret position, so it is deletable (Backspace from the body start) but not independently selectable. Not re-exported, not round-tripped through Loro. Tested: `frame_pr_*` + `drop_cap_merge` (DOCX), `read_stylesheet_drop_cap`/`drop_cap_*` (ODT), `cap_byte_len_*` + `drop_cap_enlarges_initial_and_shifts_first_lines` / `drop_cap_enlarged_and_hit_testable_in_editor` (layout). ACID TC-DOCX-015 (Word ref) and TC-ODT-013 both visually verified — the ODT path (`acid_odt.odt`, the `T` initial of the case label) renders identically via the shared `para_band` renderer: enlarged initial over three lines, body wrapping to its right, the tail lines reclaiming full width. | --- @@ -77,7 +77,7 @@ This is the living source of truth documenting which document features, characte | **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). **Combined vMerge + gridSpan (L-merges) place correctly:** the layout assigns each cell its grid columns via a shared coverage grid (`assign_cell_columns`), so a cell in a row whose leading column is occupied by a vertical merge above is shifted into the next free column instead of overlapping the merged cell (TC-DOCX-005). Tested by `vmerge_gridspan_l_merge_places_cells_correctly` and `test_table_row_span_distribution`. | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | -| **Floating Images / Wrap Mode** | Yes | Partial | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Cross-paragraph wrap:** a float taller than its anchoring paragraph keeps wrapping the *following* paragraphs (tracked as `flow::float_impl::ActiveFloat`) until its bottom is cleared; bounded to a single page and to consecutive plain paragraphs (a table/list/rule or page break reserves the float's remaining height instead). **Limitations (gap #12):** the live-editor path keeps the uniform-narrow fallback; the tight wrap **contour** is approximated by the bounding box; true `behindDoc` overlap and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. Tested: `flow_float` unit tests + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 / TC-ODT-007. | +| **Floating Images / Wrap Mode** | Yes | Partial | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Cross-paragraph wrap:** a float taller than its anchoring paragraph keeps wrapping the *following* paragraphs (tracked as `flow::float_impl::ActiveFloat`) until its bottom is cleared; bounded to a single page and to consecutive plain paragraphs (a table/list/rule or page break reserves the float's remaining height instead). **Limitations (gap #12):** the live-editor path keeps the uniform-narrow fallback; the tight wrap **contour** is approximated by the bounding box; true `behindDoc` overlap and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. Tested: `flow_float` unit tests + `tall_float_wraps_following_paragraph` + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 visually verified against the Word reference; **TC-ODT-007 has no `draw:frame` content in the current `acid_odt.odt` fixture**, so the ODT import→float wiring is unit-tested but not yet visually spot-checked (the layout itself is renderer-shared with the verified DOCX path). | | **External Images** | Yes | No | No | Renders as gray placeholder rectangles. | --- diff --git a/loki-acid/examples/render_acid_pdf.rs b/loki-acid/examples/render_acid_pdf.rs index f7170516..0f7dbdbc 100644 --- a/loki-acid/examples/render_acid_pdf.rs +++ b/loki-acid/examples/render_acid_pdf.rs @@ -5,26 +5,33 @@ //! comparison against a canonical (Word / LibreOffice) render. //! //! Usage: -//! cargo run -p loki-acid --example render_acid_pdf -- +//! cargo run -p loki-acid --example render_acid_pdf -- //! //! Reuses `loki-layout` for pagination (the same engine the editor/GPU renderer //! use), so the PDF geometry matches Loki's on-screen layout. GPU-free. use std::io::Cursor; +use loki_doc_model::Document; +use loki_odf::OdtImport; use loki_ooxml::docx::import::{DocxImportOptions, DocxImporter}; fn main() { let args: Vec = std::env::args().collect(); if args.len() != 3 { - eprintln!("usage: render_acid_pdf "); + eprintln!("usage: render_acid_pdf "); std::process::exit(2); } - let bytes = std::fs::read(&args[1]).expect("read input docx"); - let result = DocxImporter::new(DocxImportOptions::default()) - .run(Cursor::new(bytes)) - .expect("import docx"); - let doc = result.document; + let bytes = std::fs::read(&args[1]).expect("read input"); + let doc: Document = if args[1].to_ascii_lowercase().ends_with(".odt") { + use loki_doc_model::io::DocumentImport; + OdtImport::import(Cursor::new(bytes), Default::default()).expect("import odt") + } else { + DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(bytes)) + .expect("import docx") + .document + }; let mut out = Vec::new(); loki_pdf::export_document(&doc, &loki_pdf::PdfXOptions::default(), &mut out) From 75da5fa18a261fc403027da1045598f442a68e87 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 03:26:52 +0000 Subject: [PATCH 19/35] feat(odf): float ODT frames; add TC-ODT-007 fixture case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ODT float path had two gaps that meant a `draw:frame` image never wrapped text and, in fact, never rendered: 1. The frame's `svg:width`/`svg:height` were parsed but never carried to the image, so the layout engine saw a zero-sized image and skipped it (and `plan_float` could not reserve a wrap band). `map_frame` now sets `cx_emu`/`cy_emu` on the image attr (1 pt = 12700 EMU) for every image frame (as-char and floating alike). 2. A floating image frame was emitted as a separate `Block::Figure` that the flow engine block-stacks, so the paragraph-level `plan_float` (which reads paragraph inlines, like the DOCX path) never saw it. A non-`as-char` image frame with a `style:wrap` graphic style is now emitted as a floating inline image (wrap + `FLOATING_CLASS`) in its anchoring paragraph — the same representation as DOCX — so text wraps beside it via the shared `para_band` renderer. Wrap-less floating frames and text boxes/objects keep the block-figure path. Fixture: `acid_odt.odt` gains the TC-ODT-007 case — a 1in float (`Pictures/float.png`) with a `style:wrap="right"` graphic style and wrapping body text, plus the `svg`/`xlink` namespace declarations and a manifest entry. The example's ODT support (added earlier) renders it. Verified: image floats at the left margin, text wraps to its right for the lines beside it and reclaims full width below the frame — matching the DOCX TC-DOCX-023 reference. New mapper test `floating_image_frame_becomes_inline_float_with_size`; all loki-odf/loki-acid/loki-layout suites green; clippy gate clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-acid/assets/acid_odt.odt | Bin 3244 -> 4375 bytes loki-odf/src/odt/mapper/document.rs | 164 +++++++++++++++++++++++++--- 3 files changed, 147 insertions(+), 19 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index d0c1424b..9bd1da32 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -77,7 +77,7 @@ This is the living source of truth documenting which document features, characte | **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). **Combined vMerge + gridSpan (L-merges) place correctly:** the layout assigns each cell its grid columns via a shared coverage grid (`assign_cell_columns`), so a cell in a row whose leading column is occupied by a vertical merge above is shifted into the next free column instead of overlapping the merged cell (TC-DOCX-005). Tested by `vmerge_gridspan_l_merge_places_cells_correctly` and `test_table_row_span_distribution`. | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | -| **Floating Images / Wrap Mode** | Yes | Partial | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Cross-paragraph wrap:** a float taller than its anchoring paragraph keeps wrapping the *following* paragraphs (tracked as `flow::float_impl::ActiveFloat`) until its bottom is cleared; bounded to a single page and to consecutive plain paragraphs (a table/list/rule or page break reserves the float's remaining height instead). **Limitations (gap #12):** the live-editor path keeps the uniform-narrow fallback; the tight wrap **contour** is approximated by the bounding box; true `behindDoc` overlap and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. Tested: `flow_float` unit tests + `tall_float_wraps_following_paragraph` + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` (ODT). ACID TC-DOCX-023 visually verified against the Word reference; **TC-ODT-007 has no `draw:frame` content in the current `acid_odt.odt` fixture**, so the ODT import→float wiring is unit-tested but not yet visually spot-checked (the layout itself is renderer-shared with the verified DOCX path). | +| **Floating Images / Wrap Mode** | Yes | Yes | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Cross-paragraph wrap:** a float taller than its anchoring paragraph keeps wrapping the *following* paragraphs (tracked as `flow::float_impl::ActiveFloat`) until its bottom is cleared; bounded to a single page and to consecutive plain paragraphs (a table/list/rule or page break reserves the float's remaining height instead). **Limitations (gap #12):** the live-editor path keeps the uniform-narrow fallback; the tight wrap **contour** is approximated by the bounding box; true `behindDoc` overlap and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. **ODT parity:** a non-`as-char` `draw:frame` image carrying a `style:wrap` graphic style is now mapped to a floating inline image (wrap + `FLOATING_CLASS`) in its anchoring paragraph — the same representation as the DOCX path — and the frame's `svg:width`/`svg:height` are carried as `cx_emu`/`cy_emu` (without which the layout treated every ODT image as zero-sized and skipped it). Tested: `flow_float` unit tests + `tall_float_wraps_following_paragraph` + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` + `floating_image_frame_becomes_inline_float_with_size` (ODT). ACID TC-DOCX-023 and TC-ODT-007 both visually verified (image at the left margin, text wrapping to its right and reclaiming full width below the frame). | | **External Images** | Yes | No | No | Renders as gray placeholder rectangles. | --- diff --git a/loki-acid/assets/acid_odt.odt b/loki-acid/assets/acid_odt.odt index 842a9b30f66f2e7b3b80937e173b3cb927d5a756..b6da2fbcb14d5d5c79c4e9218aa4d7e5adcb3923 100644 GIT binary patch delta 3312 zcmZ{n2{=@38^;Gj_N_s9EyL~S!=Atmg;9e} zHye&A>z{@H9;i;DzPjmEacu()$x}V#{Bm8iLa0nbn=+Kw+l z)bGR3I@RkNJ+IC3pXBDHqmM}f`0_(*?U<|i#!Q9Lf!DV82>40*h{cy?evhVf-4B*a zg5V-jGqJ;Z!8?TAp+@=6Pq=4k38YHHSWT7(9VVh-OM??V9)-@ffo`c3*Aqt9PgSkttu80D%4-dK=T8hec{+s0*xNGPIwYs>S-$BNA-lW%UE`!J$RF;&zM z$=_H{^kZ%puZxj#+=6CAlW1qwccu}N(sShSrk?)LeO}67s$z@CZe^4#zF0UHDF(@u|MK} zd`yBHw-;M}GrBL8{p2X=uM{aEz9i>K1*q~v-9ACyOWdnV1ClQLKNsNlggPxVdwin+ zfp9z^5Y5RnJ9>E{oIMc|_u(EJR(*G|a*U08!cuDp4|xIM?t9Fl$ew&B9>T3h03ogU1zFKPdx!UO2c2b3q5VSNx zKlcTa!9Os>{|pm%ROHwDbvteQGeK{69()Y1+DbGJL8=v>( z5uxPVW>#GUOPBah5^ixC3LPNrs{!S5W6uk9NYl0zhmA`Y#RDxZ@ul@sb&dziNkhhi5K z#qZ3gMmUi#xK!b-+j*H9a6n{Mn9SQ}I}|gt2I?iE4oe(^R`+g|4;-HrRytt1iaM*5lx3tLjUE~F+Gzt_VH z>N8Qu+?UTc8f7J6%OrMm?s>c9NFH+92sg`h?U}WPl@x*%9Rt-VN&w*q)za}>qK|Ml z{MP$XM7fpE><;ULV;A*3%w0&>#l-2gRa>=hlV!W+f^(P-j*1A5G_q|8??_3m`4=GCtGAoV~d`GlGHy&~8HmnA^O##T}`~`!TUnOhfQphsyW3 zU}LAuu4=B(mmFL+k-!8bD>L|z)TRZj&mRVU_n}B|b#|cXMT%sH#1wPEMPF^3CC3UL zD(9gIVZ?(@GWLO#sSf*GCD;!$dNK%^H=D$CQz!*7W* zjK%8_yTdk!T6I)S!Ho17S1;>&=eKO?8c+)ijv|f9fF@^onThc43`cEyj1Lx6lmi(m zLL297AXq_W;7p&^gA}Mf+*frOajl-Iy=b0EWIWLBd1RPOc)A4ZK~O09y17U9+6NK} z4)q%j+--#CGA@*&qoq#?*%wz8SgP{xmtTa8LL`_bEbptUl6lBB$FSDSB@)6rJmzu? zZ%M{>i3wN5&39Q03~JAbe`6X=P_2Jn#6uab5Z5Q%3#6uQjAfLXxg2Y>e<3Bcl#7DV z(aU>X?x0w>1*5tmsCc=Km*Gljyo4afN@mtXrT~teO##)bTr!>NmAv3)xt>vPUkU!C zy3r@xqG4P++pRp?* zWMP-K2Qco`D5mO2J;V>ZZE+K+=}nD5#oKPL83Rmpv1c~Z2VciQw``Qzr|T=tXyGz~ zU02@IGi&F!^$XDXNua3nJEAb#lk}awYFI>_P#?X&=Mqa`Sg+HS@?px}zg03A)i2yy zA#tCe?HuQLf;pzKdVYBOagPaBXF?yc*yOh{MOSppS}mxHT(p8P|c+XE)+CuO|43f7TVuKEH;L*J4~THK-8W5 zuMkh3t#C+qhLO%UO#yLrgzP-AiEfK1M0vTeVB`o@aKC|y;|r`M{WzD0Kv$uE@^XMs zv5)}2K;_}TfQq53Bf{^lv+p0Eaw>y3CVAf>1%cvDcIK1s>OV3U4=)FVgtzDIb=oc8 z6nXx{v~O^*2WQ(|;nC{XIh}ZO zbB;*kqGa3jY%Qr>Y@+OH+_*5Xka(l&jp~;yGiH-7dA&c*LakHt%Z zBlwvg-%JU%TUf2J-{GtuhC}z<+#e7*ZYwAZhGn`U9_Uy=!44KvWP|L+mf{eKQtiD|B#RKsAi{M#%{i-Z~MvaC^1%{ z)i|bei58H{?~l!t@IjX4x^I;9)r^Q&$tKg}yP@nN#Y|u-E7_|_UIv&&M^j8upYlhC zmhQR*%$=7lIRUjiudM7BzYAFUN7owo&L^aO9az#Ub#3rw`k}BJ-Lb%hK|^L^?hRC@ z?sTbCZ^RL6*fj#u^H?)1$T1eFiW%>o2CPMYy{s8C8L?8Q8TQ6#szmQ~Pgf{0GCqAP~zh59ox-LAj3*=)bAYUHNsp-}?Wh z%u~xR{me=DgE@a1(G@g29KWi6P=Y|dhyV{~-%}#})hwEtiUauFg4qeS!krNgzXkt4 zSx;S{pTk4Y{*jY^cK6?s_m}N-cp3k-@&8Que{$JSm;8jGPk&M}35(`U(UAaMF3Z_9y#%4)EWdU2V!mbt)H;c2gu zya3U(A{~IiB`FbJXLgmkpw)7VA*WSqDU%u&qcx)p!em@}mkDyWb93a56<3t~wSN(|bcmB5D% z(ySZJ#E^DKZ5h3MT?!;=fm8#dFa^Z7Mq}s7g++lzo14Xmn)8n#anrrtj!rfeqT?d< z`2Awf!s;(C-P+CmH8jGdgsiT_hE0hP@s!3i%{;zIWF)M$M*6b8pAh2T@Qlj-J%l)TzJibyxbRY9KFI zc@VjICmz@9i$Mz0x3#{M*+GVh25{Bf&%jo$5;xhv!o2|# zIjHzOG2%d&az-C|gT05)%ZN+DVq%Cx;mlfdx~q3;L1nlQz2}TGwhFHVp1{JtGgmQ} zEMbt%o03Z#B+S-spmDm9n`lc0n;hSWiJKA#b5mC4rRE)A`b=R{Pn!{?S@BUDHG45y z$*|TksS})gF74W&-(Xk+*qf$;f(=vf7o-kEOYm^BrjabA-wB6KU?%iBltuczEW2tI zkdOYF@$lnT;NwFFm_HXvi!*2jdwb^Yxq02rE~U4jA;SqTG^&%K7fFM$)}6>!yMhv} zCnIqhLRE=o8R+G=@i=co#k_~N%onBGld>Ksd5S;d5Q1Xf7c5i{1eEAk!=EdENm(Yc zf<)F($o0JlCxZ7}Jr#9!vP0y)J9=D#V%*oS$pw+LP5t0yumEa0+qU=7+S-Xjx`rta z&r`zcY9MJkx+c5VUkPK~Z)iSRm{1gZ3h_HgP3iYHs9>8|<2GxDle4}>&97WgzF)yY z1y9Q^zDAeI)JTeF=>hB|KwrL3G+BlvM}eAJ_qS0`2$QRQ{*_9CKBIgMO*52bfp zxxM3EgJ4YYvr~K$42?C-4(m?!vE*64N3Mx8*Rb;NK4nqJSeMZE=%XFy{tNQiZi7{&eJvn?!R4-{4^&oTw+BuX5^Bh8Z6-(P&;b)lWHL+ zTm)qagv&TP)4ialXG3JE1G8DN?zb)2LM#Z%58-DOxMcd&e3=+=2k6PUuCv^! z7|jg1e-ukQM`Icj5)lhco7LZ1+br^%)nE4?+CaPwP3**@AiDf!SlpBJO_}SuB^m?) zZKKfYp8`oH1~`rPp?H54Z(DU2C7TIukfrlYFC&+2u9g3Pw#Nk{^Mdt&;GE4Vff*pb%5&3$pPy$dlC8sSgRne4X9 zhz=!X+Ri0bq#pXhD7j;EjQAc15ft*AOuT9`Z{w-crq#S-I>BZ-fw|E$+)+$Bccp2N z?V$2&B&}tHY#JN}?_u1work5H1#LElyL ztV>)ww1#O$b62;X*EWjygvj0CZ;0CFQ@}?w95Tms_P^$(`|F$v$$E26Y;;~;$V#gJ ziHC&ngKN7#;gPqNZ_S~n{5)KTXvatU8yjC=5ennF?NXdEdanrW9an9qELITCjkyj) zp-iP+K!8;l6dt}MS@iaek(2nC)1SW8a&pR1{;Y zrfXaG7ico=c}1u}AXcW+{3mc>2V^S^J20xy=3-0$fG#Onj9~8jMuIo)02Q~Cdga3a zsKlL6iPgi~8W3>; zHMK{7gvt5MctvP>dyu1eS6qi>=s@qdbm+U#?Ra}Zw}N;tcC|q7Gue~h3}X{t*u1wc zZrwX)D$guPgz_Bly~saK;e5^e@joUI zKYv5_&38$w++ndaWn$)M;m<_!z?t|nC3(fIPPPajN1LC-px|&H_n=>({!iCG3;tiG z|C;$_) -> Option NodeAttr { + let mut attr = NodeAttr::default(); + let to_emu = |s: &Option| -> Option { + let pt = parse_length(s.as_deref()?)?.value(); + // Guarded positive and finite; image dimensions are far within u64 range, + // so the round-and-cast cannot truncate or lose sign in practice. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + (pt > 0.0 && pt.is_finite()).then(|| (pt * 12700.0).round() as u64) + }; + if let Some(cx) = to_emu(&frame.width) { + attr.kv.push(("cx_emu".to_string(), cx.to_string())); + } + if let Some(cy) = to_emu(&frame.height) { + attr.kv.push(("cy_emu".to_string(), cy.to_string())); + } + attr +} + /// Map an ODF drawing frame to an inline element. /// -/// For `as-char`-anchored frames, the mapped element is returned directly. -/// For floating frames, a [`Block::Figure`] or [`Block::Div`] is pushed to -/// [`OdfMappingContext::pending_figures`] and `None` is returned. +/// `as-char` frames map to an inline image directly. A non-`as-char` (floating) +/// image frame that carries a text-wrap style is emitted as a **floating inline +/// image** (the wrap mode + [`FLOATING_CLASS`] stored on its attr) so the layout +/// engine flows text around it, exactly like the DOCX path. Floating frames +/// without a wrap (and text boxes / objects) are pushed to +/// [`OdfMappingContext::pending_figures`] as a block and `None` is returned. +/// +/// [`FLOATING_CLASS`]: loki_doc_model::content::float::FLOATING_CLASS fn map_frame(frame: &OdfFrame, ctx: &mut OdfMappingContext<'_>) -> Option { use base64::Engine as _; let is_as_char = frame.anchor_type.as_deref() == Some("as-char"); @@ -508,22 +536,28 @@ fn map_frame(frame: &OdfFrame, ctx: &mut OdfMappingContext<'_>) -> Option { + wrap.store(&mut attr); + Some(Inline::Image(attr, alt, LinkTarget::new(data_uri))) + } + // as-char image: a plain inline image. + None if is_as_char => Some(Inline::Image(attr, alt, LinkTarget::new(data_uri))), + // Floating frame without a wrap style: block-stacked figure. + None => { + let img = Inline::Image(attr, alt, LinkTarget::new(data_uri)); + ctx.pending_figures.push(Block::Figure( + NodeAttr::default(), + Caption::default(), + vec![Block::Para(vec![img])], + )); + None } - ctx.pending_figures.push(Block::Figure( - fattr, - Caption::default(), - vec![Block::Para(vec![img])], - )); - None } } OdfFrameKind::TextBox { paragraphs } => { @@ -1025,6 +1059,100 @@ mod tests { } } + /// A `style:family="graphic"` style carrying a `style:wrap` value. + fn graphic_style(name: &str, wrap: &str) -> OdfStyle { + OdfStyle { + name: name.into(), + display_name: None, + family: crate::odt::model::styles::OdfStyleFamily::Graphic, + parent_name: None, + list_style_name: None, + para_props: None, + text_props: None, + col_width: None, + cell_props: None, + graphic_wrap: Some(OdfGraphicWrap { + wrap: Some(wrap.into()), + run_through: None, + }), + is_automatic: true, + master_page_name: None, + } + } + + #[test] + fn floating_image_frame_becomes_inline_float_with_size() { + use crate::odt::model::frames::{OdfFrame, OdfFrameKind}; + use loki_doc_model::content::float::{FloatWrap, WrapSide}; + + // A paragraph anchoring a 1in × 1in floating image frame with a + // `style:wrap="right"` graphic style, followed by body text. + let frame = OdfFrame { + name: None, + style_name: Some("FrFloat".into()), + anchor_type: Some("paragraph".into()), + width: Some("1in".into()), + height: Some("1in".into()), + x: None, + y: None, + kind: OdfFrameKind::Image { + href: "Pictures/x.png".into(), + media_type: Some("image/png".into()), + title: None, + desc: None, + }, + }; + let para = OdfParagraph { + style_name: None, + outline_level: None, + is_heading: false, + children: vec![ + OdfParagraphChild::Frame(frame), + OdfParagraphChild::Text("Body text beside the float.".into()), + ], + list_context: None, + }; + let doc = empty_doc(vec![OdfBodyChild::Paragraph(para)]); + + let mut sheet = empty_stylesheet(); + sheet.auto_styles.push(graphic_style("FrFloat", "right")); + let mut images = HashMap::new(); + images.insert( + "Pictures/x.png".to_string(), + ("image/png".to_string(), vec![0u8, 1, 2, 3]), + ); + + let (result, _) = map_document(&doc, &sheet, None, &images, &HashMap::new(), &options()); + + // The float stays inline in its anchoring paragraph (no separate Figure). + let blocks = &result.sections[0].blocks; + assert_eq!(blocks.len(), 1, "float must not spill into a Figure block"); + let Block::StyledPara(p) = &blocks[0] else { + panic!("expected StyledPara, got {:?}", blocks[0]); + }; + let img_attr = p + .inlines + .iter() + .find_map(|i| match i { + Inline::Image(attr, _, _) => Some(attr), + _ => None, + }) + .expect("paragraph carries a floating image inline"); + + // Size is carried as EMU (1in = 72pt = 914400 EMU). + let cx = img_attr + .kv + .iter() + .find(|(k, _)| k == "cx_emu") + .map(|(_, v)| v.parse::().unwrap()); + assert_eq!(cx, Some(914_400), "1in width → 914400 EMU"); + + // The wrap is readable and side=right (text on the right → float left). + let wrap = FloatWrap::read(img_attr).expect("float wrap stored on the image"); + assert_eq!(wrap.side, WrapSide::Right); + assert!(!wrap.behind_text); + } + #[test] fn empty_document_produces_empty_section() { let doc = empty_doc(vec![]); From 7672dc1f250ffa5810e541fdecf9d7171ba4d252 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 05:23:37 +0000 Subject: [PATCH 20/35] chore(odf): silence cross-binary dead-code + tidy test lints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/helpers.rs` is included via `mod helpers;` by six separate integration-test binaries; each flags the helpers it doesn't personally call as dead even though another binary uses them. Add a module-level `#![allow(dead_code)]` (with an explanatory comment) — the canonical fix for shared integration-test modules. Every helper is confirmed used by at least one binary, so nothing is actually removed. Silencing that unmasked a handful of pre-existing pedantic lints in test-only code (the lib's cast error had previously made clippy bail before reaching the test targets). All mechanical, no behaviour change: - round_trip.rs: drop an unused `NodeAttr` import. - document.rs test helper: elide redundant `'a` lifetimes. - props/tests.rs: method-reference closures (`LanguageTag::as_str`), hoist a `use` above statements, `unwrap_or_else(|| panic!)` instead of `expect(&format!(...))`. - styles.rs test: hoist a `use` above statements. - math_tests.rs: backtick `MathML` in a doc comment. - reader/document.rs test: terminating semicolons + inlined format args. `cargo clippy -p loki-odf --all-targets -- -D warnings` is now clean; all 15 loki-odf test groups pass; the workspace gate is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- loki-odf/src/odt/mapper/document.rs | 2 +- loki-odf/src/odt/mapper/props/tests.rs | 19 +++++++++++++++---- loki-odf/src/odt/mapper/styles.rs | 2 +- loki-odf/src/odt/math_tests.rs | 2 +- loki-odf/src/odt/reader/document.rs | 4 ++-- loki-odf/tests/helpers.rs | 7 +++++++ loki-odf/tests/round_trip.rs | 2 -- 7 files changed, 27 insertions(+), 11 deletions(-) diff --git a/loki-odf/src/odt/mapper/document.rs b/loki-odf/src/odt/mapper/document.rs index d5466d5a..ad2f4ea4 100644 --- a/loki-odf/src/odt/mapper/document.rs +++ b/loki-odf/src/odt/mapper/document.rs @@ -1319,7 +1319,7 @@ mod tests { } } - fn make_lookup<'a>(styles: &'a [OdfStyle]) -> HashMap<&'a str, &'a OdfStyle> { + fn make_lookup(styles: &[OdfStyle]) -> HashMap<&str, &OdfStyle> { styles.iter().map(|s| (s.name.as_str(), s)).collect() } diff --git a/loki-odf/src/odt/mapper/props/tests.rs b/loki-odf/src/odt/mapper/props/tests.rs index bc24950d..4ed6a4c9 100644 --- a/loki-odf/src/odt/mapper/props/tests.rs +++ b/loki-odf/src/odt/mapper/props/tests.rs @@ -277,7 +277,12 @@ fn language_with_country() { ..Default::default() }; let out = map_text_props(&props); - assert_eq!(out.language.as_ref().map(|t| t.as_str()), Some("en-US")); + assert_eq!( + out.language + .as_ref() + .map(loki_doc_model::meta::LanguageTag::as_str), + Some("en-US") + ); } #[test] @@ -287,7 +292,12 @@ fn language_without_country() { ..Default::default() }; let out = map_text_props(&props); - assert_eq!(out.language.as_ref().map(|t| t.as_str()), Some("de")); + assert_eq!( + out.language + .as_ref() + .map(loki_doc_model::meta::LanguageTag::as_str), + Some("de") + ); } #[test] @@ -363,6 +373,8 @@ fn writing_mode_lr_shorthand_maps_to_lrtb() { #[test] fn parse_odf_border_solid_black() { + use loki_doc_model::style::props::border::BorderStyle; + let b = parse_odf_border("0.06pt solid #000000").expect("should parse"); // Width rounds to 0.06pt assert!( @@ -370,7 +382,6 @@ fn parse_odf_border_solid_black() { "width should be ~0.06pt, got {}", b.width.value() ); - use loki_doc_model::style::props::border::BorderStyle; assert_eq!(b.style, BorderStyle::Solid); assert!(b.color.is_some(), "color should be parsed"); } @@ -400,7 +411,7 @@ fn fo_padding_shorthand_applies_to_all_edges() { ("right", props.padding_right), ] { let pts = val - .expect(&format!("padding_{label} should be Some")) + .unwrap_or_else(|| panic!("padding_{label} should be Some")) .value(); assert!( (pts - 5.669).abs() < 0.1, diff --git a/loki-odf/src/odt/mapper/styles.rs b/loki-odf/src/odt/mapper/styles.rs index 914cd1d9..155ee95d 100644 --- a/loki-odf/src/odt/mapper/styles.rs +++ b/loki-odf/src/odt/mapper/styles.rs @@ -222,6 +222,7 @@ mod tests { #[test] fn default_style_inserted_as_default() { + use loki_doc_model::style::props::para_props::ParagraphAlignment; let sheet = OdfStylesheet { default_styles: vec![OdfDefaultStyle { family: OdfStyleFamily::Paragraph, @@ -239,7 +240,6 @@ mod tests { .get(&StyleId::new("__Default")) .unwrap(); assert!(def.is_default); - use loki_doc_model::style::props::para_props::ParagraphAlignment; assert_eq!(def.para_props.alignment, Some(ParagraphAlignment::Justify)); } diff --git a/loki-odf/src/odt/math_tests.rs b/loki-odf/src/odt/math_tests.rs index 5f7ca420..2831d893 100644 --- a/loki-odf/src/odt/math_tests.rs +++ b/loki-odf/src/odt/math_tests.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 AppThere Loki contributors -//! Tests for the embedded-formula MathML (de)serialiser. +//! Tests for the embedded-formula `MathML` (de)serialiser. use super::{extract_mathml, object_content_xml}; diff --git a/loki-odf/src/odt/reader/document.rs b/loki-odf/src/odt/reader/document.rs index 77659dae..57150ee1 100644 --- a/loki-odf/src/odt/reader/document.rs +++ b/loki-odf/src/odt/reader/document.rs @@ -1155,10 +1155,10 @@ mod tests { assert_eq!(link.children.len(), 1); match &link.children[0] { OdfParagraphChild::Text(s) => { - assert_eq!(s, "Click here") + assert_eq!(s, "Click here"); } other => { - panic!("expected Text in link, got {:?}", other) + panic!("expected Text in link, got {other:?}"); } } } diff --git a/loki-odf/tests/helpers.rs b/loki-odf/tests/helpers.rs index 984ee865..a701bfe0 100644 --- a/loki-odf/tests/helpers.rs +++ b/loki-odf/tests/helpers.rs @@ -6,6 +6,13 @@ //! [`build_odt_zip`] produces a valid ODF ZIP archive from raw XML byte //! slices. The helper XML constructors (`heading_and_paragraphs_content_xml`, //! etc.) produce well-formed `content.xml` / `styles.xml` fixtures. +//! +//! This module is included via `mod helpers;` by several separate integration +//! test binaries (`round_trip*.rs`, `malformed_odt_test.rs`, …). Each binary +//! compiles its own copy, so any helper a given binary does not call is flagged +//! as dead there even though another binary uses it — `allow(dead_code)` +//! silences that cross-binary false positive. +#![allow(dead_code)] use std::io::{Cursor, Write}; use zip::CompressionMethod; diff --git a/loki-odf/tests/round_trip.rs b/loki-odf/tests/round_trip.rs index 0841400a..e8a65bb1 100644 --- a/loki-odf/tests/round_trip.rs +++ b/loki-odf/tests/round_trip.rs @@ -90,8 +90,6 @@ fn gap4_page_size_from_page_layout() { /// stored in `Block::Heading`'s NodeAttr so the flow engine can find it. #[test] fn gap1_heading_style_name_preserved_in_node_attr() { - use loki_doc_model::content::attr::NodeAttr; - let content = helpers::rich_content_xml_with_styles(); let styles = helpers::rich_styles_xml(); let zip = helpers::build_odt_zip(&content, &styles, None); From 3735407391b65644bcb19c4b9dc7f85619c04037 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 05:48:24 +0000 Subject: [PATCH 21/35] feat(epub): flow text around floating images via CSS float MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EPUB is reflowable, so it can't reproduce the paginated wrap band — but it can flow text around a floating image with CSS `float`, the natural reflow-target equivalent. A floating `Inline::Image` (carrying a `FloatWrap` on its attr, as both the DOCX and ODT importers now emit) gets `style="float:left|right; margin; max-width"`; `WrapSide` picks the side the float sits on (opposite the text), mirroring `loki-layout`'s `plan_float`. Behind-text and `TopAndBottom` floats stay block-level. Also fixes three pre-existing `field_reassign_with_default` lints in loki-epub test code surfaced by `--all-targets`. Tested: `floating_image_emits_css_float` / `behind_text_float_is_not_floated`. `cargo clippy -p loki-epub --all-targets -- -D warnings` clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/fidelity-status.md | 2 +- loki-epub/src/content.rs | 57 +++++++++++++++++++++++++++++++++++++++ loki-epub/src/images.rs | 19 ++++++++++--- loki-epub/src/inlines.rs | 23 ++++++++++++++-- loki-epub/src/opf_meta.rs | 14 ++++++---- loki-epub/src/package.rs | 6 +++-- 6 files changed, 108 insertions(+), 13 deletions(-) diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index 9bd1da32..35eaa613 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -77,7 +77,7 @@ This is the living source of truth documenting which document features, characte | **Row Spanning** | Yes | Yes | Yes | Spanning heights distributed across spanned rows (`w:vMerge`). **Combined vMerge + gridSpan (L-merges) place correctly:** the layout assigns each cell its grid columns via a shared coverage grid (`assign_cell_columns`), so a cell in a row whose leading column is occupied by a vertical merge above is shifted into the next free column instead of overlapping the merged cell (TC-DOCX-005). Tested by `vmerge_gridspan_l_merge_places_cells_correctly` and `test_table_row_span_distribution`. | | **Text Direction** | Yes | Yes | Yes | Mapped for vertical and rotated cell text. | | **Inline Images** | Yes | Yes | Yes | Positioned inline drawings rendered via data URIs. | -| **Floating Images / Wrap Mode** | Yes | Yes | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Cross-paragraph wrap:** a float taller than its anchoring paragraph keeps wrapping the *following* paragraphs (tracked as `flow::float_impl::ActiveFloat`) until its bottom is cleared; bounded to a single page and to consecutive plain paragraphs (a table/list/rule or page break reserves the float's remaining height instead). **Limitations (gap #12):** the live-editor path keeps the uniform-narrow fallback; the tight wrap **contour** is approximated by the bounding box; true `behindDoc` overlap and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported. **ODT parity:** a non-`as-char` `draw:frame` image carrying a `style:wrap` graphic style is now mapped to a floating inline image (wrap + `FLOATING_CLASS`) in its anchoring paragraph — the same representation as the DOCX path — and the frame's `svg:width`/`svg:height` are carried as `cx_emu`/`cy_emu` (without which the layout treated every ODT image as zero-sized and skipped it). Tested: `flow_float` unit tests + `tall_float_wraps_following_paragraph` + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` + `floating_image_frame_becomes_inline_float_with_size` (ODT). ACID TC-DOCX-023 and TC-ODT-007 both visually verified (image at the left margin, text wrapping to its right and reclaiming full width below the frame). | +| **Floating Images / Wrap Mode** | Yes | Yes | No | Anchored (floating) drawings are marked with the `floating` class and carry their **text-wrap mode** (`FloatWrap`: square/tight/through/top-and-bottom/none, side both/left/right/largest, behind-text flag) on the image/figure `NodeAttr`. DOCX `wp:wrapSquare`/`wrapTight`/`wrapThrough`/`wrapTopAndBottom`/`wrapNone` (+ `wrapText` side, anchor `behindDoc`) and ODF `style:wrap` + `style:run-through` both map. **Floats are painted and text wraps beside them** (`flow_float.rs`): for a `Square`/`Tight`/`Through`/non-behind-`None` float the flow engine reserves a side band (float on the left when text occupies the right, and vice-versa; `Both`/`Largest` default to a left float) by widening the paragraph's start/end indent, paints the image in that band, and reserves its height so the next paragraph clears it. Verified against the Word reference on ACID TC-DOCX-023 (margin-anchored image, text wrapping to its right). **Per-line precision:** lines beside the float are narrowed while lines below it reclaim the full column (two-pass `para_band` layout, shared with drop caps) — verified against the Word reference where the text below the image returns to full width. **Cross-paragraph wrap:** a float taller than its anchoring paragraph keeps wrapping the *following* paragraphs (tracked as `flow::float_impl::ActiveFloat`) until its bottom is cleared; bounded to a single page and to consecutive plain paragraphs (a table/list/rule or page break reserves the float's remaining height instead). **Limitations (gap #12):** the live-editor path keeps the uniform-narrow fallback; the tight wrap **contour** is approximated by the bounding box; true `behindDoc` overlap and absolute anchor positioning are not yet done. `TopAndBottom`/behind-text floats still stack as a block. Not re-exported to DOCX/ODT. **EPUB export** now maps a side-wrapping float to a CSS `float:left/right` on the `` so the reflowable book flows text around it (the reflow-target equivalent of the paginated wrap band; `behind`/`TopAndBottom` stay block-level). **ODT parity:** a non-`as-char` `draw:frame` image carrying a `style:wrap` graphic style is now mapped to a floating inline image (wrap + `FLOATING_CLASS`) in its anchoring paragraph — the same representation as the DOCX path — and the frame's `svg:width`/`svg:height` are carried as `cx_emu`/`cy_emu` (without which the layout treated every ODT image as zero-sized and skipped it). Tested: `flow_float` unit tests + `tall_float_wraps_following_paragraph` + `anchor_drawing_carries_wrap_mode` (DOCX), `read_stylesheet_graphic_wrap` + `floating_image_frame_becomes_inline_float_with_size` (ODT). ACID TC-DOCX-023 and TC-ODT-007 both visually verified (image at the left margin, text wrapping to its right and reclaiming full width below the frame). | | **External Images** | Yes | No | No | Renders as gray placeholder rectangles. | --- diff --git a/loki-epub/src/content.rs b/loki-epub/src/content.rs index 35e9e8ca..9a6e25ee 100644 --- a/loki-epub/src/content.rs +++ b/loki-epub/src/content.rs @@ -240,4 +240,61 @@ mod tests { .contains("\"Alt\"/") ); } + + #[test] + fn floating_image_emits_css_float() { + use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; + + let mut doc = Document::new(); + let sec = doc.first_section_mut().unwrap(); + sec.blocks.clear(); + // A left-floating image (text on the right) anchored in a paragraph. + let mut attr = NodeAttr::default(); + FloatWrap { + wrap: TextWrap::Square, + side: WrapSide::Right, + behind_text: false, + } + .store(&mut attr); + let target = loki_doc_model::content::inline::LinkTarget::new("data:image/png;base64,SGk="); + sec.blocks.push(Block::Para(vec![ + Inline::Image(attr, vec![Inline::Str("Alt".into())], target), + Inline::Str("Body text wraps beside the float.".into()), + ])); + let rendered = render_content(&doc); + // Text on the right ⇒ the image floats left so the text wraps around it. + assert!( + rendered.body.contains("float:left"), + "expected a CSS float on the wrapped image; got: {}", + rendered.body + ); + assert!(rendered.body.contains("Body text wraps beside the float.")); + } + + #[test] + fn behind_text_float_is_not_floated() { + use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; + + let mut doc = Document::new(); + let sec = doc.first_section_mut().unwrap(); + sec.blocks.clear(); + let mut attr = NodeAttr::default(); + FloatWrap { + wrap: TextWrap::Square, + side: WrapSide::Both, + behind_text: true, + } + .store(&mut attr); + let target = loki_doc_model::content::inline::LinkTarget::new("data:image/png;base64,SGk="); + sec.blocks.push(Block::Para(vec![Inline::Image( + attr, + vec![Inline::Str("Alt".into())], + target, + )])); + let rendered = render_content(&doc); + assert!( + !rendered.body.contains("float:"), + "behind-text float must stay block-level" + ); + } } diff --git a/loki-epub/src/images.rs b/loki-epub/src/images.rs index c8123c35..d07cafd5 100644 --- a/loki-epub/src/images.rs +++ b/loki-epub/src/images.rs @@ -29,15 +29,28 @@ pub struct EpubImage { impl RenderCtx { /// Renders an inline image, packaging a `data:` URI as a resource or /// referencing an external URL directly. - pub(crate) fn render_image(&mut self, url: &str, alt: &str, out: &mut String) { + /// + /// `style` is an optional inline CSS declaration (e.g. a `float` for a + /// wrapped floating image); `None` emits a plain ``. + pub(crate) fn render_image( + &mut self, + url: &str, + alt: &str, + style: Option<&str>, + out: &mut String, + ) { let alt_attr = escape_attr(alt); + let style_attr = match style { + Some(s) => format!(" style=\"{}\"", escape_attr(s)), + None => String::new(), + }; if let Some((media_type, bytes)) = decode_data_uri(url) { let ext = extension_for(&media_type); let id = format!("img{}", self.image_seq); let href = format!("images/{id}.{ext}"); self.image_seq += 1; out.push_str(&format!( - "\"{alt_attr}\"/", + "\"{alt_attr}\"{style_attr}/", href = escape_attr(&href), )); self.images.push(EpubImage { @@ -49,7 +62,7 @@ impl RenderCtx { } else if !url.is_empty() { // External URL — referenced but not packaged. out.push_str(&format!( - "\"{alt_attr}\"/", + "\"{alt_attr}\"{style_attr}/", src = escape_attr(url), )); } else { diff --git a/loki-epub/src/inlines.rs b/loki-epub/src/inlines.rs index 7c69362f..f835873f 100644 --- a/loki-epub/src/inlines.rs +++ b/loki-epub/src/inlines.rs @@ -3,12 +3,29 @@ //! Inline-level XHTML rendering for the EPUB content document. +use loki_doc_model::content::float::{FloatWrap, TextWrap, WrapSide}; use loki_doc_model::content::inline::{Inline, QuoteType}; use loki_doc_model::style::props::char_props::{CharProps, VerticalAlign}; use crate::content::RenderCtx; use crate::xml::{escape_attr, escape_text}; +/// Maps a floating image's [`FloatWrap`] to an inline CSS `float` declaration so +/// the reflowable EPUB flows text around it (the reflow-target equivalent of the +/// paginated wrap band). Side-wrapping floats only; `TopAndBottom`/behind-text +/// floats stay block-level (`None`). `WrapSide` names the side **text** occupies, +/// so the float sits opposite (mirrors `loki-layout`'s `plan_float`). +pub(crate) fn float_css(wrap: &FloatWrap) -> Option<&'static str> { + if wrap.behind_text || matches!(wrap.wrap, TextWrap::TopAndBottom) { + return None; + } + Some(match wrap.side { + // Text on the left → image floats right; otherwise image floats left. + WrapSide::Left => "float:right; margin:0 0 0.4em 0.8em; max-width:45%; height:auto;", + _ => "float:left; margin:0 0.8em 0.4em 0; max-width:45%; height:auto;", + }) +} + impl RenderCtx { /// Renders a sequence of inlines. pub(crate) fn render_inlines(&mut self, inlines: &[Inline], out: &mut String) { @@ -48,9 +65,11 @@ impl RenderCtx { self.render_inlines(c, out); out.push_str(""); } - Inline::Image(_attr, alt, target) => { + Inline::Image(attr, alt, target) => { let alt_text = plain_text(alt); - self.render_image(&target.url, &alt_text, out); + // A floating image becomes a CSS `float` so text wraps around it. + let style = FloatWrap::read(attr).as_ref().and_then(float_css); + self.render_image(&target.url, &alt_text, style, out); } Inline::StyledRun(run) => { self.render_styled_run(run.direct_props.as_deref(), &run.content, out) diff --git a/loki-epub/src/opf_meta.rs b/loki-epub/src/opf_meta.rs index 4d2c63c0..51b61b1e 100644 --- a/loki-epub/src/opf_meta.rs +++ b/loki-epub/src/opf_meta.rs @@ -147,8 +147,10 @@ mod tests { #[test] fn includes_required_elements() { - let mut meta = DocumentMeta::default(); - meta.title = Some("Test".into()); + let meta = DocumentMeta { + title: Some("Test".into()), + ..Default::default() + }; let xml = build_metadata(&meta, "urn:uuid:abc", "2026-01-01T00:00:00Z"); assert!(xml.contains("urn:uuid:abc")); assert!(xml.contains("Test")); @@ -159,9 +161,11 @@ mod tests { #[test] fn emits_publisher_and_keywords() { - let mut meta = DocumentMeta::default(); - meta.title = Some("Book".into()); - meta.keywords = Some("rust, pdf, epub".into()); + let mut meta = DocumentMeta { + title: Some("Book".into()), + keywords: Some("rust, pdf, epub".into()), + ..Default::default() + }; meta.dublin_core.publisher = Some("AppThere".into()); let xml = build_metadata(&meta, "id", "2026-01-01T00:00:00Z"); assert!(xml.contains("AppThere")); diff --git a/loki-epub/src/package.rs b/loki-epub/src/package.rs index 023d4704..4abe62ee 100644 --- a/loki-epub/src/package.rs +++ b/loki-epub/src/package.rs @@ -62,8 +62,10 @@ mod tests { #[test] fn package_has_manifest_and_spine() { - let mut meta = DocumentMeta::default(); - meta.title = Some("Doc".into()); + let meta = DocumentMeta { + title: Some("Doc".into()), + ..Default::default() + }; let images = [EpubImage { id: "img0".into(), href: "images/img0.png".into(), From 93b75b1ccd36c772a2489095168426e4d52e5d64 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 06:03:16 +0000 Subject: [PATCH 22/35] fix(editor): place the caret on touch in the non-paginated view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In reflow (non-paginated) mode a tap never placed the cursor: the touch tap and long-press handlers were paginated-only — they read `state.paginated_layout` (which is `None` in reflow) and called `hit_test_document`, with no reflow branch. Because no cursor state was ever set, the reflow caret (whose paint path is already wired in `render_layout::paint_tile`) had nothing to draw — so the editor looked unresponsive to touch and the caret was invisible, even though the mouse-per-tile reflow path worked. Add a reflow branch to both touch handlers that mirrors the proven mouse path: build the continuous layout via `ensure_reflow_layout` at the same width the renderer paints (`scroll_metrics().client_width`), map the window point through the new `reflow_hit_test_window`, and set the cursor (`page_index = 0`, paragraph, byte) exactly like `on_reflow_click`. Long-press word-select is unified to resolve the position per view mode then select the word. The change is additive — the paginated path is untouched and reflow touch was previously a no-op — so it cannot regress either. The soft keyboard already opens via the canvas `inputmode="text"` + focus-on-tap; it only seemed broken because the tap did nothing. `reflow_hit_test_window` reuses the Strategy-C transform: the vertical origin (toolbar top + scroll) is exact, so the tap always lands on the correct line; the horizontal origin is approximate (Blitz exposes no element rect) and only affects which character, which `hit_test` clamps. Tests: `reflow_tap_resolves_to_second_paragraph`, `reflow_tap_in_first_paragraph_resolves_to_block_0`, `reflow_tap_above_canvas_top_is_none` (coordinate transform); `ContinuousLayout::hit_test`/`cursor_rect_canvas` were already covered. Live touch/GPU behaviour needs on-device verification (not runnable in CI). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- loki-text/src/editing/hit_test.rs | 117 +++++++++++++- loki-text/src/routes/editor/editor_canvas.rs | 4 + loki-text/src/routes/editor/editor_pointer.rs | 148 +++++++++++++----- 3 files changed, 229 insertions(+), 40 deletions(-) diff --git a/loki-text/src/editing/hit_test.rs b/loki-text/src/editing/hit_test.rs index 8c65171c..5519b922 100644 --- a/loki-text/src/editing/hit_test.rs +++ b/loki-text/src/editing/hit_test.rs @@ -25,13 +25,45 @@ //! The conversion from CSS logical pixels is applied once at entry: //! `pt = px × (72/96)`. -use loki_layout::PaginatedLayout; +use loki_layout::{ContinuousLayout, PaginatedLayout}; +use loki_renderer::render_layout::REFLOW_PADDING_PT; use super::cursor::DocumentPosition; /// CSS pixels → layout points scale factor (72 dpi / 96 dpi). const PX_TO_PT: f32 = 72.0 / 96.0; +/// Translates a window-relative pointer position into a continuous-layout +/// document position `(block_index, byte_offset)` for **reflow** mode. +/// +/// The reflow canvas is one continuous vertical flow (no pages); the content is +/// inset by [`REFLOW_PADDING_PT`] within each band tile, so that inset is +/// removed here to match the layout's paragraph origins. `canvas_origin` and +/// `scroll_offset` follow the same Strategy-C model as [`hit_test_document`]: +/// the vertical origin (`TOOLBAR_HEIGHT_TOP + SPACE_6`) is exact, so the hit +/// always lands on the correct line; the horizontal origin is approximate +/// (Blitz exposes no element rect), affecting only which character within the +/// line is chosen — and [`ContinuousLayout::hit_test`] clamps that. +/// +/// `layout` must be the same width as the painted reflow layout (build it from +/// `scroll_metrics().client_width`), or line breaks diverge from what is drawn. +pub fn reflow_hit_test_window( + client_x: f32, + client_y: f32, + canvas_origin: (f32, f32), + scroll_offset: f32, + layout: &ContinuousLayout, +) -> Option<(usize, usize)> { + let canvas_x_px = client_x - canvas_origin.0; + let canvas_y_px = client_y - canvas_origin.1 + scroll_offset; + if canvas_y_px < 0.0 { + return None; + } + let canvas_x = canvas_x_px * PX_TO_PT - REFLOW_PADDING_PT; + let canvas_y = canvas_y_px * PX_TO_PT; + layout.hit_test(canvas_x, canvas_y) +} + /// Translates a window-relative pointer position into a [`DocumentPosition`] /// using the paginated layout's editing data. /// @@ -439,4 +471,87 @@ mod tests { ); } } + + // ── Reflow (continuous) hit-testing ─────────────────────────────────────── + + /// One reflow paragraph laid out at the given canvas origin. + fn reflow_para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagraphData { + let mut resources = FontResources::new(); + let para = layout_paragraph( + &mut resources, + text, + &[StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + link_url: None, + math: None, + }], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, // preserve_for_editing — retains the hit-test layout + ); + PageParagraphData { + block_index, + layout: Arc::new(para), + origin, + } + } + + fn two_para_continuous() -> loki_layout::ContinuousLayout { + let p0 = reflow_para("Hello world", 0, (0.0, 0.0)); + let h0 = p0.layout.height; + let p1 = reflow_para("Second paragraph here", 1, (0.0, h0)); + loki_layout::ContinuousLayout { + content_width: 400.0, + total_height: h0 + p1.layout.height, + items: vec![], + paragraphs: vec![p0, p1], + } + } + + #[test] + fn reflow_tap_resolves_to_second_paragraph() { + let cl = two_para_continuous(); + let h0 = cl.paragraphs[0].layout.height; + // A tap a couple points into the second paragraph (canvas origin (0,0), + // no scroll). client coords are CSS px, the band's content inset is + // REFLOW_PADDING_PT (removed inside the helper). + let client_y = pt_to_px(h0 + 2.0); + let client_x = pt_to_px(REFLOW_PADDING_PT + 5.0); + let (block, _byte) = reflow_hit_test_window(client_x, client_y, (0.0, 0.0), 0.0, &cl) + .expect("reflow hit lands a position"); + assert_eq!(block, 1, "tap in the second paragraph resolves to block 1"); + } + + #[test] + fn reflow_tap_in_first_paragraph_resolves_to_block_0() { + let cl = two_para_continuous(); + let client_y = pt_to_px(2.0); // near the top + let client_x = pt_to_px(REFLOW_PADDING_PT + 5.0); + let (block, _) = + reflow_hit_test_window(client_x, client_y, (0.0, 0.0), 0.0, &cl).expect("reflow hit"); + assert_eq!(block, 0); + } + + #[test] + fn reflow_tap_above_canvas_top_is_none() { + let cl = two_para_continuous(); + // origin.y above the tap ⇒ canvas_y < 0 ⇒ no position. + assert!(reflow_hit_test_window(10.0, 10.0, (0.0, 100.0), 0.0, &cl).is_none()); + } } diff --git a/loki-text/src/routes/editor/editor_canvas.rs b/loki-text/src/routes/editor/editor_canvas.rs index cdb0ae60..8f2e8071 100644 --- a/loki-text/src/routes/editor/editor_canvas.rs +++ b/loki-text/src/routes/editor/editor_canvas.rs @@ -256,6 +256,8 @@ pub(super) fn render_canvas_area( loro_doc, cursor_state, page_gap_px, + view_mode, + scroll_metrics, ), ontouchend: make_touchend_handler( @@ -266,6 +268,8 @@ pub(super) fn render_canvas_area( loro_doc, cursor_state, page_gap_px, + view_mode, + scroll_metrics, ), onkeydown: make_keydown_handler( diff --git a/loki-text/src/routes/editor/editor_pointer.rs b/loki-text/src/routes/editor/editor_pointer.rs index a45cf5ca..c09fae75 100644 --- a/loki-text/src/routes/editor/editor_pointer.rs +++ b/loki-text/src/routes/editor/editor_pointer.rs @@ -10,11 +10,38 @@ use loki_doc_model::loro_bridge::derive_loro_cursor; use loki_doc_model::loro_mutation::get_block_text; use loki_renderer::ViewMode; +use loki_renderer::render_layout::{MIN_REFLOW_CONTENT_PT, REFLOW_PADDING_PT}; use crate::editing::cursor::{CursorState, DocumentPosition}; -use crate::editing::hit_test::hit_test_document; -use crate::editing::state::DocumentState; +use crate::editing::hit_test::{hit_test_document, reflow_hit_test_window}; +use crate::editing::state::{DocumentState, ensure_reflow_layout}; use crate::editing::touch::{TouchInteractionState, TouchPhase, word_boundaries_at}; +use crate::routes::editor::editor_scrollbar::ScrollMetrics; + +/// CSS pixels → layout points (72 dpi / 96 dpi). +const PX_TO_PT: f32 = 72.0 / 96.0; + +/// Resolves a window-relative tap to a reflow document position, using the same +/// continuous layout width as the painted view. Returns `None` outside reflow +/// mode or when the canvas has not been measured yet. +fn reflow_tap_position( + doc_state: &Arc>, + client_pos: (f32, f32), + window_width: f32, + client_width_px: f32, + scroll_offset: f32, +) -> Option<(usize, usize)> { + if client_width_px <= 1.0 { + return None; + } + let content_w = + (client_width_px * PX_TO_PT - 2.0 * REFLOW_PADDING_PT).max(MIN_REFLOW_CONTENT_PT); + let layout = ensure_reflow_layout(doc_state, content_w)?; + // Reflow tiles span the canvas client width, centred in the window. + let x_off = ((window_width - client_width_px) / 2.0).max(0.0); + let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); + reflow_hit_test_window(client_pos.0, client_pos.1, origin, scroll_offset, &layout) +} // EditorMode removed — the editor is always in edit mode when a document is // open. Distraction-free reading is handled by the View ribbon tab (future @@ -86,6 +113,7 @@ pub(super) fn make_mousemove_handler( } /// Builds the `ontouchmove` handler for touch drag and long-press word selection. +#[allow(clippy::too_many_arguments)] pub(super) fn make_touchmove_handler( doc_state: Arc>, mut touch_state: Signal>, @@ -94,6 +122,8 @@ pub(super) fn make_touchmove_handler( loro_doc: Signal>, mut cursor_state: Signal, page_gap_px: f32, + view_mode: Signal, + scroll_metrics: Signal, ) -> impl FnMut(TouchEvent) { move |evt: TouchEvent| { let Some(mut ts) = touch_state() else { return }; @@ -113,45 +143,58 @@ pub(super) fn make_touchmove_handler( } } else if ts.phase == TouchPhase::LongPress { let start = ts.start_pos; - let (layout_opt, page_width_px, page_height_px) = { - let Ok(state) = doc_state.lock() else { return }; - ( - state.paginated_layout.clone(), - state.page_width_px, - state.page_height_px, + // Resolve the long-press to a (page, paragraph, byte) position via the + // view mode's hit-test path (reflow has no paginated layout). + let resolved: Option<(usize, usize, usize)> = if view_mode() == ViewMode::Reflow { + reflow_tap_position( + &doc_state, + start, + window_width(), + scroll_metrics.peek().client_width, + scroll_offset(), ) + .map(|(para, byte)| (0, para, byte)) + } else { + let (layout_opt, page_width_px, page_height_px) = { + let Ok(state) = doc_state.lock() else { return }; + ( + state.paginated_layout.clone(), + state.page_width_px, + state.page_height_px, + ) + }; + layout_opt.and_then(|layout| { + let x_off = (window_width() - page_width_px).max(0.0) / 2.0; + let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); + hit_test_document( + start.0, + start.1, + origin, + scroll_offset(), + &layout, + page_width_px, + page_height_px, + page_gap_px, + ) + .map(|p| (p.page_index, p.paragraph_index, p.byte_offset)) + }) }; - if let Some(layout) = layout_opt { - let x_off = (window_width() - page_width_px).max(0.0) / 2.0; - let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); - if let Some(pos) = hit_test_document( - start.0, - start.1, - origin, - scroll_offset(), - &layout, - page_width_px, - page_height_px, - page_gap_px, - ) { - let ldoc_guard = loro_doc.read(); - if let Some(ldoc) = ldoc_guard.as_ref() { - let text = get_block_text(ldoc, pos.paragraph_index); - if let Some((ws, we)) = word_boundaries_at(&text, pos.byte_offset) { - let anchor = DocumentPosition { - page_index: pos.page_index, - paragraph_index: pos.paragraph_index, - byte_offset: ws, - }; - let focus = DocumentPosition { - page_index: pos.page_index, - paragraph_index: pos.paragraph_index, - byte_offset: we, - }; - let mut cs = cursor_state.write(); - cs.anchor = Some(anchor); - cs.focus = Some(focus); - } + if let Some((page, para, byte)) = resolved { + let ldoc_guard = loro_doc.read(); + if let Some(ldoc) = ldoc_guard.as_ref() { + let text = get_block_text(ldoc, para); + if let Some((ws, we)) = word_boundaries_at(&text, byte) { + let mut cs = cursor_state.write(); + cs.anchor = Some(DocumentPosition { + page_index: page, + paragraph_index: para, + byte_offset: ws, + }); + cs.focus = Some(DocumentPosition { + page_index: page, + paragraph_index: para, + byte_offset: we, + }); } } } @@ -161,6 +204,7 @@ pub(super) fn make_touchmove_handler( } /// Builds the `ontouchend` handler for tap cursor placement. +#[allow(clippy::too_many_arguments)] pub(super) fn make_touchend_handler( doc_state: Arc>, mut touch_state: Signal>, @@ -169,10 +213,36 @@ pub(super) fn make_touchend_handler( loro_doc: Signal>, mut cursor_state: Signal, page_gap_px: f32, + view_mode: Signal, + scroll_metrics: Signal, ) -> impl FnMut(TouchEvent) { move |_evt: TouchEvent| { let Some(ts) = touch_state() else { return }; match ts.phase { + // Reflow mode has no paginated layout: hit-test the continuous flow. + TouchPhase::Indeterminate | TouchPhase::Tap if view_mode() == ViewMode::Reflow => { + if let Some((para, byte)) = reflow_tap_position( + &doc_state, + ts.start_pos, + window_width(), + scroll_metrics.peek().client_width, + scroll_offset(), + ) { + let loro_cursor = loro_doc + .read() + .as_ref() + .and_then(|ldoc| derive_loro_cursor(ldoc, para, byte)); + let pos = DocumentPosition { + page_index: 0, + paragraph_index: para, + byte_offset: byte, + }; + let mut cs = cursor_state.write(); + cs.loro_cursor = loro_cursor; + cs.anchor = Some(pos.clone()); + cs.focus = Some(pos); + } + } TouchPhase::Indeterminate | TouchPhase::Tap => { // Short tap — place cursor via the same hit-test path as a mouse click. let (layout_opt, page_width_px, page_height_px) = { From 24da82e449f1e73dc48074d8bc29005dcc9780b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 23:14:14 +0000 Subject: [PATCH 23/35] fix(shell): re-summon the soft keyboard on every tap, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android soft keyboard appeared on the first tap into the editor but could not be brought back: when the user dismisses it (back / swipe-down) the OS sends no notification, so the patch's `ime_active` flag stayed `true`, and `update_ime_for_focus`'s `wants_ime != ime_active` guard then suppressed every later `set_ime_allowed(true)` — a second tap to move the caret in the already-focused canvas did nothing. `update_ime_for_focus` now takes a `force_show` flag. A focus change still toggles IME on/off (unchanged), but a fresh pointer release (mouse-up / touch tap) on a text surface re-asserts `set_ime_allowed(true)` even when IME is already active, re-summoning a dismissed keyboard. Tab focus moves pass `force_show = false`. Vendored-patch change (blitz-shell); documented in docs/patches.md. Builds across the workspace; live keyboard behaviour needs on-device verification. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ty4wHkqwPBpHsnZkgGiaQ7 --- docs/patches.md | 10 ++++++++++ patches/blitz-shell/src/window.rs | 26 +++++++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/patches.md b/docs/patches.md index db9d8040..ca31e1c2 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -122,6 +122,16 @@ focusable `
`, so tapping it raises the keyboard while tapping a ribbon `