use rdocx_oxml::document::{BodyContent, CT_SectPr};
use rdocx_oxml::drawing::WrapType;
use rdocx_oxml::header_footer::HdrFtrType;
use rdocx_oxml::properties::CT_PPr;
use rdocx_oxml::shared::ST_HighlightColor;
use rdocx_oxml::styles::CT_Styles;
use rdocx_oxml::text::{BreakType, CT_P, FieldType, RunContent};
use crate::block::{self, LayoutBlock, ParagraphBlock};
use crate::convert;
use crate::input::{LayoutInput, MediaRegistry};
use crate::notes::NoteRegistry;
use crate::paginator::{self, HeaderFooterContent, PageGeometry};
use crate::style_resolver::{self, NumberingState};
use crate::table;
use oxml_layout::{
Color, DocumentMetadata, FieldKind, FontManager, InlineItem, LayoutResult, NoteRef, NoteStream,
PageFrame, PositionedElement, Rect, Result, TextSegment, break_into_lines,
};
pub struct Engine {
font_manager: FontManager,
}
impl Default for Engine {
fn default() -> Self {
Self::new()
}
}
impl Engine {
pub fn new() -> Self {
Engine {
font_manager: FontManager::new(),
}
}
pub fn new_deterministic() -> Result<Self> {
Ok(Engine {
font_manager: FontManager::new_deterministic()?,
})
}
pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
if !input.fonts.is_empty() {
self.font_manager.load_additional_fonts(&input.fonts);
}
let styles = &input.styles;
let mut num_state = NumberingState::new();
let media = MediaRegistry::new(&input.images);
let document_wraps = document_has_wrapping_drawing(input);
let final_sect_pr = input
.document
.body
.sect_pr
.as_ref()
.cloned()
.unwrap_or_else(CT_SectPr::default_letter);
let mut sections: Vec<paginator::Section> = Vec::new();
let mut current_blocks: Vec<LayoutBlock> = Vec::new();
let mut current_sect_pr: Option<CT_SectPr> = None;
for content in &input.document.body.content {
match content {
BodyContent::Paragraph(para) => {
let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
let sect_pr_for_layout = para_sect_pr
.as_ref()
.or(current_sect_pr.as_ref())
.unwrap_or(&final_sect_pr);
let geometry = sect_pr_to_geometry(sect_pr_for_layout);
let mut para_block = layout_paragraph(
para,
geometry.content_width(),
styles,
input,
&media,
&mut self.font_manager,
&mut num_state,
)?;
if !document_wraps {
para_block.reflow = None;
}
if let Some(level) = detect_heading_level(para, styles) {
para_block.heading_level = Some(level);
para_block.heading_text = Some(para.text());
}
current_blocks.push(LayoutBlock::Paragraph(para_block));
if let Some(sect_pr) = para_sect_pr {
let geometry = sect_pr_to_geometry(§_pr);
let header_footer = layout_header_footer(
§_pr,
input,
styles,
&media,
&mut self.font_manager,
&mut num_state,
)?;
let title_pg = sect_pr.title_pg.unwrap_or(false);
sections.push(paginator::Section {
blocks: std::mem::take(&mut current_blocks),
geometry,
header_footer,
title_pg,
});
current_sect_pr = Some(sect_pr);
}
}
BodyContent::Table(tbl) => {
let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
let geometry = sect_pr_to_geometry(sect_pr_for_layout);
let table_block = table::layout_table(
tbl,
geometry.content_width(),
styles,
input,
&media,
&mut self.font_manager,
&mut num_state,
)?;
current_blocks.push(LayoutBlock::Table(table_block));
}
_ => {} }
}
let final_geometry = sect_pr_to_geometry(&final_sect_pr);
let final_hf = layout_header_footer(
&final_sect_pr,
input,
styles,
&media,
&mut self.font_manager,
&mut num_state,
)?;
let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
sections.push(paginator::Section {
blocks: current_blocks,
geometry: final_geometry,
header_footer: final_hf,
title_pg: final_title_pg,
});
let notes = NoteRegistry::build(
input,
styles,
&media,
&mut self.font_manager,
&mut num_state,
final_geometry.content_width(),
)?;
let (mut pages, outlines) =
paginator::paginate_sections(§ions, &self.font_manager, &media, ¬es);
paginator::append_endnote_pages(&mut pages, ¬es, final_geometry);
let total_pages = pages.len();
for page in &mut pages {
let page_num = page.page_number;
substitute_fields(
&mut page.elements,
page_num,
total_pages,
&mut self.font_manager,
);
}
apply_page_background(&mut pages, input);
let fonts = self.font_manager.all_font_data();
let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
title: cp.title.clone(),
author: cp.creator.clone(),
subject: cp.subject.clone(),
keywords: cp.keywords.clone(),
creator: Some("rdocx".to_string()),
});
Ok(LayoutResult::new(pages, fonts, metadata, outlines))
}
}
fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
let bg_xml = match &input.document.background_xml {
Some(xml) => xml,
None => return,
};
let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
let color = extract_background_color(xml_str);
let color = match color {
Some(c) => c,
None => return,
};
for page in pages.iter_mut() {
page.elements.insert(
0,
PositionedElement::FilledRect {
rect: Rect {
x: 0.0,
y: 0.0,
width: page.width,
height: page.height,
},
color,
},
);
}
}
fn extract_background_color(xml: &str) -> Option<Color> {
for attr in ["w:color=\"", "color=\""] {
if let Some(start) = xml.find(attr) {
let val_start = start + attr.len();
if let Some(end) = xml[val_start..].find('"') {
let hex = &xml[val_start..val_start + end];
if hex.len() == 6 && hex != "auto" {
return Some(Color::from_hex(hex));
}
}
}
}
None
}
fn substitute_fields(
elements: &mut [PositionedElement],
page_number: usize,
total_pages: usize,
fm: &mut FontManager,
) {
for element in elements.iter_mut() {
if let PositionedElement::Text(run) = element
&& let Some(fk) = run.field_kind
{
let value = match fk {
FieldKind::Page => page_number.to_string(),
FieldKind::NumPages => total_pages.to_string(),
};
if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
run.text = value;
run.glyph_ids = shaped.glyph_ids;
run.advances = shaped.advances;
}
}
}
}
fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
let style_id = para.properties.as_ref()?.style_id.as_deref()?;
if let Some(rest) = style_id.strip_prefix("Heading") {
return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
}
if let Some(style_def) = styles.get_by_id(style_id)
&& let Some(ref name) = style_def.name
&& let Some(rest) = name.strip_prefix("heading ")
{
return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
}
None
}
pub fn layout_paragraph(
para: &CT_P,
available_width: f64,
styles: &CT_Styles,
input: &LayoutInput,
media: &MediaRegistry,
fm: &mut FontManager,
num_state: &mut NumberingState,
) -> Result<ParagraphBlock> {
let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
let resolved_ppr = style_resolver::resolve_paragraph_properties(para_style_id, styles);
let mut effective_ppr = resolved_ppr;
let direct_ppr = para.properties.as_ref();
let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
let list_ilvl = direct_ppr
.and_then(|p| p.num_ilvl)
.or(effective_ppr.num_ilvl)
.unwrap_or(0);
if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
&& let Some(lvl_ppr) =
style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
{
merge_direct_ppr(&mut effective_ppr, lvl_ppr);
}
if let Some(direct_ppr) = direct_ppr {
merge_direct_ppr(&mut effective_ppr, direct_ppr);
}
let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
let ind_left = effective_ppr.ind_left.map(|t| t.to_pt()).unwrap_or(0.0);
let ind_right = effective_ppr.ind_right.map(|t| t.to_pt()).unwrap_or(0.0);
let keep_next = effective_ppr.keep_next.unwrap_or(false);
let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
let widow_control = effective_ppr.widow_control.unwrap_or(true);
let jc = convert::alignment(effective_ppr.jc);
let shading = effective_ppr
.shading
.as_ref()
.and_then(|shd| shd.fill.as_ref())
.filter(|f| f != &"auto")
.map(|f| Color::from_hex(f));
let mut inline_items = Vec::new();
if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
let marker_rpr = marker.marker_rpr;
let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
style_resolver::resolve_run_properties(para_style_id, None, styles)
.sz
.map(|hp| hp.to_pt())
.unwrap_or(11.0)
});
let marker_bold = marker_rpr.bold.unwrap_or(false);
let marker_italic = marker_rpr.italic.unwrap_or(false);
let marker_font_family = marker_rpr.font_ascii.as_deref();
if let Ok(font_id) = fm.resolve_font_for_text(
marker_font_family,
marker_bold,
marker_italic,
&marker.marker_text,
) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
{
let metrics = fm.metrics(font_id, marker_font_size)?;
let color = marker_rpr
.color
.as_ref()
.map(|c| Color::from_hex(c))
.unwrap_or(Color::BLACK);
inline_items.push(InlineItem::Marker(TextSegment {
text: marker.marker_text,
font_id,
font_size: marker_font_size,
glyph_ids: shaped.glyph_ids,
advances: shaped.advances,
width: shaped.width,
ascent: metrics.ascent,
descent: metrics.descent,
line_gap: 0.0,
color,
bold: marker_bold,
italic: marker_italic,
underline: None,
strike: false,
dstrike: false,
highlight: None,
baseline_offset: 0.0,
hyperlink_url: None,
field_kind: None,
note: None,
}));
inline_items.push(InlineItem::Tab);
}
}
}
let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
for hl in ¶.hyperlinks {
if let Some(ref rel_id) = hl.rel_id
&& let Some(url) = input.hyperlink_urls.get(rel_id)
{
for run_idx in hl.run_start..hl.run_end {
run_hyperlink_url.insert(run_idx, url.clone());
}
}
}
for (run_idx, run) in para.runs.iter().enumerate() {
let current_hyperlink_url = run_hyperlink_url.get(&run_idx).cloned();
let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
let resolved_rpr =
style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
let mut effective_rpr = resolved_rpr;
if let Some(ref direct_rpr) = run.properties {
effective_rpr.merge_from(direct_rpr);
}
if effective_rpr.vanish == Some(true) {
continue;
}
let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
let bold = effective_rpr.bold.unwrap_or(false);
let italic = effective_rpr.italic.unwrap_or(false);
let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
let underline = convert::underline(effective_rpr.underline);
let strike = effective_rpr.strike.unwrap_or(false);
let dstrike = effective_rpr.dstrike.unwrap_or(false);
let highlight = effective_rpr.highlight.and_then(highlight_to_color);
let mut baseline_offset = 0.0;
if let Some(ref va) = effective_rpr.vert_align {
match va.as_str() {
"superscript" => {
let original_size = font_size;
font_size *= 0.58;
baseline_offset = original_size * 0.33; }
"subscript" => {
let original_size = font_size;
font_size *= 0.58;
baseline_offset = -(original_size * 0.14); }
_ => {}
}
}
if let Some(pos) = effective_rpr.position {
baseline_offset += pos as f64 / 2.0; }
let font_id =
fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
let metrics = fm.metrics(font_id, font_size)?;
for content in &run.content {
match content {
RunContent::Text(ct_text) => {
let text = if effective_rpr.caps == Some(true) {
ct_text.text.to_uppercase()
} else {
ct_text.text.clone()
};
if text.is_empty() {
continue;
}
let mut shaped = fm.shape_text(font_id, &text, font_size)?;
if let Some(spacing) = effective_rpr.spacing {
let extra = spacing.to_pt();
for advance in &mut shaped.advances {
*advance += extra;
}
shaped.width += extra * shaped.advances.len() as f64;
}
inline_items.extend(convert::text_segments(TextSegment {
text,
font_id,
font_size,
glyph_ids: shaped.glyph_ids,
advances: shaped.advances,
width: shaped.width,
ascent: metrics.ascent,
descent: metrics.descent,
line_gap: 0.0,
color,
bold,
italic,
underline,
strike,
dstrike,
highlight,
baseline_offset,
hyperlink_url: current_hyperlink_url.clone(),
field_kind: None,
note: None,
}));
}
RunContent::Tab => {
inline_items.push(InlineItem::Tab);
}
RunContent::Break(bt) => match bt {
BreakType::Line => inline_items.push(InlineItem::LineBreak),
BreakType::Page => inline_items.push(InlineItem::PageBreak),
BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
},
RunContent::Drawing(drawing) => {
if let Some(ref inline) = drawing.inline {
let width = inline.extent_cx.to_pt();
let height = inline.extent_cy.to_pt();
inline_items.push(InlineItem::Image {
width,
height,
media_id: media.id_for_relationship(&inline.embed_id),
});
}
}
RunContent::Field { field_type } => {
let placeholder = "99";
let fk = match field_type {
FieldType::Page => FieldKind::Page,
FieldType::NumPages => FieldKind::NumPages,
FieldType::Other(_) => continue, };
let shaped = fm.shape_text(font_id, placeholder, font_size)?;
inline_items.push(InlineItem::Text(TextSegment {
text: placeholder.to_string(),
font_id,
font_size,
glyph_ids: shaped.glyph_ids,
advances: shaped.advances,
width: shaped.width,
ascent: metrics.ascent,
descent: metrics.descent,
line_gap: 0.0,
color,
bold,
italic,
underline: None,
strike: false,
dstrike: false,
highlight: None,
baseline_offset,
hyperlink_url: None,
field_kind: Some(fk),
note: None,
}));
}
RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
let stream = match content {
RunContent::EndnoteRef { .. } => NoteStream::Endnote,
_ => NoteStream::Footnote,
};
let marker = id.to_string();
let sup_size = font_size * 0.58;
let sup_offset = font_size * 0.33; let shaped = fm.shape_text(font_id, &marker, sup_size)?;
let sup_metrics = fm.metrics(font_id, sup_size)?;
inline_items.push(InlineItem::Text(TextSegment {
text: marker,
font_id,
font_size: sup_size,
glyph_ids: shaped.glyph_ids,
advances: shaped.advances,
width: shaped.width,
ascent: sup_metrics.ascent,
descent: sup_metrics.descent,
line_gap: 0.0,
color,
bold,
italic,
underline: None,
strike: false,
dstrike: false,
highlight: None,
baseline_offset: sup_offset,
hyperlink_url: None,
field_kind: None,
note: Some(NoteRef { stream, id: *id }),
}));
}
}
}
}
let line_params = convert::line_break_params(&effective_ppr, available_width);
let mut lines = break_into_lines(&inline_items, &line_params, fm)?;
convert::restore_word_line_heights(&mut lines, &effective_ppr);
let mut result = block::build_paragraph_block(
lines,
space_before,
space_after,
effective_ppr.borders,
shading,
ind_left,
ind_right,
jc,
keep_next,
keep_lines,
page_break_before,
widow_control,
);
result.anchored = collect_anchored_drawings(para, styles, input, media, fm, num_state)?;
result.reflow = Some(Box::new(block::ParagraphReflow {
items: inline_items,
params: line_params,
}));
Ok(result)
}
fn document_has_wrapping_drawing(input: &LayoutInput) -> bool {
fn paragraph_wraps(para: &CT_P) -> bool {
para.runs.iter().any(|run| {
run.content
.iter()
.filter_map(|rc| match rc {
RunContent::Drawing(d) => Some(d),
_ => None,
})
.chain(run.alt_drawings.iter())
.any(|drawing| {
drawing
.anchor
.as_ref()
.is_some_and(|anchor| anchor.wrap != WrapType::None)
})
})
}
input
.document
.body
.content
.iter()
.any(|content| match content {
BodyContent::Paragraph(para) => paragraph_wraps(para),
BodyContent::Table(table) => table
.rows
.iter()
.flat_map(|row| row.cells.iter())
.flat_map(|cell| cell.content.iter())
.any(|content| match content {
rdocx_oxml::table::CellContent::Paragraph(para) => paragraph_wraps(para),
rdocx_oxml::table::CellContent::Table(_) => false,
}),
_ => false,
})
}
fn collect_anchored_drawings(
para: &CT_P,
styles: &CT_Styles,
input: &LayoutInput,
media: &MediaRegistry,
fm: &mut FontManager,
num_state: &mut NumberingState,
) -> Result<Vec<block::AnchoredDrawing>> {
let mut out = Vec::new();
for run in ¶.runs {
let plain = run.content.iter().filter_map(|rc| match rc {
RunContent::Drawing(d) => Some(d),
_ => None,
});
for drawing in plain.chain(run.alt_drawings.iter()) {
let Some(anchor) = drawing.anchor.as_ref() else {
continue;
};
let shape = if anchor.embed_id.is_empty() {
anchor.shape.as_ref()
} else {
None
};
let content = match shape {
Some(shape) => {
let mut text = Vec::new();
for p in &shape.text {
text.push(layout_paragraph(
p,
anchor.extent_cx.to_pt(),
styles,
input,
media,
fm,
num_state,
)?);
}
block::AnchoredContent::Shape {
preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
fill: shape.solid_fill.as_deref().map(Color::from_hex),
text,
}
}
None if anchor.embed_id.is_empty() => continue,
None => block::AnchoredContent::Image {
media_id: media.id_for_relationship(&anchor.embed_id),
},
};
out.push(block::AnchoredDrawing {
behind_doc: anchor.behind_doc,
rel_h: anchor.pos_h_relative_from,
off_h: anchor.pos_h_offset.to_pt(),
rel_v: anchor.pos_v_relative_from,
off_v: anchor.pos_v_offset.to_pt(),
width: anchor.extent_cx.to_pt(),
height: anchor.extent_cy.to_pt(),
wrap: anchor.wrap,
dist_top: anchor.dist_t.to_pt(),
dist_bottom: anchor.dist_b.to_pt(),
dist_left: anchor.dist_l.to_pt(),
dist_right: anchor.dist_r.to_pt(),
align_h: anchor.pos_h_align,
align_v: anchor.pos_v_align,
content,
});
}
}
Ok(out)
}
fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
if direct.jc.is_some() {
effective.jc = direct.jc;
}
if direct.space_before.is_some() {
effective.space_before = direct.space_before;
}
if direct.space_after.is_some() {
effective.space_after = direct.space_after;
}
if direct.line_spacing.is_some() {
effective.line_spacing = direct.line_spacing;
}
if direct.line_rule.is_some() {
effective.line_rule = direct.line_rule.clone();
}
if direct.ind_left.is_some() {
effective.ind_left = direct.ind_left;
}
if direct.ind_right.is_some() {
effective.ind_right = direct.ind_right;
}
if direct.ind_first_line.is_some() {
effective.ind_first_line = direct.ind_first_line;
}
if direct.ind_hanging.is_some() {
effective.ind_hanging = direct.ind_hanging;
}
if direct.keep_next.is_some() {
effective.keep_next = direct.keep_next;
}
if direct.keep_lines.is_some() {
effective.keep_lines = direct.keep_lines;
}
if direct.page_break_before.is_some() {
effective.page_break_before = direct.page_break_before;
}
if direct.widow_control.is_some() {
effective.widow_control = direct.widow_control;
}
if direct.borders.is_some() {
effective.borders = direct.borders.clone();
}
if direct.tabs.is_some() {
effective.tabs = direct.tabs.clone();
}
if direct.shading.is_some() {
effective.shading = direct.shading.clone();
}
if direct.num_id.is_some() {
effective.num_id = direct.num_id;
}
if direct.num_ilvl.is_some() {
effective.num_ilvl = direct.num_ilvl;
}
}
fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
PageGeometry {
page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
}
}
fn layout_header_footer(
sect_pr: &CT_SectPr,
input: &LayoutInput,
styles: &CT_Styles,
media: &MediaRegistry,
fm: &mut FontManager,
num_state: &mut NumberingState,
) -> Result<Option<HeaderFooterContent>> {
let mut has_content = false;
let mut header_blocks = Vec::new();
let mut footer_blocks = Vec::new();
let mut first_header_blocks = Vec::new();
let mut first_footer_blocks = Vec::new();
let geometry = sect_pr_to_geometry(sect_pr);
let width = geometry.content_width();
for href in §_pr.header_refs {
let target_blocks = match href.hdr_ftr_type {
HdrFtrType::Default => &mut header_blocks,
HdrFtrType::First => &mut first_header_blocks,
_ => continue, };
if let Some(hdr) = input.headers.get(&href.rel_id) {
for para in &hdr.paragraphs {
let block = layout_paragraph(para, width, styles, input, media, fm, num_state)?;
target_blocks.push(block);
}
has_content = true;
}
}
for fref in §_pr.footer_refs {
let target_blocks = match fref.hdr_ftr_type {
HdrFtrType::Default => &mut footer_blocks,
HdrFtrType::First => &mut first_footer_blocks,
_ => continue, };
if let Some(ftr) = input.footers.get(&fref.rel_id) {
for para in &ftr.paragraphs {
let block = layout_paragraph(para, width, styles, input, media, fm, num_state)?;
target_blocks.push(block);
}
has_content = true;
}
}
if has_content {
Ok(Some(HeaderFooterContent {
header_blocks,
footer_blocks,
first_header_blocks,
first_footer_blocks,
}))
} else {
Ok(None)
}
}
fn resolve_font_family(
rpr: &rdocx_oxml::properties::CT_RPr,
theme: Option<&rdocx_oxml::theme::Theme>,
) -> Option<String> {
if rpr.font_ascii.is_some() {
return rpr.font_ascii.clone();
}
if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
let font = match theme_ref.as_str() {
"majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
theme.major_font.as_deref()
}
"minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
theme.minor_font.as_deref()
}
_ => None,
};
if let Some(f) = font {
return Some(f.to_string());
}
}
None
}
fn resolve_run_color(
rpr: &rdocx_oxml::properties::CT_RPr,
theme: Option<&rdocx_oxml::theme::Theme>,
) -> Color {
if let Some(ref theme_name) = rpr.color_theme
&& let Some(theme) = theme
&& let Some(hex) = theme.colors.get(theme_name)
{
return Color::from_hex(hex);
}
rpr.color
.as_ref()
.filter(|c| c.as_str() != "auto")
.map(|c| Color::from_hex(c))
.unwrap_or(Color::BLACK)
}
fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
match h {
ST_HighlightColor::None => None,
ST_HighlightColor::Black => Some(Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
ST_HighlightColor::Blue => Some(Color {
r: 0.0,
g: 0.0,
b: 1.0,
a: 1.0,
}),
ST_HighlightColor::Cyan => Some(Color {
r: 0.0,
g: 1.0,
b: 1.0,
a: 1.0,
}),
ST_HighlightColor::DarkBlue => Some(Color {
r: 0.0,
g: 0.0,
b: 0.545,
a: 1.0,
}),
ST_HighlightColor::DarkCyan => Some(Color {
r: 0.0,
g: 0.545,
b: 0.545,
a: 1.0,
}),
ST_HighlightColor::DarkGray => Some(Color {
r: 0.663,
g: 0.663,
b: 0.663,
a: 1.0,
}),
ST_HighlightColor::DarkGreen => Some(Color {
r: 0.0,
g: 0.392,
b: 0.0,
a: 1.0,
}),
ST_HighlightColor::DarkMagenta => Some(Color {
r: 0.545,
g: 0.0,
b: 0.545,
a: 1.0,
}),
ST_HighlightColor::DarkRed => Some(Color {
r: 0.545,
g: 0.0,
b: 0.0,
a: 1.0,
}),
ST_HighlightColor::DarkYellow => Some(Color {
r: 0.545,
g: 0.545,
b: 0.0,
a: 1.0,
}),
ST_HighlightColor::Green => Some(Color {
r: 0.0,
g: 1.0,
b: 0.0,
a: 1.0,
}),
ST_HighlightColor::LightGray => Some(Color {
r: 0.827,
g: 0.827,
b: 0.827,
a: 1.0,
}),
ST_HighlightColor::Magenta => Some(Color {
r: 1.0,
g: 0.0,
b: 1.0,
a: 1.0,
}),
ST_HighlightColor::Red => Some(Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
ST_HighlightColor::White => Some(Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
}),
ST_HighlightColor::Yellow => Some(Color {
r: 1.0,
g: 1.0,
b: 0.0,
a: 1.0,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::ImageData;
use oxml_layout::MediaId;
use std::collections::HashMap;
fn make_input_with_text(text: &str) -> LayoutInput {
let mut doc = rdocx_oxml::document::CT_Document::new();
let mut p = CT_P::new();
p.add_run(text);
doc.body.add_paragraph(p);
LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: None,
endnotes: None,
theme: None,
fonts: Vec::new(),
}
}
#[test]
fn layout_simple_document() {
let input = make_input_with_text("Hello World");
let result = Engine::new().layout(&input);
if let Ok(result) = result {
assert!(!result.pages.is_empty());
assert_eq!(result.pages[0].page_number, 1);
assert!((result.pages[0].width - 612.0).abs() < 0.01);
}
}
#[test]
fn layout_empty_document() {
let mut doc = rdocx_oxml::document::CT_Document::new();
doc.body.add_paragraph(CT_P::new());
let input = LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: None,
endnotes: None,
theme: None,
fonts: Vec::new(),
};
let result = Engine::new().layout(&input);
if let Ok(result) = result {
assert_eq!(result.pages.len(), 1);
}
}
#[test]
fn empty_shapeless_anchor_keeps_the_pre_cutover_omission() {
let input = make_input_with_text("");
let mut paragraph = CT_P::new();
paragraph.add_run("").content = vec![RunContent::Drawing(
rdocx_oxml::drawing::CT_Drawing::anchor(rdocx_oxml::drawing::CT_Anchor::background(
"", 914_400, 914_400,
)),
)];
let mut font_manager = FontManager::new();
let mut numbering_state = NumberingState::new();
let media = MediaRegistry::new(&input.images);
let anchored = collect_anchored_drawings(
¶graph,
&input.styles,
&input,
&media,
&mut font_manager,
&mut numbering_state,
)
.expect("empty shapeless anchor collection should succeed");
assert!(anchored.is_empty());
}
#[test]
fn colliding_media_ids_keep_inline_and_anchored_image_bytes_distinct() {
let mut input = make_input_with_text("");
input.images.insert(
"rIdInline".to_string(),
ImageData {
data: vec![1, 2, 3],
content_type: "image/png".to_string(),
},
);
input.images.insert(
"rIdAnchor".to_string(),
ImageData {
data: vec![4, 5, 6],
content_type: "image/jpeg".to_string(),
},
);
let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
let inline_id = media.id_for_relationship("rIdInline");
let anchor_id = media.id_for_relationship("rIdAnchor");
assert_ne!(inline_id, anchor_id);
let line = oxml_layout::LayoutLine {
items: vec![oxml_layout::LineItem::Image {
width: 12.0,
height: 10.0,
media_id: inline_id,
}],
width: 12.0,
ascent: 10.0,
descent: 0.0,
line_gap: 0.0,
height: 10.0,
indent_left: 0.0,
available_width: 468.0,
is_last: true,
};
let mut paragraph = block::build_paragraph_block(
vec![line],
0.0,
0.0,
None,
None,
0.0,
0.0,
None,
false,
false,
false,
true,
);
paragraph.anchored.push(block::AnchoredDrawing {
behind_doc: false,
rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
off_h: 20.0,
rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
off_v: 20.0,
width: 12.0,
height: 10.0,
wrap: rdocx_oxml::drawing::WrapType::None,
dist_top: 0.0,
dist_bottom: 0.0,
dist_left: 0.0,
dist_right: 0.0,
align_h: None,
align_v: None,
content: block::AnchoredContent::Image {
media_id: anchor_id,
},
});
let sections = [paginator::Section {
blocks: vec![LayoutBlock::Paragraph(paragraph)],
geometry: PageGeometry::default(),
header_footer: None,
title_pg: false,
}];
let (pages, _) = paginator::paginate_sections(
§ions,
&FontManager::new(),
&media,
&NoteRegistry::default(),
);
let images = pages[0]
.elements
.iter()
.filter_map(|element| match element {
PositionedElement::Image {
data,
content_type,
media_id,
..
} => Some((data.as_slice(), content_type.as_str(), *media_id)),
_ => None,
})
.collect::<Vec<_>>();
assert!(images.contains(&(b"\x01\x02\x03".as_slice(), "image/png", inline_id)));
assert!(images.contains(&(b"\x04\x05\x06".as_slice(), "image/jpeg", anchor_id)));
}
#[test]
fn layout_with_heading_style() {
let mut doc = rdocx_oxml::document::CT_Document::new();
let mut p = CT_P::new();
p.properties = Some(CT_PPr {
style_id: Some("Heading1".to_string()),
..Default::default()
});
p.add_run("Chapter 1");
doc.body.add_paragraph(p);
let input = LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: None,
endnotes: None,
theme: None,
fonts: Vec::new(),
};
let result = Engine::new().layout(&input);
if let Ok(result) = result {
assert!(!result.pages.is_empty());
assert_eq!(result.outlines.len(), 1);
assert_eq!(result.outlines[0].title, "Chapter 1");
assert_eq!(result.outlines[0].level, 1);
assert_eq!(result.outlines[0].page_index, 0);
}
}
#[test]
fn layout_nested_headings_produce_outlines() {
let mut doc = rdocx_oxml::document::CT_Document::new();
let mut h1 = CT_P::new();
h1.properties = Some(CT_PPr {
style_id: Some("Heading1".to_string()),
..Default::default()
});
h1.add_run("Chapter 1");
doc.body.add_paragraph(h1);
let mut h2 = CT_P::new();
h2.properties = Some(CT_PPr {
style_id: Some("Heading2".to_string()),
..Default::default()
});
h2.add_run("Section 1.1");
doc.body.add_paragraph(h2);
let mut h1b = CT_P::new();
h1b.properties = Some(CT_PPr {
style_id: Some("Heading1".to_string()),
..Default::default()
});
h1b.add_run("Chapter 2");
doc.body.add_paragraph(h1b);
let input = LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: None,
endnotes: None,
theme: None,
fonts: Vec::new(),
};
let result = Engine::new().layout(&input);
if let Ok(result) = result {
assert_eq!(result.outlines.len(), 3);
assert_eq!(result.outlines[0].level, 1);
assert_eq!(result.outlines[0].title, "Chapter 1");
assert_eq!(result.outlines[1].level, 2);
assert_eq!(result.outlines[1].title, "Section 1.1");
assert_eq!(result.outlines[2].level, 1);
assert_eq!(result.outlines[2].title, "Chapter 2");
}
}
#[test]
fn sect_pr_geometry_conversion() {
let sect = CT_SectPr::default_letter();
let geom = sect_pr_to_geometry(§);
assert!((geom.page_width - 612.0).abs() < 0.01);
assert!((geom.page_height - 792.0).abs() < 0.01);
assert!((geom.margin_top - 72.0).abs() < 0.01);
assert!((geom.content_width() - 468.0).abs() < 0.01);
}
#[test]
fn sect_pr_a4_geometry() {
let sect = CT_SectPr::default_a4();
let geom = sect_pr_to_geometry(§);
assert!((geom.page_width - 595.3).abs() < 0.5);
assert!((geom.page_height - 841.9).abs() < 0.5);
}
fn make_input_with_footnote(note_runs: &[&str]) -> LayoutInput {
use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
use rdocx_oxml::text::CT_R;
let mut doc = rdocx_oxml::document::CT_Document::new();
let mut body = CT_P::new();
body.add_run("Body text carrying a note");
let mut marker_run = CT_R::new("");
marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
body.runs.push(marker_run);
doc.body.add_paragraph(body);
let mut note = CT_P::new();
for text in note_runs {
note.add_run(text);
}
LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: Some(CT_Footnotes {
footnotes: vec![CT_Footnote {
id: 1,
note_type: NoteType::Normal,
paragraphs: vec![note],
}],
}),
endnotes: None,
theme: None,
fonts: Vec::new(),
}
}
fn footnote_glyph_x(page: &oxml_layout::output::PageFrame) -> Vec<f64> {
let separator_y = page
.elements
.iter()
.find_map(|element| match element {
PositionedElement::Line { start, .. } => Some(start.y),
_ => None,
})
.expect("a page with a footnote draws a separator line");
page.elements
.iter()
.filter_map(|element| match element {
PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.x),
_ => None,
})
.collect()
}
#[test]
fn a_multi_segment_footnote_does_not_stack_its_segments_at_one_x() {
let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let xs = footnote_glyph_x(&output.pages[0]);
assert!(
xs.len() >= 4,
"expected a marker and three note segments, got {xs:?}"
);
for pair in xs.windows(2) {
assert!(
pair[1] > pair[0],
"footnote segments must advance, got {xs:?}"
);
}
}
#[test]
fn a_single_segment_footnote_keeps_its_original_position() {
let input = make_input_with_footnote(&["Solitary"]);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let xs = footnote_glyph_x(&output.pages[0]);
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
assert_eq!(xs.len(), 2, "expected a marker and one segment, got {xs:?}");
assert!(
(xs[0] - geometry.margin_left).abs() < 0.01,
"marker at {xs:?}"
);
assert!(
(xs[1] - (geometry.margin_left + 12.0)).abs() < 0.01,
"segment at {xs:?}"
);
}
#[test]
fn a_long_footnote_does_not_overrun_the_right_margin() {
let long = "In paged media, footnotes are usually displayed at the \
bottom of the text. However, in ebooks, a better paradigm \
is to make them clickable endnotes that the reader can \
browse at leisure, which this sentence exists to force.";
let input = make_input_with_footnote(&[long]);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let page = &output.pages[0];
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
let right_margin = geometry.page_width - geometry.margin_right;
let separator_y = page
.elements
.iter()
.find_map(|element| match element {
PositionedElement::Line { start, .. } => Some(start.y),
_ => None,
})
.expect("a page with a footnote draws a separator line");
let mut wrapped = false;
let mut first_y = None;
for element in &page.elements {
let PositionedElement::Text(run) = element else {
continue;
};
if run.origin.y <= separator_y {
continue;
}
let first = *first_y.get_or_insert(run.origin.y);
if run.origin.y > first + 0.01 {
wrapped = true;
}
let right_edge = run.origin.x + run.advances.iter().sum::<f64>();
assert!(
right_edge <= right_margin + 0.01,
"note text reaches {right_edge}, past the right margin {right_margin}"
);
}
assert!(wrapped, "the note must wrap for this test to mean anything");
}
#[test]
fn a_tab_inside_a_footnote_still_advances_the_text_after_it() {
use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
use rdocx_oxml::text::CT_R;
let build = |with_tab: bool| {
let mut doc = rdocx_oxml::document::CT_Document::new();
let mut body = CT_P::new();
body.add_run("Body");
let mut marker_run = CT_R::new("");
marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
body.runs.push(marker_run);
doc.body.add_paragraph(body);
let mut note = CT_P::new();
note.add_run("Alpha");
if with_tab {
let mut tab_run = CT_R::new("");
tab_run.content = vec![RunContent::Tab];
note.runs.push(tab_run);
}
note.add_run("Beta");
LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: Some(CT_Footnotes {
footnotes: vec![CT_Footnote {
id: 1,
note_type: NoteType::Normal,
paragraphs: vec![note],
}],
}),
endnotes: None,
theme: None,
fonts: Vec::new(),
}
};
let mut engine = Engine::new();
let plain = engine.layout(&build(false)).expect("layout succeeds");
let tabbed = engine.layout(&build(true)).expect("layout succeeds");
let plain_x = footnote_glyph_x(&plain.pages[0]);
let tabbed_x = footnote_glyph_x(&tabbed.pages[0]);
assert_eq!(plain_x.len(), 3, "plain note glyphs {plain_x:?}");
assert_eq!(tabbed_x.len(), 3, "tabbed note glyphs {tabbed_x:?}");
assert!(
tabbed_x[2] > plain_x[2] + 1.0,
"the run after a tab must shift right, plain {plain_x:?} tabbed {tabbed_x:?}"
);
}
#[test]
fn footnote_segment_advance_matches_body_segment_advance() {
let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let page = &output.pages[0];
let separator_y = page
.elements
.iter()
.find_map(|element| match element {
PositionedElement::Line { start, .. } => Some(start.y),
_ => None,
})
.expect("a page with a footnote draws a separator line");
let notes: Vec<&oxml_layout::GlyphRun> = page
.elements
.iter()
.filter_map(|element| match element {
PositionedElement::Text(run) if run.origin.y > separator_y => Some(run),
_ => None,
})
.skip(1) .collect();
assert_eq!(notes.len(), 3, "expected three note segments");
for pair in notes.windows(2) {
let advance: f64 = pair[0].advances.iter().sum();
let gap = pair[1].origin.x - pair[0].origin.x;
assert!(
(gap - advance).abs() < 0.01,
"gap {gap} should equal preceding segment advance {advance}"
);
}
}
fn make_noted_document(
body_paras: usize,
ref_positions: &[usize],
note_paras: usize,
note_text: &str,
continuation_separator: bool,
) -> LayoutInput {
use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
use rdocx_oxml::text::CT_R;
let mut doc = rdocx_oxml::document::CT_Document::new();
for index in 0..body_paras {
let mut para = CT_P::new();
para.add_run("Body paragraph text that occupies a line of the page.");
if ref_positions.contains(&index) {
let mut marker = CT_R::new("");
marker.content = vec![RunContent::FootnoteRef { id: 1 }];
para.runs.push(marker);
}
doc.body.add_paragraph(para);
}
let mut entries = Vec::new();
if continuation_separator {
entries.push(CT_Footnote {
id: 0,
note_type: NoteType::ContinuationSeparator,
paragraphs: vec![CT_P::new()],
});
}
entries.push(CT_Footnote {
id: 1,
note_type: NoteType::Normal,
paragraphs: (0..note_paras)
.map(|_| {
let mut p = CT_P::new();
p.add_run(note_text);
p
})
.collect(),
});
LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: Some(CT_Footnotes { footnotes: entries }),
endnotes: None,
theme: None,
fonts: Vec::new(),
}
}
fn split_at_separator(
page: &oxml_layout::output::PageFrame,
) -> Option<(f64, Vec<f64>, Vec<String>)> {
let separator_index = page.elements.iter().position(|element| {
matches!(element, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001)
})?;
let PositionedElement::Line { start, end, .. } = &page.elements[separator_index] else {
return None;
};
let separator_y = start.y;
let separator_width = end.x - start.x;
let body_ys: Vec<f64> = page.elements[..separator_index]
.iter()
.filter_map(|element| match element {
PositionedElement::Text(run) => Some(run.origin.y),
_ => None,
})
.collect();
let note_text: Vec<String> = page.elements[separator_index + 1..]
.iter()
.filter_map(|element| match element {
PositionedElement::Text(run) => Some(run.text.clone()),
_ => None,
})
.collect();
let _ = separator_y;
Some((separator_width, body_ys, note_text))
}
fn separator_y_of(page: &oxml_layout::output::PageFrame) -> Option<f64> {
page.elements.iter().find_map(|element| match element {
PositionedElement::Line { start, width, .. } if (*width - 0.5).abs() < 0.001 => {
Some(start.y)
}
_ => None,
})
}
#[test]
fn a_page_whose_body_fills_the_text_area_does_not_overlap_its_notes() {
let input = make_noted_document(
60,
&[0],
2,
"A note long enough to wrap onto a second line of the note area.",
false,
);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let page = &output.pages[0];
let separator_y = separator_y_of(page).expect("the page draws a separator");
let (_, body_ys, note_text) = split_at_separator(page).unwrap();
assert!(!note_text.is_empty(), "the note must be drawn");
let lowest_body = body_ys.iter().cloned().fold(f64::MIN, f64::max);
assert!(
lowest_body < separator_y,
"body text reaches {lowest_body}, at or below the separator at {separator_y}"
);
}
#[test]
fn a_page_referencing_one_note_twice_reserves_it_once() {
let input = make_noted_document(4, &[0, 1], 1, "Referenced twice from one page.", false);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let page = &output.pages[0];
let separators = page
.elements
.iter()
.filter(|e| matches!(e, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001))
.count();
assert_eq!(separators, 1, "one note area, so one separator");
let (_, _, note_text) = split_at_separator(page).unwrap();
let markers = note_text.iter().filter(|t| t.as_str() == "1").count();
assert_eq!(markers, 1, "the note is drawn once, got {note_text:?}");
}
#[test]
fn a_note_taller_than_its_remaining_space_continues_on_the_next_page() {
let input = make_noted_document(30, &[25], 120, "Note paragraph line.", true);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let note_pages: Vec<usize> = output
.pages
.iter()
.enumerate()
.filter(|(_, page)| separator_y_of(page).is_some())
.map(|(index, _)| index)
.collect();
assert!(
note_pages.len() >= 2,
"a note taller than a page must span pages, got {note_pages:?}"
);
let first = split_at_separator(&output.pages[note_pages[0]]).unwrap().2;
let second = split_at_separator(&output.pages[note_pages[1]]).unwrap().2;
assert!(!first.is_empty(), "the first page draws part of the note");
assert!(!second.is_empty(), "the next page draws the rest");
assert_eq!(
first.iter().filter(|t| t.as_str() == "1").count(),
1,
"the marker is drawn on the page the note starts on"
);
assert_eq!(
second.iter().filter(|t| t.as_str() == "1").count(),
0,
"a continuation does not repeat the marker, got {second:?}"
);
}
#[test]
fn a_continued_note_draws_the_continuation_separator() {
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
let widths = |continuation: bool| {
let input = make_noted_document(30, &[25], 120, "Note paragraph line.", continuation);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let pages: Vec<usize> = output
.pages
.iter()
.enumerate()
.filter(|(_, page)| separator_y_of(page).is_some())
.map(|(index, _)| index)
.collect();
assert!(pages.len() >= 2, "the note must span pages");
(
split_at_separator(&output.pages[pages[0]]).unwrap().0,
split_at_separator(&output.pages[pages[1]]).unwrap().0,
)
};
let (first, second) = widths(true);
assert!(
(first - geometry.content_width() * 0.33).abs() < 0.5,
"a note starting on its page gets the short rule, got {first}"
);
assert!(
(second - geometry.content_width()).abs() < 0.5,
"a continued note gets the full-width rule, got {second}"
);
let (_, second) = widths(false);
assert!(
(second - geometry.content_width() * 0.33).abs() < 0.5,
"without a continuation separator the short rule is kept, got {second}"
);
}
#[test]
fn an_oversized_note_still_leaves_room_for_body_text() {
let input = make_noted_document(3, &[0], 200, "A line of an enormous note.", true);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout terminates");
let (_, body_ys, _) = split_at_separator(&output.pages[0]).unwrap();
assert!(
!body_ys.is_empty(),
"an oversized note must not starve the page of body text"
);
assert!(
output.pages.len() > 1 && output.pages.len() < 100,
"the note spills over a bounded number of pages, got {}",
output.pages.len()
);
for (index, page) in output.pages.iter().enumerate() {
let Some(separator_y) = separator_y_of(page) else {
continue;
};
assert!(
separator_y >= 0.0,
"page {} draws its separator at {separator_y}, off the sheet",
index + 1
);
}
}
#[test]
fn a_note_is_drawn_on_the_page_that_carries_its_reference() {
let mut mismatches = Vec::new();
for position in 0..60 {
let input = make_noted_document(60, &[position], 1, "Note text.", false);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let reference_page = output.pages.iter().position(|page| {
page.elements.iter().any(|element| {
matches!(element, PositionedElement::Text(run)
if run.note == Some(oxml_layout::NoteRef {
stream: oxml_layout::NoteStream::Footnote,
id: 1,
}))
})
});
let note_page = output
.pages
.iter()
.position(|page| separator_y_of(page).is_some());
if reference_page != note_page {
mismatches.push((position, reference_page, note_page));
}
}
assert!(
mismatches.is_empty(),
"note and reference landed on different pages for (position, ref, note): {mismatches:?}"
);
}
fn make_document_with_both_streams(id: i32, body_paras: usize) -> LayoutInput {
use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
use rdocx_oxml::text::CT_R;
let mut doc = rdocx_oxml::document::CT_Document::new();
for index in 0..body_paras {
let mut para = CT_P::new();
para.add_run("Body paragraph text that occupies a line of the page.");
if index == 0 {
let mut foot = CT_R::new("");
foot.content = vec![RunContent::FootnoteRef { id }];
para.runs.push(foot);
let mut end = CT_R::new("");
end.content = vec![RunContent::EndnoteRef { id }];
para.runs.push(end);
}
doc.body.add_paragraph(para);
}
let note = |text: &str| {
let mut p = CT_P::new();
p.add_run(text);
CT_Footnote {
id,
note_type: NoteType::Normal,
paragraphs: vec![p],
}
};
LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: Some(CT_Footnotes {
footnotes: vec![note("FOOTNOTETEXT")],
}),
endnotes: Some(CT_Footnotes {
footnotes: vec![note("ENDNOTETEXT")],
}),
theme: None,
fonts: Vec::new(),
}
}
fn page_text(page: &oxml_layout::output::PageFrame) -> String {
page.elements
.iter()
.filter_map(|element| match element {
PositionedElement::Text(run) => Some(run.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn a_footnote_and_an_endnote_sharing_a_number_render_their_own_text() {
let input = make_document_with_both_streams(2, 3);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let all: String = output
.pages
.iter()
.map(page_text)
.collect::<Vec<_>>()
.join(" | ");
assert!(
all.contains("FOOTNOTETEXT"),
"the footnote must render its own text, got {all}"
);
assert!(
all.contains("ENDNOTETEXT"),
"the endnote must render its own text, got {all}"
);
}
#[test]
fn endnotes_render_after_the_last_body_page() {
let input = make_document_with_both_streams(2, 3);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let endnote_page = output
.pages
.iter()
.position(|page| page_text(page).contains("ENDNOTETEXT"))
.expect("the endnote is rendered somewhere");
let last_body_page = output
.pages
.iter()
.rposition(|page| page_text(page).contains("occupies"))
.expect("the body is rendered somewhere");
assert!(
endnote_page > last_body_page,
"endnotes come after every body page, endnote on {endnote_page} and body to {last_body_page}"
);
assert!(
!page_text(&output.pages[endnote_page]).contains("occupies"),
"an endnote page carries no body text"
);
}
#[test]
fn footnotes_and_endnotes_keep_their_own_regions() {
let input = make_document_with_both_streams(2, 3);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let footnote_page = output
.pages
.iter()
.position(|page| page_text(page).contains("FOOTNOTETEXT"))
.expect("the footnote is rendered");
assert!(
page_text(&output.pages[footnote_page]).contains("occupies"),
"a footnote sits on the page carrying its reference"
);
assert!(
separator_y_of(&output.pages[footnote_page]).is_some(),
"the footnote page draws a separator"
);
let endnote_page = output
.pages
.iter()
.position(|page| page_text(page).contains("ENDNOTETEXT"))
.expect("the endnote is rendered");
assert_ne!(footnote_page, endnote_page, "the two regions are distinct");
assert!(
separator_y_of(&output.pages[endnote_page]).is_none(),
"an endnote page draws no separator rule"
);
}
#[test]
fn an_endnote_reference_does_not_reserve_space_at_the_page_foot() {
use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
use rdocx_oxml::text::CT_R;
let build = |with_endnote: bool| {
let mut doc = rdocx_oxml::document::CT_Document::new();
for index in 0..60 {
let mut para = CT_P::new();
para.add_run("Body paragraph text that occupies a line of the page.");
if index == 0 && with_endnote {
let mut end = CT_R::new("");
end.content = vec![RunContent::EndnoteRef { id: 1 }];
para.runs.push(end);
}
doc.body.add_paragraph(para);
}
let mut note = CT_P::new();
note.add_run("An endnote that would be tall in the margin.");
LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images: HashMap::new(),
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: None,
endnotes: Some(CT_Footnotes {
footnotes: vec![CT_Footnote {
id: 1,
note_type: NoteType::Normal,
paragraphs: vec![note],
}],
}),
theme: None,
fonts: Vec::new(),
}
};
let mut engine = Engine::new();
let plain = engine.layout(&build(false)).expect("layout succeeds");
let noted = engine.layout(&build(true)).expect("layout succeeds");
assert_eq!(
noted.pages.len(),
plain.pages.len() + 1,
"an endnote adds its own page and takes none from the body"
);
for (index, page) in noted.pages.iter().enumerate() {
if index < plain.pages.len() {
assert!(
separator_y_of(page).is_none(),
"page {} reserved foot space for an endnote",
index + 1
);
}
}
for (index, plain_page) in plain.pages.iter().enumerate() {
let body_lines = |page: &oxml_layout::output::PageFrame| {
page.elements
.iter()
.filter(|element| {
matches!(element, PositionedElement::Text(run)
if run.text.starts_with("occupies"))
})
.count()
};
assert_eq!(
body_lines(plain_page),
body_lines(¬ed.pages[index]),
"page {} holds a different amount of body text",
index + 1
);
}
}
fn make_wrapping_document(
wrap: rdocx_oxml::drawing::WrapType,
align: Option<rdocx_oxml::drawing::AnchorAlignH>,
width_pt: f64,
height_pt: f64,
dist_pt: f64,
) -> LayoutInput {
use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV};
use rdocx_oxml::text::CT_R;
use rdocx_oxml::units::Emu;
let emu = |pt: f64| Emu((pt * 12700.0) as i64);
let mut doc = rdocx_oxml::document::CT_Document::new();
let mut para = CT_P::new();
let mut body = String::new();
for index in 0..40 {
body.push_str(&format!(
"Sentence {index} of running text that fills the paragraph out. "
));
}
para.add_run(&body);
let mut anchor = CT_Anchor::background("rId1", 0, 0);
anchor.extent_cx = emu(width_pt);
anchor.extent_cy = emu(height_pt);
anchor.behind_doc = false;
anchor.wrap = wrap;
anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
anchor.pos_h_align = align;
anchor.pos_v_relative_from = ST_RelativeFromV::Paragraph;
anchor.pos_v_offset = Emu(0);
anchor.dist_t = emu(dist_pt);
anchor.dist_b = emu(dist_pt);
anchor.dist_l = emu(dist_pt);
anchor.dist_r = emu(dist_pt);
let mut drawing_run = CT_R::new("");
drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
inline: None,
anchor: Some(anchor),
})];
para.runs.push(drawing_run);
doc.body.add_paragraph(para);
let mut images = HashMap::new();
images.insert(
"rId1".to_string(),
ImageData {
data: vec![0u8; 8],
content_type: "image/png".to_string(),
},
);
LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images,
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: None,
endnotes: None,
theme: None,
fonts: Vec::new(),
}
}
fn text_extents(page: &oxml_layout::output::PageFrame) -> Vec<(f64, f64)> {
let mut by_line: Vec<(f64, f64, f64)> = Vec::new();
for element in &page.elements {
let PositionedElement::Text(run) = element else {
continue;
};
let right = run.origin.x + run.advances.iter().sum::<f64>();
if let Some(entry) = by_line
.iter_mut()
.find(|(y, _, _)| (*y - run.origin.y).abs() < 0.01)
{
entry.1 = entry.1.min(run.origin.x);
entry.2 = entry.2.max(right);
} else {
by_line.push((run.origin.y, run.origin.x, right));
}
}
by_line.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
by_line.into_iter().map(|(_, l, r)| (l, r)).collect()
}
#[test]
fn text_wraps_beside_a_left_aligned_square_drawing() {
use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
let input =
make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
let extents = text_extents(&output.pages[0]);
assert!(
extents.len() > 2,
"the paragraph must wrap, got {extents:?}"
);
let expected_left = geometry.margin_left + 100.0 + 5.0;
assert!(
(extents[0].0 - expected_left).abs() < 1.0,
"first line should start at {expected_left}, got {:?}",
extents[0]
);
let last = extents.last().unwrap();
assert!(
(last.0 - geometry.margin_left).abs() < 1.0,
"the last line should return to the margin, got {last:?}"
);
}
#[test]
fn text_wraps_beside_a_right_aligned_square_drawing() {
use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
let input = make_wrapping_document(
WrapType::Square,
Some(AnchorAlignH::Right),
100.0,
40.0,
5.0,
);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
let extents = text_extents(&output.pages[0]);
assert!(
extents.len() > 2,
"the paragraph must wrap, got {extents:?}"
);
let text_right = geometry.page_width - geometry.margin_right;
let drawing_left = text_right - 100.0;
assert!(
(extents[0].0 - geometry.margin_left).abs() < 1.0,
"a right-aligned drawing does not move the line start, got {:?}",
extents[0]
);
assert!(
extents[0].1 <= drawing_left - 5.0 + 1.0,
"the first line should stop before the drawing at {}, got {:?}",
drawing_left - 5.0,
extents[0]
);
let widest = extents
.iter()
.map(|(_, right)| *right)
.fold(f64::MIN, f64::max);
assert!(
widest > drawing_left,
"a line below the drawing should reach past {drawing_left}, got {extents:?}"
);
}
#[test]
fn a_top_and_bottom_drawing_pushes_text_below_it() {
use rdocx_oxml::drawing::WrapType;
let input = make_wrapping_document(WrapType::TopAndBottom, None, 100.0, 40.0, 5.0);
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
let extents = text_extents(&output.pages[0]);
assert!(!extents.is_empty(), "the paragraph renders");
let first_baseline = output.pages[0]
.elements
.iter()
.find_map(|element| match element {
PositionedElement::Text(run) => Some(run.origin.y),
_ => None,
})
.expect("text is rendered");
let drawing_bottom = geometry.margin_top + 40.0 + 5.0;
assert!(
first_baseline >= drawing_bottom,
"the first line at {first_baseline} should sit below {drawing_bottom}"
);
}
#[test]
fn a_wrap_none_drawing_leaves_text_untouched() {
use rdocx_oxml::drawing::WrapType;
let with = make_wrapping_document(WrapType::None, None, 100.0, 40.0, 5.0);
let mut engine = Engine::new();
let output = engine.layout(&with).expect("layout succeeds");
let wrapped_extents = text_extents(&output.pages[0]);
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
for (left, _) in &wrapped_extents {
assert!(
(left - geometry.margin_left).abs() < 0.01,
"a wrapNone drawing must not indent any line, got {wrapped_extents:?}"
);
}
}
#[test]
fn a_drawing_anchored_to_a_later_paragraph_still_pushes_text_aside() {
use rdocx_oxml::drawing::{
AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
WrapType,
};
use rdocx_oxml::text::CT_R;
use rdocx_oxml::units::Emu;
let emu = |pt: f64| Emu((pt * 12700.0) as i64);
let mut doc = rdocx_oxml::document::CT_Document::new();
let mut first = CT_P::new();
let mut body = String::new();
for index in 0..40 {
body.push_str(&format!("Sentence {index} of running text to fill lines. "));
}
first.add_run(&body);
doc.body.add_paragraph(first);
let mut second = CT_P::new();
second.add_run("A later paragraph that owns the drawing.");
let mut anchor = CT_Anchor::background("rId1", 0, 0);
anchor.extent_cx = emu(100.0);
anchor.extent_cy = emu(40.0);
anchor.behind_doc = false;
anchor.wrap = WrapType::Square;
anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
anchor.pos_h_align = Some(AnchorAlignH::Left);
anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
anchor.pos_v_align = Some(AnchorAlignV::Top);
let mut drawing_run = CT_R::new("");
drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
inline: None,
anchor: Some(anchor),
})];
second.runs.push(drawing_run);
doc.body.add_paragraph(second);
let mut images = HashMap::new();
images.insert(
"rId1".to_string(),
ImageData {
data: vec![0u8; 8],
content_type: "image/png".to_string(),
},
);
let input = LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images,
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: None,
endnotes: None,
theme: None,
fonts: Vec::new(),
};
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
let extents = text_extents(&output.pages[0]);
assert!(!extents.is_empty(), "text renders");
let expected_left = geometry.margin_left + 100.0;
assert!(
extents[0].0 >= expected_left - 1.0,
"the first line of the earlier paragraph should clear the drawing at \
{expected_left}, got {:?}",
extents[0]
);
}
#[test]
fn a_split_paragraph_clearing_a_drawing_stays_inside_the_page() {
use rdocx_oxml::drawing::{
AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
WrapType,
};
use rdocx_oxml::text::CT_R;
use rdocx_oxml::units::Emu;
let emu = |pt: f64| Emu((pt * 12700.0) as i64);
let mut doc = rdocx_oxml::document::CT_Document::new();
let mut para = CT_P::new();
let mut body = String::new();
for index in 0..300 {
body.push_str(&format!("Sentence {index} of a very long paragraph. "));
}
para.add_run(&body);
let mut anchor = CT_Anchor::background("rId1", 0, 0);
anchor.extent_cx = emu(200.0);
anchor.extent_cy = emu(120.0);
anchor.behind_doc = false;
anchor.wrap = WrapType::TopAndBottom;
anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
anchor.pos_h_align = Some(AnchorAlignH::Center);
anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
anchor.pos_v_align = Some(AnchorAlignV::Top);
anchor.dist_b = emu(10.0);
let mut drawing_run = CT_R::new("");
drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
inline: None,
anchor: Some(anchor),
})];
para.runs.push(drawing_run);
doc.body.add_paragraph(para);
let mut images = HashMap::new();
images.insert(
"rId1".to_string(),
ImageData {
data: vec![0u8; 8],
content_type: "image/png".to_string(),
},
);
let input = LayoutInput {
document: doc,
styles: CT_Styles::new_default(),
numbering: None,
headers: HashMap::new(),
footers: HashMap::new(),
images,
core_properties: None,
hyperlink_urls: HashMap::new(),
footnotes: None,
endnotes: None,
theme: None,
fonts: Vec::new(),
};
let mut engine = Engine::new();
let output = engine.layout(&input).expect("layout succeeds");
let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
let bottom = geometry.page_height - geometry.margin_bottom;
assert!(output.pages.len() > 1, "the paragraph must split");
for (index, page) in output.pages.iter().enumerate() {
for element in &page.elements {
let PositionedElement::Text(run) = element else {
continue;
};
assert!(
run.origin.y <= bottom + 0.5,
"page {} draws text at {}, past the bottom margin at {bottom}",
index + 1,
run.origin.y
);
}
}
}
}