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::ExportPlainTextDto;
use anyhow::{Result, anyhow};
use common::database::QueryUnitOfWork;
use common::database::Store;
use common::database::rope_helpers::{block_content_via_store, rope_flat_text_if_simple};
use common::entities::{Block, Document, Frame, Root};
use common::format_runs::InlineContent;
use common::format_runs_query::inline_segments_for_block;
use common::parser_tools::{FORM_FEED, PlainTextExportOptions};
use common::types::{EntityId, ROOT_ENTITY_ID};
use std::collections::HashMap;

/// One level of blockquote indentation in the plain-text export.
///
/// Four spaces, the plain-text convention a `.txt` manuscript uses for quoted matter
/// (Shunn sets block quotations in from both margins; only the left one survives a
/// format with no margins to speak of). Deliberately not `"> "`: that is Markdown's
/// marker, and this export is the one that promises no markup at all.
const QUOTE_INDENT: &str = "    ";

/// How many blockquote frames enclose `frame_id`, walking up `parent_frame`.
///
/// A frame whose parent chain is broken (a parent missing from the document's own frame
/// list, which should not happen) stops the walk rather than looping or failing: an
/// under-indented line is a cosmetic loss, and this export must not be the thing that
/// refuses to produce a file.
fn blockquote_depth(frame_id: EntityId, frames: &HashMap<EntityId, Frame>) -> usize {
    let mut depth = 0;
    let mut current = Some(frame_id);
    // Bounded by the frame count: a cycle would otherwise hang the export, and this runs
    // on data that has been through an importer.
    let mut guard = frames.len() + 1;
    while let Some(id) = current {
        if guard == 0 {
            break;
        }
        guard -= 1;
        let Some(frame) = frames.get(&id) else { break };
        if frame.fmt_is_blockquote == Some(true) {
            depth += 1;
        }
        current = frame.parent_frame;
    }
    depth
}

/// Indent every non-empty line of `text` by `depth` levels.
///
/// Blank lines are left bare on purpose — indenting one would emit trailing whitespace,
/// which is invisible in the editor, survives into the exported file, and is exactly the
/// kind of thing a diff of two exports trips over.
fn indent_quoted(text: &str, depth: usize) -> String {
    let prefix = QUOTE_INDENT.repeat(depth);
    text.split('\n')
        .map(|line| {
            if line.trim().is_empty() {
                line.to_string()
            } else {
                format!("{prefix}{line}")
            }
        })
        .collect::<Vec<String>>()
        .join("\n")
}

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

#[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 = "GetMultiRO")]
pub trait ExportPlainTextUnitOfWorkTrait: QueryUnitOfWork {}

/// Drop inline-image sentinels from plain-text output, when asked.
///
/// An image occupies one `U+FFFC` OBJECT REPLACEMENT CHARACTER in the document
/// text. Plain text has no way to represent a picture, and emitting the raw
/// sentinel put an unrenderable box in the writer's `.txt` — so the image is
/// omitted rather than transliterated. Its alt text is deliberately *not*
/// substituted: alt describes the image for a reader who cannot see it, and
/// silently promoting it to prose would put words in the manuscript that the
/// writer never typed.
///
/// Only for the *presentation* view. Removing the sentinel removes a character
/// the document counts, so an addressable view that stripped it would hand every
/// caller — search, a cursor, a comment's anchor — offsets that drift one place
/// per image. See `PlainTextExportOptions::strip_images`.
///
/// **Only safe when `text` holds no footnote-reference sentinel.** A footnote
/// reference occupies the identical `U+FFFC` codepoint (see
/// `common::format_runs::FootnoteRefAnchor`'s doc) and this is a blind
/// string replace with no way to tell the two apart — used only by the
/// rope fast path below, which refuses to run at all when the document has
/// any footnotes and `strip_images`/`endnote_footnotes` might act on them.
/// The general (slow, per-block) path uses [`render_block_plain_text`]
/// instead, which walks typed inline segments and so never confuses the two.
fn strip_image_sentinels(text: &str, strip: bool) -> String {
    if strip && text.contains('\u{FFFC}') {
        text.replace('\u{FFFC}', "")
    } else {
        text.to_string()
    }
}

/// Render one block's plain-text form, resolving its `U+FFFC` inline anchors instead of
/// leaving both kinds of anchor as the identical, indistinguishable control character
/// [`strip_image_sentinels`] cannot tell apart.
///
/// An image's sentinel is dropped when `strip_images` asks for it — there is no way to draw a
/// picture in a `.txt` — exactly as `strip_image_sentinels` already did. A footnote reference's
/// sentinel becomes its printed marker (`[1]`, matching the endnote list `execute` appends)
/// when `mark_footnotes` asks for it: `endnote_footnotes`, since a citation left as a bare
/// invisible sentinel while its body moves to a numbered list at the end of the file is a
/// citation with no visible point at all — the export's own `endnote_footnotes` presentation
/// choice, not `strip_images`, decides whether that citation point should be described in
/// words. Neither flag active reproduces `block_content_via_store`'s text byte for byte, which
/// the addressable view (`PlainTextExportOptions::addressable`) requires character-for-
/// character.
fn render_block_plain_text(
    store: &Store,
    block: &Block,
    block_text: &str,
    strip_images: bool,
    mark_footnotes: bool,
    notes: &crate::footnotes::Footnotes,
) -> String {
    let elements = inline_segments_for_block(store, block.id, block_text);
    let mut out = String::with_capacity(block_text.len());
    for elem in &elements {
        match &elem.content {
            InlineContent::Text(t) => out.push_str(t),
            InlineContent::Image { .. } => {
                if !strip_images {
                    out.push('\u{FFFC}');
                }
            }
            InlineContent::FootnoteRef { label } => {
                if mark_footnotes {
                    out.push_str(&format!("[{}]", notes.marker(label)));
                } else {
                    out.push('\u{FFFC}');
                }
            }
            InlineContent::Empty => {}
        }
    }
    out
}

pub struct ExportPlainTextUseCase {
    uow_factory: Box<dyn ExportPlainTextUnitOfWorkFactoryTrait>,
}

impl ExportPlainTextUseCase {
    pub fn new(uow_factory: Box<dyn ExportPlainTextUnitOfWorkFactoryTrait>) -> Self {
        ExportPlainTextUseCase { uow_factory }
    }

    /// Every option is opt-in, and that is not mere caution. `to_plain_text()` is pinned
    /// character-for-character to the document's own addressable text (bar table anchors),
    /// which is what `find_all` and `replace_text` compute offsets against; indenting a
    /// quote — or inserting a form feed — shifts every offset after it and silently
    /// desynchronises search from the document.
    /// `plain_text_order_tests::the_human_view_is_the_addressable_view_minus_its_anchors`
    /// is the test that says so. Presentation is a *file export* concern, so only the
    /// caller writing a `.txt` file asks for it.
    pub fn execute(&mut self, options: PlainTextExportOptions) -> Result<ExportPlainTextDto> {
        let PlainTextExportOptions {
            quote_indent,
            page_breaks,
            strip_images,
            endnote_footnotes,
        } = options;
        let uow = self.uow_factory.create();
        uow.begin_transaction()?;

        // Step 1: Get Root (id=1) and its Document via relationship
        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"))?;

        // Step 2: Get all Frame IDs from Document.Frames relationship
        let frame_ids = uow.get_document_relationship(
            &doc_id,
            &common::direct_access::document::DocumentRelationshipField::Frames,
        )?;

        let store = uow.store();

        // Fast path: flat single-frame document with no tables has its
        // entire plain-text representation already laid out in rope
        // byte order. One allocation replaces the per-block walk.
        // The flat-rope fast path reproduces the document verbatim, which is
        // right for the addressable view and wrong the moment notes have to be
        // lifted to the end — it would print each one twice, once in place and
        // once in the list.
        //
        // Short-circuit on purpose: `Footnotes::build` walks every block and
        // frame in the document, which is exactly the work this fast path exists
        // to avoid. It runs only when notes would actually have to move —
        // AND, since `strip_image_sentinels` cannot tell a footnote's `U+FFFC`
        // from an image's, only when no footnote-sensitive option is even
        // asking this pass to act on that sentinel. `strip_images` alone,
        // with footnotes present, must fall through to the slow path too:
        // otherwise it would blindly strip a citation's sentinel right along
        // with any image's, exactly the bug `render_block_plain_text` exists
        // to fix.
        let footnote_sentinel_at_risk = (strip_images || endnote_footnotes)
            && !crate::footnotes::Footnotes::build(&store).is_empty();
        if !page_breaks
            && !footnote_sentinel_at_risk
            && let Some(plain_text) = rope_flat_text_if_simple(&store, frame_ids.len())
        {
            uow.end_transaction()?;
            return Ok(ExportPlainTextDto {
                plain_text: strip_image_sentinels(&plain_text, strip_images),
            });
        }

        // Slow path: tables or multi-frame documents. Cell content and a blockquote's prose
        // live in their own Frames, so the whole document's blocks have to be gathered and
        // put back into reading order.
        //
        // Pool EVERY frame's blocks first, then sort ONCE, GLOBALLY, by `document_position`.
        //
        // This used to sort each frame's blocks against only *their own frame's* siblings and
        // then concatenate the frames in the order `Document.Frames` hands them back — which
        // is frame-CREATION order (the root frame is created up front; a blockquote's frame
        // when the quote is opened, a table's cell frames when the table is reached). That
        // silently assumed creation order equals document order, and it is false the instant a
        // sub-frame's content precedes sibling content in the parent's flow. `"> a0\n\na"`
        // exported as `"a\na0"`: every blockquote's prose was hoisted to the END of the
        // document. The CLI's `cat`/`convert` wrote that straight to stdout.
        //
        // A global sort is correct because `document_position` is a single counter over the
        // WHOLE parse — declared once, outside the frame-stack machinery, and advanced for
        // every block regardless of which frame it lands in (`import_djot_uc`). It is a
        // globally comparable reading-order key ACROSS frame boundaries, not a per-frame one.
        // This is exactly what `find_all` already does to build the text it searches, and why
        // search and this export used to disagree about where a blockquote sat.
        // Frames are also what tells a quoted block from an ordinary one: a blockquote's
        // prose lives in its own Frame, flagged `fmt_is_blockquote`. Nothing below the
        // frame layer records it, so the depth has to be resolved here, before the blocks
        // are pooled and the frame they came from is forgotten.
        let mut frames_by_id: HashMap<EntityId, Frame> = HashMap::new();
        if quote_indent {
            for frame_id in &frame_ids {
                if let Some(frame) = uow.get_frame(frame_id)? {
                    frames_by_id.insert(*frame_id, frame);
                }
            }
        }

        let notes = crate::footnotes::Footnotes::build(&store);

        let mut all_block_ids: Vec<EntityId> = Vec::new();
        let mut quote_depth: HashMap<EntityId, usize> = HashMap::new();
        for frame_id in &frame_ids {
            // A note's body is out of flow in every view: the document does not
            // count its characters, so including it here would make this string
            // longer than the document it claims to reproduce. The presentation
            // view puts it back, once, as an endnote at the end.
            if notes.is_definition(*frame_id) {
                continue;
            }
            let depth = if quote_indent {
                blockquote_depth(*frame_id, &frames_by_id)
            } else {
                0
            };
            for block_id in uow.get_frame_relationship(
                frame_id,
                &common::direct_access::frame::FrameRelationshipField::Blocks,
            )? {
                if depth > 0 {
                    quote_depth.insert(block_id, depth);
                }
                all_block_ids.push(block_id);
            }
        }

        let mut blocks: Vec<Block> = uow
            .get_block_multi(&all_block_ids)?
            .into_iter()
            .flatten()
            .collect();
        blocks.sort_by_key(|b| b.document_position);

        let plain_text = blocks
            .iter()
            .map(|block| {
                let block_text = block_content_via_store(block, &store);
                let text = render_block_plain_text(
                    &store,
                    block,
                    &block_text,
                    strip_images,
                    endnote_footnotes,
                    &notes,
                );
                let text = match quote_depth.get(&block.id) {
                    Some(&depth) => indent_quoted(&text, depth),
                    None => text,
                };
                // The form feed leads its block, on its own — that is what a page break
                // is in a text file, and putting it inside the line would make it part
                // of the first word.
                if page_breaks && block.fmt_page_break_before == Some(true) {
                    format!("{FORM_FEED}{text}")
                } else {
                    text
                }
            })
            .collect::<Vec<String>>()
            .join("\n");

        // Plain text has no way to mark a note as a note, and no page to put one
        // at the foot of — so the notes become an endnote list, which is what a
        // manuscript printed without markup has always done. Numbered to match
        // the markers already in the prose.
        let mut plain_text = plain_text;
        let printed = if endnote_footnotes {
            notes.in_print_order()
        } else {
            Vec::new()
        };
        if !printed.is_empty() {
            plain_text.push('\n');
            for (number, _, frame_id) in printed {
                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 body = blocks
                    .iter()
                    .map(|b| block_content_via_store(b, &store))
                    .collect::<Vec<_>>()
                    .join(" ");
                let body = body.trim();
                if body.is_empty() {
                    continue;
                }
                plain_text.push_str(&format!("\n{number}. {body}"));
            }
        }

        uow.end_transaction()?;

        Ok(ExportPlainTextDto { plain_text })
    }
}