use crate::ExportPdfDto;
use crate::ExportPdfResultDto;
use crate::typst_compile::compile_typst_pdf;
use crate::typst_markup::{render_blocks_typst, render_table_typst, typst_preamble};
use anyhow::{Result, anyhow};
use common::database::QueryUnitOfWork;
use common::entities::{Block, Document, Frame, List, Root, Table, TableCell};
use common::long_operation::{LongOperation, OperationProgress};
use common::types::{EntityId, ROOT_ENTITY_ID};
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
pub trait ExportPdfUnitOfWorkFactoryTrait: Send + Sync {
fn create(&self) -> Box<dyn ExportPdfUnitOfWorkTrait>;
}
#[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 ExportPdfUnitOfWorkTrait: QueryUnitOfWork + Send + Sync {}
pub struct ExportPdfUseCase {
uow_factory: Box<dyn ExportPdfUnitOfWorkFactoryTrait>,
dto: ExportPdfDto,
}
impl ExportPdfUseCase {
pub fn new(uow_factory: Box<dyn ExportPdfUnitOfWorkFactoryTrait>, dto: &ExportPdfDto) -> Self {
ExportPdfUseCase {
uow_factory,
dto: dto.clone(),
}
}
}
impl LongOperation for ExportPdfUseCase {
type Output = ExportPdfResultDto;
fn execute(
&self,
progress_callback: Box<dyn Fn(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(OperationProgress::new(
0.0,
Some("Starting PDF export...".to_string()),
));
let uow = self.uow_factory.create();
uow.begin_transaction()?;
let build_result = self.build_markup(
&*uow,
progress_callback.as_ref(),
Some(cancel_flag.as_ref()),
);
uow.end_transaction()?;
let markup = build_result?;
progress_callback(OperationProgress::new(
85.0,
Some("Compiling Typst to PDF...".to_string()),
));
let (pdf_bytes, page_count) =
compile_typst_pdf(&markup, self.dto.options.font_bytes.clone())?;
progress_callback(OperationProgress::new(
95.0,
Some("Writing PDF file...".to_string()),
));
std::fs::write(&self.dto.output_path, &pdf_bytes).map_err(|e| {
anyhow!(
"Failed to write output file '{}': {}",
self.dto.output_path,
e
)
})?;
progress_callback(OperationProgress::new(100.0, Some("completed".to_string())));
Ok(ExportPdfResultDto {
file_path: self.dto.output_path.clone(),
page_count: page_count as i64,
})
}
}
impl ExportPdfUseCase {
pub(crate) fn build_document(&self) -> Result<(Vec<u8>, i64)> {
let uow = self.uow_factory.create();
uow.begin_transaction()?;
let result = self.build_markup(&*uow, &|_progress| {}, None);
uow.end_transaction()?;
let markup = result?;
let (pdf_bytes, page_count) =
compile_typst_pdf(&markup, self.dto.options.font_bytes.clone())?;
Ok((pdf_bytes, page_count as i64))
}
fn build_markup(
&self,
uow: &dyn ExportPdfUnitOfWorkTrait,
progress_callback: &dyn Fn(OperationProgress),
cancel_flag: Option<&AtomicBool>,
) -> Result<String> {
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(OperationProgress::new(
10.0,
Some("Walking document tree...".to_string()),
));
let mut body_parts: Vec<String> = Vec::new();
let total_frames = frame_ids.len().max(1);
for (frame_idx, frame_id) in frame_ids.iter().enumerate() {
check_cancelled(cancel_flag)?;
if cell_frame_ids.contains(frame_id) {
continue;
}
if let Some(f) = uow.get_frame(frame_id)?
&& f.parent_frame.is_some()
{
continue;
}
let frame_typst = self.render_frame_typst(uow, frame_id, &cell_frame_ids)?;
if !frame_typst.is_empty() {
body_parts.push(frame_typst);
}
let pct = 10.0 + (frame_idx as f32 / total_frames as f32) * 70.0;
progress_callback(OperationProgress::new(
pct,
Some(format!(
"Processing frame {}/{}",
frame_idx + 1,
total_frames
)),
));
}
progress_callback(OperationProgress::new(
80.0,
Some("Assembling document...".to_string()),
));
let body = body_parts.join("\n\n");
let preamble = typst_preamble(&self.dto.options);
Ok(if preamble.is_empty() {
body
} else {
format!("{preamble}\n{body}")
})
}
fn render_frame_typst(
&self,
uow: &dyn ExportPdfUnitOfWorkTrait,
frame_id: &EntityId,
cell_frame_ids: &HashSet<EntityId>,
) -> Result<String> {
let frame = uow
.get_frame(frame_id)?
.ok_or_else(|| anyhow!("Frame not found"))?;
if let Some(table_id) = frame.table {
return render_table_typst(&uow.store(), table_id);
}
if !frame.child_order.is_empty() {
return self.render_frame_typst_by_child_order(uow, &frame, cell_frame_ids);
}
let block_ids = uow.get_frame_relationship(
frame_id,
&common::direct_access::frame::FrameRelationshipField::Blocks,
)?;
if block_ids.is_empty() {
return Ok(String::new());
}
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);
Ok(render_blocks_typst(
&uow.store(),
&blocks,
&self.dto.options,
))
}
fn render_frame_typst_by_child_order(
&self,
uow: &dyn ExportPdfUnitOfWorkTrait,
frame: &Frame,
cell_frame_ids: &HashSet<EntityId>,
) -> Result<String> {
let mut parts: Vec<String> = Vec::new();
let mut pending_blocks: Vec<Block> = Vec::new();
for &entry in &frame.child_order {
if entry > 0 {
let block_id = entry as u64;
if let Some(block) = uow.get_block(&block_id)? {
pending_blocks.push(block);
}
} else {
if !pending_blocks.is_empty() {
let typst =
render_blocks_typst(&uow.store(), &pending_blocks, &self.dto.options);
if !typst.is_empty() {
parts.push(typst);
}
pending_blocks.clear();
}
let sub_frame_id = (-entry) as u64;
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 sf.fmt_is_blockquote == Some(true) {
let inner = self.render_frame_typst(uow, &sub_frame_id, cell_frame_ids)?;
if !inner.is_empty() {
parts.push(format!("#quote(block: true)[{inner}]"));
}
} else {
let inner = self.render_frame_typst(uow, &sub_frame_id, cell_frame_ids)?;
if !inner.is_empty() {
parts.push(inner);
}
}
}
}
}
if !pending_blocks.is_empty() {
let typst = render_blocks_typst(&uow.store(), &pending_blocks, &self.dto.options);
if !typst.is_empty() {
parts.push(typst);
}
}
Ok(parts.join("\n\n"))
}
}
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(())
}