use crate::ExportDjotDto;
use anyhow::{Result, anyhow};
use common::database::QueryUnitOfWork;
use common::database::rope_helpers::block_content_via_store;
use common::entities::{
Alignment, Block, CharVerticalAlignment, Document, Frame, List, ListStyle, MarkerType, Root,
SemanticRole, Table, TableCell, TextDirection,
};
use common::format_runs::{InlineContent, InlineSegment};
use common::parser_tools::DjotExportOptions;
use common::types::{EntityId, ROOT_ENTITY_ID};
use std::collections::HashSet;
pub trait ExportDjotUnitOfWorkFactoryTrait: Send + Sync {
fn create(&self) -> Box<dyn ExportDjotUnitOfWorkTrait>;
}
#[macros::uow_action(entity = "Root", action = "GetRO")]
#[macros::uow_action(entity = "Root", action = "GetRelationshipRO")]
#[macros::uow_action(entity = "Document", action = "GetRO")]
#[macros::uow_action(entity = "Document", action = "GetRelationshipRO")]
#[macros::uow_action(entity = "Frame", action = "GetRO")]
#[macros::uow_action(entity = "Frame", action = "GetMultiRO")]
#[macros::uow_action(entity = "Frame", action = "GetRelationshipRO")]
#[macros::uow_action(entity = "Block", action = "GetRO")]
#[macros::uow_action(entity = "Block", action = "GetMultiRO")]
#[macros::uow_action(entity = "Block", action = "GetRelationshipRO")]
#[macros::uow_action(entity = "List", action = "GetRO")]
#[macros::uow_action(entity = "Table", action = "GetRO")]
#[macros::uow_action(entity = "Table", action = "GetRelationshipRO")]
#[macros::uow_action(entity = "TableCell", action = "GetMultiRO")]
pub trait ExportDjotUnitOfWorkTrait: QueryUnitOfWork {}
pub struct ExportDjotUseCase {
uow_factory: Box<dyn ExportDjotUnitOfWorkFactoryTrait>,
omit_images: bool,
}
impl ExportDjotUseCase {
pub fn new(uow_factory: Box<dyn ExportDjotUnitOfWorkFactoryTrait>) -> Self {
ExportDjotUseCase {
uow_factory,
omit_images: false,
}
}
pub fn execute(&mut self, options: &DjotExportOptions) -> Result<ExportDjotDto> {
self.omit_images = options.omit_images;
let uow = self.uow_factory.create();
uow.begin_transaction()?;
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);
}
}
}
let mut output_parts: Vec<String> = Vec::new();
for frame_id in &frame_ids {
if cell_frame_ids.contains(frame_id) {
continue;
}
let frame = uow.get_frame(frame_id)?;
if let Some(ref f) = frame
&& f.parent_frame.is_some()
{
continue;
}
if let Some(ref f) = frame
&& let Some(table_id) = f.table
{
let table_md = self.render_table_djot(&*uow, &table_id)?;
if !output_parts.is_empty() {
output_parts.push("\n\n".to_string());
}
output_parts.push(table_md);
continue;
}
if let Some(ref f) = frame {
let frame_text =
self.render_frame_content(&*uow, f, &cell_frame_ids, "", options)?;
if !frame_text.is_empty() {
if !output_parts.is_empty() {
output_parts.push("\n\n".to_string());
}
if let Some(ref label) = f.footnote_label {
let mut lines = frame_text.lines();
let mut out = String::new();
if let Some(first) = lines.next() {
out.push_str(&format!("[^{label}]: {first}"));
}
for line in lines {
out.push('\n');
if line.is_empty() {
continue;
}
out.push_str(" ");
out.push_str(line);
}
output_parts.push(out);
} else {
output_parts.push(frame_text);
}
}
}
}
uow.end_transaction()?;
let djot_text = output_parts.concat();
Ok(ExportDjotDto { djot_text })
}
fn render_frame_content(
&self,
uow: &dyn ExportDjotUnitOfWorkTrait,
frame: &Frame,
cell_frame_ids: &HashSet<EntityId>,
quote_prefix: &str,
options: &DjotExportOptions,
) -> Result<String> {
let mut result = String::new();
let mut ordered_list_counter: i64 = 0;
let mut current_list_id: Option<EntityId> = None;
let mut first_block_role = frame.fmt_semantic_role.as_ref();
let use_child_order = !frame.child_order.is_empty();
if use_child_order {
for &entry in &frame.child_order {
if entry > 0 {
let block_id = entry as EntityId;
let block = uow.get_block(&block_id)?;
if let Some(ref b) = block {
let (line, _) = self.render_block_line(
uow,
b,
quote_prefix,
first_block_role.take(),
&mut ordered_list_counter,
&mut current_list_id,
options,
)?;
if !result.is_empty() {
result.push_str("\n\n");
}
result.push_str(&line);
}
} else {
let sub_frame_id = (-entry) as EntityId;
if cell_frame_ids.contains(&sub_frame_id) {
continue;
}
let sub_frame = uow.get_frame(&sub_frame_id)?;
if let Some(ref sf) = sub_frame {
if let Some(table_id) = sf.table {
let table_md = self.render_table_djot(uow, &table_id)?;
let prefixed = if !quote_prefix.is_empty() {
prefix_lines(&table_md, quote_prefix)
} else {
table_md
};
if !result.is_empty() {
result.push_str("\n\n");
}
result.push_str(&prefixed);
current_list_id = None;
ordered_list_counter = 0;
continue;
}
let sub_prefix = if sf.fmt_is_blockquote == Some(true) {
format!("{}> ", quote_prefix)
} else {
quote_prefix.to_string()
};
let sub_text = self.render_frame_content(
uow,
sf,
cell_frame_ids,
&sub_prefix,
options,
)?;
if !sub_text.is_empty() {
if !result.is_empty() {
result.push_str("\n\n");
}
result.push_str(&sub_text);
}
current_list_id = None;
ordered_list_counter = 0;
}
}
}
} else {
let block_ids = uow.get_frame_relationship(
&frame.id,
&common::direct_access::frame::FrameRelationshipField::Blocks,
)?;
if block_ids.is_empty() {
return Ok(result);
}
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 {
let (line, _) = self.render_block_line(
uow,
block,
quote_prefix,
first_block_role.take(),
&mut ordered_list_counter,
&mut current_list_id,
options,
)?;
if !result.is_empty() {
result.push_str("\n\n");
}
result.push_str(&line);
}
}
Ok(result)
}
#[allow(clippy::too_many_arguments)]
fn render_block_line(
&self,
uow: &dyn ExportDjotUnitOfWorkTrait,
block: &Block,
quote_prefix: &str,
frame_role: Option<&SemanticRole>,
ordered_list_counter: &mut i64,
current_list_id: &mut Option<EntityId>,
options: &DjotExportOptions,
) -> Result<(String, bool)> {
if block.fmt_is_code_block == Some(true) {
let lang = block.fmt_code_language.as_deref().unwrap_or("");
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 mut raw_text = String::new();
for elem in &elements {
match &elem.content {
InlineContent::Text(t) => raw_text.push_str(t),
InlineContent::Empty => {}
InlineContent::Image { .. } | InlineContent::FootnoteRef { .. } => {}
}
}
let code_block = if quote_prefix.is_empty() {
format!("```{}\n{}\n```", lang, raw_text)
} else {
let mut lines = Vec::new();
lines.push(format!("{}```{}", quote_prefix, lang));
for line in raw_text.lines() {
lines.push(format!("{}{}", quote_prefix, line));
}
if raw_text.is_empty() {
lines.push(quote_prefix.to_string());
}
lines.push(format!("{}```", quote_prefix));
lines.join("\n")
};
*current_list_id = None;
*ordered_list_counter = 0;
return Ok((code_block, false));
}
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 list_ids = uow.get_block_relationship(
&block.id,
&common::direct_access::block::BlockRelationshipField::List,
)?;
let list = if let Some(list_id) = list_ids.first() {
uow.get_list(list_id)?
} else {
None
};
let is_list_item = list.is_some();
let inline_md = self.render_inline_segments(&elements)?;
let attr_line = render_block_attrs(block, frame_role, options);
let block_line = if let Some(level) = block.fmt_heading_level {
let prefix = "#".repeat(level as usize);
let head = format!("{}{} {}", quote_prefix, prefix, inline_md);
prepend_block_attrs(&attr_line, quote_prefix, head)
} else if let Some(ref list_entity) = list {
let indent_prefix = " ".repeat(list_entity.indent.max(0) as usize);
let is_task = matches!(
block.fmt_marker,
Some(MarkerType::Checked) | Some(MarkerType::Unchecked)
);
if is_task {
let bullet = djot_bullet_char(&list_entity.style);
let check = if block.fmt_marker == Some(MarkerType::Checked) {
"[x]"
} else {
"[ ]"
};
format!("{quote_prefix}{indent_prefix}{bullet} {check} {inline_md}")
} else {
match list_entity.style {
ListStyle::Decimal
| ListStyle::LowerAlpha
| ListStyle::UpperAlpha
| ListStyle::LowerRoman
| ListStyle::UpperRoman => {
let this_list_id = list_ids.first().copied();
if this_list_id != *current_list_id {
*ordered_list_counter = 1;
*current_list_id = this_list_id;
} else {
*ordered_list_counter += 1;
}
let token = djot_ordered_token(&list_entity.style, *ordered_list_counter);
let suffix = if list_entity.suffix.is_empty() {
"."
} else {
list_entity.suffix.as_str()
};
format!(
"{quote_prefix}{indent_prefix}{}{token}{suffix} {inline_md}",
list_entity.prefix
)
}
_ => {
let bullet = djot_bullet_char(&list_entity.style);
format!("{quote_prefix}{indent_prefix}{bullet} {inline_md}")
}
}
}
} else {
*current_list_id = None;
*ordered_list_counter = 0;
let para = format!("{}{}", quote_prefix, guard_block_start(&inline_md));
prepend_block_attrs(&attr_line, quote_prefix, para)
};
Ok((block_line, is_list_item))
}
fn render_inline_segments(&self, elements: &[InlineSegment]) -> Result<String> {
let mut inline = String::new();
for elem in elements {
let is_code = elem.fmt_font_family.as_deref() == Some("monospace");
let (lead, mut formatted, trail): (&str, String, &str) = match &elem.content {
InlineContent::Text(t) if is_code => ("", djot_inline_code(t), ""),
InlineContent::Text(t) => {
let (l, core, tr) = split_surrounding_ws(t);
if core.is_empty() {
inline.push_str(t);
continue;
}
(l, escape_djot(core), tr)
}
InlineContent::Image {
name,
alt,
width,
height,
..
} => {
if self.omit_images {
continue;
}
let mut out = format!("", escape_djot(alt), name);
if *width > 0 && *height > 0 {
out.push_str(&format!("{{width={width} height={height}}}"));
}
("", out, "")
}
InlineContent::FootnoteRef { label } => {
inline.push_str(&format!("[^{label}]"));
continue;
}
InlineContent::Empty => continue,
};
if let Some(ref href) = elem.fmt_anchor_href {
formatted = format!("[{formatted}]({})", djot_link_dest(href));
}
if elem.fmt_vertical_alignment == Some(CharVerticalAlignment::SubScript) {
formatted = format!("~{formatted}~");
} else if elem.fmt_vertical_alignment == Some(CharVerticalAlignment::SuperScript) {
formatted = format!("^{formatted}^");
}
if elem.fmt_font_strikeout == Some(true) {
formatted = format!("{{-{formatted}-}}");
}
if elem.fmt_font_underline == Some(true) {
formatted = format!("{{+{formatted}+}}");
}
if elem.fmt_font_bold == Some(true) && elem.fmt_font_italic == Some(true) {
formatted = format!("*_{formatted}_*");
} else if elem.fmt_font_bold == Some(true) {
formatted = format!("*{formatted}*");
} else if elem.fmt_font_italic == Some(true) {
formatted = format!("_{formatted}_");
}
inline.push_str(lead);
inline.push_str(&formatted);
inline.push_str(trail);
}
Ok(inline)
}
fn render_table_djot(
&self,
uow: &dyn ExportDjotUnitOfWorkTrait,
table_id: &EntityId,
) -> 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 as usize;
let cols = table.columns as usize;
let mut grid: Vec<Vec<String>> = vec![vec![String::new(); cols]; rows];
for cell in &cells {
let r = cell.row as usize;
let c = cell.column as usize;
if r >= rows || c >= cols {
continue;
}
let mut cell_text = String::new();
if let Some(cf_id) = cell.cell_frame {
let block_ids = uow.get_frame_relationship(
&cf_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 parts: Vec<String> = Vec::new();
for block in &blocks {
let inline_md = self.render_inline_djot(uow, block)?;
if !inline_md.is_empty() {
parts.push(inline_md);
}
}
cell_text = parts.join(" ");
}
grid[r][c] = cell_text;
}
let mut md = String::new();
for (r, row) in grid.iter().enumerate() {
md.push('|');
for cell_text in row {
md.push(' ');
md.push_str(cell_text);
md.push_str(" |");
}
md.push('\n');
if r == 0 {
md.push('|');
for _ in 0..cols {
md.push_str("---|");
}
md.push('\n');
}
}
if md.ends_with('\n') {
md.pop();
}
Ok(md)
}
fn render_inline_djot(
&self,
uow: &dyn ExportDjotUnitOfWorkTrait,
block: &Block,
) -> Result<String> {
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,
);
self.render_inline_segments(&elements)
}
}
fn prefix_lines(text: &str, prefix: &str) -> String {
text.lines()
.map(|line| format!("{}{}", prefix, line))
.collect::<Vec<_>>()
.join("\n")
}
fn render_block_attrs(
block: &Block,
frame_role: Option<&SemanticRole>,
options: &DjotExportOptions,
) -> String {
let mut pairs: Vec<String> = Vec::new();
if options.semantic_role
&& let Some(role) = frame_role
{
let v = match role {
SemanticRole::Epigraph => "epigraph",
};
pairs.push(format!("semantic_role={v}"));
}
if options.alignment
&& let Some(alignment) = &block.fmt_alignment
{
let v = match alignment {
Alignment::Left => "left",
Alignment::Right => "right",
Alignment::Center => "center",
Alignment::Justify => "justify",
};
pairs.push(format!("alignment={v}"));
}
if options.line_height
&& let Some(lh) = block.fmt_line_height
{
pairs.push(format!("line_height={lh}"));
}
if options.direction
&& let Some(direction) = &block.fmt_direction
{
let v = match direction {
TextDirection::LeftToRight => "ltr",
TextDirection::RightToLeft => "rtl",
};
pairs.push(format!("direction={v}"));
}
if options.non_breakable_lines
&& let Some(nbl) = block.fmt_non_breakable_lines
{
pairs.push(format!("non_breakable_lines={nbl}"));
}
if options.page_break_before
&& let Some(pbb) = block.fmt_page_break_before
{
pairs.push(format!("page_break_before={pbb}"));
}
if options.background_color
&& let Some(bg) = &block.fmt_background_color
{
pairs.push(format!("background_color={}", djot_attr_value(bg)));
}
if options.top_margin
&& let Some(tm) = block.fmt_top_margin
{
pairs.push(format!("top_margin={tm}"));
}
if options.text_indent
&& let Some(ti) = block.fmt_text_indent
{
pairs.push(format!("text_indent={ti}"));
}
if pairs.is_empty() {
String::new()
} else {
format!("{{{}}}", pairs.join(" "))
}
}
fn prepend_block_attrs(attr_line: &str, quote_prefix: &str, body: String) -> String {
if attr_line.is_empty() {
body
} else {
format!("{quote_prefix}{attr_line}\n{body}")
}
}
fn djot_attr_value(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn escape_djot(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '*' | '_' | '`' | '~' | '^' | '[' | ']' | '(' | ')' | '{' | '}' | '|' | '<' => {
result.push('\\');
result.push(c);
}
_ => result.push(c),
}
}
result
}
fn guard_block_start(s: &str) -> String {
let Some(first) = s.chars().next() else {
return s.to_string();
};
if matches!(first, '#' | '>' | '-' | '+' | ':') {
return format!("\\{s}");
}
if first.is_ascii_digit() {
let rest = s.trim_start_matches(|c: char| c.is_ascii_digit());
if rest.starts_with('.') || rest.starts_with(')') {
let digits_len = s.len() - rest.len();
return format!("{}\\{}", &s[..digits_len], &s[digits_len..]);
}
}
s.to_string()
}
fn djot_bullet_char(style: &ListStyle) -> &'static str {
match style {
ListStyle::Circle => "*",
ListStyle::Square => "+",
_ => "-",
}
}
fn djot_ordered_token(style: &ListStyle, n: i64) -> String {
match style {
ListStyle::LowerAlpha => djot_alpha(n, false),
ListStyle::UpperAlpha => djot_alpha(n, true),
ListStyle::LowerRoman => djot_roman(n).to_lowercase(),
ListStyle::UpperRoman => djot_roman(n),
_ => n.max(1).to_string(),
}
}
fn djot_alpha(n: i64, upper: bool) -> String {
let n = n.max(1);
let base = if upper { b'A' } else { b'a' };
let mut n = n as u64;
let mut s = Vec::new();
while n > 0 {
let rem = ((n - 1) % 26) as u8;
s.push(base + rem);
n = (n - 1) / 26;
}
s.reverse();
String::from_utf8(s).unwrap()
}
fn djot_roman(n: i64) -> String {
let mut n = n.max(1);
let table = [
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
];
let mut out = String::new();
for (value, sym) in table {
while n >= value {
out.push_str(sym);
n -= value;
}
}
out
}
fn djot_inline_code(text: &str) -> String {
let mut max_run = 0usize;
let mut cur = 0usize;
for c in text.chars() {
if c == '`' {
cur += 1;
max_run = max_run.max(cur);
} else {
cur = 0;
}
}
let fence = "`".repeat(max_run + 1);
let lead = if text.starts_with('`') { " " } else { "" };
let trail = if text.ends_with('`') { " " } else { "" };
format!("{fence}{lead}{text}{trail}{fence}")
}
fn split_surrounding_ws(s: &str) -> (&str, &str, &str) {
let is_ws = |c: char| c == ' ' || c == '\t';
let start = s.len() - s.trim_start_matches(is_ws).len();
let end = s.trim_end_matches(is_ws).len();
if end <= start {
(s, "", "")
} else {
(&s[..start], &s[start..end], &s[end..])
}
}
fn djot_link_dest(href: &str) -> String {
if href.contains([')', '(', ' ', '<', '>']) {
format!("<{}>", href.replace('>', "%3E").replace('<', "%3C"))
} else {
href.to_string()
}
}