diff --git a/assets/bugfixes/issue-2231/after.jpg b/assets/bugfixes/issue-2231/after.jpg new file mode 100644 index 00000000..39b0a0e1 Binary files /dev/null and b/assets/bugfixes/issue-2231/after.jpg differ diff --git a/assets/bugfixes/issue-2231/before.jpg b/assets/bugfixes/issue-2231/before.jpg new file mode 100644 index 00000000..835b11de Binary files /dev/null and b/assets/bugfixes/issue-2231/before.jpg differ diff --git a/assets/bugfixes/issue-2231/gt.jpg b/assets/bugfixes/issue-2231/gt.jpg new file mode 100644 index 00000000..3a6038db Binary files /dev/null and b/assets/bugfixes/issue-2231/gt.jpg differ diff --git a/assets/bugfixes/issue-2231/layout-audit.json b/assets/bugfixes/issue-2231/layout-audit.json new file mode 100644 index 00000000..9666ae03 --- /dev/null +++ b/assets/bugfixes/issue-2231/layout-audit.json @@ -0,0 +1,147 @@ +{ + "pages": [ + { + "lines": { + "gt": 3, + "out": 3, + "matched": 3, + "missing": 0, + "extra": 0, + "deviant": 1, + "missing_text": [], + "extra_text": [] + }, + "baseline": { + "mean_abs_dy": 0.07919366666667334, + "worst_dy": 0.13894900000002508, + "worst_dy_signed": 0.13894900000002508, + "worst_line": "Aboutprovidinginformation" + }, + "dx0": { + "mean_abs": 23.71833333333333, + "worst": 71.025 + }, + "width": { + "mean_abs_pct": 0.15638265639235263, + "worst_pct": 0.31213315474126385 + }, + "instances": { + "compared": 3, + "large_shift_threshold": 5.0, + "large_shift_count": 1, + "large_shifts": [ + { + "label": "Aboutprovidinginformation", + "gt_x": 72.275, + "gt_y": 102.52999999999997, + "out_x": 143.3, + "out_y": 102.668949, + "dx": 71.025, + "dy": 0.13894900000002508, + "width_pct": -0.05168531892225313 + } + ], + "fine_shift_threshold": 0.5, + "fine_shift_count": 1, + "fine_shifts": [ + { + "label": "Aboutprovidinginformation", + "gt_x": 72.275, + "gt_y": 102.52999999999997, + "out_x": 143.3, + "out_y": 102.668949, + "dx": 71.025, + "dy": 0.13894900000002508, + "width_pct": -0.05168531892225313 + } + ] + }, + "visibility": { + "mismatch_count": 0, + "mismatches": [], + "unmatched_hidden_gt": 0, + "unmatched_hidden_out": 0, + "unmatched_hidden_gt_text": [], + "unmatched_hidden_out_text": [] + }, + "visible_fills": { + "mismatch_count": 0, + "mismatches": [] + }, + "pitch": { + "pairs": 2, + "worst_delta": 0.09863199999999495 + }, + "wraps": { + "count": 0, + "samples": [] + }, + "topology": { + "groups": 0, + "gt_lines": 0, + "out_lines": 0, + "samples": [] + }, + "reflow": { + "gt_lines": 0, + "out_lines": 0, + "samples": [] + }, + "rects": { + "gt_count": 0, + "out_count": 0, + "canonical_gt_count": 0, + "canonical_out_count": 0, + "matched": 0, + "unmatched_gt": 0, + "unmatched_out": 0, + "mean_center_delta": 0.0, + "geometry_threshold": 0.5, + "raw_geometry_mismatch_count": 0, + "raw_geometry_mismatch_samples": [], + "coverage_equivalent_count": 0, + "coverage_unmodeled_count": 0, + "geometry_mismatch_count": 0, + "geometry_mismatch_samples": [], + "samples": [], + "x": { + "mean_abs": 0.0, + "worst": 0.0 + }, + "y": { + "mean_abs": 0.0, + "worst": 0.0 + }, + "width": { + "mean_abs": 0.0, + "worst": 0.0 + }, + "height": { + "mean_abs": 0.0, + "worst": 0.0 + }, + "edges": { + "left": { + "mean_abs": 0.0, + "worst": 0.0 + }, + "top": { + "mean_abs": 0.0, + "worst": 0.0 + }, + "right": { + "mean_abs": 0.0, + "worst": 0.0 + }, + "bottom": { + "mean_abs": 0.0, + "worst": 0.0 + } + } + }, + "noise_floor": 0.12 + } + ], + "gt_pages": 1, + "out_pages": 1 +} diff --git a/assets/bugfixes/issue-2231/render-clusters-page-1.json b/assets/bugfixes/issue-2231/render-clusters-page-1.json new file mode 100644 index 00000000..e4b00bdc --- /dev/null +++ b/assets/bugfixes/issue-2231/render-clusters-page-1.json @@ -0,0 +1,279 @@ +{ + "clusters": [ + { + "area_pt2": 139.7376, + "bbox_pt": { + "height": 12.96, + "width": 25.92, + "x": 140.88, + "y": 92.64 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-0269eeea1320", + "region": "top-left" + }, + { + "area_pt2": 136.5696, + "bbox_pt": { + "height": 12.96, + "width": 27.6, + "x": 180.0, + "y": 92.64 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-1d02ab209e0b", + "region": "top-left" + }, + { + "area_pt2": 81.9648, + "bbox_pt": { + "height": 10.32, + "width": 14.64, + "x": 221.28, + "y": 95.28 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-8d4effefaa9f", + "region": "top" + }, + { + "area_pt2": 80.1216, + "bbox_pt": { + "height": 10.08, + "width": 14.64, + "x": 207.36, + "y": 92.64 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-4dd85f6b7425", + "region": "top" + }, + { + "area_pt2": 56.16, + "bbox_pt": { + "height": 8.16, + "width": 12.24, + "x": 167.28, + "y": 94.56 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-86ba312e7a8b", + "region": "top-left" + }, + { + "area_pt2": 34.0416, + "bbox_pt": { + "height": 6.72, + "width": 10.56, + "x": 267.12, + "y": 95.76 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-dc4ae44afcb5", + "region": "top" + }, + { + "area_pt2": 26.784, + "bbox_pt": { + "height": 9.6, + "width": 6.96, + "x": 110.4, + "y": 96.0 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-e29ef7f179cb", + "region": "top-left" + }, + { + "area_pt2": 26.208, + "bbox_pt": { + "height": 9.6, + "width": 10.08, + "x": 72.24, + "y": 92.88 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-91b5c225622b", + "region": "top-left" + }, + { + "area_pt2": 24.7104, + "bbox_pt": { + "height": 10.08, + "width": 6.72, + "x": 82.08, + "y": 92.64 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-c616c48f1eb6", + "region": "top-left" + }, + { + "area_pt2": 23.4432, + "bbox_pt": { + "height": 6.96, + "width": 8.64, + "x": 101.52, + "y": 78.96 + }, + "disposition": { + "class": "glyph-edge-rasterization", + "kind": "accepted-rendering", + "note": "Inside 'Dear recipient': the same glyphs at the same position (dy 0.09pt), outlines differing only because Word paints Times New Roman and this host its metric-compatible Liberation Serif." + }, + "id": "p1-0e2fe98d3654", + "region": "top-left" + }, + { + "area_pt2": 21.2544, + "bbox_pt": { + "height": 6.72, + "width": 6.96, + "x": 96.24, + "y": 96.0 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-541c831c6411", + "region": "top-left" + }, + { + "area_pt2": 21.1968, + "bbox_pt": { + "height": 6.72, + "width": 6.72, + "x": 243.84, + "y": 95.76 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-738121fe7f6b", + "region": "top" + }, + { + "area_pt2": 21.1968, + "bbox_pt": { + "height": 6.72, + "width": 6.72, + "x": 299.04, + "y": 95.76 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-8aafa3a15d60", + "region": "top" + }, + { + "area_pt2": 20.6784, + "bbox_pt": { + "height": 6.96, + "width": 6.0, + "x": 255.6, + "y": 95.76 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-1d64d0521b5e", + "region": "top" + }, + { + "area_pt2": 20.6784, + "bbox_pt": { + "height": 6.96, + "width": 6.0, + "x": 292.08, + "y": 95.76 + }, + "disposition": { + "issue": "#2233", + "kind": "issue", + "note": "The framed line 'About providing information' on Word's baseline (dy 0.14pt) but 71.03pt right: its w:ind w:left=-1418 is clamped to zero, so it starts at the frame's left edge plus the 78pt first-line indent instead of 70.9pt left of that." + }, + "id": "p1-4f8d478c7077", + "region": "top" + }, + { + "area_pt2": 20.6208, + "bbox_pt": { + "height": 9.84, + "width": 6.96, + "x": 136.56, + "y": 78.96 + }, + "disposition": { + "class": "glyph-edge-rasterization", + "kind": "accepted-rendering", + "note": "Inside 'Dear recipient': the same glyphs at the same position (dy 0.09pt), outlines differing only because Word paints Times New Roman and this host its metric-compatible Liberation Serif." + }, + "id": "p1-99b893eb6d01", + "region": "top-left" + } + ], + "dpi": 300, + "duplicate_disposition_cluster_ids": [], + "errors": [], + "fuzz_percent": 5, + "minimum_area_pt2": 20.0, + "page": 1, + "passed": true, + "renderer_observations": [], + "schema_version": 1, + "strict": true, + "summary": { + "dispositioned": 16, + "duplicate": 0, + "total": 16, + "undispositioned": 0, + "unknown": 0 + }, + "undispositioned_cluster_ids": [], + "unknown_disposition_cluster_ids": [] +} diff --git a/assets/bugfixes/issue-2355/compare.jpg b/assets/bugfixes/issue-2355/compare.jpg new file mode 100644 index 00000000..9301d3e0 Binary files /dev/null and b/assets/bugfixes/issue-2355/compare.jpg differ diff --git a/crates/office2pdf/src/ir/elements.rs b/crates/office2pdf/src/ir/elements.rs index 2af4f7aa..5d9c6c8c 100644 --- a/crates/office2pdf/src/ir/elements.rs +++ b/crates/office2pdf/src/ir/elements.rs @@ -159,6 +159,9 @@ pub enum Block { Table(Table), /// A Word table positioned by `w:tblpPr`, outside the document's text flow. FloatingTable(Box), + /// Body paragraphs Word lifts out of the text flow into a `w:framePr` + /// frame. + TextFrame(Box), Image(ImageData), /// Consecutive inline images from one flow paragraph. InlineImages(Vec), @@ -1295,6 +1298,39 @@ pub struct FloatingShape { pub wrap_mode: WrapMode, } +/// A body paragraph in a `w:framePr` frame that hangs `w:y` below the +/// paragraph's own place in the flow (`w:vAnchor="text"`) with a stated +/// height. Word draws it in its frame, out of the text flow, and lays the text +/// after it beside the frame (issue #2231). +#[derive(Debug, Clone)] +pub struct TextFrame { + pub content: Vec, + /// `w:x` in points. Before section resolution it is measured from + /// `horizontal_anchor`, or replaced by `horizontal_align`; afterwards it is + /// measured from the left edge of the page text area, and the anchor is + /// Text. + pub x: f64, + pub horizontal_anchor: FrameAnchor, + /// `w:xAlign` in place of `w:x`. + pub horizontal_align: Option, + /// `w:y`: how far below the top of the paragraph's place in the flow the + /// frame starts, in points. + pub y: f64, + /// `w:w` in points. + pub width: f64, + /// `w:h` in points: the frame's height when `exact_height`, otherwise the + /// least height its content stretches. + pub height: f64, + /// `w:hRule="exact"`. + pub exact_height: bool, + /// `w:hSpace`: how far body text beside the frame keeps from its left and + /// right edges, in points. + pub horizontal_space: f64, + /// `w:vSpace`: how far body text keeps from its top and bottom edges, in + /// points. + pub vertical_space: f64, +} + /// A DOCX body table whose `w:tblpPr` frame has been resolved against its /// section's page, margin, or text area. #[derive(Debug, Clone)] diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index 566f2985..dea86598 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -14,8 +14,8 @@ use crate::ir::{ FloatingTable, FloatingTextBox, ImageData, ImageFormat, ImageParagraphIndent, ImageParagraphSpacing, InlineTextBox, Insets, Ligatures, LineCap, LineJoin, LineSpacing, Page, PageNumbering, PairKerning, Paragraph, ParagraphStyle, Run, StyleSheet, TabAlignment, - TabLeader, TabStop, Table, TableCell, TableOfContents, TableRow, TextDirection, TextStyle, - VerticalTextAlign, WordCompatibilityMode, + TabLeader, TabStop, Table, TableCell, TableOfContents, TableRow, TextDirection, TextFrame, + TextStyle, VerticalTextAlign, WordCompatibilityMode, }; use crate::parser::Parser; @@ -45,7 +45,7 @@ use self::media::{ use self::sections::extract_page_size; use self::sections::{ HeaderFooterAssets, HeaderFooterStyleContext, LinkedFirstStories, SectionHeaderFooterInputs, - SectionOverrides, build_flow_page_from_section, build_header_footer_assets, + SectionOverrides, body_text_frame, build_flow_page_from_section, build_header_footer_assets, }; #[cfg(test)] use self::styles::parse_ligatures_value; @@ -597,6 +597,21 @@ impl Parser for DocxParser { "Empty framed paragraph leaves no body line" ); tagged.clear(); + } else if let Some(frame) = + lifted_text_frame(para, &tagged, &docx.document.children, idx) + { + tracing::debug!( + format = "DOCX", + body_index = idx, + x_pt = frame.x, + y_pt = frame.y, + width_pt = frame.width, + height_pt = frame.height, + "Framed paragraph lifted out of the body flow" + ); + tagged = vec![TaggedElement::Plain(vec![Block::TextFrame(Box::new( + frame, + ))])]; } // Inject math equations for this body child let eqs = math.take(idx); @@ -2270,6 +2285,45 @@ fn is_empty_lifted_frame( ) } +/// A body paragraph alone in a `w:framePr` frame hung below its own place in +/// the flow, as [`body_text_frame`] describes, carrying the paragraph's +/// converted blocks. Adjacent paragraphs, or a table, that share an identical +/// frame stay in the flow, as does a list paragraph or one holding a page or +/// column break, which a placed frame cannot hold. +/// +/// TODO(frames shared by several paragraphs): Word draws a group of +/// paragraphs with an identical `w:framePr` in one frame. +fn lifted_text_frame( + para: &docx_rs::Paragraph, + converted: &[TaggedElement], + body: &[docx_rs::DocumentChild], + index: usize, +) -> Option { + let frame_property: &docx_rs::FrameProperty = para.property.frame_property.as_ref()?; + let mut frame: TextFrame = body_text_frame(frame_property)?; + let shares_frame = |neighbour: Option<&docx_rs::DocumentChild>| { + neighbour.and_then(leading_frame_property) == Some(frame_property) + }; + if shares_frame(index.checked_sub(1).and_then(|previous| body.get(previous))) + || shares_frame(body.get(index + 1)) + { + return None; + } + for element in converted { + let TaggedElement::Plain(blocks) = element else { + return None; + }; + if blocks + .iter() + .any(|block| matches!(block, Block::PageBreak | Block::ColumnBreak)) + { + return None; + } + frame.content.extend(blocks.iter().cloned()); + } + (!frame.content.is_empty()).then_some(frame) +} + /// The `w:framePr` a body child would sit in: a paragraph's own, or a table's /// first cell paragraph's, which is how Word frames a table. fn leading_frame_property(child: &docx_rs::DocumentChild) -> Option<&docx_rs::FrameProperty> { diff --git a/crates/office2pdf/src/parser/docx_sections.rs b/crates/office2pdf/src/parser/docx_sections.rs index d5618ac1..b25c5ab5 100644 --- a/crates/office2pdf/src/parser/docx_sections.rs +++ b/crates/office2pdf/src/parser/docx_sections.rs @@ -8,7 +8,7 @@ use crate::ir::{ HeaderFooter, HeaderFooterFrame, HeaderFooterParagraph, HeaderFooterShape, HeaderFooterShapeContent, Margins, PageNumbering, PageSize, PositionedTab, PositionedTabAlignment, PositionedTabRelativeTo, Run, TabLeader, Table, TextDirection, - TextStyle, + TextFrame, TextStyle, }; use super::contexts::{DocxConversionContext, PictureCropContext, WrapContext}; @@ -444,6 +444,7 @@ pub(super) fn build_flow_page_from_section( let doc_default_style: Option<&ResolvedStyle> = styles.style_map.get(DOC_DEFAULT_STYLE_ID); let mut content = group_into_lists(elements, numberings); resolve_body_floating_table_positions(&mut content, &size, &margins); + resolve_body_text_frame_positions(&mut content, &size, &margins); for block in &content { if let Block::Chart(chart) = block { @@ -986,6 +987,75 @@ pub(super) fn floating_table_frame( }) } +/// The frame of a body paragraph whose `w:framePr` hangs it `w:y` below its +/// own place in the flow (`w:vAnchor="text"`), with a stated width and height +/// and text allowed beside it. Word lifts such a paragraph out of the flow; +/// docxide-pdf's Word-probed `paragraph_offset_frame` draws the same line, and +/// keeps the other text-anchored frames in the flow (issue #2231). The caller +/// fills in the content. +/// +/// FIXME(unprobed height rule): `w:hRule` other than `exact` is taken as a +/// least height, as `atLeast` states; no Word reference probes a frame that +/// omits the rule. +pub(super) fn body_text_frame(frame: &docx_rs::FrameProperty) -> Option { + let y: i32 = frame.y.filter(|&y| y != 0)?; + let width: u32 = frame.w.filter(|&width| width > 0)?; + let height: u32 = frame.h.filter(|&height| height > 0)?; + let hangs_below_text: bool = frame.v_anchor.as_deref() == Some("text") + && frame.y_align.is_none() + && !matches!(frame.wrap.as_deref(), Some("none" | "notBeside")); + hangs_below_text.then(|| TextFrame { + content: Vec::new(), + x: frame.x.map(twips_to_pt).unwrap_or(0.0), + horizontal_anchor: frame_anchor(frame.h_anchor.as_deref()), + // `w:xAlign` replaces `w:x`. `inside` and `outside` mirror on facing + // pages, which a frame's section does not tell apart here. + horizontal_align: frame.x_align.as_deref().and_then(|align| match align { + "inside" => Some(FrameAlign::Start), + "outside" => Some(FrameAlign::End), + align => frame_align(align), + }), + y: twips_to_pt(y), + width: twips_to_pt(width as i32), + height: twips_to_pt(height as i32), + exact_height: frame.h_rule.as_deref() == Some("exact"), + horizontal_space: frame.h_space.map(twips_to_pt).unwrap_or(0.0).max(0.0), + vertical_space: frame.v_space.map(twips_to_pt).unwrap_or(0.0).max(0.0), + }) +} + +/// Measure each body text frame's `x` from the text area's left edge, where +/// the renderer's `#place` starts. Its `y` stays an offset from the frame's +/// place in the flow, which only layout knows. +/// +/// TODO(text frames in multi-column sections): a frame's `#place` starts at +/// its column's left edge, which is the text area's only in the first column. +fn resolve_body_text_frame_positions( + blocks: &mut [Block], + page_size: &PageSize, + margins: &Margins, +) { + for block in blocks { + let Block::TextFrame(frame) = block else { + continue; + }; + let (origin, available): (f64, f64) = match frame.horizontal_anchor { + FrameAnchor::Page => (0.0, page_size.width), + FrameAnchor::Margin | FrameAnchor::Text => ( + margins.left, + (page_size.width - margins.left - margins.right).max(0.0), + ), + }; + let offset: f64 = match frame.horizontal_align { + Some(align) => hf_aligned_offset(Some(align), available, Some(frame.width)), + None => frame.x, + }; + frame.x = origin + offset - margins.left; + frame.horizontal_anchor = FrameAnchor::Text; + frame.horizontal_align = None; + } +} + fn resolve_body_floating_table_positions( blocks: &mut [Block], page_size: &PageSize, diff --git a/crates/office2pdf/src/parser/docx_tests.rs b/crates/office2pdf/src/parser/docx_tests.rs index cf5f3880..6d5a732e 100644 --- a/crates/office2pdf/src/parser/docx_tests.rs +++ b/crates/office2pdf/src/parser/docx_tests.rs @@ -1569,6 +1569,8 @@ mod math_chart_metadata_tests; mod paragraph_cursor_tests; #[path = "docx_paragraph_tab_tests.rs"] mod paragraph_tab_tests; +#[path = "docx_text_frame_tests.rs"] +mod text_frame_tests; #[path = "docx_tracked_paragraph_mark_tests.rs"] mod tracked_paragraph_mark_tests; diff --git a/crates/office2pdf/src/parser/docx_text_frame_tests.rs b/crates/office2pdf/src/parser/docx_text_frame_tests.rs new file mode 100644 index 00000000..33903b87 --- /dev/null +++ b/crates/office2pdf/src/parser/docx_text_frame_tests.rs @@ -0,0 +1,579 @@ +//! A body paragraph in a `w:framePr` frame hung `w:y` below its anchor +//! paragraph: Word lifts it out of the flow, draws it in its frame, and lays +//! the text after it beside the frame (issue #2231). + +use super::*; + +const MARGIN_PT: f64 = 72.0; +/// A4's 11906 twips less two 1440-twip margins. +const TEXT_WIDTH_PT: f64 = 451.3; +/// The frame width every frame here states: 3000 twips. +const FRAME_WIDTH_PT: f64 = 150.0; +/// The `w:hSpace` the wrapping tests state: 180 twips. +const FRAME_SPACE_PT: f64 = 9.0; + +fn run(content: &str) -> String { + format!( + r#"{content}"# + ) +} + +fn paragraph(paragraph_properties: &str, text: &str) -> String { + format!( + r#"{paragraph_properties}{}"#, + run(&format!("{text}")) + ) +} + +/// A 3000-twip-wide wrapping frame `height_twips` tall, hung `y_twips` below +/// its anchor paragraph; `extra` states the rest of its attributes. +fn offset_frame(height_twips: u32, y_twips: i32, extra: &str) -> String { + format!( + r#""# + ) +} + +/// An A4 section's page size and 72pt margins. +const PAGE_SETUP: &str = r#""#; + +/// An A4 package with 72pt margins, no settings part and no styles part. +fn frame_docx(body: &str) -> Vec { + section_docx(body, PAGE_SETUP) +} + +/// [`frame_docx`] with `section_properties` in the body's closing +/// `w:sectPr`. +fn section_docx(body: &str, section_properties: &str) -> Vec { + let document = format!( + r#"{body}{section_properties}"# + ); + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + let parts: [(&str, &str); 3] = [ + ( + "[Content_Types].xml", + r#""#, + ), + ( + "_rels/.rels", + r#""#, + ), + ("word/document.xml", &document), + ]; + for (name, content) in parts { + zip.start_file(name, options).unwrap(); + std::io::Write::write_all(&mut zip, content.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +/// The painted non-blank runs, one per line, with each run's advance width. +#[cfg(not(target_arch = "wasm32"))] +fn placed(data: &[u8]) -> Vec<(crate::render::pdf::PlacedTextRun, f64)> { + let (document, _) = DocxParser.parse(data, &ConvertOptions::default()).unwrap(); + let source = crate::render::typst_gen::generate_typst(&document).unwrap(); + let text = crate::render::pdf::compiled_text_runs(&source.source, 0).unwrap(); + let glyphs = crate::render::pdf::compiled_glyph_runs(&source.source, 0, true).unwrap(); + text.into_iter() + .filter(|run| !run.text.trim().is_empty()) + .map(|run| { + let width = glyphs + .iter() + .find(|glyph| glyph.text == run.text) + .unwrap_or_else(|| panic!("no glyph run {:?}", run.text)) + .width_pt(); + (run, width) + }) + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +fn find<'a>( + runs: &'a [(crate::render::pdf::PlacedTextRun, f64)], + text: &str, +) -> &'a (crate::render::pdf::PlacedTextRun, f64) { + runs.iter() + .find(|(run, _)| run.text.trim_end() == text) + .unwrap_or_else(|| panic!("no run {text:?} in {runs:?}")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn assert_close(actual: f64, expected: f64, context: &str) { + assert!( + (actual - expected).abs() < 0.05, + "{context}: {actual} != {expected}" + ); +} + +/// The body blocks of the package's only section. +fn body_blocks(data: &[u8]) -> Vec { + let (document, _) = DocxParser.parse(data, &ConvertOptions::default()).unwrap(); + let Page::Flow(page) = &document.pages[0] else { + panic!("expected a flow page"); + }; + page.content.clone() +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn offset_frame_is_drawn_below_its_anchor_and_leaves_no_line() { + let control = placed(&frame_docx( + &[ + paragraph("", "Anchor line"), + paragraph("", "Following text"), + ] + .concat(), + )); + let (control_following, _) = find(&control, "Following text"); + for (h_anchor, x_twips, origin_pt) in [ + ("page", 2000_i32, 0.0), + ("margin", 600, MARGIN_PT), + ("text", 1200, MARGIN_PT), + ] { + for y_twips in [800_i32, 1400] { + let frame = offset_frame( + 600, + y_twips, + &format!(r#"w:hRule="exact" w:hAnchor="{h_anchor}" w:x="{x_twips}""#), + ); + let data = frame_docx( + &[ + paragraph("", "Anchor line"), + paragraph(&frame, "Framed text"), + paragraph("", "Following text"), + ] + .concat(), + ); + let runs = placed(&data); + let context = format!("hAnchor={h_anchor} x={x_twips} y={y_twips}"); + // The frame takes no line: the next paragraph starts where the + // framed one would have, and its line ends above the frame. + let (following, _) = find(&runs, "Following text"); + assert_close( + following.baseline_pt, + control_following.baseline_pt, + &context, + ); + assert_close(following.left_pt, MARGIN_PT, &context); + // The frame hangs w:y below that same point, w:x from its anchor. + let (framed, _) = find(&runs, "Framed text"); + assert_close( + framed.left_pt, + origin_pt + f64::from(x_twips) / 20.0, + &context, + ); + assert_close( + framed.baseline_pt, + following.baseline_pt + f64::from(y_twips) / 20.0, + &context, + ); + } + } +} + +/// A frame's content starts at its top, though the section around it is +/// centred vertically on the page. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn offset_frame_content_starts_at_its_top_in_a_vertically_centred_section() { + let y_twips: i32 = 800; + let centred: String = format!(r#"{PAGE_SETUP}"#); + for height_twips in [1000_u32, 2400] { + let frame = offset_frame( + height_twips, + y_twips, + r#"w:hRule="exact" w:hAnchor="margin" w:x="0""#, + ); + let data = section_docx( + &[ + paragraph("", "Anchor line"), + paragraph(&frame, "Framed text"), + paragraph("", "Following text"), + ] + .concat(), + ¢red, + ); + let runs = placed(&data); + let context = format!("h={height_twips}"); + let (anchor, _) = find(&runs, "Anchor line"); + assert!( + anchor.baseline_pt > 300.0, + "{context}: the section must be centred: {runs:?}" + ); + let (following, _) = find(&runs, "Following text"); + let (framed, _) = find(&runs, "Framed text"); + assert_close( + framed.baseline_pt, + following.baseline_pt + f64::from(y_twips) / 20.0, + &context, + ); + } +} + +/// The frame hangs from the end of the gap after the paragraph before it; +/// the gap before the paragraph after it, which Word lays only between the +/// two flowing paragraphs, does not move it. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn an_offset_frame_hangs_below_the_gap_after_the_paragraph_before_it() { + let y_twips: i32 = 1000; + let spaced = |before: u32, after: u32, text: &str| { + format!( + r#"{}"#, + run(&format!("{text}")) + ) + }; + // The following paragraph can also open a continuous section of its + // own, as in the issue's case, where the framed paragraph ends the first. + let section_end: String = format!("{PAGE_SETUP}"); + let continuous: String = format!(r#"{PAGE_SETUP}"#); + for ends_section in [false, true] { + for (after_twips, before_twips) in [(0_u32, 240_u32), (120, 240), (240, 120)] { + let frame = offset_frame( + 600, + y_twips, + r#"w:hRule="exact" w:hAnchor="margin" w:x="0""#, + ); + let frame_properties: String = if ends_section { + format!("{frame}{section_end}") + } else { + frame + }; + let data = section_docx( + &[ + spaced(0, after_twips, "Anchor line"), + format!( + r#"{frame_properties}{}"#, + run("Framed text") + ), + spaced(before_twips, 0, "Following text"), + ] + .concat(), + if ends_section { + &continuous + } else { + PAGE_SETUP + }, + ); + let runs = placed(&data); + let context = + format!("after={after_twips} before={before_twips} ends section={ends_section}"); + let (following, _) = find(&runs, "Following text"); + let (framed, _) = find(&runs, "Framed text"); + let (after, before): (f64, f64) = ( + f64::from(after_twips) / 20.0, + f64::from(before_twips) / 20.0, + ); + // The following line starts the larger of the two gaps below the + // anchor line; the frame, the first gap and w:y below it. + assert_close( + framed.baseline_pt, + following.baseline_pt - after.max(before) + after + f64::from(y_twips) / 20.0, + &context, + ); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn text_meeting_an_offset_frame_lies_beside_it() { + let lines: Vec = (1..=8) + .map(|line| paragraph("", &format!("Line {line}"))) + .collect(); + let right_lines: Vec = (1..=8) + .map(|line| paragraph(r#""#, &format!("Line {line}"))) + .collect(); + // A frame at the column's left edge leaves its room on the right, one at + // the right edge its room on the left. + let frame_right_x: f64 = TEXT_WIDTH_PT - FRAME_WIDTH_PT; + for (x_twips, body) in [(0.0, &lines), (frame_right_x, &right_lines)] { + for height_twips in [700_u32, 1100] { + let frame = offset_frame( + height_twips, + 20, + &format!( + r#"w:hRule="exact" w:hSpace="180" w:hAnchor="margin" w:x="{}""#, + x_twips * 20.0 + ), + ); + let data = frame_docx( + &[ + paragraph("", "Anchor line"), + paragraph(&frame, "Framed text"), + body.concat(), + ] + .concat(), + ); + let runs = placed(&data); + let context = format!("frame x={x_twips} h={height_twips}"); + let (first, _) = find(&runs, "Line 1"); + // The frame hangs 1pt below the first line's top. + let band_bottom: f64 = + first.baseline_pt - first.font_size_pt + 1.0 + f64::from(height_twips) / 20.0; + let (mut beside, mut below) = (0, 0); + for (run, width) in runs.iter().filter(|(run, _)| run.text.starts_with("Line")) { + let (left, right): (f64, f64) = (run.left_pt, run.left_pt + width); + if run.baseline_pt < band_bottom { + if x_twips == 0.0 { + assert_close(left, MARGIN_PT + FRAME_WIDTH_PT + FRAME_SPACE_PT, &context); + } else { + assert_close(right, MARGIN_PT + frame_right_x - FRAME_SPACE_PT, &context); + } + beside += 1; + } else if run.baseline_pt - run.font_size_pt > band_bottom { + if x_twips == 0.0 { + assert_close(left, MARGIN_PT, &context); + } else { + assert_close(right, MARGIN_PT + TEXT_WIDTH_PT, &context); + } + below += 1; + } + } + assert!( + beside > 0 && below > 0, + "{context}: {beside} lines beside, {below} below in {runs:?}" + ); + } + } +} + +/// An exact frame keeps text off the height it states; an at-least one off +/// its content when that is taller. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn an_offset_frame_excludes_its_stated_or_its_content_height() { + let framed_text: &str = + "Framed words that need several lines in a narrow frame, which is what this paragraph does"; + let lines: Vec = (1..=8) + .map(|line| paragraph("", &format!("Line {line}"))) + .collect(); + for rule in ["exact", "atLeast"] { + let frame = offset_frame( + 200, + 20, + &format!(r#"w:hRule="{rule}" w:hAnchor="margin" w:x="0""#), + ); + let data = frame_docx( + &[ + paragraph("", "Anchor line"), + paragraph(&frame, framed_text), + lines.concat(), + ] + .concat(), + ); + let runs = placed(&data); + let (first, _) = find(&runs, "Line 1"); + let frame_top: f64 = first.baseline_pt - first.font_size_pt + 1.0; + let content_bottom: f64 = runs + .iter() + .filter(|(run, _)| framed_text.contains(run.text.trim())) + .map(|(run, _)| run.baseline_pt) + .fold(f64::MIN, f64::max); + assert!( + content_bottom > frame_top + 30.0, + "{rule}: the framed text must take several lines: {runs:?}" + ); + // Line tops, like the frame top, are taken an em above the baseline. + // An exact frame ends 10pt (200 twips) down; an at-least one below + // its last line, whose box reaches some way past that baseline. + let (beside_before, below_after): (f64, f64) = match rule { + "exact" => (frame_top + 9.5, frame_top + 10.5), + _ => (content_bottom, content_bottom + 0.5 * first.font_size_pt), + }; + let (mut beside, mut below) = (0, 0); + for (run, _) in runs.iter().filter(|(run, _)| run.text.starts_with("Line")) { + let line_top: f64 = run.baseline_pt - run.font_size_pt; + if line_top < beside_before { + assert_close(run.left_pt, MARGIN_PT + FRAME_WIDTH_PT, rule); + beside += 1; + } else if line_top > below_after { + assert_close(run.left_pt, MARGIN_PT, rule); + below += 1; + } + } + assert!( + beside > 0 && below > 0, + "{rule}: {beside} lines beside, {below} below in {runs:?}" + ); + } +} + +/// A paragraph whose first line ends above the frame but which reaches it +/// starts below the frame rather than run over it; one that ends above the +/// frame stays where it is. +/// +/// TODO(per-line wrap beside a float, #2355): Word keeps the first lines +/// above the frame and narrows the ones beside it. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_paragraph_reaching_an_offset_frame_from_above_starts_below_it() { + // Ten lines, which reach both frames; the short paragraph is one line. + let long_text: String = "Long paragraph words ".repeat(40); + for y_twips in [1000_i32, 1400] { + for (text, reaches_frame) in [("Short paragraph", false), (long_text.trim_end(), true)] { + let first_words: String = text.split(' ').take(2).collect::>().join(" "); + let first_line = |runs: &[(crate::render::pdf::PlacedTextRun, f64)]| { + runs.iter() + .find(|(run, _)| run.text.starts_with(&first_words)) + .map(|(run, _)| run.clone()) + .unwrap_or_else(|| panic!("no line starting {first_words:?} in {runs:?}")) + }; + let control = placed(&frame_docx( + &[paragraph("", "Anchor line"), paragraph("", text)].concat(), + )); + let frame = offset_frame( + 600, + y_twips, + r#"w:hRule="exact" w:hAnchor="margin" w:x="0""#, + ); + let data = frame_docx( + &[ + paragraph("", "Anchor line"), + paragraph(&frame, "Framed text"), + paragraph("", text), + ] + .concat(), + ); + let runs = placed(&data); + let context = format!("y={y_twips} reaches frame={reaches_frame}"); + let line = first_line(&runs); + let expected: f64 = first_line(&control).baseline_pt + + if reaches_frame { + f64::from(y_twips) / 20.0 + 30.0 + } else { + 0.0 + }; + assert_close(line.baseline_pt, expected, &context); + assert_close(line.left_pt, MARGIN_PT, &context); + } + } +} + +/// A frame that ends its section still keeps the text of a continuous +/// section after it off its extent, while that section is laid out in the +/// same text area. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn an_offset_frame_excludes_text_in_the_continuous_section_after_it() { + let lines: Vec = (1..=8) + .map(|line| paragraph("", &format!("Line {line}"))) + .collect(); + let frame = offset_frame(700, 20, r#"w:hRule="exact" w:hAnchor="margin" w:x="0""#); + let continuous: &str = r#""#; + // The second section keeps the first's margins, or moves its left one in + // to 144pt (2880 twips), which leaves the frame's band in another area. + for (second_left_twips, wraps) in [(1440_u32, true), (2880, false)] { + let body: String = [ + paragraph("", "Anchor line"), + format!( + r#"{frame}{PAGE_SETUP}{}"#, + run("Framed text") + ), + lines.concat(), + ] + .concat(); + let second_section: String = format!( + r#"{continuous}"# + ); + let runs = placed(§ion_docx(&body, &second_section)); + let context = format!("second section left={second_left_twips}"); + let margin: f64 = f64::from(second_left_twips) / 20.0; + let (first, _) = find(&runs, "Line 1"); + if wraps { + assert_close(first.left_pt, MARGIN_PT + FRAME_WIDTH_PT, &context); + } else { + assert_close(first.left_pt, margin, &context); + } + let (last, _) = find(&runs, "Line 8"); + assert_close(last.left_pt, margin, &context); + } +} + +#[test] +fn only_a_wrapping_text_anchored_frame_with_height_and_offset_is_lifted() { + let lifted: String = offset_frame(600, 400, r#"w:hRule="exact" w:hAnchor="margin" w:x="0""#); + let kept: [(&str, String); 8] = [ + ( + "no height", + r#""#.to_string(), + ), + ( + "no width", + r#""#.to_string(), + ), + ("no offset", offset_frame(600, 0, r#"w:hRule="exact""#)), + ( + "aligned vertically", + offset_frame(600, 400, r#"w:hRule="exact" w:yAlign="top""#), + ), + ( + "not beside", + r#""#.to_string(), + ), + ( + "no wrap", + r#""#.to_string(), + ), + ( + "margin anchored", + r#""#.to_string(), + ), + ( + "page anchored", + r#""#.to_string(), + ), + ]; + let is_frame = |block: &Block| matches!(block, Block::TextFrame(_)); + let blocks = body_blocks(&frame_docx(¶graph(&lifted, "Framed text"))); + let [Block::TextFrame(frame)] = blocks.as_slice() else { + panic!("expected one text frame: {blocks:?}"); + }; + assert!( + matches!(frame.content.as_slice(), [Block::Paragraph(paragraph)] if paragraph.runs[0].text == "Framed text"), + "{frame:?}" + ); + for (case, frame) in kept { + let blocks = body_blocks(&frame_docx(¶graph(&frame, "Framed text"))); + assert!(!blocks.iter().any(is_frame), "{case}: {blocks:?}"); + } + // Adjacent paragraphs with an identical frame share it; this path lifts + // a frame of one paragraph only. + let shared = body_blocks(&frame_docx( + &[ + paragraph(&lifted, "Framed one"), + paragraph(&lifted, "Framed two"), + ] + .concat(), + )); + assert!(!shared.iter().any(is_frame), "shared frame: {shared:?}"); +} + +/// docxide-pdf's `paragraph-offset-frame-immediate`, the issue's case: Word +/// sets "Dear recipient" on the line after the table, as if the framed +/// paragraph between them were absent, and hangs the frame 16.8pt (`w:y`) +/// below that line's top. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn issue_case_frame_hangs_below_the_table_and_leaves_no_line() { + let data: &[u8] = + include_bytes!("../../../../tests/fixtures/docx/docxide/offset_text_frame.docx"); + let runs = placed(data); + let (recipient, _) = find(&runs, "Recipient"); + let (dear, _) = find(&runs, "Dear recipient"); + let (about, _) = find(&runs, "About providing information"); + // Word: Recipient 69.78, Dear recipient 85.78; the one-line table row is + // the only thing between them. + assert!( + (dear.baseline_pt - recipient.baseline_pt - 16.0).abs() < 0.3, + "Dear recipient {} after Recipient {}", + dear.baseline_pt, + recipient.baseline_pt + ); + // Word: About providing information 102.53, 16.75 below Dear recipient. + assert_close( + about.baseline_pt, + dear.baseline_pt + 16.8, + "framed paragraph", + ); +} diff --git a/crates/office2pdf/src/render/font_subst.rs b/crates/office2pdf/src/render/font_subst.rs index 98f192be..2e823bcf 100644 --- a/crates/office2pdf/src/render/font_subst.rs +++ b/crates/office2pdf/src/render/font_subst.rs @@ -1921,6 +1921,7 @@ fn visit_block_fonts(block: &Block, visitor: &mut impl FnMut(&str, &str) -> bool Block::Table(table) => visit_table_fonts(table, visitor), Block::FloatingTable(table) => visit_table_fonts(&table.table, visitor), Block::FloatingTextBox(text_box) => visit_blocks_fonts(&text_box.content, visitor), + Block::TextFrame(frame) => visit_blocks_fonts(&frame.content, visitor), Block::List(list) => list.items.iter().all(|item| { item.content .iter() diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index cdc9ace4..bae283c4 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -17,7 +17,7 @@ use crate::ir::{ ParagraphStyle, PatternFill, PatternPreset, PositionedTabAlignment, PositionedTabRelativeTo, Run, Shadow, Shape, ShapeKind, SheetPage, SmartArt, SparklineInfo, TabAlignment, TabLeader, TabStop, Table, TableBorderPaintModel, TableCell, TableOfContents, TableRow, TextBoxData, - TextBoxVerticalAlign, TextDirection, TextStyle, VerticalTextAlign, WrapMode, + TextBoxVerticalAlign, TextDirection, TextFrame, TextStyle, VerticalTextAlign, WrapMode, }; use self::diagrams::{ @@ -277,14 +277,16 @@ struct GenCtx { /// which corroborates the direction and magnitude without being ground /// truth. table_uses_powerpoint_line_box: bool, - /// The floating tables placed so far in the current flow, which the root - /// paragraphs after them wrap around until a page, column or section - /// break, or until those paragraphs have advanced a page (issue #2229). + /// The floating tables and text frames placed so far in the current + /// flow, which the root paragraphs after them wrap around until a page, + /// column or section break, or until those paragraphs have advanced a + /// page (issues #2229, #2231). A text frame outlasts the start of a + /// continuous section laid out in the same text area. body_floats: Vec, - next_floating_table_id: usize, - /// The width and height of the single text column floating tables wrap - /// in, or `None` where they do not wrap yet: outside a flow body and in - /// multi-column sections. + next_body_float_id: usize, + /// The width and height of the single text column floating tables and + /// text frames wrap in, or `None` where they do not wrap yet: outside a + /// flow body and in multi-column sections. float_wrap_text_area_pt: Option<(f64, f64)>, /// Set for the paragraph `generate_block` is about to write at the flow /// root while `body_floats` is non-empty; `generate_paragraph` takes it, @@ -297,6 +299,12 @@ struct GenCtx { /// Whether nothing that takes flow space has been written yet in a /// section that starts a page, so the next block's top is the text area's. flow_at_page_top: bool, + /// Set for the root text frame `generate_block` is about to write: the gap + /// after the flow block before it, when there is one (issue #2231). + text_frame_anchor_gap_pt: Option, + /// The text area the previous flow section's floats wrapped in, whose + /// text frames a continuous section in the same area keeps in force. + carried_float_text_area_pt: Option<(f64, f64)>, } impl GenCtx { @@ -359,11 +367,13 @@ impl GenCtx { active_page_margins: None, sheet_cell_box: None, body_floats: Vec::new(), - next_floating_table_id: 0, + next_body_float_id: 0, float_wrap_text_area_pt: None, float_wrap_pending: false, float_wrap_clear: None, flow_at_page_top: false, + text_frame_anchor_gap_pt: None, + carried_float_text_area_pt: None, } } @@ -721,7 +731,7 @@ fn generate_pages(doc: &Document, options: &ConvertOptions) -> Result = ctx .available_measure_pt .filter(|_| page.columns.is_none() && text_height_pt > 0.0) .map(|width_pt| (width_pt, text_height_pt)); + // A text frame still excludes text in a continuous section laid out in + // the same text area (issue #2231), as docxide-pdf's + // `paragraph-offset-frame-immediate-long` shows: its frame ends the first + // section, and the paragraph beside it opens the next. + // TODO(floating table into a continuous section): a floating table's + // extent reaches there too. + if !begins_continuously || ctx.carried_float_text_area_pt != text_area_pt { + ctx.body_floats.clear(); + } + ctx.float_wrap_text_area_pt = text_area_pt; // Word keeps `w:spacing w:before` on the document's first body paragraph, // but Typst collapses leading block spacing at a page boundary, pulling the @@ -928,8 +947,8 @@ fn generate_flow_page( ctx.flow_body_is_breakable = false; ctx.in_flow_body = false; ctx.preserve_page_after_spacing = false; - ctx.body_floats.clear(); - ctx.float_wrap_text_area_pt = None; + ctx.body_floats.retain(float_wrap::BodyFloat::is_text_frame); + ctx.carried_float_text_area_pt = ctx.float_wrap_text_area_pt.take(); ctx.flow_at_page_top = false; reset_flow_body_alignment(out, ctx); Ok(()) @@ -5365,6 +5384,7 @@ fn generate_blocks_with_layout_space_before( let after_shading_space_pt: Option = joins_next .then(|| take_joined_paragraph_after_shading_space(&mut block)) .flatten(); + ctx.text_frame_anchor_gap_pt = text_frame_anchor_gap(blocks, index); let follows_column_break: bool = index > 0 && matches!(blocks[index - 1], Block::ColumnBreak); let follows_source_paragraph_page_break: bool = index > 0 @@ -5422,6 +5442,21 @@ fn generate_blocks_with_layout_space_before( Ok(()) } +/// The gap Typst lays after the flow block before the text frame at +/// `index`, or `None` when no block before it on its page or column takes +/// flow space (issue #2231). +fn text_frame_anchor_gap(blocks: &[Block], index: usize) -> Option { + if !matches!(blocks[index], Block::TextFrame(_)) { + return None; + } + let previous: &Block = blocks[..index] + .iter() + .rev() + .find(|block| !section_alignment::leaves_flow_gap_unchanged(block)) + .filter(|block| !is_layout_break(block))?; + Some(section_alignment::paragraph_edge_spacing(previous).map_or(0.0, |(_, after)| after)) +} + fn reduce_paragraph_space_before(block: &mut Block, preceding_after_space_pt: f64) { let style: Option<&mut ParagraphStyle> = match block { Block::Paragraph(paragraph) => Some(&mut paragraph.style), @@ -5953,7 +5988,7 @@ fn generate_block(out: &mut String, block: &Block, ctx: &mut GenCtx) -> Result<( let result = generate_block_inner(out, block, ctx); ctx.block_depth -= 1; ctx.float_wrap_pending = false; - // A table only reaches the text on its own page and in its own column. + // A float only reaches the text on its own page and in its own column. if is_root_flow_block && matches!(block, Block::PageBreak | Block::ColumnBreak) { ctx.body_floats.clear(); } @@ -6019,9 +6054,15 @@ fn generate_block_inner( .float_wrap_text_area_pt .filter(|_| ctx.in_flow_body && ctx.block_depth == 1 && drawn_at_its_anchor) .map(|(width_pt, height_pt)| { - let id: usize = ctx.next_floating_table_id; - ctx.next_floating_table_id += 1; - float_wrap::BodyFloat::new(id, floating_table, width_pt, height_pt) + let id: usize = ctx.next_body_float_id; + ctx.next_body_float_id += 1; + float_wrap::BodyFloat::new( + id, + float_wrap::FloatExtent::of_table(floating_table), + width_pt, + height_pt, + false, + ) }); if let Some(wrap) = wrap { float_wrap::write_float_top_label(out, wrap.id()); @@ -6034,6 +6075,7 @@ fn generate_block_inner( out.push_str("]\n"); result } + Block::TextFrame(frame) => generate_text_frame(out, frame, ctx), Block::Table(table) => { // Word gives a table no vertical spacing of its own: the gap above // it is the preceding paragraph's `w:after` and the gap below it is @@ -6480,6 +6522,120 @@ fn generate_floating_image_content(out: &mut String, image: &ImageData, path: &s } } +/// Draw a lifted `w:framePr` frame where Word does (issue #2231): `y` below +/// its anchor, which a `#place` without a vertical alignment finds in the +/// flow, and `x` from the text area's left edge. At the flow root, the +/// paragraphs after the frame wrap around it as they do around a floating +/// table, with `w:vSpace` widening the band they keep clear of. +/// +/// The frame takes no flow space. A bare placed item does not either, but +/// Typst merges the gaps on both sides of it and lands it below the merged +/// gap, where Word hangs the frame from the end of the gap after the block +/// before it: docxide-pdf's `paragraph-offset-frame-exact` puts its frame +/// where the immediate case does, though the paragraph after it states a +/// 12pt `w:before`. So the frame is placed with the previous block's gap +/// collapsed to nothing above it and that gap added to its offset, and a +/// zero-height block after it lays the gap again, where it still merges with +/// the next block's gap as before. +fn generate_text_frame( + out: &mut String, + frame: &TextFrame, + ctx: &mut GenCtx, +) -> Result<(), ConvertError> { + // A vertically centred section writes its own explicit gaps between root + // blocks; there the frame lands below them. + let anchor_gap: Option = std::mem::take(&mut ctx.text_frame_anchor_gap_pt) + .filter(|_| ctx.block_depth == 1 && !ctx.preserve_page_after_spacing); + if anchor_gap.is_some() { + // The strongest weak spacing replaces the gap the previous block + // left, and no gap a later block brings outweighs it. + out.push_str("#v(0pt, weak: true)\n"); + } + let vertical_space: f64 = frame.vertical_space; + let _ = writeln!( + out, + "#place(left, dx: {}pt, dy: {}pt)[", + format_f64(frame.x), + format_f64(frame.y + anchor_gap.unwrap_or(0.0) - vertical_space) + ); + // The paragraphs after an exact frame start at its anchor, so once they + // have advanced past its bottom edge none of them can meet it; an + // at-least frame's content can reach further, to the page's end. + let frame_bottom_pt: f64 = frame.y + anchor_gap.unwrap_or(0.0) + frame.height + vertical_space; + let wrap: Option = ctx + .float_wrap_text_area_pt + .filter(|_| ctx.in_flow_body && ctx.block_depth == 1) + .map(|(width_pt, height_pt)| { + let id: usize = ctx.next_body_float_id; + ctx.next_body_float_id += 1; + let reach_pt: f64 = if frame.exact_height { + frame_bottom_pt.clamp(0.0, height_pt) + } else { + height_pt + }; + float_wrap::BodyFloat::new( + id, + float_wrap::FloatExtent::of_text_frame(frame), + width_pt, + reach_pt, + true, + ) + }); + if let Some(wrap) = wrap { + float_wrap::write_float_top_label(out, wrap.id()); + } + let write_vertical_space = |out: &mut String| { + if vertical_space > 0.0 { + let _ = writeln!( + out, + "#block(height: {}pt, above: 0pt, below: 0pt)", + format_f64(vertical_space) + ); + } + }; + write_vertical_space(out); + let width: String = format_f64(frame.width); + let height: String = format_f64(frame.height); + if frame.exact_height { + let _ = writeln!( + out, + "#block(width: {width}pt, height: {height}pt, above: 0pt, below: 0pt)[" + ); + } else { + // A least height: the frame is as tall as the taller of its stated + // height and its content. + let _ = writeln!( + out, + "#block(above: 0pt, below: 0pt, stack(dir: ltr, box(height: {height}pt), \ + block(width: {width}pt)[" + ); + } + // A frame's content starts at its top, whatever the vertical alignment of + // the section around it. + out.push_str("#set align(top)\n"); + let enclosing_measure_pt: Option = ctx.available_measure_pt.replace(frame.width); + let result: Result<(), ConvertError> = frame + .content + .iter() + .try_for_each(|block| generate_block(out, block, ctx)); + ctx.available_measure_pt = enclosing_measure_pt; + out.push_str(if frame.exact_height { "]\n" } else { "]))\n" }); + write_vertical_space(out); + if let Some(wrap) = wrap { + float_wrap::write_float_bottom_label(out, wrap.id()); + ctx.body_floats.push(wrap); + } + out.push_str("]\n"); + if let Some(gap) = anchor_gap { + let _ = writeln!( + out, + "#block(height: 0pt, above: 0pt, below: {}pt)", + format_f64(gap) + ); + } + result +} + fn generate_floating_text_box( out: &mut String, ftb: &FloatingTextBox, diff --git a/crates/office2pdf/src/render/typst_gen_float_wrap.rs b/crates/office2pdf/src/render/typst_gen_float_wrap.rs index 35c1ef8d..b59a0ffe 100644 --- a/crates/office2pdf/src/render/typst_gen_float_wrap.rs +++ b/crates/office2pdf/src/render/typst_gen_float_wrap.rs @@ -1,15 +1,17 @@ -//! Body text beside a DOCX floating table (issue #2229). +//! Body text beside a DOCX floating table (issue #2229) or text frame +//! (issue #2231). //! //! Word treats a `w:tblpPr` table as an exclusion region: a line of body text //! that falls within the table's vertical extent is laid out in the room -//! beside it, and moves below the table when neither side leaves room. Typst -//! places the table with `#place`, which excludes nothing, and has no text -//! wrap of its own. Which lines meet the table is only known once the page is -//! laid out, so each root paragraph that follows a floating table asks at -//! layout time, through the `-top`/`-bottom` labels the placed table carries, -//! whether its first line falls within the table's extent, and narrows or -//! moves itself accordingly. The room on each side is known from the frame -//! alone, so that choice is made here. +//! beside it, and moves below the table when neither side leaves room. A +//! paragraph Word lifts into a `w:framePr` frame excludes text the same way. +//! Typst places the table with `#place`, which excludes nothing, and has no +//! text wrap of its own. Which lines meet the table is only known once the +//! page is laid out, so each root paragraph that follows a floating table asks +//! at layout time, through the `-top`/`-bottom` labels the placed table +//! carries, whether its first line falls within the table's extent, and +//! narrows or moves itself accordingly. The room on each side is known from +//! the frame alone, so that choice is made here. //! //! The rule follows docxide-pdf's `FloatZone` (`src/pdf/mod.rs` at //! `adb8c2b8`), which checks a paragraph at its first line only. @@ -19,67 +21,109 @@ //! paragraph is laid out as two at that break, and the part after it wraps //! as though each table it clears left no room beside it. //! -//! TODO(per-line wrap beside a floating table): Word narrows each line in the -//! table's extent, so a paragraph that starts beside the table returns to the -//! full column below its bottom edge; here the whole paragraph stays narrowed, -//! and one that starts above the table runs over it. +//! TODO(per-line wrap beside a float, #2355): Word narrows each line in a +//! float's extent, so a paragraph that starts beside the float returns to the +//! full column below its bottom edge; here the whole paragraph stays narrowed. +//! One that starts above a floating table runs over it, and one that starts +//! above a text frame moves below the frame (issue #2231). use std::fmt::Write as _; -use crate::ir::{Block, BreakClear, Document, FloatingTable, Page, Paragraph, Run}; +use crate::ir::{Block, BreakClear, Document, FloatingTable, Page, Paragraph, Run, TextFrame}; use super::format_f64; const FLOAT_WRAP_HELPER: &str = "o2p-float-wrap"; -/// The narrowest strip beside a floating table that body text is laid out in. +/// The narrowest strip beside a floating table or text frame that body text +/// is laid out in. /// /// FIXME(unprobed minimum wrap width): docxide-pdf's empirical -/// `min_wrap_w`. Its Word references at `adb8c2b8` only separate no room at -/// all (`fullwidth-float-empty-clear`) from 162pt -/// (`empty-float-float-indent-0-0`) and 201pt (`leading-table-clear-*`), so -/// the threshold itself still needs a Word probe. +/// `min_wrap_w`, which its `FloatZone` applies to frames as well. Its Word +/// references at `adb8c2b8` only separate no room at all beside a table +/// (`fullwidth-float-empty-clear`) from 162pt (`empty-float-float-indent-0-0`) +/// and 201pt (`leading-table-clear-*`), and none probes a frame, so the +/// threshold itself still needs a Word probe. const MIN_ROOM_BESIDE_PT: f64 = 72.0; -/// What a root paragraph whose first line meets a floating table does. +/// What a root paragraph whose first line meets a float does. #[derive(Debug, Clone, Copy, PartialEq)] enum FloatSide { - /// Lies in the room right of the table, inset this far from the column's + /// Lies in the room right of the float, inset this far from the column's /// left edge. Right { inset_pt: f64 }, - /// Lies in the room left of the table, inset this far from the column's + /// Lies in the room left of the float, inset this far from the column's /// right edge. Left { inset_pt: f64 }, - /// Starts below the table. + /// Starts below the float. Below, } -/// A floating table placed in the current flow, which the root paragraphs -/// after it wrap around. +/// The horizontal extent of a float that body text keeps clear of, measured +/// from the left edge of the column it wraps in. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct FloatExtent { + x_pt: f64, + width_pt: f64, + /// How far text keeps from the float's left edge. + left_from_text_pt: f64, + /// How far text keeps from the float's right edge. + right_from_text_pt: f64, +} + +impl FloatExtent { + /// A floating table's extent, its frame already resolved against the + /// column. + pub(super) fn of_table(table: &FloatingTable) -> Self { + Self { + x_pt: table.frame.x.unwrap_or_default(), + width_pt: table.table.column_widths.iter().sum(), + left_from_text_pt: table.frame.left_from_text, + right_from_text_pt: table.frame.right_from_text, + } + } + + /// A text frame's extent, its `x` already resolved against the column. + pub(super) fn of_text_frame(frame: &TextFrame) -> Self { + Self { + x_pt: frame.x, + width_pt: frame.width, + left_from_text_pt: frame.horizontal_space, + right_from_text_pt: frame.horizontal_space, + } + } +} + +/// A floating table or text frame placed in the current flow, which the root +/// paragraphs after it wrap around. #[derive(Debug, Clone, Copy, PartialEq)] pub(super) struct BodyFloat { id: usize, side: FloatSide, - /// How much more flow the paragraphs after the table can advance before + /// How much more flow the paragraphs after the float can advance before /// none of them can still be on its page. reach_pt: f64, + /// Whether the float is a text frame. A text frame typically hangs into + /// the paragraph after its own, which starts above it; that paragraph + /// moves below the frame rather than run over it, and the frame still + /// excludes text in a continuous section that follows on its page. + text_frame: bool, } impl BodyFloat { - /// The wrap a floating table imposes in a column `column_width_pt` wide - /// and `column_height_pt` tall, with the table's frame already resolved - /// against that column. + /// The wrap a float of `extent` imposes in a column `column_width_pt` + /// wide, on the paragraphs after it until they have advanced `reach_pt`. pub(super) fn new( id: usize, - table: &FloatingTable, + extent: FloatExtent, column_width_pt: f64, - column_height_pt: f64, + reach_pt: f64, + text_frame: bool, ) -> Self { - let frame = &table.frame; - let x: f64 = frame.x.unwrap_or_default(); - let width: f64 = table.table.column_widths.iter().sum(); - let left_edge: f64 = x - frame.left_from_text; - let right_edge: f64 = x + width + frame.right_from_text; + let x: f64 = extent.x_pt; + let width: f64 = extent.width_pt; + let left_edge: f64 = x - extent.left_from_text_pt; + let right_edge: f64 = x + width + extent.right_from_text_pt; let room_left: f64 = left_edge.min(column_width_pt); let room_right: f64 = column_width_pt - right_edge.max(0.0); // The wider side; the right one on a tie, a choice no Word reference @@ -97,28 +141,35 @@ impl BodyFloat { }; tracing::debug!( id, - table_width_pt = width, + float_width_pt = width, room_left_pt = room_left, room_right_pt = room_right, ?side, - "resolved body text wrap beside a floating table" + "resolved body text wrap beside a float" ); Self { id, side, - reach_pt: column_height_pt, + reach_pt, + text_frame, } } pub(super) fn id(&self) -> usize { self.id } + + pub(super) fn is_text_frame(&self) -> bool { + self.text_frame + } } -/// Count a wrapped paragraph's least advance against each table's reach and -/// drop the tables no later paragraph can share a page with. Each wrapped -/// paragraph is a layout-time query, so a table at the top of a long section -/// would otherwise cost one for every paragraph after it. +/// Count a wrapped paragraph's least advance against each float's reach and +/// drop the floats no later paragraph can meet: a floating table once the +/// paragraphs have advanced a page, an exact text frame once they have +/// passed its bottom edge. Each wrapped paragraph is a layout-time query, so +/// a float at the top of a long section would otherwise cost one for every +/// paragraph after it. pub(super) fn advance_past_floats(floats: &mut Vec, advance_pt: f64) { floats.retain_mut(|float| { float.reach_pt -= advance_pt.max(0.0); @@ -126,33 +177,39 @@ pub(super) fn advance_past_floats(floats: &mut Vec, advance_pt: f64) }); } -/// Whether any flow section places a body floating table at its root, which -/// is when the wrap helper has to be defined. -pub(super) fn document_has_body_floating_table(doc: &Document) -> bool { +/// Whether any flow section places a body floating table or text frame at its +/// root, which is when the wrap helper has to be defined. +pub(super) fn document_has_body_float(doc: &Document) -> bool { doc.pages.iter().any(|page| match page { Page::Flow(flow) | Page::FlowContinuous(flow) => flow .content .iter() - .any(|block| matches!(block, Block::FloatingTable(_))), + .any(|block| matches!(block, Block::FloatingTable(_) | Block::TextFrame(_))), _ => false, }) } -/// Define the layout-time wrap: `floats` are the tables in force, `line` the +/// Define the layout-time wrap: `floats` are the floats in force, `line` the /// paragraph's first-line height and `before` its own `w:before`. /// -/// A paragraph meets a table when its first line overlaps the table's +/// A paragraph meets a float when its first line overlaps the float's /// vertical extent on the same page. Beside it, the paragraph is padded off -/// the table's side so its indents and `w:jc` resolve against the room left; -/// below it, the paragraph restarts at the table's bottom edge plus its own +/// the float's side so its indents and `w:jc` resolve against the room left; +/// below it, the paragraph restarts at the float's bottom edge plus its own /// before-gap, as Word restarts a line that has no room. The move is a top /// pad rather than `v()`: Typst drops spacing that opens a block's content. +/// +/// A paragraph whose first line ends above a text frame (`pushes`) but which +/// is tall enough to reach it also restarts below the frame. Its height is +/// measured at the width it is laid out in, which only a `layout` call knows, +/// so only a paragraph in reach of a text frame pays for one. pub(super) fn write_float_wrap_helper(out: &mut String) { let _ = writeln!( out, "#let {FLOAT_WRAP_HELPER}(floats, line, before, body) = context {{ \ let at = here().position(); \ - let push = 0pt; let left = 0pt; let right = 0pt; \ + let wrap(width) = {{ \ + let push = 0pt; let left = 0pt; let right = 0pt; let height = none; \ for float in floats {{ \ let tops = query(label(\"o2p-float-\" + str(float.id) + \"-top\")); \ let bottoms = query(label(\"o2p-float-\" + str(float.id) + \"-bottom\")); \ @@ -163,12 +220,18 @@ pub(super) fn write_float_wrap_helper(out: &mut String) { if top.page == at.page and line-top < bottom.y and line-top + line > top.y {{ \ if float.side == \"below\" {{ push = calc.max(push, bottom.y + before - at.y) }} \ else if float.side == \"left\" {{ right = calc.max(right, float.inset) }} \ - else {{ left = calc.max(left, float.inset) }} }} }} }}; \ - pad(top: push, left: left, right: right, body) }}" + else {{ left = calc.max(left, float.inset) }} }} \ + else if float.pushes and top.page == at.page and line-top < top.y {{ \ + if height == none {{ height = measure(body, width: width).height }}; \ + if line-top + height > top.y {{ push = calc.max(push, bottom.y + before - at.y) }} }} }} }}; \ + pad(top: push, left: left, right: right, body) }}; \ + if floats.any(float => float.pushes) {{ layout(size => wrap(size.width)) }} \ + else {{ wrap(auto) }} }}" ); } -/// The labels a placed floating table carries at its top and bottom edges. +/// The labels a placed floating table or text frame carries at its top and +/// bottom edges. pub(super) fn write_float_top_label(out: &mut String, id: usize) { let _ = writeln!(out, "#metadata(none) "); } @@ -297,9 +360,10 @@ pub(super) fn write_float_wrap_open( }; let _ = write!( out, - "(id: {}, side: \"{side}\", inset: {}pt),", + "(id: {}, side: \"{side}\", inset: {}pt, pushes: {}),", float.id, - format_f64(inset_pt) + format_f64(inset_pt), + float.text_frame ); } let _ = writeln!( diff --git a/crates/office2pdf/src/render/typst_gen_section_alignment.rs b/crates/office2pdf/src/render/typst_gen_section_alignment.rs index f331389c..99cd5204 100644 --- a/crates/office2pdf/src/render/typst_gen_section_alignment.rs +++ b/crates/office2pdf/src/render/typst_gen_section_alignment.rs @@ -49,6 +49,7 @@ pub(super) fn leaves_flow_gap_unchanged(block: &Block) -> bool { | Block::FloatingTextBox(_) | Block::FloatingShape(_) | Block::FloatingTable(_) + | Block::TextFrame(_) ) } diff --git a/crates/office2pdf/src/render/typst_gen_tables.rs b/crates/office2pdf/src/render/typst_gen_tables.rs index cf3251c9..f0211909 100644 --- a/crates/office2pdf/src/render/typst_gen_tables.rs +++ b/crates/office2pdf/src/render/typst_gen_tables.rs @@ -3394,6 +3394,9 @@ fn generate_cell_content( } Block::FloatingImage(fi) => generate_floating_image(out, fi, ctx), Block::FloatingTextBox(ftb) => generate_floating_text_box(out, ftb, ctx)?, + // The parser lifts only body paragraphs into frames; a cell + // would lay a frame's paragraphs out in place. + Block::TextFrame(frame) => generate_cell_content(out, &frame.content, ctx)?, Block::FloatingShape(fs) => generate_floating_shape(out, fs, ctx), Block::DrawingGroup(group) => generate_drawing_group(out, group, ctx)?, Block::List(list) => { diff --git a/crates/office2pdf/src/render/typst_gen_text.rs b/crates/office2pdf/src/render/typst_gen_text.rs index ab766c2e..9428d4e1 100644 --- a/crates/office2pdf/src/render/typst_gen_text.rs +++ b/crates/office2pdf/src/render/typst_gen_text.rs @@ -380,8 +380,9 @@ pub(super) fn generate_paragraph( // own spacing has already been laid and its content starts at the first // line's top. A paragraph that writes no block of its own is left as it // is. - // TODO(bare paragraph beside a floating table): such a paragraph, which - // states no spacing, line or decoration, still runs under the table. + // TODO(bare paragraph beside a float): such a paragraph, which states no + // spacing, line or decoration, still runs under a floating table or text + // frame. let float_wrap: Option<(String, f64)> = (wraps_beside_floats && (has_para_style || has_outer_spacing_block)).then(|| { let first_line_pt: f64 = if is_empty_paragraph { diff --git a/crates/office2pdf/tests/docx_fixtures.rs b/crates/office2pdf/tests/docx_fixtures.rs index 147ac400..d5f1c0d9 100644 --- a/crates/office2pdf/tests/docx_fixtures.rs +++ b/crates/office2pdf/tests/docx_fixtures.rs @@ -126,6 +126,11 @@ fn collect_runs_from_block<'a>(block: &'a Block, out: &mut Vec<&'a Run>) { collect_runs_from_block(block, out); } } + Block::TextFrame(frame) => { + for block in &frame.content { + collect_runs_from_block(block, out); + } + } Block::DrawingGroup(group) => { for element in &group.elements { if let office2pdf::ir::FixedElementKind::TextBox(text_box) = &element.kind { @@ -185,6 +190,12 @@ fn block_text(block: &Block) -> String { .map(block_text) .collect::>() .join("\n"), + Block::TextFrame(frame) => frame + .content + .iter() + .map(block_text) + .collect::>() + .join("\n"), Block::DrawingGroup(group) => group .elements .iter() diff --git a/references/docxide-pdf.md b/references/docxide-pdf.md index d5adaf71..ddaadabd 100644 --- a/references/docxide-pdf.md +++ b/references/docxide-pdf.md @@ -33,7 +33,7 @@ Fixes need fresh native GT, all comparison axes, image inspection and issue disp HTML expansion follows its CSS mapping, using Rust [html5ever](https://github.com/servo/html5ever) and [mailparse](https://github.com/staktrace/mailparse); docx-rs and contextual scans read the expanded XML. Section line numbering maps starts, intervals and restarts to Typst margin labels; case70 native exports check fonts/clearance, compiled tests section suppression. -An empty body `w:framePr` paragraph leaves no flow line per `is_empty_wrapping_frame` (`src/pdf/mod.rs` at `adb8c2b8`), checked against Word's `cont-first-first-fits` and `empty-frame-control` (#2161); `section_break_spacing` is the lead for #2232. Body text beside a `w:tblpPr` table follows `FloatZone` there: a paragraph is checked at its first line and restarts below the table when the room beside it is under 72pt (`min_wrap_w`, empirical; combined room for a `BothSides` zone, the wider side for `Largest`). Ours always takes the wider side (#2229). Its `has_clear_break` (`src/docx/runs.rs`) marks a paragraph holding a `w:clear="all"` break; ours keeps each clearing break's position and side, and starts the text after it below the tables it clears (#2230). +An empty body `w:framePr` paragraph leaves no flow line per `is_empty_wrapping_frame` (`src/pdf/mod.rs` at `adb8c2b8`), checked against Word's `cont-first-first-fits` and `empty-frame-control` (#2161); a text-anchored frame with a stated height and a nonzero `w:y` is lifted into its frame, which text wraps around, per `paragraph_offset_frame` (`fe7b8bea`), checked against Word's `paragraph-offset-frame-*` (#2231); `section_break_spacing` is the lead for #2232. Body text beside a `w:tblpPr` table follows `FloatZone` there: a paragraph is checked at its first line and restarts below the table when the room beside it is under 72pt (`min_wrap_w`, empirical; combined room for a `BothSides` zone, the wider side for `Largest`). Ours always takes the wider side (#2229). Its `has_clear_break` (`src/docx/runs.rs`) marks a paragraph holding a `w:clear="all"` break; ours keeps each clearing break's position and side, and starts the text after it below the tables it clears (#2230). Section alignment maps `sections.rs` properties into Typst page flow. Fresh Word case69 probes calibrate trailing spacing and continuous-section uniformity; `both` and mixed automatic pagination warn; mixed explicit shared pages use top. diff --git a/tests/fixtures/docx/docxide/README.md b/tests/fixtures/docx/docxide/README.md index a3279751..e1a89bc0 100644 --- a/tests/fixtures/docx/docxide/README.md +++ b/tests/fixtures/docx/docxide/README.md @@ -143,6 +143,15 @@ with only a 180×90pt inline picture and 72pt side margins. Native Word puts the picture where the paragraph's first line of text would start, inside its indents; a compiled-layout regression checks the eight left edges. +`offset_text_frame.docx` is the unmodified synthetic +`cases/paragraph-offset-frame-immediate/input.docx` from +[docxide-pdf at fe7b8bea](https://github.com/sverrejb/docxide-pdf/tree/fe7b8beafa7612cc89ad62a7d27b2c6141c4d6ed/tests/fixtures/cases/paragraph-offset-frame-immediate), +Apache-2.0. A one-cell table ("Recipient") is followed by a paragraph in a +text-anchored `w:framePr` frame with a stated height, hung 16.8pt (`w:y`) below +its place in the flow, and then by "Dear recipient". Native Word sets "Dear +recipient" on the line after the table and the framed line 16.75pt below it; a +compiled-layout regression checks both baselines. + `floating_table_wrap.docx` is the unmodified synthetic `cases/leading-table-clear-leading-no-clear/input.docx` from [docxide-pdf at adb8c2b8](https://github.com/sverrejb/docxide-pdf/tree/adb8c2b82baad8deb2973d1d059bfb7513b8e1fe/tests/fixtures/cases/leading-table-clear-leading-no-clear), diff --git a/tests/fixtures/docx/docxide/offset_text_frame.docx b/tests/fixtures/docx/docxide/offset_text_frame.docx new file mode 100644 index 00000000..47097c2e Binary files /dev/null and b/tests/fixtures/docx/docxide/offset_text_frame.docx differ