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, Table, TableCell,
};
use common::format_runs::{InlineContent, InlineSegment};
use common::long_operation::LongOperation;
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 {}
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 HANGING_TWIPS: i32 = 360;
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 mut numbering = NumberingBuilder::default();
let mut elements: Vec<DocxElement> = Vec::new();
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 frame.parent_frame.is_some() {
continue;
}
if let Some(table_id) = frame.table {
let table = self.render_table_docx(uow, &table_id, &mut numbering)?;
elements.push(DocxElement::Table(Box::new(table)));
continue;
}
self.render_frame_content(
uow,
&frame,
&cell_frame_ids,
0,
&mut numbering,
cancel_flag,
&mut elements,
)?;
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();
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),
};
}
Ok((docx, paragraph_count))
}
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,
numbering: &mut NumberingBuilder,
cancel_flag: Option<&AtomicBool>,
out: &mut Vec<DocxElement>,
) -> 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, numbering)?;
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)?;
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
};
self.render_frame_content(
uow,
&sub_frame,
cell_frame_ids,
sub_depth,
numbering,
cancel_flag,
out,
)?;
}
}
}
} 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, numbering)?;
out.push(DocxElement::Paragraph(Box::new(paragraph)));
}
}
Ok(())
}
fn render_block(
&self,
uow: &dyn ExportDocxUnitOfWorkTrait,
block: &Block,
quote_depth: usize,
numbering: &mut NumberingBuilder,
) -> 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 let Some(alignment) = &block.fmt_alignment {
paragraph = paragraph.align(map_alignment(alignment));
}
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);
}
} 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 {
if quote_indent > 0 {
paragraph = paragraph.indent(Some(quote_indent), None, None, None);
}
}
Ok(add_inline_content(paragraph, &elements))
}
fn render_table_docx(
&self,
uow: &dyn ExportDocxUnitOfWorkTrait,
table_id: &EntityId,
numbering: &mut NumberingBuilder,
) -> 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,
numbering,
None,
&mut cell_elements,
)?;
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 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_run(elem: &InlineSegment) -> Option<docx_rs::Run> {
use docx_rs::*;
let text = match &elem.content {
InlineContent::Text(t) => t.clone(),
InlineContent::Image { name, .. } => format!("[Image: {}]", name),
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],
) -> 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) 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)
}