use crate::ExportDocxDto;
use crate::ExportDocxResultDto;
use anyhow::{Result, anyhow};
use common::database::QueryUnitOfWork;
use common::database::rope_helpers::block_content_via_store;
use common::entities::{
Alignment, Block, Document, Frame, List, ListStyle, MarkerType, Root, SemanticRole, Table,
TableCell,
};
use common::format_runs::{InlineContent, InlineSegment};
use common::long_operation::LongOperation;
use common::parser_tools::ExportImages;
use common::types::{EntityId, ROOT_ENTITY_ID};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
pub trait ExportDocxUnitOfWorkFactoryTrait: Send + Sync {
fn create(&self) -> Box<dyn ExportDocxUnitOfWorkTrait>;
}
#[macros::uow_action(entity = "Root", action = "GetRO", thread_safe = true)]
#[macros::uow_action(entity = "Root", action = "GetRelationshipRO", thread_safe = true)]
#[macros::uow_action(entity = "Document", action = "GetRO", thread_safe = true)]
#[macros::uow_action(entity = "Document", action = "GetRelationshipRO", thread_safe = true)]
#[macros::uow_action(entity = "Frame", action = "GetRO", thread_safe = true)]
#[macros::uow_action(entity = "Frame", action = "GetRelationshipRO", thread_safe = true)]
#[macros::uow_action(entity = "Block", action = "GetRO", thread_safe = true)]
#[macros::uow_action(entity = "Block", action = "GetMultiRO", thread_safe = true)]
#[macros::uow_action(entity = "Block", action = "GetRelationshipRO", thread_safe = true)]
#[macros::uow_action(entity = "List", action = "GetRO", thread_safe = true)]
#[macros::uow_action(entity = "Table", action = "GetRO", thread_safe = true)]
#[macros::uow_action(entity = "Table", action = "GetRelationshipRO", thread_safe = true)]
#[macros::uow_action(entity = "TableCell", action = "GetMultiRO", thread_safe = true)]
pub trait ExportDocxUnitOfWorkTrait: QueryUnitOfWork + Send + Sync {}
type NoteParagraphs = std::collections::HashMap<String, Vec<docx_rs::Paragraph>>;
struct FootnoteRefState<'a> {
numbers: &'a crate::footnotes::Footnotes,
emitted: std::cell::RefCell<std::collections::HashSet<String>>,
}
impl<'a> FootnoteRefState<'a> {
fn new(numbers: &'a crate::footnotes::Footnotes) -> Self {
FootnoteRefState {
numbers,
emitted: std::cell::RefCell::new(std::collections::HashSet::new()),
}
}
}
pub struct ExportDocxUseCase {
uow_factory: Box<dyn ExportDocxUnitOfWorkFactoryTrait>,
dto: ExportDocxDto,
}
impl ExportDocxUseCase {
pub fn new(
uow_factory: Box<dyn ExportDocxUnitOfWorkFactoryTrait>,
dto: &ExportDocxDto,
) -> Self {
ExportDocxUseCase {
uow_factory,
dto: dto.clone(),
}
}
}
impl LongOperation for ExportDocxUseCase {
type Output = ExportDocxResultDto;
fn execute(
&self,
progress_callback: Box<dyn Fn(common::long_operation::OperationProgress) + Send>,
cancel_flag: Arc<AtomicBool>,
) -> Result<Self::Output> {
let output_path = std::path::Path::new(&self.dto.output_path);
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
return Err(anyhow!(
"Output directory does not exist: '{}'",
parent.display()
));
}
progress_callback(common::long_operation::OperationProgress::new(
0.0,
Some("Starting DOCX export...".to_string()),
));
let uow = self.uow_factory.create();
uow.begin_transaction()?;
let build_result = self.build_docx(
&*uow,
progress_callback.as_ref(),
Some(cancel_flag.as_ref()),
);
uow.end_transaction()?;
let (docx, paragraph_count) = build_result?;
progress_callback(common::long_operation::OperationProgress::new(
90.0,
Some("Writing DOCX file...".to_string()),
));
let file = std::fs::File::create(&self.dto.output_path).map_err(|e| {
anyhow!(
"Failed to create output file '{}': {}",
self.dto.output_path,
e
)
})?;
docx.build()
.pack(file)
.map_err(|e| anyhow!("Failed to write DOCX: {}", e))?;
progress_callback(common::long_operation::OperationProgress::new(
100.0,
Some("completed".to_string()),
));
Ok(ExportDocxResultDto {
file_path: self.dto.output_path.clone(),
paragraph_count,
})
}
}
const INDENT_STEP_TWIPS: i32 = 720;
const EPIGRAPH_STYLE_ID: &str = "Epigraph";
const EPIGRAPH_ATTRIBUTION_STYLE_ID: &str = "EpigraphAttribution";
const HANGING_TWIPS: i32 = 360;
const TWIPS_PER_PX: i64 = 15;
fn px_to_twips(px: i64) -> i32 {
px.saturating_mul(TWIPS_PER_PX).clamp(0, i32::MAX as i64) as i32
}
const CODE_BLOCK_FILL: &str = "F5F5F5";
enum DocxElement {
Paragraph(Box<docx_rs::Paragraph>),
Table(Box<docx_rs::Table>),
}
#[derive(Default)]
struct NumberingBuilder {
map: HashMap<EntityId, usize>,
defs: Vec<(docx_rs::AbstractNumbering, docx_rs::Numbering)>,
}
impl NumberingBuilder {
fn get_or_create(&mut self, list_id: EntityId, list: &List) -> usize {
if let Some(&id) = self.map.get(&list_id) {
return id;
}
let id = self.map.len() + 1;
let abstract_num = build_abstract_numbering(id, list);
let numbering = docx_rs::Numbering::new(id, id);
self.defs.push((abstract_num, numbering));
self.map.insert(list_id, id);
id
}
}
impl ExportDocxUseCase {
pub(crate) fn build_docx(
&self,
uow: &dyn ExportDocxUnitOfWorkTrait,
progress_callback: &dyn Fn(common::long_operation::OperationProgress),
cancel_flag: Option<&AtomicBool>,
) -> Result<(docx_rs::Docx, i64)> {
use docx_rs::*;
let root = uow
.get_root(&ROOT_ENTITY_ID)?
.ok_or_else(|| anyhow!("Root entity not found"))?;
let doc_ids = uow.get_root_relationship(
&root.id,
&common::direct_access::root::RootRelationshipField::Document,
)?;
let doc_id = *doc_ids
.first()
.ok_or_else(|| anyhow!("Root has no associated Document"))?;
let frame_ids = uow.get_document_relationship(
&doc_id,
&common::direct_access::document::DocumentRelationshipField::Frames,
)?;
let table_ids = uow.get_document_relationship(
&doc_id,
&common::direct_access::document::DocumentRelationshipField::Tables,
)?;
let mut cell_frame_ids: HashSet<EntityId> = HashSet::new();
for tid in &table_ids {
let cell_ids = uow.get_table_relationship(
tid,
&common::direct_access::table::TableRelationshipField::Cells,
)?;
let cells_opt = uow.get_table_cell_multi(&cell_ids)?;
for cell in cells_opt.into_iter().flatten() {
if let Some(cf_id) = cell.cell_frame {
cell_frame_ids.insert(cf_id);
}
}
}
progress_callback(common::long_operation::OperationProgress::new(
10.0,
Some("Walking document tree...".to_string()),
));
let notes = crate::footnotes::Footnotes::build(&uow.store());
let note_paragraphs: NoteParagraphs = {
let mut built: NoteParagraphs = std::collections::HashMap::new();
let mut note_numbering = NumberingBuilder::default();
for (_, label, frame_id) in notes.in_print_order() {
let block_ids = uow.get_frame_relationship(
&frame_id,
&common::direct_access::frame::FrameRelationshipField::Blocks,
)?;
let blocks_opt = uow.get_block_multi(&block_ids)?;
let mut blocks: Vec<Block> = blocks_opt.into_iter().flatten().collect();
blocks.sort_by_key(|b| b.document_position);
let mut paragraphs = Vec::with_capacity(blocks.len());
let body_footnote_state = FootnoteRefState::new(¬es);
for block in &blocks {
paragraphs.push(self.render_block(
uow,
block,
0,
None,
&mut note_numbering,
&std::collections::HashMap::new(),
&body_footnote_state,
)?);
}
built.insert(label, paragraphs);
}
built
};
let mut numbering = NumberingBuilder::default();
let mut elements: Vec<DocxElement> = Vec::new();
let footnote_state = FootnoteRefState::new(¬es);
let total_frames = frame_ids.len().max(1);
for (frame_idx, frame_id) in frame_ids.iter().enumerate() {
check_cancelled(cancel_flag)?;
if cell_frame_ids.contains(frame_id) {
continue;
}
let frame = uow.get_frame(frame_id)?;
let Some(frame) = frame else {
continue;
};
if notes.is_definition(frame.id) {
continue;
}
if frame.parent_frame.is_some() {
continue;
}
if let Some(table_id) = frame.table {
let table = self.render_table_docx(
uow,
&table_id,
&mut numbering,
¬e_paragraphs,
&footnote_state,
)?;
elements.push(DocxElement::Table(Box::new(table)));
continue;
}
self.render_frame_content(
uow,
&frame,
&cell_frame_ids,
0,
None,
&mut numbering,
¬e_paragraphs,
cancel_flag,
&mut elements,
&footnote_state,
)?;
let pct = 10.0 + (frame_idx as f32 / total_frames as f32) * 70.0;
progress_callback(common::long_operation::OperationProgress::new(
pct,
Some(format!(
"Processing frame {}/{}",
frame_idx + 1,
total_frames
)),
));
}
progress_callback(common::long_operation::OperationProgress::new(
85.0,
Some("Assembling document...".to_string()),
));
let paragraph_count = elements.len() as i64;
let mut docx = Docx::new();
docx = docx
.add_style(
Style::new(EPIGRAPH_STYLE_ID, StyleType::Paragraph)
.name("Epigraph")
.italic()
.indent(Some(INDENT_STEP_TWIPS), None, None, None),
)
.add_style(
Style::new(EPIGRAPH_ATTRIBUTION_STYLE_ID, StyleType::Paragraph)
.name("Epigraph Attribution")
.indent(Some(INDENT_STEP_TWIPS), None, None, None)
.align(AlignmentType::Right),
);
for (i, h) in self
.dto
.options
.resolved_heading_styles()
.iter()
.enumerate()
{
docx = docx.add_style(heading_style(i + 1, h));
}
for (abstract_num, num) in numbering.defs {
docx = docx.add_abstract_numbering(abstract_num).add_numbering(num);
}
for element in elements {
docx = match element {
DocxElement::Paragraph(p) => docx.add_paragraph(*p),
DocxElement::Table(t) => docx.add_table(*t),
};
}
docx = self.apply_document_options(docx);
Ok((docx, paragraph_count))
}
fn apply_document_options(&self, mut docx: docx_rs::Docx) -> docx_rs::Docx {
use docx_rs::*;
let o = &self.dto.options;
if let (Some(w), Some(h)) = (o.page_width_twips, o.page_height_twips) {
docx = docx.page_size(w, h);
}
if o.margin_top_twips.is_some()
|| o.margin_bottom_twips.is_some()
|| o.margin_left_twips.is_some()
|| o.margin_right_twips.is_some()
{
let mut m = PageMargin::new();
m = m.top(o.margin_top_twips.unwrap_or(1440));
m = m.bottom(o.margin_bottom_twips.unwrap_or(1440));
m = m.left(o.margin_left_twips.unwrap_or(1440));
m = m.right(o.margin_right_twips.unwrap_or(1440));
docx = docx.page_margin(m);
}
if let Some(family) = &o.font_family {
docx = docx.default_fonts(
RunFonts::new()
.ascii(family)
.hi_ansi(family)
.cs(family)
.east_asia(family),
);
}
if let Some(half_pt) = o.font_half_points {
docx = docx.default_size(half_pt);
}
if o.page_numbers {
let mut header_para = Paragraph::new().align(AlignmentType::Right);
if let Some(text) = &o.running_header
&& !text.trim().is_empty()
{
header_para =
header_para.add_run(Run::new().add_text(format!("{} ", text.trim())));
}
header_para = header_para.add_page_num(PageNum::new());
docx = docx.header(Header::new().add_paragraph(header_para));
}
docx
}
pub(crate) fn build_document(&self) -> Result<(docx_rs::Docx, i64)> {
let uow = self.uow_factory.create();
uow.begin_transaction()?;
let result = self.build_docx(&*uow, &|_progress| {}, None);
uow.end_transaction()?;
result
}
#[allow(clippy::too_many_arguments)]
fn render_frame_content(
&self,
uow: &dyn ExportDocxUnitOfWorkTrait,
frame: &Frame,
cell_frame_ids: &HashSet<EntityId>,
quote_depth: usize,
semantic: Option<&SemanticRole>,
numbering: &mut NumberingBuilder,
notes: &NoteParagraphs,
cancel_flag: Option<&AtomicBool>,
out: &mut Vec<DocxElement>,
footnote_state: &FootnoteRefState,
) -> Result<()> {
if !frame.child_order.is_empty() {
for &entry in &frame.child_order {
check_cancelled(cancel_flag)?;
if entry == 0 {
continue;
}
if entry > 0 {
let block_id = entry as EntityId;
if let Some(block) = uow.get_block(&block_id)? {
let paragraph = self.render_block(
uow,
&block,
quote_depth,
semantic,
numbering,
notes,
footnote_state,
)?;
out.push(DocxElement::Paragraph(Box::new(paragraph)));
}
} else {
let sub_frame_id = (-entry) as EntityId;
if cell_frame_ids.contains(&sub_frame_id) {
continue;
}
if let Some(sub_frame) = uow.get_frame(&sub_frame_id)? {
if let Some(table_id) = sub_frame.table {
let table = self.render_table_docx(
uow,
&table_id,
numbering,
notes,
footnote_state,
)?;
out.push(DocxElement::Table(Box::new(table)));
continue;
}
let sub_depth = if sub_frame.fmt_is_blockquote == Some(true) {
quote_depth + 1
} else {
quote_depth
};
let sub_semantic = if sub_frame.fmt_is_blockquote == Some(true) {
sub_frame.fmt_semantic_role.as_ref()
} else {
semantic
};
self.render_frame_content(
uow,
&sub_frame,
cell_frame_ids,
sub_depth,
sub_semantic,
numbering,
notes,
cancel_flag,
out,
footnote_state,
)?;
}
}
}
} else {
let block_ids = uow.get_frame_relationship(
&frame.id,
&common::direct_access::frame::FrameRelationshipField::Blocks,
)?;
if block_ids.is_empty() {
return Ok(());
}
let blocks_opt = uow.get_block_multi(&block_ids)?;
let mut blocks: Vec<Block> = blocks_opt.into_iter().flatten().collect();
blocks.sort_by_key(|b| b.document_position);
for block in &blocks {
check_cancelled(cancel_flag)?;
let paragraph = self.render_block(
uow,
block,
quote_depth,
semantic,
numbering,
notes,
footnote_state,
)?;
out.push(DocxElement::Paragraph(Box::new(paragraph)));
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn render_block(
&self,
uow: &dyn ExportDocxUnitOfWorkTrait,
block: &Block,
quote_depth: usize,
semantic: Option<&SemanticRole>,
numbering: &mut NumberingBuilder,
notes: &NoteParagraphs,
footnote_state: &FootnoteRefState,
) -> Result<docx_rs::Paragraph> {
use docx_rs::*;
let block_text = block_content_via_store(block, &uow.store());
let elements = common::format_runs_query::inline_segments_for_block(
&uow.store(),
block.id,
&block_text,
);
let quote_indent = quote_depth as i32 * INDENT_STEP_TWIPS;
if block.fmt_is_code_block == Some(true) {
return Ok(render_code_block(&elements, quote_indent));
}
let list_ids = uow.get_block_relationship(
&block.id,
&common::direct_access::block::BlockRelationshipField::List,
)?;
let list = match list_ids.first() {
Some(list_id) => uow.get_list(list_id)?.map(|l| (*list_id, l)),
None => None,
};
let mut paragraph = Paragraph::new();
if let Some(lh) = block.fmt_line_height {
let twips = (lh as f64 / 1000.0 * 240.0) as i32;
paragraph = paragraph.line_spacing(
LineSpacing::new()
.line_rule(LineSpacingType::Auto)
.line(twips),
);
}
if block.fmt_non_breakable_lines == Some(true) {
paragraph = paragraph.keep_lines(true);
}
if block.fmt_page_break_before == Some(true) {
paragraph = paragraph.page_break_before(true);
}
if let Some(alignment) = &block.fmt_alignment {
paragraph = paragraph.align(map_alignment(alignment));
}
if block.fmt_direction == Some(common::entities::TextDirection::RightToLeft) {
paragraph.property = paragraph.property.bidi(true);
}
let is_task = matches!(
block.fmt_marker,
Some(MarkerType::Checked) | Some(MarkerType::Unchecked)
);
if let Some(level) = block.fmt_heading_level {
let style_name = format!("Heading{}", level.clamp(1, 6));
paragraph = paragraph.style(&style_name);
if quote_indent > 0 {
paragraph = paragraph.indent(Some(quote_indent), None, None, None);
}
if let Some(before) = block.fmt_top_margin.filter(|&t| t > 0) {
let mut ls = LineSpacing::new().before(px_to_twips(before) as u32);
if let Some(lh) = block.fmt_line_height {
ls = ls
.line_rule(LineSpacingType::Auto)
.line((lh as f64 / 1000.0 * 240.0) as i32);
}
paragraph = paragraph.line_spacing(ls);
}
} else if let Some((list_id, list_entity)) = &list {
let level = list_entity.indent.clamp(0, 8) as usize;
if is_task {
let left = quote_indent + INDENT_STEP_TWIPS * (level as i32 + 1);
paragraph = paragraph.indent(
Some(left),
Some(SpecialIndentType::Hanging(HANGING_TWIPS)),
None,
None,
);
let glyph = if block.fmt_marker == Some(MarkerType::Checked) {
"\u{2612} " } else {
"\u{2610} " };
paragraph = paragraph.add_run(Run::new().add_text(glyph));
} else {
let num_id = numbering.get_or_create(*list_id, list_entity);
paragraph = paragraph.numbering(NumberingId::new(num_id), IndentLevel::new(level));
if quote_indent > 0 {
let left = quote_indent + INDENT_STEP_TWIPS * (level as i32 + 1);
paragraph = paragraph.indent(
Some(left),
Some(SpecialIndentType::Hanging(HANGING_TWIPS)),
None,
None,
);
}
}
} else {
paragraph = self.apply_body_style(paragraph, block, quote_indent);
if let Some(SemanticRole::Epigraph) = semantic {
paragraph = paragraph.style(if block.fmt_alignment == Some(Alignment::Right) {
EPIGRAPH_ATTRIBUTION_STYLE_ID
} else {
EPIGRAPH_STYLE_ID
});
}
}
Ok(add_inline_content(
paragraph,
&elements,
&self.dto.options.images,
notes,
footnote_state,
))
}
fn apply_body_style(
&self,
mut p: docx_rs::Paragraph,
block: &Block,
quote_indent: i32,
) -> docx_rs::Paragraph {
use docx_rs::*;
let o = &self.dto.options;
let rtl = block.fmt_direction == Some(common::entities::TextDirection::RightToLeft);
let mut ls = LineSpacing::new();
let mut ls_used = false;
if block.fmt_line_height.is_none()
&& let Some(line) = o.line_spacing_twips
{
ls = ls.line_rule(LineSpacingType::Auto).line(line);
ls_used = true;
}
if let Some(after) = o.paragraph_spacing_after_twips.filter(|&a| a > 0) {
ls = ls.after(after as u32);
ls_used = true;
}
if let Some(before) = block.fmt_top_margin.filter(|&t| t > 0) {
ls = ls.before(px_to_twips(before) as u32);
ls_used = true;
}
if ls_used {
p = p.line_spacing(ls);
}
let first_line = match block.fmt_text_indent {
Some(ti) => (ti > 0).then(|| px_to_twips(ti)),
None => o.first_line_indent_twips.filter(|&f| f > 0),
};
let left = (quote_indent > 0).then_some(quote_indent);
if left.is_some() || first_line.is_some() {
p = p.indent(
left,
first_line.map(SpecialIndentType::FirstLine),
None,
None,
);
}
if block.fmt_alignment.is_none() {
let align = if o.justify {
Some(AlignmentType::Justified)
} else if rtl {
Some(AlignmentType::Right)
} else {
None
};
if let Some(a) = align {
p = p.align(a);
}
}
p
}
fn render_table_docx(
&self,
uow: &dyn ExportDocxUnitOfWorkTrait,
table_id: &EntityId,
numbering: &mut NumberingBuilder,
notes: &NoteParagraphs,
footnote_state: &FootnoteRefState,
) -> Result<docx_rs::Table> {
use docx_rs::*;
let table = uow
.get_table(table_id)?
.ok_or_else(|| anyhow!("Table not found"))?;
let cell_ids = uow.get_table_relationship(
table_id,
&common::direct_access::table::TableRelationshipField::Cells,
)?;
let cells_opt = uow.get_table_cell_multi(&cell_ids)?;
let mut cells: Vec<common::entities::TableCell> = cells_opt.into_iter().flatten().collect();
cells.sort_by(|a, b| a.row.cmp(&b.row).then(a.column.cmp(&b.column)));
let rows = table.rows as usize;
let cols = table.columns as usize;
let mut covered = vec![vec![false; cols]; rows];
let grid: Vec<usize> = table.column_widths.iter().map(|w| *w as usize).collect();
let mut docx_rows: Vec<TableRow> = Vec::new();
for r in 0..rows {
let mut docx_cells: Vec<docx_rs::TableCell> = Vec::new();
for c in 0..cols {
if covered[r][c] {
let needs_vmerge_continue = r > 0 && {
cells.iter().any(|cell| {
cell.column == c as i64
&& cell.row < r as i64
&& (cell.row + cell.row_span) > r as i64
})
};
if needs_vmerge_continue {
let cont_cell =
docx_rs::TableCell::new().vertical_merge(VMergeType::Continue);
docx_cells.push(cont_cell);
}
continue;
}
let cell = cells
.iter()
.find(|cell| cell.row == r as i64 && cell.column == c as i64);
if let Some(cell) = cell {
let mut docx_cell = docx_rs::TableCell::new();
let row_span = cell.row_span.max(1) as usize;
let col_span = cell.column_span.max(1) as usize;
if col_span > 1 {
docx_cell = docx_cell.grid_span(col_span);
}
if row_span > 1 {
docx_cell = docx_cell.vertical_merge(VMergeType::Restart);
}
if let Some(cf_id) = cell.cell_frame
&& let Some(cell_frame) = uow.get_frame(&cf_id)?
{
let mut cell_elements: Vec<DocxElement> = Vec::new();
self.render_frame_content(
uow,
&cell_frame,
&HashSet::new(),
0,
None,
numbering,
notes,
None,
&mut cell_elements,
footnote_state,
)?;
for element in cell_elements {
docx_cell = match element {
DocxElement::Paragraph(p) => docx_cell.add_paragraph(*p),
DocxElement::Table(t) => docx_cell.add_table(*t),
};
}
}
docx_cells.push(docx_cell);
for sr in 0..row_span {
for sc in 0..col_span {
if sr == 0 && sc == 0 {
continue;
}
if r + sr < rows && c + sc < cols {
covered[r + sr][c + sc] = true;
}
}
}
} else {
let docx_cell = docx_rs::TableCell::new().add_paragraph(Paragraph::new());
docx_cells.push(docx_cell);
}
}
docx_rows.push(TableRow::new(docx_cells));
}
let mut docx_table = docx_rs::Table::new(docx_rows);
if !grid.is_empty() {
docx_table = docx_table.set_grid(grid);
}
Ok(docx_table)
}
}
fn check_cancelled(cancel_flag: Option<&AtomicBool>) -> Result<()> {
if let Some(flag) = cancel_flag
&& flag.load(Ordering::Relaxed)
{
return Err(anyhow!("Operation was cancelled"));
}
Ok(())
}
fn map_alignment(alignment: &Alignment) -> docx_rs::AlignmentType {
use docx_rs::AlignmentType;
match alignment {
Alignment::Left => AlignmentType::Left,
Alignment::Right => AlignmentType::Right,
Alignment::Center => AlignmentType::Center,
Alignment::Justify => AlignmentType::Justified,
}
}
fn heading_style(level: usize, h: &common::parser_tools::DocxHeadingStyle) -> docx_rs::Style {
use docx_rs::*;
let mut style = Style::new(format!("Heading{level}"), StyleType::Paragraph)
.name(format!("heading {level}"))
.outline_lvl(level.clamp(1, 9) - 1);
if let Some(size) = h.size_half_points {
style = style.size(size);
}
if h.bold {
style = style.bold();
}
if h.italic {
style = style.italic();
}
if let Some(a) = &h.alignment {
style = style.align(map_alignment(a));
}
if h.space_before_twips.is_some() || h.space_after_twips.is_some() {
let mut ls = LineSpacing::new();
if let Some(before) = h.space_before_twips {
ls = ls.before(before.max(0) as u32);
}
if let Some(after) = h.space_after_twips {
ls = ls.after(after.max(0) as u32);
}
style = style.line_spacing(ls);
}
if h.keep_with_next {
style.paragraph_property = style.paragraph_property.keep_next(true);
}
if h.page_break_before {
style.paragraph_property = style.paragraph_property.page_break_before(true);
}
style
}
fn render_code_block(elements: &[InlineSegment], quote_indent: i32) -> docx_rs::Paragraph {
use docx_rs::*;
let mut raw = String::new();
for elem in elements {
if let InlineContent::Text(t) = &elem.content {
raw.push_str(t);
}
}
let mut paragraph = Paragraph::new().keep_lines(true);
if quote_indent > 0 {
paragraph = paragraph.indent(Some(quote_indent), None, None, None);
}
for (idx, line) in raw.split('\n').enumerate() {
let mut run = Run::new()
.fonts(RunFonts::new().ascii("Courier New").hi_ansi("Courier New"))
.shading(
Shading::new()
.shd_type(ShdType::Clear)
.fill(CODE_BLOCK_FILL),
);
if idx > 0 {
run = run.add_break(BreakType::TextWrapping);
}
if !line.is_empty() {
run = run.add_text(line);
}
paragraph = paragraph.add_run(run);
}
paragraph
}
fn build_image_run(
name: &str,
alt: &str,
width: i64,
height: i64,
images: &ExportImages,
) -> Option<docx_rs::Run> {
use docx_rs::*;
use image::GenericImageView;
let bytes = &images.get(name)?.bytes;
let decoded = image::load_from_memory(bytes).ok()?;
let (natural_w, natural_h) = decoded.dimensions();
let mut png = std::io::Cursor::new(Vec::new());
decoded.write_to(&mut png, image::ImageFormat::Png).ok()?;
const EMU_PER_PX: u32 = 9525;
let display_w = if width > 0 { width as u32 } else { natural_w };
let display_h = if height > 0 { height as u32 } else { natural_h };
let pic = Pic::new_with_dimensions(png.into_inner(), natural_w, natural_h)
.size(display_w * EMU_PER_PX, display_h * EMU_PER_PX);
let _ = alt;
Some(Run::new().add_image(pic))
}
fn build_run(
elem: &InlineSegment,
images: &ExportImages,
notes: &std::collections::HashMap<String, Vec<docx_rs::Paragraph>>,
footnote_state: &FootnoteRefState,
) -> Option<docx_rs::Run> {
use docx_rs::*;
if let InlineContent::FootnoteRef { label } = &elem.content {
if footnote_state.emitted.borrow_mut().insert(label.clone()) {
let mut footnote = Footnote::new();
for paragraph in notes.get(label).cloned().unwrap_or_default() {
footnote = footnote.add_content(paragraph);
}
return Some(Run::new().add_footnote_reference(footnote));
}
let marker = footnote_state.numbers.marker(label);
let mut run = Run::new().add_text(marker);
run.run_property = run.run_property.style("FootnoteReference");
return Some(run);
}
let text = match &elem.content {
InlineContent::FootnoteRef { .. } => return None,
InlineContent::Text(t) => t.clone(),
InlineContent::Image {
name,
alt,
width,
height,
..
} => {
if let Some(run) = build_image_run(name, alt, *width, *height, images) {
return Some(run);
}
if alt.is_empty() {
return None;
}
alt.clone()
}
InlineContent::Empty => return None,
};
if text.is_empty() {
return None;
}
let mut run = Run::new().add_text(text);
if elem.fmt_font_bold == Some(true) {
run = run.bold();
}
if elem.fmt_font_italic == Some(true) {
run = run.italic();
}
if elem.fmt_font_underline == Some(true) {
run = run.underline("single");
}
if elem.fmt_font_strikeout == Some(true) {
run = run.strike();
}
if elem.fmt_font_family.as_deref() == Some("monospace") {
run = run.fonts(RunFonts::new().ascii("Courier New").hi_ansi("Courier New"));
}
Some(run)
}
fn add_inline_content(
mut paragraph: docx_rs::Paragraph,
elements: &[InlineSegment],
images: &ExportImages,
notes: &std::collections::HashMap<String, Vec<docx_rs::Paragraph>>,
footnote_state: &FootnoteRefState,
) -> docx_rs::Paragraph {
use docx_rs::*;
enum Piece {
Run(Box<Run>),
Link(String, Vec<Run>),
}
let mut pieces: Vec<Piece> = Vec::new();
for elem in elements {
let Some(run) = build_run(elem, images, notes, footnote_state) else {
continue;
};
match &elem.fmt_anchor_href {
Some(href) if !href.is_empty() => {
if let Some(Piece::Link(open_href, runs)) = pieces.last_mut()
&& open_href == href
{
runs.push(run);
continue;
}
pieces.push(Piece::Link(href.clone(), vec![run]));
}
_ => pieces.push(Piece::Run(Box::new(run))),
}
}
for piece in pieces {
paragraph = match piece {
Piece::Run(run) => paragraph.add_run(*run),
Piece::Link(href, runs) => {
let mut link = Hyperlink::new(href, HyperlinkType::External);
for run in runs {
link = link.add_run(run);
}
paragraph.add_hyperlink(link)
}
};
}
paragraph
}
fn build_abstract_numbering(id: usize, list: &List) -> docx_rs::AbstractNumbering {
let mut abstract_num = docx_rs::AbstractNumbering::new(id);
for level in 0..=8usize {
abstract_num = abstract_num.add_level(build_level(level, list));
}
abstract_num
}
fn build_level(level: usize, list: &List) -> docx_rs::Level {
use docx_rs::*;
let (format, text) = match list.style {
ListStyle::Decimal => ("decimal", ordered_level_text(level, list)),
ListStyle::LowerAlpha => ("lowerLetter", ordered_level_text(level, list)),
ListStyle::UpperAlpha => ("upperLetter", ordered_level_text(level, list)),
ListStyle::LowerRoman => ("lowerRoman", ordered_level_text(level, list)),
ListStyle::UpperRoman => ("upperRoman", ordered_level_text(level, list)),
ListStyle::Disc => ("bullet", "\u{2022}".to_string()), ListStyle::Circle => ("bullet", "\u{25CB}".to_string()), ListStyle::Square => ("bullet", "\u{25AA}".to_string()), };
let left = INDENT_STEP_TWIPS * (level as i32 + 1);
Level::new(
level,
Start::new(1),
NumberFormat::new(format),
LevelText::new(text),
LevelJc::new("left"),
)
.indent(
Some(left),
Some(SpecialIndentType::Hanging(HANGING_TWIPS)),
None,
None,
)
}
fn ordered_level_text(level: usize, list: &List) -> String {
let suffix = if list.suffix.is_empty() {
"."
} else {
list.suffix.as_str()
};
format!("{}%{}{}", list.prefix, level + 1, suffix)
}