From cc1c279be00000d0ddfd1eb660ebbd3068305d1d Mon Sep 17 00:00:00 2001 From: Atul Sharma Date: Wed, 29 Jul 2026 22:17:32 +0100 Subject: [PATCH] Resolve anchored drawing offsets against their relativeFrom frame A wp:anchor offset is meaningless on its own. The same number lands somewhere different depending on whether it is measured from the page, the margin, the text column or the paragraph. We read relativeFrom into CT_Anchor and then ignored it, treating every offset as an absolute page coordinate, so anchored drawings ended up in the corner of the sheet. In the file attached to #3 the anchor asks for column plus 4.75pt horizontally and paragraph plus 0.05pt vertically. As page coordinates that is the very top left of the page, off the printable area. Resolved properly it is x 76.75, y 107.4, which is beside its paragraph. Resolving a paragraph-relative offset needs the laid-out position of the paragraph, which is only known during pagination. So the anchors now travel with the ParagraphBlock and the paginator places them once the page and the cursor are settled. That also removes the old pass, which had to guess and put everything on page one regardless of where the anchoring paragraph actually was. behindDoc drawings are collected separately and emitted before the rest of the page so they sit underneath the text rather than over it. A paragraph split across a page boundary places its anchors with the first part only, so they are not emitted twice. Closes #10. --- crates/rdocx-layout/src/block.rs | 35 ++++++ crates/rdocx-layout/src/engine.rs | 105 +++++----------- crates/rdocx-layout/src/paginator.rs | 172 ++++++++++++++++++++++++++- 3 files changed, 238 insertions(+), 74 deletions(-) diff --git a/crates/rdocx-layout/src/block.rs b/crates/rdocx-layout/src/block.rs index 25e1748e..cf3736d8 100644 --- a/crates/rdocx-layout/src/block.rs +++ b/crates/rdocx-layout/src/block.rs @@ -1,12 +1,39 @@ //! Block-level layout: paragraphs and tables as positioned blocks. use rdocx_oxml::borders::CT_PBdr; +use rdocx_oxml::drawing::{ST_RelativeFromH, ST_RelativeFromV}; use rdocx_oxml::shared::ST_Jc; use crate::line::LayoutLine; use crate::output::Color; use crate::table::TableBlock; +/// A floating drawing anchored to a paragraph. +/// +/// Offsets are kept in points alongside the frame they are measured from. A +/// `wp:anchor` offset is meaningless on its own: the same number means a +/// different place depending on whether it is relative to the page, the +/// margin, the text column or the paragraph. +#[derive(Debug, Clone)] +pub struct AnchoredDrawing { + /// Render underneath the text rather than on top of it. + pub behind_doc: bool, + /// Frame the horizontal offset is measured from. + pub rel_h: ST_RelativeFromH, + /// Horizontal offset in points. + pub off_h: f64, + /// Frame the vertical offset is measured from. + pub rel_v: ST_RelativeFromV, + /// Vertical offset in points. + pub off_v: f64, + /// Width in points. + pub width: f64, + /// Height in points. + pub height: f64, + /// Relationship ID of the image part. + pub embed_id: String, +} + /// A laid-out block element (paragraph or table). #[derive(Debug, Clone)] pub enum LayoutBlock { @@ -79,6 +106,12 @@ impl LayoutBlock { pub struct ParagraphBlock { /// Laid-out lines. pub lines: Vec, + /// Floating drawings anchored to this paragraph. + /// + /// These travel with the paragraph so the paginator can resolve a + /// paragraph-relative or line-relative offset once it knows where the + /// paragraph actually landed. + pub anchored: Vec, /// Space before the paragraph in points. pub space_before: f64, /// Space after the paragraph in points. @@ -141,6 +174,7 @@ pub fn build_paragraph_block( ) -> ParagraphBlock { ParagraphBlock { lines, + anchored: Vec::new(), space_before, space_after, borders, @@ -164,6 +198,7 @@ mod tests { #[test] fn paragraph_block_height() { let block = ParagraphBlock { + anchored: Vec::new(), lines: vec![ LayoutLine { items: vec![], diff --git a/crates/rdocx-layout/src/engine.rs b/crates/rdocx-layout/src/engine.rs index c91535fa..46694c5e 100644 --- a/crates/rdocx-layout/src/engine.rs +++ b/crates/rdocx-layout/src/engine.rs @@ -164,9 +164,6 @@ impl Engine { // Post-pagination pass: apply page background color apply_page_background(&mut pages, input); - // Post-pagination pass: resolve anchor (background) images - resolve_anchor_images(&mut pages, input); - // Post-pagination pass: resolve inline image data resolve_inline_images(&mut pages, input); @@ -252,74 +249,6 @@ fn extract_background_color(xml: &str) -> Option { None } -/// Resolve anchor (floating) images from the document and inject them into page frames. -/// -/// For `behind_doc=true` images: inserts at the START of page elements (renders underneath). -/// For `behind_doc=false` images: inserts at the END (renders on top). -fn resolve_anchor_images(pages: &mut [PageFrame], input: &LayoutInput) { - use crate::output::Rect; - use rdocx_oxml::text::RunContent; - - // Collect all anchor drawings from body content - let mut anchor_images: Vec<(bool, f64, f64, f64, f64, String)> = Vec::new(); - - for content in &input.document.body.content { - if let BodyContent::Paragraph(p) = content { - for run in &p.runs { - for rc in &run.content { - if let RunContent::Drawing(drawing) = rc - && let Some(ref anchor) = drawing.anchor - { - let behind = anchor.behind_doc; - // Convert EMU positions and extents to points - let x = anchor.pos_h_offset.to_pt(); - let y = anchor.pos_v_offset.to_pt(); - let w = anchor.extent_cx.to_pt(); - let h = anchor.extent_cy.to_pt(); - anchor_images.push((behind, x, y, w, h, anchor.embed_id.clone())); - } - } - } - } - } - - if anchor_images.is_empty() { - return; - } - - // For each anchor image, resolve image data and add to pages - for (behind, x, y, w, h, embed_id) in &anchor_images { - let (data, content_type) = if let Some(img) = input.images.get(embed_id) { - (img.data.clone(), img.content_type.clone()) - } else { - continue; - }; - - let element = PositionedElement::Image { - rect: Rect { - x: *x, - y: *y, - width: *w, - height: *h, - }, - data, - content_type, - embed_id: None, // Already resolved - }; - - if *behind { - // Behind-doc images go on the first page only - // (proper page association would require paragraph-to-page mapping) - if let Some(page) = pages.first_mut() { - page.elements.insert(0, element); - } - } else if let Some(page) = pages.first_mut() { - // Foreground anchor images go on the first page only - page.elements.push(element); - } - } -} - /// Resolve inline image data from input.images by embed_id. /// /// During pagination, inline images are created with empty data and an embed_id. @@ -870,7 +799,7 @@ pub fn layout_paragraph( let lines = line::break_into_lines(&inline_items, &line_params, fm)?; - Ok(block::build_paragraph_block( + let mut result = block::build_paragraph_block( lines, space_before, space_after, @@ -883,7 +812,37 @@ pub fn layout_paragraph( keep_lines, page_break_before, widow_control, - )) + ); + result.anchored = collect_anchored_drawings(para); + Ok(result) +} + +/// Collect the floating drawings anchored to a paragraph. +/// +/// The offsets stay paired with the frame they are measured from. Resolving +/// them here is not possible: a paragraph-relative offset needs the laid-out +/// position of the paragraph, which only the paginator knows. +fn collect_anchored_drawings(para: &CT_P) -> Vec { + let mut out = Vec::new(); + for run in ¶.runs { + for rc in &run.content { + if let RunContent::Drawing(drawing) = rc + && let Some(ref anchor) = drawing.anchor + { + out.push(block::AnchoredDrawing { + behind_doc: anchor.behind_doc, + rel_h: anchor.pos_h_relative_from, + off_h: anchor.pos_h_offset.to_pt(), + rel_v: anchor.pos_v_relative_from, + off_v: anchor.pos_v_offset.to_pt(), + width: anchor.extent_cx.to_pt(), + height: anchor.extent_cy.to_pt(), + embed_id: anchor.embed_id.clone(), + }); + } + } + } + out } /// Merge direct paragraph properties (only fields explicitly set in the XML). diff --git a/crates/rdocx-layout/src/paginator.rs b/crates/rdocx-layout/src/paginator.rs index 1a83f586..c76228b0 100644 --- a/crates/rdocx-layout/src/paginator.rs +++ b/crates/rdocx-layout/src/paginator.rs @@ -3,11 +3,12 @@ //! Handles page breaks, widow/orphan control, keep-with-next, //! keep-lines-together, and header/footer placement. -use crate::block::{LayoutBlock, ParagraphBlock}; +use crate::block::{AnchoredDrawing, LayoutBlock, ParagraphBlock}; use crate::font::FontManager; use crate::line::{LayoutLine, LineItem}; use crate::output::{Color, GlyphRun, OutlineEntry, PageFrame, Point, PositionedElement, Rect}; +use rdocx_oxml::drawing::{ST_RelativeFromH, ST_RelativeFromV}; use rdocx_oxml::shared::{ST_Border, ST_Jc, ST_Underline}; /// A resolved border edge: (thickness in pt, color, optional dash pattern as (dash, gap)). @@ -217,6 +218,10 @@ pub fn paginate( struct Pager<'a> { pages: Vec, elements: Vec, + /// Anchored drawings marked behindDoc. Held apart from the normal element + /// list so they can be emitted before everything else on the page, which + /// is what puts them underneath the text. + behind_elements: Vec, cursor_y: f64, page_number: usize, content_height: f64, @@ -239,6 +244,7 @@ impl<'a> Pager<'a> { Pager { pages: Vec::new(), elements: Vec::new(), + behind_elements: Vec::new(), cursor_y: 0.0, page_number: 1, content_height: geometry.content_height(), @@ -259,9 +265,41 @@ impl<'a> Pager<'a> { self.has_content_flag = true; } + /// Place the drawings anchored to a paragraph whose top sits at `para_top`, + /// measured from the top of the content area. + fn place_anchored(&mut self, anchored: &[AnchoredDrawing], para_top: f64, indent_left: f64) { + for a in anchored { + if a.embed_id.is_empty() { + continue; + } + let x = resolve_anchor_h(a.rel_h, a.off_h, &self.geometry, indent_left); + let y = resolve_anchor_v(a.rel_v, a.off_v, &self.geometry, para_top); + let element = PositionedElement::Image { + rect: Rect { + x, + y, + width: a.width, + height: a.height, + }, + // The inline image pass fills these in from the embed id. + data: Vec::new(), + content_type: String::new(), + embed_id: Some(a.embed_id.clone()), + }; + if a.behind_doc { + self.behind_elements.push(element); + } else { + self.elements.push(element); + } + } + } + fn finish_page(&mut self) { let mut all_elements = Vec::new(); + // behindDoc drawings render underneath everything else on the page. + all_elements.append(&mut self.behind_elements); + if let Some(hf) = self.header_footer { // Choose header blocks: first-page or default let header_blocks = if self.is_first_page && self.title_pg { @@ -314,6 +352,44 @@ impl<'a> Pager<'a> { } /// Paginate a single paragraph, handling splitting across pages. +/// Resolve a horizontal anchor offset against the frame it is measured from. +/// +/// An offset says nothing on its own. The same number lands somewhere +/// different depending on the frame, and treating every offset as a page +/// coordinate put anchored drawings in the corner of the sheet. +fn resolve_anchor_h(rel: ST_RelativeFromH, off: f64, g: &PageGeometry, indent_left: f64) -> f64 { + match rel { + ST_RelativeFromH::Page | ST_RelativeFromH::LeftMargin => off, + ST_RelativeFromH::RightMargin | ST_RelativeFromH::OutsideMargin => { + g.page_width - g.margin_right + off + } + ST_RelativeFromH::InsideMargin => g.margin_left + off, + // A character-relative offset starts where the text does on the line. + ST_RelativeFromH::Character => g.margin_left + indent_left + off, + // Margin and column both start at the left edge of the text area. + // Multiple columns are not laid out yet, so the two coincide. + ST_RelativeFromH::Margin | ST_RelativeFromH::Column => g.margin_left + off, + } +} + +/// Resolve a vertical anchor offset against the frame it is measured from. +/// +/// `para_top` is the top of the anchoring paragraph, measured from the top of +/// the content area. +fn resolve_anchor_v(rel: ST_RelativeFromV, off: f64, g: &PageGeometry, para_top: f64) -> f64 { + match rel { + ST_RelativeFromV::Page | ST_RelativeFromV::TopMargin => off, + ST_RelativeFromV::BottomMargin | ST_RelativeFromV::OutsideMargin => { + g.page_height - g.margin_bottom + off + } + ST_RelativeFromV::Margin | ST_RelativeFromV::InsideMargin => g.margin_top + off, + // Paragraph and line are both relative to where this paragraph landed. + // Per-line anchoring would need the line box, which is finer than we + // track here, so the paragraph top stands in for both. + ST_RelativeFromV::Paragraph | ST_RelativeFromV::Line => g.margin_top + para_top + off, + } +} + fn paginate_paragraph( para: &ParagraphBlock, block_idx: usize, @@ -428,6 +504,10 @@ fn paginate_paragraph( ); } + // Anchored drawings resolve against the paragraph's position, so place + // them now that the page and the cursor are settled. + pager.place_anchored(¶.anchored, pager.cursor_y, para.indent_left); + render_paragraph_lines( ¶.lines, para, @@ -445,6 +525,8 @@ fn paginate_paragraph( fn render_para_split(para: &ParagraphBlock, split_at: usize, space_before: f64, pager: &mut Pager) { // Render lines before split on current page pager.cursor_y += space_before; + // A split paragraph anchors its drawings to where it starts. + pager.place_anchored(¶.anchored, pager.cursor_y, para.indent_left); render_paragraph_lines( ¶.lines[..split_at], para, @@ -465,6 +547,9 @@ fn render_para_split(para: &ParagraphBlock, split_at: usize, space_before: f64, if lines_that_fit > 0 && lines_that_fit < remaining_lines.len() { // Build a temporary para with remaining lines let temp_para = ParagraphBlock { + // The anchors were placed with the first part of the + // paragraph, so the continuation must not place them again. + anchored: Vec::new(), lines: remaining_lines.to_vec(), space_before: 0.0, space_after: para.space_after, @@ -1164,6 +1249,7 @@ mod tests { lines.push(make_line(line_height)); } ParagraphBlock { + anchored: Vec::new(), lines, space_before: 0.0, space_after: 0.0, @@ -1264,6 +1350,7 @@ mod tests { fn underline_renders_line_element() { let fm = FontManager::new(); let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![make_text_line(14.0, Some(ST_Underline::Single), false)], space_before: 0.0, space_after: 0.0, @@ -1294,6 +1381,7 @@ mod tests { fn strikethrough_renders_line_element() { let fm = FontManager::new(); let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![make_text_line(14.0, None, true)], space_before: 0.0, space_after: 0.0, @@ -1360,6 +1448,7 @@ mod tests { is_last: true, }; let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![line], space_before: 0.0, space_after: 0.0, @@ -1390,6 +1479,7 @@ mod tests { use rdocx_oxml::borders::{CT_BorderEdge, CT_PBdr}; let fm = FontManager::new(); let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![make_line(14.0)], space_before: 0.0, space_after: 0.0, @@ -1433,6 +1523,7 @@ mod tests { fn paragraph_shading_renders_filled_rect() { let fm = FontManager::new(); let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![make_line(14.0)], space_before: 0.0, space_after: 0.0, @@ -1467,6 +1558,7 @@ mod tests { fn double_underline_renders_two_lines() { let fm = FontManager::new(); let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![make_text_line(14.0, Some(ST_Underline::Double), false)], space_before: 0.0, space_after: 0.0, @@ -1563,6 +1655,7 @@ mod tests { is_last: true, }; let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![line], space_before: 0.0, space_after: 0.0, @@ -1596,6 +1689,7 @@ mod tests { let fm = FontManager::new(); // Line with "Hello World" (1 space = 1 gap), width 200 out of 468 available let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![ make_justified_line("Hello World", 200.0, false), make_justified_line("End.", 40.0, true), @@ -1640,6 +1734,7 @@ mod tests { fn justified_last_line_stays_left_aligned() { let fm = FontManager::new(); let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![ make_justified_line("Hello World Test", 200.0, false), make_justified_line("End.", 40.0, true), @@ -1689,6 +1784,7 @@ mod tests { let fm = FontManager::new(); // A line with a single word (no spaces) should not be stretched let para = ParagraphBlock { + anchored: Vec::new(), lines: vec![ make_justified_line("Superlongword", 100.0, false), make_justified_line("End.", 40.0, true), @@ -1727,4 +1823,78 @@ mod tests { "single word should not be stretched: {total_advance}" ); } + + /// A wp:anchor offset means nothing without the frame it is measured from. + /// Treating every offset as a page coordinate put anchored drawings in the + /// corner of the sheet instead of beside their paragraph. + #[test] + fn anchor_offsets_resolve_against_their_frame() { + let g = PageGeometry::default(); // 612 x 792, 72pt margins + let para_top = 100.0; + let off = 10.0; + + assert_eq!(resolve_anchor_h(ST_RelativeFromH::Page, off, &g, 0.0), 10.0); + assert_eq!( + resolve_anchor_h(ST_RelativeFromH::LeftMargin, off, &g, 0.0), + 10.0 + ); + assert_eq!( + resolve_anchor_h(ST_RelativeFromH::Margin, off, &g, 0.0), + 82.0, + "margin-relative starts at the left margin" + ); + assert_eq!( + resolve_anchor_h(ST_RelativeFromH::Column, off, &g, 0.0), + 82.0, + "column-relative starts at the text area" + ); + assert_eq!( + resolve_anchor_h(ST_RelativeFromH::RightMargin, off, &g, 0.0), + 550.0, + "right-margin-relative starts at the right margin edge" + ); + assert_eq!( + resolve_anchor_h(ST_RelativeFromH::Character, off, &g, 36.0), + 118.0, + "character-relative includes the paragraph indent" + ); + + assert_eq!( + resolve_anchor_v(ST_RelativeFromV::Page, off, &g, para_top), + 10.0 + ); + assert_eq!( + resolve_anchor_v(ST_RelativeFromV::TopMargin, off, &g, para_top), + 10.0 + ); + assert_eq!( + resolve_anchor_v(ST_RelativeFromV::Margin, off, &g, para_top), + 82.0 + ); + assert_eq!( + resolve_anchor_v(ST_RelativeFromV::Paragraph, off, &g, para_top), + 182.0, + "paragraph-relative follows the paragraph down the page" + ); + assert_eq!( + resolve_anchor_v(ST_RelativeFromV::Line, off, &g, para_top), + 182.0 + ); + assert_eq!( + resolve_anchor_v(ST_RelativeFromV::BottomMargin, off, &g, para_top), + 730.0 + ); + } + + /// The same offset must land somewhere different once the paragraph moves. + /// This is the property the old code could not express at all. + #[test] + fn paragraph_relative_anchor_tracks_the_paragraph() { + let g = PageGeometry::default(); + let near_top = resolve_anchor_v(ST_RelativeFromV::Paragraph, 5.0, &g, 0.0); + let further_down = resolve_anchor_v(ST_RelativeFromV::Paragraph, 5.0, &g, 300.0); + assert_eq!(near_top, 77.0); + assert_eq!(further_down, 377.0); + assert!(further_down > near_top); + } }