use crate::ExportDocxDto;
use crate::ExportDocxResultDto;
use anyhow::{Result, anyhow};
use common::database::QueryUnitOfWork;
use common::database::rope_helpers::{block_content_via_store, block_document_position};
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::{DocumentComments, DocumentMarks, ExportImages};
use common::types::{EntityId, ROOT_ENTITY_ID};
use regex::Regex;
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(crate) struct PreparedSpan {
id: usize,
uid: String,
start: u32,
end: u32,
required: bool,
emit: SpanEmit,
}
enum SpanEmit {
Comment {
author_initials: String,
resolved: bool,
comment: docx_rs::Comment,
},
Mark {
bookmark_id: usize,
name: String,
point: bool,
},
}
impl PreparedSpan {
fn as_comment(&self) -> Option<(&str, bool, &docx_rs::Comment)> {
match &self.emit {
SpanEmit::Comment {
author_initials,
resolved,
comment,
} => Some((author_initials, *resolved, comment)),
SpanEmit::Mark { .. } => None,
}
}
}
fn fnv1a32(s: &str) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for b in s.bytes() {
hash ^= b as u32;
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
fn prepare_comments(comments: &DocumentComments) -> Vec<PreparedSpan> {
let mut out = Vec::new();
let mut used_para_ids: HashSet<String> = HashSet::new();
let mut next_id: usize = 1;
fn para_id_for(seed: &str, used: &mut HashSet<String>) -> String {
let mut h = fnv1a32(seed);
loop {
let candidate = format!("{h:08x}");
if used.insert(candidate.clone()) {
return candidate;
}
h = h.wrapping_add(1);
}
}
for c in comments.in_document_order() {
let root_id = next_id;
next_id += 1;
let root_para_id = para_id_for(&c.uid, &mut used_para_ids);
let root_comment = docx_rs::Comment::new(root_id)
.author(c.author.clone())
.date(c.date.clone())
.add_paragraph(render_comment_body(&c.body).id(root_para_id.clone()));
out.push(PreparedSpan {
id: root_id,
uid: c.uid.clone(),
start: c.start,
end: c.end,
required: true,
emit: SpanEmit::Comment {
author_initials: c.author_initials.clone(),
resolved: c.resolved,
comment: root_comment,
},
});
for reply in &c.replies {
let reply_id = next_id;
next_id += 1;
let reply_para_id = para_id_for(&reply.uid, &mut used_para_ids);
let reply_comment = docx_rs::Comment::new(reply_id)
.author(reply.author.clone())
.date(reply.date.clone())
.add_paragraph(render_comment_body(&reply.body).id(reply_para_id.clone()))
.parent_comment_id(root_id);
out.push(PreparedSpan {
id: reply_id,
uid: reply.uid.clone(),
start: c.start,
end: c.end,
required: true,
emit: SpanEmit::Comment {
author_initials: reply.author_initials.clone(),
resolved: false,
comment: reply_comment,
},
});
}
}
out
}
fn prepare_spans(comments: &DocumentComments, marks: &DocumentMarks) -> Result<Vec<PreparedSpan>> {
let mut out = prepare_comments(comments);
let first_span_id = out.len() + 1;
out.extend(prepare_marks(marks, first_span_id)?);
Ok(out)
}
fn prepare_marks(marks: &DocumentMarks, first_span_id: usize) -> Result<Vec<PreparedSpan>> {
marks
.validate()
.map_err(|e| anyhow!("invalid round-trip mark(s): {e}"))?;
Ok(marks
.in_document_order()
.into_iter()
.enumerate()
.map(|(i, m)| PreparedSpan {
id: first_span_id + i,
uid: m.name.clone(),
start: m.start,
end: m.end,
required: false,
emit: SpanEmit::Mark {
bookmark_id: i,
name: m.name.clone(),
point: m.is_point(),
},
})
.collect())
}
fn render_comment_body(djot: &str) -> docx_rs::Paragraph {
use docx_rs::*;
use jotdown::{Container as C, Event as E, Parser};
let mut paragraph = Paragraph::new();
let mut bold = false;
let mut italic = false;
let mut underline = false;
let mut strikeout = false;
let mut buffer = String::new();
let mut buf_bold = false;
let mut buf_italic = false;
let mut buf_underline = false;
let mut buf_strikeout = false;
let mut wrote_any_block = false;
macro_rules! flush {
() => {
if !buffer.is_empty() {
paragraph = append_formatted_text(
paragraph,
&buffer,
buf_bold,
buf_italic,
buf_underline,
buf_strikeout,
);
buffer.clear();
}
};
}
for event in Parser::new(djot) {
match event {
E::Start(C::Paragraph, _) | E::Start(C::Heading { .. }, _) => {
if wrote_any_block {
flush!();
paragraph = paragraph.add_run(Run::new().add_break(BreakType::TextWrapping));
}
}
E::End(C::Paragraph) | E::End(C::Heading { .. }) => {
flush!();
wrote_any_block = true;
}
E::Start(C::Strong, _) => {
flush!();
bold = true;
}
E::End(C::Strong) => {
flush!();
bold = false;
}
E::Start(C::Emphasis, _) => {
flush!();
italic = true;
}
E::End(C::Emphasis) => {
flush!();
italic = false;
}
E::Start(C::Insert, _) => {
flush!();
underline = true;
}
E::End(C::Insert) => {
flush!();
underline = false;
}
E::Start(C::Delete, _) => {
flush!();
strikeout = true;
}
E::End(C::Delete) => {
flush!();
strikeout = false;
}
E::Str(s) => {
if buffer.is_empty() {
buf_bold = bold;
buf_italic = italic;
buf_underline = underline;
buf_strikeout = strikeout;
}
buffer.push_str(s.as_ref());
}
E::LeftSingleQuote => buffer.push('\u{2018}'),
E::RightSingleQuote => buffer.push('\u{2019}'),
E::LeftDoubleQuote => buffer.push('\u{201C}'),
E::RightDoubleQuote => buffer.push('\u{201D}'),
E::Ellipsis => buffer.push('\u{2026}'),
E::EnDash => buffer.push('\u{2013}'),
E::EmDash => buffer.push('\u{2014}'),
E::NonBreakingSpace => buffer.push('\u{00A0}'),
_ => {}
}
}
flush!();
paragraph
}
fn append_formatted_text(
mut paragraph: docx_rs::Paragraph,
text: &str,
bold: bool,
italic: bool,
underline: bool,
strikeout: bool,
) -> docx_rs::Paragraph {
use docx_rs::*;
for (i, line) in text.split('\n').enumerate() {
let mut run = Run::new();
if i > 0 {
run = run.add_break(BreakType::TextWrapping);
}
if !line.is_empty() {
run = run.add_text(line);
}
if bold {
run = run.bold();
}
if italic {
run = run.italic();
}
if underline {
run = run.underline("single");
}
if strikeout {
run = run.strike();
}
paragraph = paragraph.add_run(run);
}
paragraph
}
enum Marker<'a> {
Start(&'a PreparedSpan),
End(&'a PreparedSpan),
}
struct CommentEmitState<'a> {
prepared: &'a [PreparedSpan],
started: std::cell::RefCell<HashSet<usize>>,
ended: std::cell::RefCell<HashSet<usize>>,
}
struct BlockCommentWindow<'a> {
starts: Vec<&'a PreparedSpan>,
ends: Vec<&'a PreparedSpan>,
}
impl<'a> CommentEmitState<'a> {
fn new(prepared: &'a [PreparedSpan]) -> Self {
Self {
prepared,
started: std::cell::RefCell::new(HashSet::new()),
ended: std::cell::RefCell::new(HashSet::new()),
}
}
fn window_for_block(&self, block_start: u32, block_end: u32) -> BlockCommentWindow<'a> {
let empty = block_start == block_end;
let mut starts: Vec<&'a PreparedSpan> = self
.prepared
.iter()
.filter(|c| {
(block_start <= c.start && c.start < block_end) || (empty && c.start == block_start)
})
.collect();
starts.sort_by_key(|c| (c.start, c.id));
let mut ends: Vec<&'a PreparedSpan> = self
.prepared
.iter()
.filter(|c| {
(block_start < c.end && c.end <= block_end) || (empty && c.end == block_end)
})
.collect();
ends.sort_by_key(|c| (c.end, c.id));
BlockCommentWindow { starts, ends }
}
fn mark_started(&self, id: usize) {
self.started.borrow_mut().insert(id);
}
fn mark_ended(&self, id: usize) {
self.ended.borrow_mut().insert(id);
}
fn ensure_all_anchored(&self) -> Result<()> {
let started = self.started.borrow();
let ended = self.ended.borrow();
let missing: Vec<String> = self
.prepared
.iter()
.filter(|c| c.required && (!started.contains(&c.id) || !ended.contains(&c.id)))
.map(|c| format!("{} [{}, {})", c.uid, c.start, c.end))
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(anyhow!(
"{} comment(s) could not be anchored to any exported text (range outside the \
document, or targeting a footnote body/table cell, neither of which carries \
comment ranges): {}",
missing.len(),
missing.join(", ")
))
}
}
}
fn markers_for_piece<'a>(
window: &BlockCommentWindow<'a>,
piece_start: u32,
piece_end: u32,
) -> Vec<(u32, Marker<'a>)> {
let mut out: Vec<(u32, Marker<'a>)> = Vec::new();
for &c in &window.starts {
if piece_start <= c.start && c.start < piece_end {
out.push((c.start - piece_start, Marker::Start(c)));
}
}
for &c in &window.ends {
if piece_start < c.end && c.end <= piece_end {
out.push((c.end - piece_start, Marker::End(c)));
}
}
out.sort_by_key(|(idx, m)| {
let (kind_rank, id) = match m {
Marker::End(c) => (0u8, c.id),
Marker::Start(c) if !c.required => (1u8, c.id),
Marker::Start(c) => (2u8, c.id),
};
(*idx, kind_rank, id)
});
out
}
trait InlineHost: Sized {
fn host_add_run(self, run: docx_rs::Run) -> Self;
fn host_add_comment_start(self, comment: docx_rs::Comment) -> Self;
fn host_add_comment_end(self, id: usize) -> Self;
fn host_add_bookmark_start(self, id: usize, name: &str) -> Self;
fn host_add_bookmark_end(self, id: usize) -> Self;
}
impl InlineHost for docx_rs::Paragraph {
fn host_add_run(self, run: docx_rs::Run) -> Self {
self.add_run(run)
}
fn host_add_comment_start(self, comment: docx_rs::Comment) -> Self {
self.add_comment_start(comment)
}
fn host_add_comment_end(self, id: usize) -> Self {
self.add_comment_end(id)
}
fn host_add_bookmark_start(self, id: usize, name: &str) -> Self {
self.add_bookmark_start(id, name)
}
fn host_add_bookmark_end(self, id: usize) -> Self {
self.add_bookmark_end(id)
}
}
impl InlineHost for docx_rs::Hyperlink {
fn host_add_run(self, run: docx_rs::Run) -> Self {
self.add_run(run)
}
fn host_add_comment_start(self, comment: docx_rs::Comment) -> Self {
self.add_comment_start(comment)
}
fn host_add_comment_end(self, id: usize) -> Self {
self.add_comment_end(id)
}
fn host_add_bookmark_start(self, id: usize, name: &str) -> Self {
self.add_bookmark_start(id, name)
}
fn host_add_bookmark_end(self, id: usize) -> Self {
self.add_bookmark_end(id)
}
}
fn apply_marker<H: InlineHost>(host: H, marker: &Marker<'_>, state: &CommentEmitState<'_>) -> H {
match marker {
Marker::Start(c) => {
state.mark_started(c.id);
match &c.emit {
SpanEmit::Comment { comment, .. } => host.host_add_comment_start(comment.clone()),
SpanEmit::Mark {
bookmark_id,
name,
point,
} => {
let host = host.host_add_bookmark_start(*bookmark_id, name);
if *point {
host.host_add_bookmark_end(*bookmark_id)
} else {
host
}
}
}
}
Marker::End(c) => {
state.mark_ended(c.id);
match &c.emit {
SpanEmit::Comment { .. } => host.host_add_comment_end(c.id),
SpanEmit::Mark { bookmark_id, .. } => host.host_add_bookmark_end(*bookmark_id),
}
}
}
}
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, prepared_comments) = 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
)
})?;
let mut xml_docx = docx.build();
patch_comment_extras(&mut xml_docx, &prepared_comments)?;
xml_docx
.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 QUOTE_STYLE_ID: &str = "Quote";
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, Vec<PreparedSpan>)> {
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 prepared_comments = prepare_spans(&self.dto.options.comments, &self.dto.options.marks)?;
let comment_state: Option<CommentEmitState<'_>> = if prepared_comments.is_empty() {
None
} else {
Some(CommentEmitState::new(&prepared_comments))
};
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,
None,
)?);
}
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,
comment_state.as_ref(),
)?;
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
)),
));
}
if let Some(state) = &comment_state {
state.ensure_all_anchored()?;
}
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),
)
.add_style(
Style::new(QUOTE_STYLE_ID, StyleType::Paragraph)
.name("Quote")
.indent(Some(INDENT_STEP_TWIPS), None, None, None),
);
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, prepared_comments))
}
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()?;
let (docx, paragraph_count, _prepared_comments) = result?;
Ok((docx, paragraph_count))
}
pub(crate) fn build_document_xml(&self) -> Result<docx_rs::XMLDocx> {
let uow = self.uow_factory.create();
uow.begin_transaction()?;
let result = self.build_docx(&*uow, &|_progress| {}, None);
uow.end_transaction()?;
let (docx, _paragraph_count, prepared_comments) = result?;
let mut xml_docx = docx.build();
patch_comment_extras(&mut xml_docx, &prepared_comments)?;
Ok(xml_docx)
}
#[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,
comments: Option<&CommentEmitState<'_>>,
) -> 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,
comments,
)?;
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,
comments,
)?;
}
}
}
} 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,
comments,
)?;
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,
comments: Option<&CommentEmitState<'_>>,
) -> 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 addressable = common::format_runs_query::addressable_inline_pieces_for_block(
&uow.store(),
block,
&block_text,
);
debug_assert_eq!(
elements.len(),
addressable.len(),
"inline_segments_for_block and addressable_inline_pieces_for_block must stay in \
lockstep — both are views over the same merge_runs_and_anchors() pieces"
);
let pieces: Vec<(InlineSegment, u32, u32)> = elements
.into_iter()
.zip(addressable.iter())
.map(|(elem, piece)| (elem, piece.start, piece.end))
.collect();
let quote_indent = quote_depth as i32 * INDENT_STEP_TWIPS;
if block.fmt_is_code_block == Some(true) {
return Ok(render_code_block(&pieces, quote_indent));
}
let comment_window = comments.map(|state| {
let block_start = block_document_position(block, &uow.store()) as u32;
let block_end = block_start + block_text.chars().count() as u32;
(state, state.window_for_block(block_start, block_end))
});
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
});
} else if quote_depth > 0 {
paragraph = paragraph.style(QUOTE_STYLE_ID);
}
}
Ok(add_inline_content(
paragraph,
&pieces,
&self.dto.options.images,
notes,
footnote_state,
comment_window
.as_ref()
.map(|(state, window)| (*state, window)),
))
}
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,
None,
)?;
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(
pieces: &[(InlineSegment, u32, u32)],
quote_indent: i32,
) -> docx_rs::Paragraph {
use docx_rs::*;
let mut raw = String::new();
for (elem, _, _) in pieces {
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;
}
Some(text_run_with_format(&text, elem))
}
fn text_run_with_format(text: &str, elem: &InlineSegment) -> docx_rs::Run {
use docx_rs::*;
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"));
}
run
}
struct RenderedPiece<'p> {
elem: &'p InlineSegment,
start: u32,
end: u32,
run: Option<docx_rs::Run>,
}
fn add_inline_content(
mut paragraph: docx_rs::Paragraph,
pieces: &[(InlineSegment, u32, u32)],
images: &ExportImages,
notes: &std::collections::HashMap<String, Vec<docx_rs::Paragraph>>,
footnote_state: &FootnoteRefState,
comments: Option<(&CommentEmitState<'_>, &BlockCommentWindow<'_>)>,
) -> docx_rs::Paragraph {
use docx_rs::*;
let rendered: Vec<RenderedPiece<'_>> = pieces
.iter()
.map(|(elem, start, end)| RenderedPiece {
elem,
start: *start,
end: *end,
run: build_run(elem, images, notes, footnote_state),
})
.collect();
if rendered.is_empty() {
if let Some((state, window)) = comments {
for &c in &window.starts {
paragraph = apply_marker(paragraph, &Marker::Start(c), state);
}
for &c in window.ends.iter().rev() {
paragraph = apply_marker(paragraph, &Marker::End(c), state);
}
}
return paragraph;
}
enum Group {
Plain(usize),
Link(String, std::ops::Range<usize>),
}
let mut groups: Vec<Group> = Vec::new();
for (i, piece) in rendered.iter().enumerate() {
match &piece.elem.fmt_anchor_href {
Some(href) if !href.is_empty() => {
if let Some(Group::Link(open_href, range)) = groups.last_mut()
&& open_href == href
{
range.end = i + 1;
continue;
}
groups.push(Group::Link(href.clone(), i..i + 1));
}
_ => groups.push(Group::Plain(i)),
}
}
for group in groups {
match group {
Group::Plain(i) => {
paragraph = append_piece(paragraph, &rendered[i], comments);
}
Group::Link(href, range) => {
let mut link = Hyperlink::new(href, HyperlinkType::External);
for i in range {
link = append_piece(link, &rendered[i], comments);
}
paragraph = paragraph.add_hyperlink(link);
}
}
}
paragraph
}
fn append_piece<H: InlineHost>(
mut host: H,
piece: &RenderedPiece<'_>,
comments: Option<(&CommentEmitState<'_>, &BlockCommentWindow<'_>)>,
) -> H {
let Some((state, window)) = comments else {
return match &piece.run {
Some(run) => host.host_add_run(run.clone()),
None => host,
};
};
let markers = markers_for_piece(window, piece.start, piece.end);
if markers.is_empty() {
return match &piece.run {
Some(run) => host.host_add_run(run.clone()),
None => host,
};
}
if let InlineContent::Text(text) = &piece.elem.content {
let chars: Vec<char> = text.chars().collect();
let mut cursor = 0usize;
for (idx, marker) in &markers {
let local = (*idx as usize).min(chars.len());
if local > cursor {
let slice: String = chars[cursor..local].iter().collect();
host = host.host_add_run(text_run_with_format(&slice, piece.elem));
cursor = local;
}
host = apply_marker(host, marker, state);
}
if cursor < chars.len() {
let slice: String = chars[cursor..].iter().collect();
host = host.host_add_run(text_run_with_format(&slice, piece.elem));
}
} else {
for (idx, marker) in &markers {
if *idx == 0 {
host = apply_marker(host, marker, state);
}
}
if let Some(run) = &piece.run {
host = host.host_add_run(run.clone());
}
for (idx, marker) in &markers {
if *idx != 0 {
host = apply_marker(host, marker, state);
}
}
}
host
}
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)
}
fn patch_comment_extras(xml_docx: &mut docx_rs::XMLDocx, spans: &[PreparedSpan]) -> Result<()> {
let prepared: Vec<&PreparedSpan> = spans.iter().filter(|s| s.as_comment().is_some()).collect();
if prepared.is_empty() {
return Ok(());
}
let comments_text = String::from_utf8(std::mem::take(&mut xml_docx.comments))
.map_err(|e| anyhow!("word/comments.xml was not valid UTF-8: {e}"))?;
let comments_extended_text = String::from_utf8(std::mem::take(&mut xml_docx.comments_extended))
.map_err(|e| anyhow!("word/commentsExtended.xml was not valid UTF-8: {e}"))?;
let by_id: HashMap<usize, &PreparedSpan> = prepared.iter().map(|c| (c.id, *c)).collect();
let comments_text = declare_skrb_namespace(comments_text)?;
let id_and_para_re =
Regex::new(r#"(?s)<w:comment\s+w:id="(\d+)"[^>]*>.*?w14:paraId="([0-9a-fA-F]{8})""#)
.expect("static regex is valid");
let mut actual_para_id: HashMap<usize, String> = HashMap::new();
for caps in id_and_para_re.captures_iter(&comments_text) {
let id: usize = caps[1]
.parse()
.expect("\\d+ capture is always a valid usize");
actual_para_id.insert(id, caps[2].to_string());
}
if actual_para_id.len() != prepared.len() {
return Err(anyhow!(
"expected {} comment(s) in word/comments.xml, found {} well-formed enough to \
correlate a w:id to a body paragraph's w14:paraId — the raw-XML patch step \
(w15:done / w:initials / uid) cannot proceed on a shape it doesn't recognise",
prepared.len(),
actual_para_id.len()
));
}
let initials_re = Regex::new(r#"(<w:comment\s+w:id="(\d+)"[^>]*?)w:initials="""#)
.expect("static regex is valid");
let comments_text = initials_re
.replace_all(&comments_text, |caps: ®ex::Captures<'_>| {
let id: usize = caps[2]
.parse()
.expect("\\d+ capture is always a valid usize");
let pc = by_id
.get(&id)
.expect("every w:id captured here was assigned by prepare_comments");
let (author_initials, _, _) = pc
.as_comment()
.expect("by_id holds comments only — marks are filtered out above");
format!(
r#"{}w:initials="{}" skrb:uid="{}""#,
&caps[1],
xml_attr_escape(author_initials),
xml_attr_escape(&pc.uid),
)
})
.into_owned();
let resolved_para_ids: HashSet<&str> = prepared
.iter()
.filter(|c| c.as_comment().is_some_and(|(_, resolved, _)| resolved))
.filter_map(|c| actual_para_id.get(&c.id).map(String::as_str))
.collect();
let done_re =
Regex::new(r#"(<w15:commentEx\s+w15:paraId="([0-9a-fA-F]{8})"[^>]*?)w15:done="0""#)
.expect("static regex is valid");
let comments_extended_text = done_re
.replace_all(&comments_extended_text, |caps: ®ex::Captures<'_>| {
let done = if resolved_para_ids.contains(&caps[2]) {
"1"
} else {
"0"
};
format!(r#"{}w15:done="{}""#, &caps[1], done)
})
.into_owned();
xml_docx.comments = comments_text.into_bytes();
xml_docx.comments_extended = comments_extended_text.into_bytes();
Ok(())
}
const SKRB_NAMESPACE_URI: &str = "urn:ferntech:text-document:comment:1";
fn declare_skrb_namespace(comments_text: String) -> Result<String> {
let patched = comments_text.replacen(
"<w:comments ",
&format!(r#"<w:comments xmlns:skrb="{SKRB_NAMESPACE_URI}" "#),
1,
);
if patched == comments_text {
return Err(anyhow!(
"word/comments.xml did not start with the expected '<w:comments ' root element — \
cannot declare the skrb: namespace the uid attribute needs"
));
}
Ok(patched)
}
fn xml_attr_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' => {}
_ => out.push(c),
}
}
out
}