text-document-io 1.12.0

Import/export for text-document: plain text, Markdown, HTML, LaTeX, DOCX
Documentation
// Generated by Qleany v1.4.8 from feature_use_case.tera
use crate::ExportHtmlDto;
use crate::html_render;
use anyhow::{Result, anyhow};
use common::database::QueryUnitOfWork;
use common::entities::{Block, Document, Frame, List, Root, SemanticRole, Table, TableCell};
use common::parser_tools::{HtmlExportOptions, HtmlImageMode};
use common::types::{EntityId, ROOT_ENTITY_ID};
use std::collections::HashSet;

pub trait ExportHtmlUnitOfWorkFactoryTrait: Send + Sync {
    fn create(&self) -> Box<dyn ExportHtmlUnitOfWorkTrait>;
}

#[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 = "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 ExportHtmlUnitOfWorkTrait: QueryUnitOfWork {}

pub struct ExportHtmlUseCase {
    uow_factory: Box<dyn ExportHtmlUnitOfWorkFactoryTrait>,
    options: HtmlExportOptions,
}

impl ExportHtmlUseCase {
    /// One constructor, taking the options — the shape
    /// `ExportMarkdownUseCase::new` already uses. `HtmlExportOptions::default()`
    /// is [`HtmlImageMode::Reference`], which emits `src` verbatim and is what
    /// plain `to_html` has always produced.
    pub fn new(
        uow_factory: Box<dyn ExportHtmlUnitOfWorkFactoryTrait>,
        options: HtmlExportOptions,
    ) -> Self {
        ExportHtmlUseCase {
            uow_factory,
            options,
        }
    }

    /// The rendering policy implied by this export's options.
    fn image_policy(&self) -> html_render::HtmlImagePolicy<'_> {
        match self.options.image_mode {
            HtmlImageMode::Reference => html_render::HtmlImagePolicy::Reference,
            HtmlImageMode::DataUri => html_render::HtmlImagePolicy::DataUri(&self.options.images),
            HtmlImageMode::Omit => html_render::HtmlImagePolicy::Omit,
        }
    }

    pub fn execute(&mut self) -> Result<ExportHtmlDto> {
        let uow = self.uow_factory.create();
        uow.begin_transaction()?;

        // Step 1: Get Root and Document
        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,
        )?;

        // Collect all cell frame IDs so we can skip them in the main loop
        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 notes = crate::footnotes::Footnotes::build(&uow.store());

        let mut body_parts: Vec<String> = Vec::new();

        for frame_id in &frame_ids {
            // Skip cell frames — they're rendered as part of their table
            if cell_frame_ids.contains(frame_id) {
                continue;
            }
            // Skip note bodies. They are top-level frames, so this outer walk
            // would otherwise render each one as ordinary prose, in the middle
            // of the chapter, wherever its definition was typed. They come back
            // below as `<aside>`s.
            if notes.is_definition(*frame_id) {
                continue;
            }
            // Skip sub-frames (parent_frame != None) — recursively rendered
            // by their parent's render_frame_html walk; rendering at the
            // top level again would duplicate their content.
            if let Some(f) = uow.get_frame(frame_id)?
                && f.parent_frame.is_some()
            {
                continue;
            }

            let frame_html = self.render_frame_html(&*uow, frame_id, &cell_frame_ids, &notes)?;
            if !frame_html.is_empty() {
                body_parts.push(frame_html);
            }
        }

        // The notes themselves, after the prose. `doc-footnote` on an `<aside>`
        // is what a reading system turns into a pop-up; the back-link is what
        // lets a reader who followed the marker get back to the sentence.
        for (number, label, frame_id) in notes.in_print_order() {
            let body = self.render_frame_html(&*uow, &frame_id, &cell_frame_ids, &notes)?;
            let id = crate::html_render::escape_html(&label);
            body_parts.push(format!(
                "<aside epub:type=\"footnote\" role=\"doc-footnote\" id=\"fn-{id}\">\
                 <a href=\"#fnref-{id}\" role=\"doc-backlink\">{number}</a>. {body}</aside>"
            ));
        }

        uow.end_transaction()?;

        let html_text = format!(
            "<html><head><meta charset=\"utf-8\"></head><body>{}</body></html>",
            body_parts.join("")
        );

        Ok(ExportHtmlDto { html_text })
    }

    /// Render a frame's content as HTML, walking its `child_order` to interleave
    /// blocks and sub-frames (blockquotes). Falls back to sorted blocks when
    /// `child_order` is empty.
    fn render_frame_html(
        &self,
        uow: &dyn ExportHtmlUnitOfWorkTrait,
        frame_id: &EntityId,
        cell_frame_ids: &HashSet<EntityId>,
        notes: &crate::footnotes::Footnotes,
    ) -> Result<String> {
        let image_policy = self.image_policy();
        let frame = uow
            .get_frame(frame_id)?
            .ok_or_else(|| anyhow!("Frame not found"))?;

        // Table anchor frame — render the table instead of blocks
        if let Some(table_id) = frame.table {
            return html_render::render_table_html(&uow.store(), table_id, image_policy, notes);
        }

        // If child_order is populated, use it to interleave blocks and sub-frames
        if !frame.child_order.is_empty() {
            return self.render_frame_by_child_order(uow, &frame, cell_frame_ids, notes);
        }

        // Fallback: render all blocks in document_position order (original behaviour)
        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(html_render::render_blocks_html(
            &uow.store(),
            &blocks,
            image_policy,
            notes,
        ))
    }

    /// Walk `child_order` entries: positive values are block IDs, negative values
    /// are negated sub-frame IDs.
    fn render_frame_by_child_order(
        &self,
        uow: &dyn ExportHtmlUnitOfWorkTrait,
        frame: &Frame,
        cell_frame_ids: &HashSet<EntityId>,
        notes: &crate::footnotes::Footnotes,
    ) -> Result<String> {
        let image_policy = self.image_policy();
        let mut parts: Vec<String> = Vec::new();
        // Accumulate consecutive blocks so we can group list items
        let mut pending_blocks: Vec<Block> = Vec::new();

        for &entry in &frame.child_order {
            if entry > 0 {
                // Positive: block ID
                let block_id = entry as u64;
                if let Some(block) = uow.get_block(&block_id)? {
                    pending_blocks.push(block);
                }
            } else {
                // Negative: negated sub-frame ID
                // First, flush any accumulated blocks
                if !pending_blocks.is_empty() {
                    let html = html_render::render_blocks_html(
                        &uow.store(),
                        &pending_blocks,
                        image_policy,
                        notes,
                    );
                    if !html.is_empty() {
                        parts.push(html);
                    }
                    pending_blocks.clear();
                }

                let sub_frame_id = (-entry) as u64;

                // Skip cell frames
                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) {
                        // Recursively render the blockquote frame content
                        let inner =
                            self.render_frame_html(uow, &sub_frame_id, cell_frame_ids, notes)?;
                        if !inner.is_empty() {
                            // A blockquote standing in for something a format can name
                            // gets said so. `epub:type` is the EPUB Structural Semantics
                            // vocabulary; `role` is DPUB-ARIA, and both are needed —
                            // `epub:type` alone reaches no assistive technology, which is
                            // the whole point of marking it.
                            let semantics = match &sf.fmt_semantic_role {
                                Some(SemanticRole::Epigraph) => {
                                    r#" epub:type="epigraph" role="doc-epigraph""#
                                }
                                None => "",
                            };
                            parts.push(format!("<blockquote{}>{}</blockquote>", semantics, inner));
                        }
                    } else {
                        // Non-blockquote sub-frame: render normally
                        let inner =
                            self.render_frame_html(uow, &sub_frame_id, cell_frame_ids, notes)?;
                        if !inner.is_empty() {
                            parts.push(inner);
                        }
                    }
                }
            }
        }

        // Flush remaining blocks
        if !pending_blocks.is_empty() {
            let html =
                html_render::render_blocks_html(&uow.store(), &pending_blocks, image_policy, notes);
            if !html.is_empty() {
                parts.push(html);
            }
        }

        Ok(parts.join(""))
    }
}