diff --git a/assets/bugfixes/issue-204/after.jpg b/assets/bugfixes/issue-204/after.jpg new file mode 100644 index 0000000..577fcee Binary files /dev/null and b/assets/bugfixes/issue-204/after.jpg differ diff --git a/assets/bugfixes/issue-204/before.jpg b/assets/bugfixes/issue-204/before.jpg new file mode 100644 index 0000000..ec4f302 Binary files /dev/null and b/assets/bugfixes/issue-204/before.jpg differ diff --git a/assets/bugfixes/issue-204/gt.jpg b/assets/bugfixes/issue-204/gt.jpg new file mode 100644 index 0000000..e500819 Binary files /dev/null and b/assets/bugfixes/issue-204/gt.jpg differ diff --git a/crates/office2pdf/src/ir/elements.rs b/crates/office2pdf/src/ir/elements.rs index 68f3340..b76f5dc 100644 --- a/crates/office2pdf/src/ir/elements.rs +++ b/crates/office2pdf/src/ir/elements.rs @@ -1,11 +1,13 @@ use std::collections::BTreeMap; -use super::style::{Alignment, Color, ParagraphStyle, TextStyle}; +use super::style::{Alignment, Color, ParagraphStyle, TabLeader, TextStyle}; /// Header or footer content for flow pages. #[derive(Debug, Clone)] pub struct HeaderFooter { pub paragraphs: Vec, + /// Distance in points from the page edge, as specified by the section page margins. + pub distance_from_edge: Option, } /// A paragraph within a header or footer. @@ -13,6 +15,50 @@ pub struct HeaderFooter { pub struct HeaderFooterParagraph { pub style: ParagraphStyle, pub elements: Vec, + pub border: Option, + pub frame: Option, +} + +/// Page- or margin-relative positioning for a header/footer paragraph frame. +#[derive(Debug, Clone, PartialEq)] +pub struct HeaderFooterFrame { + pub x: Option, + pub y: Option, + pub width: Option, + pub height: Option, + pub horizontal_anchor: FrameAnchor, + pub vertical_anchor: FrameAnchor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FrameAnchor { + Page, + Margin, + #[default] + Text, +} + +/// A position-relative tab (`w:ptab`) inside header/footer content. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PositionedTab { + pub alignment: PositionedTabAlignment, + pub relative_to: PositionedTabRelativeTo, + pub leader: TabLeader, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PositionedTabAlignment { + Center, + #[default] + Left, + Right, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PositionedTabRelativeTo { + Indent, + #[default] + Margin, } /// An inline element within a header or footer paragraph. @@ -26,6 +72,8 @@ pub enum HFInline { PageNumber, /// Total page count field. TotalPages, + /// Alignment tab positioned relative to the paragraph indent or page margin. + PositionedTab(PositionedTab), } /// Block-level content elements. diff --git a/crates/office2pdf/src/ir/elements_tests.rs b/crates/office2pdf/src/ir/elements_tests.rs index 96ba044..0c5693c 100644 --- a/crates/office2pdf/src/ir/elements_tests.rs +++ b/crates/office2pdf/src/ir/elements_tests.rs @@ -187,6 +187,7 @@ fn test_paragraph_with_runs() { #[test] fn test_header_footer_with_text() { let hf = HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle::default(), elements: vec![HFInline::Run(Run { @@ -195,6 +196,8 @@ fn test_header_footer_with_text() { href: None, footnote: None, })], + border: None, + frame: None, }], }; assert_eq!(hf.paragraphs.len(), 1); @@ -208,6 +211,7 @@ fn test_header_footer_with_text() { #[test] fn test_header_footer_with_page_number() { let hf = HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle::default(), elements: vec![ @@ -219,6 +223,8 @@ fn test_header_footer_with_page_number() { }), HFInline::PageNumber, ], + border: None, + frame: None, }], }; assert_eq!(hf.paragraphs[0].elements.len(), 2); diff --git a/crates/office2pdf/src/lib_render_tests.rs b/crates/office2pdf/src/lib_render_tests.rs index 5407f3f..ca17a08 100644 --- a/crates/office2pdf/src/lib_render_tests.rs +++ b/crates/office2pdf/src/lib_render_tests.rs @@ -534,6 +534,7 @@ fn test_render_document_with_header() { }], })], header: Some(HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle::default(), elements: vec![HFInline::Run(Run { @@ -542,6 +543,8 @@ fn test_render_document_with_header() { href: None, footnote: None, })], + border: None, + frame: None, }], }), footer: None, @@ -572,6 +575,7 @@ fn test_render_document_with_page_number_footer() { })], header: None, footer: Some(HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle::default(), elements: vec![ @@ -583,6 +587,8 @@ fn test_render_document_with_page_number_footer() { }), HFInline::PageNumber, ], + border: None, + frame: None, }], }), columns: None, diff --git a/crates/office2pdf/src/parser/docx_sections.rs b/crates/office2pdf/src/parser/docx_sections.rs index 676fb02..b92bfbd 100644 --- a/crates/office2pdf/src/parser/docx_sections.rs +++ b/crates/office2pdf/src/parser/docx_sections.rs @@ -3,8 +3,10 @@ use std::io::{Cursor, Read, Seek}; use crate::error::ConvertWarning; use crate::ir::{ - Block, ColumnLayout, FlowPage, HFInline, HeaderFooter, HeaderFooterParagraph, Margins, - PageSize, Run, TextDirection, TextStyle, + Block, BorderLineStyle, BorderSide, CellBorder, Color, ColumnLayout, FlowPage, FrameAnchor, + HFInline, HeaderFooter, HeaderFooterFrame, HeaderFooterParagraph, Margins, PageSize, + PositionedTab, PositionedTabAlignment, PositionedTabRelativeTo, Run, TabLeader, TextDirection, + TextStyle, }; use super::contexts::WrapContext; @@ -15,6 +17,7 @@ use super::{ merge_paragraph_style, read_zip_text, }; use crate::parser::units::twips_to_pt; +use crate::parser::xml_util::parse_hex_color; /// Parsed header/footer assets addressed by relationship ID. #[derive(Default)] @@ -418,12 +421,21 @@ pub(super) fn build_flow_page_from_section( }); } + let mut header = extract_docx_header(section_prop, header_footer_assets); + if let Some(header) = &mut header { + header.distance_from_edge = Some(twips_to_pt(section_prop.page_margin.header)); + } + let mut footer = extract_docx_footer(section_prop, header_footer_assets); + if let Some(footer) = &mut footer { + footer.distance_from_edge = Some(twips_to_pt(section_prop.page_margin.footer)); + } + FlowPage { size, margins, content, - header: extract_docx_header(section_prop, header_footer_assets), - footer: extract_docx_footer(section_prop, header_footer_assets), + header, + footer, columns: column_layout .or_else(|| extract_column_layout_from_section_property(section_prop)), } @@ -454,7 +466,10 @@ fn convert_docx_header( if paragraphs.is_empty() { return None; } - Some(HeaderFooter { paragraphs }) + Some(HeaderFooter { + paragraphs, + distance_from_edge: None, + }) } fn convert_docx_footer( @@ -483,7 +498,10 @@ fn convert_docx_footer( if paragraphs.is_empty() { return None; } - Some(HeaderFooter { paragraphs }) + Some(HeaderFooter { + paragraphs, + distance_from_edge: None, + }) } /// Extract the header for a section, preferring the default variant and falling back to @@ -634,7 +652,87 @@ fn convert_hf_paragraph( } } - HeaderFooterParagraph { style, elements } + HeaderFooterParagraph { + style, + elements, + border: extract_hf_paragraph_border(¶graph.property), + frame: extract_hf_frame(¶graph.property), + } +} + +fn extract_hf_paragraph_border(property: &docx_rs::ParagraphProperty) -> Option { + let borders = serde_json::to_value(property.borders.as_ref()?).ok()?; + let extract_side = |key: &str| -> Option { + let side = borders.get(key)?.as_object()?; + let border_type = side + .get("borderType") + .or_else(|| side.get("val"))? + .as_str()?; + if matches!(border_type, "none" | "nil") { + return None; + } + let width = side.get("size")?.as_f64()? / 8.0; + let color = side + .get("color") + .and_then(serde_json::Value::as_str) + .filter(|value| *value != "auto") + .and_then(parse_hex_color) + .unwrap_or_else(Color::black); + let style = match border_type { + "dashed" | "dashSmallGap" => BorderLineStyle::Dashed, + "dotted" => BorderLineStyle::Dotted, + "dashDotStroked" | "dotDash" => BorderLineStyle::DashDot, + "dotDotDash" => BorderLineStyle::DashDotDot, + "double" + | "thinThickSmallGap" + | "thickThinSmallGap" + | "thinThickMediumGap" + | "thickThinMediumGap" + | "thinThickLargeGap" + | "thickThinLargeGap" + | "thinThickThinSmallGap" + | "thinThickThinMediumGap" + | "thinThickThinLargeGap" + | "triple" => BorderLineStyle::Double, + _ => BorderLineStyle::Solid, + }; + Some(BorderSide { + width, + color, + style, + }) + }; + let border = CellBorder { + top: extract_side("top"), + bottom: extract_side("bottom"), + left: extract_side("left"), + right: extract_side("right"), + }; + (border.top.is_some() + || border.bottom.is_some() + || border.left.is_some() + || border.right.is_some()) + .then_some(border) +} + +fn extract_hf_frame(property: &docx_rs::ParagraphProperty) -> Option { + let frame = property.frame_property.as_ref()?; + Some(HeaderFooterFrame { + x: frame.x.map(twips_to_pt), + y: frame.y.map(twips_to_pt), + width: frame.w.map(|value| twips_to_pt(value as i32)), + height: frame.h.map(|value| twips_to_pt(value as i32)), + horizontal_anchor: frame_anchor(frame.h_anchor.as_deref()), + vertical_anchor: frame_anchor(frame.v_anchor.as_deref()), + }) +} + +fn frame_anchor(value: Option<&str>) -> FrameAnchor { + match value { + Some("page") => FrameAnchor::Page, + Some("margin") => FrameAnchor::Margin, + _ => FrameAnchor::Text, + } } fn append_simple_fields( @@ -728,6 +826,28 @@ fn extract_hf_run_elements( footnote: None, })); } + docx_rs::RunChild::PTab(tab) if !in_field => { + let alignment = match tab.alignment { + docx_rs::PositionalTabAlignmentType::Center => PositionedTabAlignment::Center, + docx_rs::PositionalTabAlignmentType::Right => PositionedTabAlignment::Right, + docx_rs::PositionalTabAlignmentType::Left => PositionedTabAlignment::Left, + }; + let relative_to = match tab.relative_to { + docx_rs::PositionalTabRelativeTo::Indent => PositionedTabRelativeTo::Indent, + docx_rs::PositionalTabRelativeTo::Margin => PositionedTabRelativeTo::Margin, + }; + let leader = match tab.leader { + docx_rs::TabLeaderType::Dot => TabLeader::Dot, + docx_rs::TabLeaderType::Hyphen => TabLeader::Hyphen, + docx_rs::TabLeaderType::Underscore => TabLeader::Underscore, + _ => TabLeader::None, + }; + elements.push(HFInline::PositionedTab(PositionedTab { + alignment, + relative_to, + leader, + })); + } _ => {} } } diff --git a/crates/office2pdf/src/parser/xlsx_hf.rs b/crates/office2pdf/src/parser/xlsx_hf.rs index aac420b..6d24ce4 100644 --- a/crates/office2pdf/src/parser/xlsx_hf.rs +++ b/crates/office2pdf/src/parser/xlsx_hf.rs @@ -98,6 +98,8 @@ pub(super) fn parse_hf_format_string(format_str: &str) -> Option { ..ParagraphStyle::default() }, elements, + border: None, + frame: None, }); } } @@ -105,7 +107,10 @@ pub(super) fn parse_hf_format_string(format_str: &str) -> Option { if paragraphs.is_empty() { None } else { - Some(HeaderFooter { paragraphs }) + Some(HeaderFooter { + paragraphs, + distance_from_edge: None, + }) } } diff --git a/crates/office2pdf/src/render/font_subst.rs b/crates/office2pdf/src/render/font_subst.rs index 979c669..67b8ef3 100644 --- a/crates/office2pdf/src/render/font_subst.rs +++ b/crates/office2pdf/src/render/font_subst.rs @@ -357,7 +357,10 @@ fn visit_header_footer_fonts( .map(str::trim) .filter(|f| !f.is_empty()) .is_none_or(&mut *visitor), - HFInline::Image(_) | HFInline::PageNumber | HFInline::TotalPages => true, + HFInline::Image(_) + | HFInline::PageNumber + | HFInline::TotalPages + | HFInline::PositionedTab(_) => true, }) }) } diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 0dd5e44..45cecbd 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -8,9 +8,10 @@ use crate::error::ConvertError; use crate::ir::{ Alignment, ArrowHead, Block, BorderLineStyle, BorderSide, CellBorder, CellVerticalAlign, Chart, ChartType, Color, ColumnLayout, Document, FixedElement, FixedElementKind, FixedPage, - FloatingImage, FloatingShape, FloatingTextBox, FlowPage, GradientFill, HFInline, HeaderFooter, - ImageCrop, ImageData, ImageFormat, Insets, LineSpacing, List, ListKind, Margins, MathEquation, - Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, Shadow, Shape, ShapeKind, SheetPage, + FloatingImage, FloatingShape, FloatingTextBox, FlowPage, FrameAnchor, GradientFill, HFInline, + HeaderFooter, HeaderFooterFrame, ImageCrop, ImageData, ImageFormat, Insets, LineSpacing, List, + ListKind, Margins, MathEquation, Metadata, Page, PageSize, Paragraph, ParagraphStyle, + PositionedTabAlignment, PositionedTabRelativeTo, Run, Shadow, Shape, ShapeKind, SheetPage, SmartArt, TabAlignment, TabLeader, TabStop, Table, TableCell, TableRow, TextBoxData, TextBoxVerticalAlign, TextDirection, TextStyle, VerticalTextAlign, WrapMode, }; @@ -761,35 +762,164 @@ fn write_flow_page_setup(out: &mut String, page: &FlowPage, size: &PageSize, ctx format_f64(page.margins.right), ); - if let Some(header) = &page.header { + if let Some(header) = &page.header + && hf_has_flow_content(header) + { if hf_needs_context(header) { out.push_str(", header: context ["); } else { out.push_str(", header: ["); } - generate_hf_content(out, header, ctx); + generate_flow_hf_content(out, header, ctx); out.push(']'); } - if let Some(footer) = &page.footer { - if hf_needs_stack_offset(footer) { + if let Some(footer) = &page.footer + && hf_has_flow_content(footer) + { + let edge_offset = footer + .distance_from_edge + .map(|distance| (page.margins.bottom - distance).max(0.0)) + .unwrap_or(0.0); + if hf_needs_stack_offset(footer) || edge_offset > 0.0 { out.push_str(", footer: context { let footer_content = block(width: 100%)["); - generate_hf_content(out, footer, ctx); - out.push_str("]; move(dy: -measure(footer_content).height / 2)[#footer_content] }"); + generate_flow_hf_content(out, footer, ctx); + out.push_str("]; move(dy: "); + if hf_needs_stack_offset(footer) { + out.push_str("-measure(footer_content).height / 2"); + if edge_offset > 0.0 { + let _ = write!(out, " - {}pt", format_f64(edge_offset)); + } + } else { + let _ = write!(out, "-{}pt", format_f64(edge_offset)); + } + out.push_str(")[#footer_content] }"); } else if hf_needs_context(footer) { out.push_str(", footer: context ["); - generate_hf_content(out, footer, ctx); + generate_flow_hf_content(out, footer, ctx); out.push(']'); } else { out.push_str(", footer: ["); - generate_hf_content(out, footer, ctx); + generate_flow_hf_content(out, footer, ctx); out.push(']'); } } + if page + .header + .iter() + .chain(page.footer.iter()) + .any(hf_has_page_anchored_frames) + { + out.push_str(", foreground: ["); + if let Some(header) = &page.header { + generate_page_anchored_hf_frames(out, header, size.width, page.margins.right, ctx); + } + if let Some(footer) = &page.footer { + generate_page_anchored_hf_frames(out, footer, size.width, page.margins.right, ctx); + } + out.push(']'); + } + out.push_str(")\n"); } +fn is_page_anchored_frame(frame: &HeaderFooterFrame) -> bool { + frame.horizontal_anchor == FrameAnchor::Page && frame.vertical_anchor == FrameAnchor::Page +} + +fn hf_has_flow_content(header_footer: &HeaderFooter) -> bool { + header_footer + .paragraphs + .iter() + .any(hf_paragraph_has_flow_content) +} + +fn hf_paragraph_has_content(paragraph: &crate::ir::HeaderFooterParagraph) -> bool { + !paragraph.elements.is_empty() || paragraph.border.is_some() +} + +fn hf_paragraph_has_flow_content(paragraph: &crate::ir::HeaderFooterParagraph) -> bool { + hf_paragraph_has_content(paragraph) + && paragraph + .frame + .as_ref() + .is_none_or(|frame| !is_page_anchored_frame(frame)) +} + +fn hf_has_page_anchored_frames(header_footer: &HeaderFooter) -> bool { + header_footer + .paragraphs + .iter() + .any(|paragraph| paragraph.frame.as_ref().is_some_and(is_page_anchored_frame)) +} + +fn generate_flow_hf_content(out: &mut String, hf: &HeaderFooter, ctx: &mut GenCtx) { + let mut is_first: bool = true; + for paragraph in &hf.paragraphs { + if paragraph.frame.as_ref().is_some_and(is_page_anchored_frame) + || !hf_paragraph_has_content(paragraph) + { + continue; + } + if !is_first { + out.push_str("\\\n"); + } + generate_hf_styled_paragraph(out, paragraph, ctx); + is_first = false; + } +} + +fn generate_page_anchored_hf_frames( + out: &mut String, + hf: &HeaderFooter, + page_width: f64, + right_margin: f64, + ctx: &mut GenCtx, +) { + let mut index: usize = 0; + while index < hf.paragraphs.len() { + let Some(frame) = hf.paragraphs[index].frame.as_ref() else { + index += 1; + continue; + }; + if !is_page_anchored_frame(frame) { + index += 1; + continue; + } + let mut end: usize = index + 1; + while end < hf.paragraphs.len() && hf.paragraphs[end].frame.as_ref() == Some(frame) { + end += 1; + } + let x = frame.x.unwrap_or(0.0); + let y = frame.y.unwrap_or(0.0); + let _ = write!( + out, + "#place(top + left, dx: {}pt, dy: {}pt)[#block(", + format_f64(x), + format_f64(y) + ); + if let Some(width) = frame.width { + let _ = write!(out, "width: {}pt", format_f64(width)); + } else { + let width = (page_width - x - right_margin).max(0.0); + let _ = write!(out, "width: {}pt", format_f64(width)); + } + out.push_str(")[#stack(dir: ttb, spacing: 4pt"); + for paragraph in &hf.paragraphs[index..end] { + out.push_str(", ["); + if hf_paragraph_has_content(paragraph) { + generate_hf_styled_paragraph(out, paragraph, ctx); + } else { + out.push_str("#box(height: 12pt)"); + } + out.push(']'); + } + out.push_str(")]]"); + index = end; + } +} + /// Write the full page setup for a SheetPage, including optional header/footer. fn write_table_page_setup(out: &mut String, page: &SheetPage, size: &PageSize, ctx: &mut GenCtx) { if page.header.is_none() && page.footer.is_none() { @@ -847,10 +977,15 @@ fn hf_needs_context(hf: &HeaderFooter) -> bool { } fn hf_needs_stack_offset(hf: &HeaderFooter) -> bool { - hf.paragraphs.len() > 1 + hf.paragraphs + .iter() + .filter(|paragraph| hf_paragraph_has_flow_content(paragraph)) + .count() + > 1 || hf .paragraphs .iter() + .filter(|paragraph| hf_paragraph_has_flow_content(paragraph)) .flat_map(|paragraph| ¶graph.elements) .any(|element| matches!(element, HFInline::Image(_))) } @@ -861,40 +996,112 @@ fn generate_hf_content(out: &mut String, hf: &HeaderFooter, ctx: &mut GenCtx) { if i > 0 { out.push_str("\\\n"); } - // Apply paragraph alignment if set - if let Some(align) = para.style.alignment { - let align_str = match align { - Alignment::Left => "left", - Alignment::Center => "center", - Alignment::Right => "right", - Alignment::Justify => "left", - }; - let _ = write!(out, "#align({align_str})["); - } - if para.style.direction == Some(TextDirection::Rtl) { - out.push_str("#text(dir: rtl)["); - } - for elem in ¶.elements { - match elem { - HFInline::Run(run) => { - generate_run(out, run); - } - HFInline::Image(image) => { - generate_image(out, image, ctx); - } - HFInline::PageNumber => { - out.push_str("#counter(page).display()"); - } - HFInline::TotalPages => { - out.push_str("#counter(page).final().first()"); - } - } + generate_hf_styled_paragraph(out, para, ctx); + } +} + +fn generate_hf_styled_paragraph( + out: &mut String, + paragraph: &crate::ir::HeaderFooterParagraph, + ctx: &mut GenCtx, +) { + if let Some(align) = paragraph.style.alignment { + let align_str = match align { + Alignment::Left => "left", + Alignment::Center => "center", + Alignment::Right => "right", + Alignment::Justify => "left", + }; + let _ = write!(out, "#align({align_str})["); + } + if paragraph.style.direction == Some(TextDirection::Rtl) { + out.push_str("#text(dir: rtl)["); + } + generate_hf_paragraph(out, paragraph, ctx); + if paragraph.style.direction == Some(TextDirection::Rtl) { + out.push(']'); + } + if paragraph.style.alignment.is_some() { + out.push(']'); + } +} + +fn generate_hf_paragraph( + out: &mut String, + paragraph: &crate::ir::HeaderFooterParagraph, + ctx: &mut GenCtx, +) { + let right_tab = paragraph.elements.iter().position(|element| { + matches!( + element, + HFInline::PositionedTab(tab) + if tab.alignment == PositionedTabAlignment::Right + && tab.relative_to == PositionedTabRelativeTo::Margin + ) + }); + let has_top_border = paragraph + .border + .as_ref() + .and_then(|border| border.top.as_ref()); + + if let Some(border) = has_top_border { + out.push_str("#stack(dir: ttb, spacing: 0.5pt, "); + write_hf_border_line(out, border, border.style == BorderLineStyle::Double); + if border.style == BorderLineStyle::Double { + out.push_str(", "); + write_hf_border_line(out, border, false); } - if para.style.direction == Some(TextDirection::Rtl) { - out.push(']'); + out.push_str(", ["); + } + + if let Some(index) = right_tab { + out.push_str("#grid(columns: (1fr, auto), ["); + generate_hf_elements(out, ¶graph.elements[..index], ctx); + out.push_str("], ["); + generate_hf_elements(out, ¶graph.elements[index + 1..], ctx); + out.push_str("])"); + } else { + generate_hf_elements(out, ¶graph.elements, ctx); + } + + if has_top_border.is_some() { + out.push_str("])"); + } +} + +fn write_hf_border_line(out: &mut String, border: &BorderSide, is_primary_double: bool) { + let width = if is_primary_double { + border.width * 0.67 + } else if border.style == BorderLineStyle::Double { + border.width * 0.17 + } else { + border.width + }; + let dash = border_line_style_to_typst(border.style); + let _ = write!( + out, + "block(height: {}pt)[#line(length: 100%, stroke: (paint: rgb({}, {}, {}), thickness: {}pt, dash: \"{}\"))]", + format_f64(width), + border.color.r, + border.color.g, + border.color.b, + format_f64(width), + if border.style == BorderLineStyle::Double { + "solid" + } else { + dash } - if para.style.alignment.is_some() { - out.push(']'); + ); +} + +fn generate_hf_elements(out: &mut String, elements: &[HFInline], ctx: &mut GenCtx) { + for element in elements { + match element { + HFInline::Run(run) => generate_run(out, run), + HFInline::Image(image) => generate_image(out, image, ctx), + HFInline::PageNumber => out.push_str("#counter(page).display()"), + HFInline::TotalPages => out.push_str("#counter(page).final().first()"), + HFInline::PositionedTab(_) => out.push_str("#h(1em)"), } } } diff --git a/crates/office2pdf/src/render/typst_gen_page_misc_tests.rs b/crates/office2pdf/src/render/typst_gen_page_misc_tests.rs index 241b1d2..cc68235 100644 --- a/crates/office2pdf/src/render/typst_gen_page_misc_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_page_misc_tests.rs @@ -9,6 +9,7 @@ fn test_generate_flow_page_with_text_header() { margins: Margins::default(), content: vec![make_paragraph("Body text")], header: Some(HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle::default(), elements: vec![HFInline::Run(Run { @@ -17,6 +18,8 @@ fn test_generate_flow_page_with_text_header() { href: None, footnote: None, })], + border: None, + frame: None, }], }), footer: None, @@ -37,6 +40,7 @@ fn test_generate_flow_page_with_page_number_footer() { content: vec![make_paragraph("Body text")], header: None, footer: Some(HeaderFooter { + distance_from_edge: Some(35.4), paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle::default(), elements: vec![ @@ -48,6 +52,8 @@ fn test_generate_flow_page_with_page_number_footer() { }), HFInline::PageNumber, ], + border: None, + frame: None, }], }), columns: None, @@ -56,6 +62,110 @@ fn test_generate_flow_page_with_page_number_footer() { assert!(output.source.contains("footer:")); assert!(output.source.contains("counter(page).display()")); assert!(output.source.contains("Page ")); + assert!(output.source.contains("move(dy: -36.6pt)")); +} + +#[test] +fn test_generate_footer_with_compound_border_and_right_positioned_tab() { + use crate::ir::{ + BorderSide, CellBorder, HFInline, HeaderFooter, HeaderFooterParagraph, PositionedTab, + PositionedTabAlignment, PositionedTabRelativeTo, + }; + + let doc = make_doc(vec![Page::Flow(FlowPage { + size: PageSize::default(), + margins: Margins::default(), + content: vec![make_paragraph("Body")], + header: None, + footer: Some(HeaderFooter { + distance_from_edge: None, + paragraphs: vec![HeaderFooterParagraph { + style: ParagraphStyle::default(), + elements: vec![ + HFInline::Run(Run { + text: "Left".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }), + HFInline::PositionedTab(PositionedTab { + alignment: PositionedTabAlignment::Right, + relative_to: PositionedTabRelativeTo::Margin, + leader: TabLeader::None, + }), + HFInline::Run(Run { + text: "Page ".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }), + HFInline::PageNumber, + ], + border: Some(CellBorder { + top: Some(BorderSide { + width: 3.0, + color: Color::new(0x62, 0x24, 0x23), + style: BorderLineStyle::Double, + }), + bottom: None, + left: None, + right: None, + }), + frame: None, + }], + }), + columns: None, + })]); + + let output = generate_typst(&doc).unwrap(); + assert!(output.source.contains("#grid(columns: (1fr, auto)")); + assert!(output.source.contains("rgb(98, 36, 35)")); + assert_eq!(output.source.matches("line(length: 100%").count(), 2); +} + +#[test] +fn test_generate_page_anchored_footer_frame_in_foreground() { + use crate::ir::{ + FrameAnchor, HFInline, HeaderFooter, HeaderFooterFrame, HeaderFooterParagraph, + }; + + let doc = make_doc(vec![Page::Flow(FlowPage { + size: PageSize::default(), + margins: Margins::default(), + content: vec![make_paragraph("Body")], + header: None, + footer: Some(HeaderFooter { + distance_from_edge: None, + paragraphs: vec![HeaderFooterParagraph { + style: ParagraphStyle::default(), + elements: vec![HFInline::Run(Run { + text: "Framed footer".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + })], + border: None, + frame: Some(HeaderFooterFrame { + x: Some(71.8), + y: Some(198.5), + width: None, + height: None, + horizontal_anchor: FrameAnchor::Page, + vertical_anchor: FrameAnchor::Page, + }), + }], + }), + columns: None, + })]); + + let output = generate_typst(&doc).unwrap(); + assert!(output.source.contains("foreground: [")); + assert!( + output + .source + .contains("#place(top + left, dx: 71.8pt, dy: 198.5pt)") + ); + assert!(!output.source.contains("footer:")); } #[test] @@ -67,6 +177,7 @@ fn test_generate_flow_page_with_header_and_footer() { margins: Margins::default(), content: vec![make_paragraph("Body")], header: Some(HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle::default(), elements: vec![HFInline::Run(Run { @@ -75,12 +186,17 @@ fn test_generate_flow_page_with_header_and_footer() { href: None, footnote: None, })], + border: None, + frame: None, }], }), footer: Some(HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle::default(), elements: vec![HFInline::PageNumber], + border: None, + frame: None, }], }), columns: None, @@ -368,6 +484,7 @@ fn test_table_page_with_header() { margins: Margins::default(), table: make_simple_table(vec![vec!["A"]]), header: Some(HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle { alignment: Some(Alignment::Center), @@ -379,6 +496,8 @@ fn test_table_page_with_header() { href: None, footnote: None, })], + border: None, + frame: None, }], }), footer: None, @@ -399,6 +518,7 @@ fn test_table_page_with_page_number_footer() { table: make_simple_table(vec![vec!["A"]]), header: None, footer: Some(HeaderFooter { + distance_from_edge: None, paragraphs: vec![HeaderFooterParagraph { style: ParagraphStyle { alignment: Some(Alignment::Center), @@ -420,6 +540,8 @@ fn test_table_page_with_page_number_footer() { }), HFInline::TotalPages, ], + border: None, + frame: None, }], }), charts: vec![], diff --git a/crates/office2pdf/tests/docx_fixtures.rs b/crates/office2pdf/tests/docx_fixtures.rs index 73488e0..5b46be6 100644 --- a/crates/office2pdf/tests/docx_fixtures.rs +++ b/crates/office2pdf/tests/docx_fixtures.rs @@ -11,7 +11,8 @@ use std::path::PathBuf; use office2pdf::config::ConvertOptions; use office2pdf::ir::{ - ArrowHead, Block, Color, FlowPage, HFInline, ListKind, Page, Paragraph, Run, ShapeKind, + ArrowHead, Block, BorderLineStyle, Color, FlowPage, FrameAnchor, HFInline, ListKind, Page, + Paragraph, PositionedTabAlignment, PositionedTabRelativeTo, Run, ShapeKind, TextBoxVerticalAlign, }; use office2pdf::parser::Parser; @@ -846,6 +847,8 @@ docx_fixture_tests!(fancy_foot, "FancyFoot.docx"); #[test] fn structure_fancy_foot_preserves_text_and_simple_page_field() { let pages = flow_pages("FancyFoot.docx"); + let footer = pages[0].footer.as_ref().expect("default footer"); + let first_paragraph = &footer.paragraphs[0]; let elements = footer_elements(&pages); let text = footer_text(&pages); @@ -863,6 +866,21 @@ fn structure_fancy_foot_preserves_text_and_simple_page_field() { .any(|element| matches!(element, HFInline::PageNumber)), "the real-world fldSimple PAGE field should survive parsing" ); + let top_border = first_paragraph + .border + .as_ref() + .and_then(|border| border.top.as_ref()) + .expect("the compound top border should survive parsing"); + assert_eq!(top_border.style, BorderLineStyle::Double); + assert!((top_border.width - 3.0).abs() < f64::EPSILON); + assert_eq!(top_border.color, Color::new(0x62, 0x24, 0x23)); + assert!(first_paragraph.elements.iter().any(|element| matches!( + element, + HFInline::PositionedTab(tab) + if tab.alignment == PositionedTabAlignment::Right + && tab.relative_to == PositionedTabRelativeTo::Margin + ))); + assert!((footer.distance_from_edge.expect("footer distance") - 35.4).abs() < 0.01); } docx_fixture_tests!(field_codes, "FieldCodes.docx"); docx_fixture_tests!(header_footer_unicode, "HeaderFooterUnicode.docx"); @@ -1062,6 +1080,40 @@ docx_fixture_tests!(tdf111550, "libreoffice/tdf111550.docx"); docx_fixture_tests!(tdf111964, "libreoffice/tdf111964.docx"); docx_fixture_tests!(tdf124670, "libreoffice/tdf124670.docx"); docx_fixture_tests!(tdf129659, "libreoffice/tdf129659.docx"); +docx_fixture_tests!( + tdf159207_footer_frame_border, + "libreoffice/tdf159207_footerFramePrBorder.docx" +); + +#[test] +fn structure_tdf159207_preserves_page_anchored_footer_frame_position() { + let pages = flow_pages("libreoffice/tdf159207_footerFramePrBorder.docx"); + let footer = pages[0].footer.as_ref().expect("default footer"); + let framed_paragraphs = footer + .paragraphs + .iter() + .filter_map(|paragraph| paragraph.frame.as_ref()) + .collect::>(); + + assert_eq!(framed_paragraphs.len(), 6); + for frame in framed_paragraphs { + assert_eq!(frame.horizontal_anchor, FrameAnchor::Page); + assert_eq!(frame.vertical_anchor, FrameAnchor::Page); + assert!((frame.x.expect("absolute x") - 71.8).abs() < 0.01); + assert!((frame.y.expect("absolute y") - 198.5).abs() < 0.01); + } +} + +#[test] +fn conversion_tdf159207_renders_positioned_footer_frame() { + let result = office2pdf::convert(fixture_path( + "libreoffice/tdf159207_footerFramePrBorder.docx", + )) + .expect("the positioned footer frame should render"); + + assert!(result.pdf.starts_with(b"%PDF")); + common::validate_pdf_with_qpdf(&result.pdf); +} docx_fixture_tests!(table_rtl, "libreoffice/table-rtl.docx"); docx_fixture_tests!(wpg_only, "libreoffice/wpg-only.docx");