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); + } }