use crate::ExportOdtDto;
use crate::ExportOdtResultDto;
use crate::odt_render::{self, OdtStyleSheet};
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, TextDirection,
};
use common::format_runs::{InlineContent, InlineSegment};
use common::format_runs_query::{addressable_inline_pieces_for_block, inline_segments_for_block};
use common::long_operation::LongOperation;
use common::parser_tools::{DocumentComments, DocumentMarks, ExportImages};
use common::types::{EntityId, ROOT_ENTITY_ID};
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
pub trait ExportOdtUnitOfWorkFactoryTrait: Send + Sync {
fn create(&self) -> Box<dyn ExportOdtUnitOfWorkTrait>;
}
#[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 ExportOdtUnitOfWorkTrait: QueryUnitOfWork + Send + Sync {}
const INDENT_STEP_PT: f64 = odt_render::INDENT_STEP_PT;
type NoteBodies = HashMap<String, String>;
struct FootnoteRefState<'a> {
numbers: &'a crate::footnotes::Footnotes,
emitted: RefCell<HashSet<String>>,
next_id: Cell<usize>,
}
impl<'a> FootnoteRefState<'a> {
fn new(numbers: &'a crate::footnotes::Footnotes) -> Self {
FootnoteRefState {
numbers,
emitted: RefCell::new(HashSet::new()),
next_id: Cell::new(1),
}
}
fn take_id(&self) -> usize {
let id = self.next_id.get();
self.next_id.set(id + 1);
id
}
}
struct WalkCtx<'a> {
notes: &'a NoteBodies,
footnote_state: &'a FootnoteRefState<'a>,
inside_note_body: bool,
image_hrefs: &'a BTreeMap<String, String>,
images: &'a ExportImages,
image_seq: Cell<usize>,
}
struct PreparedSpan {
id: usize,
uid: String,
start: u32,
end: u32,
open_xml: String,
close_xml: String,
required: bool,
}
fn comment_range_name(id: usize) -> String {
format!("__Comment__{id}")
}
fn prepare_spans(
comments: &DocumentComments,
marks: &DocumentMarks,
styles: &mut OdtStyleSheet,
) -> Result<Vec<PreparedSpan>> {
let mut out = prepare_comments(comments, styles);
let first_mark_id = out.len() + 1;
out.extend(prepare_marks(marks, first_mark_id)?);
Ok(out)
}
fn prepare_marks(marks: &DocumentMarks, first_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)| {
let escaped = odt_render::xml_escape(&m.name);
let (open_xml, close_xml) = if m.is_point() {
(
format!("<text:bookmark text:name=\"{escaped}\"/>"),
String::new(),
)
} else {
(
format!("<text:bookmark-start text:name=\"{escaped}\"/>"),
format!("<text:bookmark-end text:name=\"{escaped}\"/>"),
)
};
PreparedSpan {
id: first_id + i,
uid: m.name.clone(),
start: m.start,
end: m.end,
open_xml,
close_xml,
required: false,
}
})
.collect())
}
fn prepare_comments(comments: &DocumentComments, styles: &mut OdtStyleSheet) -> Vec<PreparedSpan> {
let mut out = Vec::new();
let mut next_id: usize = 1;
for c in comments.in_document_order() {
let root_id = next_id;
next_id += 1;
let root_name = comment_range_name(root_id);
let body_xml = render_comment_body_odt(&c.body, styles);
let open_xml = annotation_open_xml(
&root_name, &c.uid, &c.author, &c.date, c.resolved, None, &body_xml,
);
out.push(PreparedSpan {
id: root_id,
uid: c.uid.clone(),
start: c.start,
end: c.end,
open_xml,
close_xml: annotation_close_xml(&root_name),
required: true,
});
for reply in &c.replies {
let reply_id = next_id;
next_id += 1;
let reply_name = comment_range_name(reply_id);
let reply_body_xml = render_comment_body_odt(&reply.body, styles);
let reply_open_xml = annotation_open_xml(
&reply_name,
&reply.uid,
&reply.author,
&reply.date,
false,
Some(&root_name),
&reply_body_xml,
);
out.push(PreparedSpan {
id: reply_id,
uid: reply.uid.clone(),
start: c.start,
end: c.end,
open_xml: reply_open_xml,
close_xml: annotation_close_xml(&reply_name),
required: true,
});
}
}
out
}
fn annotation_close_xml(name: &str) -> String {
format!(
"<office:annotation-end office:name=\"{}\"/>",
odt_render::xml_escape(name)
)
}
fn annotation_open_xml(
name: &str,
uid: &str,
author: &str,
date: &str,
resolved: bool,
parent_name: Option<&str>,
body_xml: &str,
) -> String {
let mut attrs = format!(
"office:name=\"{}\" skrb:uid=\"{}\" loext:resolved=\"{}\"",
odt_render::xml_escape(name),
odt_render::xml_escape(uid),
if resolved { "true" } else { "false" },
);
if let Some(parent) = parent_name {
attrs.push_str(&format!(
" loext:parent-name=\"{}\"",
odt_render::xml_escape(parent)
));
}
format!(
"<office:annotation {attrs}><dc:creator>{}</dc:creator><dc:date>{}</dc:date>{body_xml}</office:annotation>",
odt_render::xml_escape(author),
odt_render::xml_escape(date),
)
}
fn render_comment_body_odt(djot: &str, styles: &mut OdtStyleSheet) -> String {
use jotdown::{Container as C, Event as E, Parser};
let mut paragraphs: Vec<String> = Vec::new();
let mut current = String::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;
macro_rules! mark_flags {
() => {
if buffer.is_empty() {
buf_bold = bold;
buf_italic = italic;
buf_underline = underline;
buf_strikeout = strikeout;
}
};
}
macro_rules! flush {
() => {
if !buffer.is_empty() {
let attrs = character_style_attrs_from_flags(
buf_bold,
buf_italic,
buf_underline,
buf_strikeout,
);
let encoded = odt_render::encode_run_text(&buffer);
if attrs.is_empty() {
current.push_str(&encoded);
} else {
let style = styles.text_style(attrs.trim());
current.push_str(&format!(
"<text:span text:style-name=\"{style}\">{encoded}</text:span>"
));
}
buffer.clear();
}
};
}
for event in Parser::new(djot) {
match event {
E::Start(C::Paragraph, _) | E::Start(C::Heading { .. }, _) => {}
E::End(C::Paragraph) | E::End(C::Heading { .. }) => {
flush!();
paragraphs.push(std::mem::take(&mut current));
}
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) => {
mark_flags!();
buffer.push_str(s.as_ref());
}
E::Softbreak => {
mark_flags!();
buffer.push(' ');
}
E::Hardbreak => {
flush!();
current.push_str("<text:line-break/>");
}
E::LeftSingleQuote => {
mark_flags!();
buffer.push('\u{2018}');
}
E::RightSingleQuote => {
mark_flags!();
buffer.push('\u{2019}');
}
E::LeftDoubleQuote => {
mark_flags!();
buffer.push('\u{201C}');
}
E::RightDoubleQuote => {
mark_flags!();
buffer.push('\u{201D}');
}
E::Ellipsis => {
mark_flags!();
buffer.push('\u{2026}');
}
E::EnDash => {
mark_flags!();
buffer.push('\u{2013}');
}
E::EmDash => {
mark_flags!();
buffer.push('\u{2014}');
}
E::NonBreakingSpace => {
mark_flags!();
buffer.push('\u{00A0}');
}
_ => {}
}
}
flush!();
if !current.is_empty() {
paragraphs.push(std::mem::take(&mut current));
}
paragraphs
.iter()
.map(|p| format!("<text:p>{p}</text:p>"))
.collect()
}
fn character_style_attrs_from_flags(
bold: bool,
italic: bool,
underline: bool,
strikeout: bool,
) -> String {
let mut attrs = String::new();
if bold {
attrs.push_str(" fo:font-weight=\"bold\" style:font-weight-complex=\"bold\"");
}
if italic {
attrs.push_str(" fo:font-style=\"italic\" style:font-style-complex=\"italic\"");
}
if underline {
attrs.push_str(
" style:text-underline-style=\"solid\" style:text-underline-width=\"auto\" \
style:text-underline-color=\"font-color\"",
);
}
if strikeout {
attrs.push_str(" style:text-line-through-style=\"solid\"");
}
attrs
}
enum Marker<'a> {
Start(&'a PreparedSpan),
End(&'a PreparedSpan),
}
struct BlockCommentWindow<'a> {
starts: Vec<&'a PreparedSpan>,
ends: Vec<&'a PreparedSpan>,
}
struct CommentEmitState<'a> {
prepared: &'a [PreparedSpan],
started: RefCell<HashSet<usize>>,
ended: RefCell<HashSet<usize>>,
}
impl<'a> CommentEmitState<'a> {
fn new(prepared: &'a [PreparedSpan]) -> Self {
Self {
prepared,
started: RefCell::new(HashSet::new()),
ended: 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 fenced code block, a scene-break/rule paragraph, a \
footnote body, or table-cell content, none of which carry 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
}
fn apply_marker(out: &mut String, marker: &Marker<'_>, state: &CommentEmitState<'_>) {
match marker {
Marker::Start(c) => {
state.mark_started(c.id);
out.push_str(&c.open_xml);
}
Marker::End(c) => {
state.mark_ended(c.id);
out.push_str(&c.close_xml);
}
}
}
pub struct ExportOdtUseCase {
uow_factory: Box<dyn ExportOdtUnitOfWorkFactoryTrait>,
dto: ExportOdtDto,
}
impl ExportOdtUseCase {
pub fn new(uow_factory: Box<dyn ExportOdtUnitOfWorkFactoryTrait>, dto: &ExportOdtDto) -> Self {
ExportOdtUseCase {
uow_factory,
dto: dto.clone(),
}
}
}
impl LongOperation for ExportOdtUseCase {
type Output = ExportOdtResultDto;
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 ODT export...".to_string()),
));
let uow = self.uow_factory.create();
uow.begin_transaction()?;
let build_result = self.build_odt(
&*uow,
progress_callback.as_ref(),
Some(cancel_flag.as_ref()),
);
uow.end_transaction()?;
let (bytes, paragraph_count) = build_result?;
progress_callback(common::long_operation::OperationProgress::new(
90.0,
Some("Writing ODT file...".to_string()),
));
std::fs::write(&self.dto.output_path, &bytes).map_err(|e| {
anyhow!(
"Failed to write output file '{}': {}",
self.dto.output_path,
e
)
})?;
progress_callback(common::long_operation::OperationProgress::new(
100.0,
Some("completed".to_string()),
));
Ok(ExportOdtResultDto {
file_path: self.dto.output_path.clone(),
paragraph_count,
})
}
}
impl ExportOdtUseCase {
pub(crate) fn build_document(&self) -> Result<(Vec<u8>, i64)> {
let uow = self.uow_factory.create();
uow.begin_transaction()?;
let result = self.build_odt(&*uow, &|_progress| {}, None);
uow.end_transaction()?;
result
}
pub(crate) fn build_odt(
&self,
uow: &dyn ExportOdtUnitOfWorkTrait,
progress_callback: &dyn Fn(common::long_operation::OperationProgress),
cancel_flag: Option<&AtomicBool>,
) -> Result<(Vec<u8>, i64)> {
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 styles = OdtStyleSheet::default();
let notes = crate::footnotes::Footnotes::build(&uow.store());
let image_hrefs = build_image_href_map(&self.dto.options.images);
let prepared_spans = prepare_spans(
&self.dto.options.comments,
&self.dto.options.marks,
&mut styles,
)?;
let comment_state: Option<CommentEmitState<'_>> = if prepared_spans.is_empty() {
None
} else {
Some(CommentEmitState::new(&prepared_spans))
};
let note_bodies: NoteBodies = {
let mut built: NoteBodies = HashMap::new();
for (_, label, frame_id) in notes.in_print_order() {
let Some(note_frame) = uow.get_frame(&frame_id)? else {
continue;
};
let empty_notes = NoteBodies::new();
let body_footnote_state = FootnoteRefState::new(¬es);
let ctx = WalkCtx {
notes: &empty_notes,
footnote_state: &body_footnote_state,
inside_note_body: true,
image_hrefs: &image_hrefs,
images: &self.dto.options.images,
image_seq: Cell::new(0),
};
let mut body = String::new();
let mut counter = 0i64;
self.render_frame_content(
uow,
¬e_frame,
&cell_frame_ids,
0,
None,
&mut styles,
&ctx,
None,
&mut body,
&mut counter,
None,
)?;
built.insert(label, body);
}
built
};
let footnote_state = FootnoteRefState::new(¬es);
let ctx = WalkCtx {
notes: ¬e_bodies,
footnote_state: &footnote_state,
inside_note_body: false,
image_hrefs: &image_hrefs,
images: &self.dto.options.images,
image_seq: Cell::new(0),
};
let mut body_xml = String::new();
let mut paragraph_count = 0i64;
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 Some(frame) = uow.get_frame(frame_id)? 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_xml =
self.render_table_odt(uow, &table_id, &mut styles, &ctx, &mut paragraph_count)?;
body_xml.push_str(&table_xml);
continue;
}
self.render_frame_content(
uow,
&frame,
&cell_frame_ids,
0,
None,
&mut styles,
&ctx,
cancel_flag,
&mut body_xml,
&mut paragraph_count,
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 heading_styles = self.dto.options.resolved_heading_styles();
let content_xml = odt_render::content_xml(&styles, &body_xml);
let styles_xml = odt_render::styles_xml(&self.dto.options, &heading_styles);
let mut packaged_images: Vec<(String, Vec<u8>, String)> = Vec::new();
for (src, href) in &image_hrefs {
if let Some(image) = self.dto.options.images.get(src) {
packaged_images.push((href.clone(), image.bytes.clone(), image.mime_type.clone()));
}
}
let bytes = odt_render::package_odt(&content_xml, &styles_xml, &packaged_images)?;
Ok((bytes, paragraph_count))
}
#[allow(clippy::too_many_arguments)]
fn render_frame_content(
&self,
uow: &dyn ExportOdtUnitOfWorkTrait,
frame: &Frame,
cell_frame_ids: &HashSet<EntityId>,
quote_depth: usize,
semantic: Option<&SemanticRole>,
styles: &mut OdtStyleSheet,
ctx: &WalkCtx,
cancel_flag: Option<&AtomicBool>,
out: &mut String,
counter: &mut i64,
comments: Option<&CommentEmitState<'_>>,
) -> Result<()> {
let mut list_stack = ListStack::default();
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)? {
self.dispatch_block(
uow,
&block,
quote_depth,
semantic,
styles,
ctx,
&mut list_stack,
out,
counter,
comments,
)?;
}
} else {
list_stack.flush(out);
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_xml =
self.render_table_odt(uow, &table_id, styles, ctx, counter)?;
out.push_str(&table_xml);
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,
styles,
ctx,
cancel_flag,
out,
counter,
comments,
)?;
}
}
}
list_stack.flush(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)?;
self.dispatch_block(
uow,
block,
quote_depth,
semantic,
styles,
ctx,
&mut list_stack,
out,
counter,
comments,
)?;
}
list_stack.flush(out);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn dispatch_block(
&self,
uow: &dyn ExportOdtUnitOfWorkTrait,
block: &Block,
quote_depth: usize,
semantic: Option<&SemanticRole>,
styles: &mut OdtStyleSheet,
ctx: &WalkCtx,
list_stack: &mut ListStack,
out: &mut String,
counter: &mut i64,
comments: Option<&CommentEmitState<'_>>,
) -> Result<()> {
match self.render_block(uow, block, quote_depth, semantic, styles, ctx, comments)? {
RenderedBlock::Standalone(xml) => {
list_stack.flush(out);
out.push_str(&xml);
}
RenderedBlock::ListItem {
list_id,
depth,
style_name,
inner,
} => {
list_stack.push(out, list_id, depth, style_name, inner);
}
}
*counter += 1;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn render_block(
&self,
uow: &dyn ExportOdtUnitOfWorkTrait,
block: &Block,
quote_depth: usize,
semantic: Option<&SemanticRole>,
styles: &mut OdtStyleSheet,
ctx: &WalkCtx,
comments: Option<&CommentEmitState<'_>>,
) -> Result<RenderedBlock> {
let block_text = block_content_via_store(block, &uow.store());
let elements = inline_segments_for_block(&uow.store(), block.id, &block_text);
let addressable = 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_pt = quote_depth as f64 * INDENT_STEP_PT;
if block.fmt_is_code_block == Some(true) {
let mut raw = String::new();
for (elem, _, _) in &pieces {
if let InlineContent::Text(t) = &elem.content {
raw.push_str(t);
}
}
let attrs = if quote_indent_pt > 0.0 {
format!("fo:margin-left=\"{}\"", odt_render::fmt_pt(quote_indent_pt))
} else {
String::new()
};
let style = styles.paragraph_style("Code_Block", &attrs, "");
return Ok(RenderedBlock::Standalone(format!(
"<text:p text:style-name=\"{style}\">{}</text:p>",
odt_render::encode_run_text(&raw)
)));
}
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 is_task = matches!(
block.fmt_marker,
Some(MarkerType::Checked) | Some(MarkerType::Unchecked)
);
let common_attrs = common_para_attrs(block, quote_indent_pt);
if let Some(level) = block.fmt_heading_level {
let level = level.clamp(1, 6);
let mut attrs = common_attrs;
if let Some(before) = block.fmt_top_margin.filter(|&t| t > 0) {
attrs.push_str(&format!(
" fo:margin-top=\"{}\"",
odt_render::fmt_pt(odt_render::px_to_pt(before))
));
}
let style = styles.paragraph_style(&format!("Heading_{level}"), attrs.trim(), "");
let inner = add_inline_content(
&pieces,
ctx,
styles,
comment_window
.as_ref()
.map(|(state, window)| (*state, window)),
);
return Ok(RenderedBlock::Standalone(format!(
"<text:h text:style-name=\"{style}\" text:outline-level=\"{level}\">{inner}</text:h>"
)));
}
if let Some((list_id, list_entity)) = &list {
let depth = list_entity.indent.clamp(0, 8);
if is_task {
let style = styles.paragraph_style("Standard", common_attrs.trim(), "");
let glyph = if block.fmt_marker == Some(MarkerType::Checked) {
"\u{2612} " } else {
"\u{2610} " };
let inner = add_inline_content(
&pieces,
ctx,
styles,
comment_window
.as_ref()
.map(|(state, window)| (*state, window)),
);
return Ok(RenderedBlock::Standalone(format!(
"<text:p text:style-name=\"{style}\">{}{inner}</text:p>",
odt_render::xml_escape(glyph)
)));
}
let list_style_name =
styles.list_style(*list_id, |name| odt_list_style_xml(name, list_entity));
let para_style = styles.paragraph_style("Standard", common_attrs.trim(), "");
let inner = add_inline_content(
&pieces,
ctx,
styles,
comment_window
.as_ref()
.map(|(state, window)| (*state, window)),
);
let item_body = format!("<text:p text:style-name=\"{para_style}\">{inner}</text:p>");
return Ok(RenderedBlock::ListItem {
list_id: *list_id,
depth,
style_name: list_style_name,
inner: item_body,
});
}
if semantic.is_none() && looks_like_rule_glyph(&pieces) {
return Ok(RenderedBlock::Standalone(
"<text:p text:style-name=\"Rule\"/>".to_string(),
));
}
let mut attrs = common_attrs;
if let Some(top) = block.fmt_top_margin.filter(|&t| t > 0) {
attrs.push_str(&format!(
" fo:margin-top=\"{}\"",
odt_render::fmt_pt(odt_render::px_to_pt(top))
));
}
let first_line = match block.fmt_text_indent {
Some(ti) if ti > 0 => Some(odt_render::px_to_pt(ti)),
Some(_) => None,
None => self
.dto
.options
.first_line_indent_twips
.filter(|&f| f > 0)
.map(odt_render::twips_to_pt),
};
if let Some(fl) = first_line {
attrs.push_str(&format!(" fo:text-indent=\"{}\"", odt_render::fmt_pt(fl)));
}
if let Some(after) = self
.dto
.options
.paragraph_spacing_after_twips
.filter(|&a| a > 0)
{
attrs.push_str(&format!(
" fo:margin-bottom=\"{}\"",
odt_render::fmt_pt(odt_render::twips_to_pt(after))
));
}
if block.fmt_line_height.is_none()
&& let Some(ls) = self.dto.options.line_spacing_twips
{
let percent = (odt_render::twips_to_pt(ls) / 12.0 * 100.0).round() as i64;
attrs.push_str(&format!(" fo:line-height=\"{percent}%\""));
}
let rtl = block.fmt_direction == Some(TextDirection::RightToLeft);
if block.fmt_alignment.is_none() {
let align = if self.dto.options.justify {
Some("justify")
} else if rtl {
Some("right")
} else {
None
};
if let Some(a) = align {
attrs.push_str(&format!(" fo:text-align=\"{a}\""));
}
}
let parent = match semantic {
Some(SemanticRole::Epigraph) => {
if block.fmt_alignment == Some(Alignment::Right) {
"EpigraphAttribution"
} else {
"Epigraph"
}
}
None if quote_depth > 0 => "Quote",
None => "Standard",
};
let style = styles.paragraph_style(parent, attrs.trim(), "");
let inner = add_inline_content(
&pieces,
ctx,
styles,
comment_window
.as_ref()
.map(|(state, window)| (*state, window)),
);
Ok(RenderedBlock::Standalone(format!(
"<text:p text:style-name=\"{style}\">{inner}</text:p>"
)))
}
fn render_table_odt(
&self,
uow: &dyn ExportOdtUnitOfWorkTrait,
table_id: &EntityId,
styles: &mut OdtStyleSheet,
ctx: &WalkCtx,
counter: &mut i64,
) -> Result<String> {
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<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.max(0) as usize;
let cols = table.columns.max(0) as usize;
let mut covered = vec![vec![false; cols]; rows];
let width_attr = match table.fmt_width.filter(|&w| w > 0) {
Some(width) => format!(
"style:width=\"{}\"",
odt_render::fmt_pt(odt_render::px_to_pt(width))
),
None => "style:rel-width=\"100%\"".to_string(),
};
let align = match &table.fmt_alignment {
Some(Alignment::Left) => "left",
Some(Alignment::Right) => "right",
Some(Alignment::Center) => "center",
Some(Alignment::Justify) => "margins",
None if table.fmt_width.is_some_and(|w| w > 0) => "left",
None => "margins",
};
let table_style = styles.table_style(&format!("{width_attr} table:align=\"{align}\""));
let mut cols_xml = String::new();
if table.column_widths.is_empty() {
if cols > 0 {
cols_xml.push_str(&format!(
"<table:table-column table:number-columns-repeated=\"{cols}\"/>"
));
}
} else {
for w in &table.column_widths {
let attrs = format!(
"style:column-width=\"{}\"",
odt_render::fmt_pt(odt_render::px_to_pt(*w))
);
let col_style = styles.table_column_style(&attrs);
cols_xml.push_str(&format!(
"<table:table-column table:style-name=\"{col_style}\"/>"
));
}
}
let cell_attrs = if table.fmt_border.is_some_and(|b| b > 0) {
"fo:border=\"0.5pt solid #000000\" fo:padding=\"0.1cm\""
} else {
"fo:padding=\"0.1cm\""
};
let cell_style = styles.table_cell_style(cell_attrs);
let mut rows_xml = String::new();
for r in 0..rows {
let mut row_xml = String::new();
for c in 0..cols {
if covered[r][c] {
row_xml.push_str("<table:covered-table-cell/>");
continue;
}
let cell = cells
.iter()
.find(|cell| cell.row == r as i64 && cell.column == c as i64);
let Some(cell) = cell else {
row_xml.push_str(&format!(
"<table:table-cell table:style-name=\"{cell_style}\"><text:p/></table:table-cell>"
));
continue;
};
let row_span = cell.row_span.max(1) as usize;
let col_span = cell.column_span.max(1) as usize;
let mut span_attrs = String::new();
if col_span > 1 {
span_attrs.push_str(&format!(" table:number-columns-spanned=\"{col_span}\""));
}
if row_span > 1 {
span_attrs.push_str(&format!(" table:number-rows-spanned=\"{row_span}\""));
}
let mut inner = String::new();
if let Some(cf_id) = cell.cell_frame
&& let Some(cell_frame) = uow.get_frame(&cf_id)?
{
self.render_frame_content(
uow,
&cell_frame,
&HashSet::new(),
0,
None,
styles,
ctx,
None,
&mut inner,
counter,
None,
)?;
}
if inner.is_empty() {
inner.push_str("<text:p/>");
}
row_xml.push_str(&format!(
"<table:table-cell table:style-name=\"{cell_style}\"{span_attrs}>{inner}</table:table-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;
}
}
}
}
rows_xml.push_str(&format!("<table:table-row>{row_xml}</table:table-row>"));
}
*counter += 1;
Ok(format!(
"<table:table table:name=\"Table{table_id}\" table:style-name=\"{table_style}\">\
{cols_xml}{rows_xml}</table:table>"
))
}
}
enum RenderedBlock {
Standalone(String),
ListItem {
list_id: EntityId,
depth: i64,
style_name: String,
inner: String,
},
}
fn common_para_attrs(block: &Block, quote_indent_pt: f64) -> String {
let mut attrs = String::new();
if let Some(lh) = block.fmt_line_height {
let percent = (lh as f64 / 10.0).round() as i64;
attrs.push_str(&format!(" fo:line-height=\"{percent}%\""));
}
if block.fmt_non_breakable_lines == Some(true) {
attrs.push_str(" fo:keep-together=\"always\"");
}
if block.fmt_page_break_before == Some(true) {
attrs.push_str(" fo:break-before=\"page\"");
}
if let Some(alignment) = &block.fmt_alignment {
attrs.push_str(&format!(
" fo:text-align=\"{}\"",
odt_render::odf_align(alignment)
));
}
if block.fmt_direction == Some(TextDirection::RightToLeft) {
attrs.push_str(" style:writing-mode=\"rl-tb\"");
}
if quote_indent_pt > 0.0 {
attrs.push_str(&format!(
" fo:margin-left=\"{}\"",
odt_render::fmt_pt(quote_indent_pt)
));
}
attrs
}
fn looks_like_rule_glyph(pieces: &[(InlineSegment, u32, u32)]) -> bool {
let mut text = String::new();
for (elem, _, _) in pieces {
match &elem.content {
InlineContent::Text(t) => {
if elem.fmt_font_bold == Some(true)
|| elem.fmt_font_italic == Some(true)
|| elem.fmt_font_underline == Some(true)
|| elem.fmt_font_strikeout == Some(true)
|| elem.fmt_anchor_href.is_some()
{
return false;
}
text.push_str(t);
}
InlineContent::Empty => {}
InlineContent::Image { .. } | InlineContent::FootnoteRef { .. } => return false,
}
}
let stripped: Vec<char> = text.chars().filter(|c| !c.is_whitespace()).collect();
let Some(&first) = stripped.first() else {
return false;
};
if first.is_alphanumeric() {
return false;
}
const SENTENCE_PUNCTUATION: &[char] = &['!', '?', '.', '\u{2026}', ',', ';', ':'];
if stripped.len() == 1 && SENTENCE_PUNCTUATION.contains(&first) {
return false;
}
stripped[1..].iter().all(|&c| c == first)
}
fn odt_list_style_xml(name: &str, list: &List) -> String {
enum Marker {
Number { format: &'static str },
Bullet { glyph: &'static str },
}
let marker = match list.style {
ListStyle::Decimal => Marker::Number { format: "1" },
ListStyle::LowerAlpha => Marker::Number { format: "a" },
ListStyle::UpperAlpha => Marker::Number { format: "A" },
ListStyle::LowerRoman => Marker::Number { format: "i" },
ListStyle::UpperRoman => Marker::Number { format: "I" },
ListStyle::Disc => Marker::Bullet { glyph: "\u{2022}" },
ListStyle::Circle => Marker::Bullet { glyph: "\u{25CB}" },
ListStyle::Square => Marker::Bullet { glyph: "\u{25AA}" },
};
let suffix = if list.suffix.is_empty() {
"."
} else {
list.suffix.as_str()
};
let mut levels = String::new();
for level in 1..=9i64 {
let space_before = odt_render::fmt_pt(INDENT_STEP_PT * level as f64);
let props = format!(
"<style:list-level-properties text:space-before=\"{space_before}\" text:min-label-width=\"0.5cm\"/>"
);
match &marker {
Marker::Number { format } => levels.push_str(&format!(
"<text:list-level-style-number text:level=\"{level}\" style:num-format=\"{format}\" \
style:num-prefix=\"{prefix}\" style:num-suffix=\"{suffix}\">{props}</text:list-level-style-number>",
prefix = odt_render::xml_escape(&list.prefix),
suffix = odt_render::xml_escape(suffix),
)),
Marker::Bullet { glyph } => levels.push_str(&format!(
"<text:list-level-style-bullet text:level=\"{level}\" text:bullet-char=\"{glyph}\">{props}</text:list-level-style-bullet>"
)),
}
}
format!("<text:list-style style:name=\"{name}\">{levels}</text:list-style>")
}
struct ListFrame {
list_id: EntityId,
depth: i64,
style_name: String,
finished_items: Vec<String>,
current_item_inner: String,
has_open_item: bool,
}
#[derive(Default)]
struct ListStack {
frames: Vec<ListFrame>,
}
impl ListStack {
fn push(
&mut self,
out: &mut String,
list_id: EntityId,
depth: i64,
style_name: String,
item_body: String,
) {
while self.frames.last().is_some_and(|f| f.depth > depth) {
self.close_last(out);
}
let continues = self
.frames
.last()
.is_some_and(|f| f.depth == depth && f.list_id == list_id);
if !continues && self.frames.last().is_some_and(|f| f.depth == depth) {
self.close_last(out);
}
if !continues {
self.frames.push(ListFrame {
list_id,
depth,
style_name,
finished_items: Vec::new(),
current_item_inner: String::new(),
has_open_item: false,
});
}
if let Some(f) = self.frames.last_mut() {
if continues && f.has_open_item {
let inner = std::mem::take(&mut f.current_item_inner);
f.finished_items
.push(format!("<text:list-item>{inner}</text:list-item>"));
}
f.current_item_inner.push_str(&item_body);
f.has_open_item = true;
}
}
fn close_last(&mut self, out: &mut String) {
let Some(mut frame) = self.frames.pop() else {
return;
};
if frame.has_open_item {
let inner = std::mem::take(&mut frame.current_item_inner);
frame
.finished_items
.push(format!("<text:list-item>{inner}</text:list-item>"));
}
let list_xml = format!(
"<text:list text:style-name=\"{}\">{}</text:list>",
frame.style_name,
frame.finished_items.concat()
);
if let Some(parent) = self.frames.last_mut() {
parent.current_item_inner.push_str(&list_xml);
} else {
out.push_str(&list_xml);
}
}
fn flush(&mut self, out: &mut String) {
while !self.frames.is_empty() {
self.close_last(out);
}
}
}
fn build_run(elem: &InlineSegment, ctx: &WalkCtx, styles: &mut OdtStyleSheet) -> Option<String> {
if let InlineContent::FootnoteRef { label } = &elem.content {
let is_first = ctx
.footnote_state
.emitted
.borrow_mut()
.insert(label.clone());
if is_first && !ctx.inside_note_body {
let id = ctx.footnote_state.take_id();
let body = ctx.notes.get(label).cloned();
let body = match body {
Some(b) if !b.is_empty() => b,
_ => "<text:p/>".to_string(),
};
return Some(format!(
"<text:note text:id=\"ftn{id}\" text:note-class=\"footnote\">\
<text:note-citation>{marker}</text:note-citation>\
<text:note-body>{body}</text:note-body></text:note>",
marker = odt_render::xml_escape(&ctx.footnote_state.numbers.marker(label))
));
}
let marker = odt_render::xml_escape(&ctx.footnote_state.numbers.marker(label));
let style = styles.text_style("style:text-position=\"super 58%\"");
return Some(format!(
"<text:span text:style-name=\"{style}\">{marker}</text:span>"
));
}
let text = match &elem.content {
InlineContent::FootnoteRef { .. } => return None,
InlineContent::Text(t) => t.clone(),
InlineContent::Image {
name,
alt,
width,
height,
..
} => {
if let Some(xml) = build_image_frame(name, alt, *width, *height, ctx) {
return Some(xml);
}
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, styles))
}
fn text_run_with_format(text: &str, elem: &InlineSegment, styles: &mut OdtStyleSheet) -> String {
let mut attrs = character_style_attrs_from_flags(
elem.fmt_font_bold == Some(true),
elem.fmt_font_italic == Some(true),
elem.fmt_font_underline == Some(true),
elem.fmt_font_strikeout == Some(true),
);
if elem.fmt_font_family.as_deref() == Some("monospace") {
attrs.push_str(" style:font-name=\"Courier New\"");
}
let encoded = odt_render::encode_run_text(text);
if attrs.is_empty() {
encoded
} else {
let style = styles.text_style(attrs.trim());
format!("<text:span text:style-name=\"{style}\">{encoded}</text:span>")
}
}
struct RenderedPiece<'p> {
elem: &'p InlineSegment,
start: u32,
end: u32,
run: Option<String>,
}
fn add_inline_content(
pieces: &[(InlineSegment, u32, u32)],
ctx: &WalkCtx,
styles: &mut OdtStyleSheet,
comments: Option<(&CommentEmitState<'_>, &BlockCommentWindow<'_>)>,
) -> String {
let rendered: Vec<RenderedPiece<'_>> = pieces
.iter()
.map(|(elem, start, end)| RenderedPiece {
elem,
start: *start,
end: *end,
run: build_run(elem, ctx, styles),
})
.collect();
let mut out = String::new();
if rendered.is_empty() {
if let Some((state, window)) = comments {
for &c in &window.starts {
state.mark_started(c.id);
out.push_str(&c.open_xml);
}
for &c in window.ends.iter().rev() {
state.mark_ended(c.id);
out.push_str(&c.close_xml);
}
}
return out;
}
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) => append_piece(&mut out, &rendered[i], styles, comments),
Group::Link(href, range) => {
out.push_str(&format!(
"<text:a xlink:type=\"simple\" xlink:href=\"{}\">",
odt_render::xml_escape(&href)
));
for i in range {
append_piece(&mut out, &rendered[i], styles, comments);
}
out.push_str("</text:a>");
}
}
}
out
}
fn append_piece(
out: &mut String,
piece: &RenderedPiece<'_>,
styles: &mut OdtStyleSheet,
comments: Option<(&CommentEmitState<'_>, &BlockCommentWindow<'_>)>,
) {
let Some((state, window)) = comments else {
if let Some(run) = &piece.run {
out.push_str(run);
}
return;
};
let markers = markers_for_piece(window, piece.start, piece.end);
if markers.is_empty() {
if let Some(run) = &piece.run {
out.push_str(run);
}
return;
}
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();
out.push_str(&text_run_with_format(&slice, piece.elem, styles));
cursor = local;
}
apply_marker(out, marker, state);
}
if cursor < chars.len() {
let slice: String = chars[cursor..].iter().collect();
out.push_str(&text_run_with_format(&slice, piece.elem, styles));
}
} else {
for (idx, marker) in &markers {
if *idx == 0 {
apply_marker(out, marker, state);
}
}
if let Some(run) = &piece.run {
out.push_str(run);
}
for (idx, marker) in &markers {
if *idx != 0 {
apply_marker(out, marker, state);
}
}
}
}
fn build_image_frame(
name: &str,
alt: &str,
width: i64,
height: i64,
ctx: &WalkCtx,
) -> Option<String> {
let href = ctx.image_hrefs.get(name)?;
let image = ctx.images.get(name)?;
let (natural_w, natural_h) = if width > 0 && height > 0 {
(width, height)
} else {
use image::GenericImageView;
let decoded = image::load_from_memory(&image.bytes).ok()?;
let (w, h) = decoded.dimensions();
(
if width > 0 { width } else { w as i64 },
if height > 0 { height } else { h as i64 },
)
};
let seq = ctx.image_seq.get() + 1;
ctx.image_seq.set(seq);
let frame_name = format!("Image{seq}");
let title = if alt.is_empty() {
String::new()
} else {
format!("<svg:title>{}</svg:title>", odt_render::xml_escape(alt))
};
Some(format!(
"<draw:frame draw:name=\"{frame_name}\" svg:width=\"{}\" svg:height=\"{}\" \
text:anchor-type=\"as-char\">\
<draw:image xlink:href=\"{}\" xlink:type=\"simple\" xlink:show=\"embed\" \
xlink:actuate=\"onLoad\"/>{title}</draw:frame>",
odt_render::fmt_pt(odt_render::px_to_pt(natural_w)),
odt_render::fmt_pt(odt_render::px_to_pt(natural_h)),
odt_render::xml_escape(href),
))
}
fn build_image_href_map(images: &ExportImages) -> BTreeMap<String, String> {
images
.iter()
.enumerate()
.map(|(i, (src, image))| {
(
src.clone(),
format!("Pictures/img_{:03}.{}", i + 1, image.extension()),
)
})
.collect()
}
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(())
}