Skip to main content

text_document/
document.rs

1//! TextDocument implementation.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use crate::{DocumentError, Result};
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD as BASE64;
10
11use crate::{
12    DjotExportOptions, DjotImportOptions, MarkdownExportOptions, PlainTextExportOptions,
13    ResourceType, TextDirection, WrapMode,
14};
15use frontend::commands::{
16    block_commands, document_commands, document_inspection_commands, document_io_commands,
17    document_search_commands, frame_commands, resource_commands, table_cell_commands,
18    table_commands, undo_redo_commands,
19};
20
21use crate::HtmlExportOptions;
22use crate::convert::{self, to_i64, to_usize};
23use crate::cursor::TextCursor;
24use crate::events::{self, DocumentEvent, Subscription};
25use crate::flow::FormatChangeKind;
26use crate::inner::TextDocumentInner;
27use crate::operation::{
28    DjotImportResult, DocxExportResult, EpubExportResult, HtmlImportResult, MarkdownImportResult,
29    OdtExportResult, Operation, PdfExportResult,
30};
31use crate::{BlockFormat, BlockInfo, DocumentStats, FindMatch, FindOptions, ReplaceRange};
32
33/// A rich text document.
34///
35/// Owns the backend (database, event hub, undo/redo manager) and provides
36/// document-level operations. All cursor-based editing goes through
37/// [`TextCursor`], obtained via [`cursor()`](TextDocument::cursor) or
38/// [`cursor_at()`](TextDocument::cursor_at).
39///
40/// Internally uses `Arc<Mutex<...>>` so that multiple [`TextCursor`]s can
41/// coexist and edit concurrently. Cloning a `TextDocument` creates a new
42/// handle to the **same** underlying document (like Qt's implicit sharing).
43#[derive(Clone)]
44pub struct TextDocument {
45    pub(crate) inner: Arc<Mutex<TextDocumentInner>>,
46}
47
48/// Test-only accessor for the underlying rope-backed store. Not part
49/// of the stable public API.
50impl TextDocument {
51    #[doc(hidden)]
52    pub fn rope_store_for_test(&self) -> std::sync::Arc<common::database::Store> {
53        let inner = self.inner.lock();
54        std::sync::Arc::clone(inner.ctx.db_context.get_store())
55    }
56}
57
58impl TextDocument {
59    // ── Construction ──────────────────────────────────────────
60
61    /// Create a new, empty document.
62    ///
63    /// # Panics
64    ///
65    /// Panics if the database context cannot be created (e.g. filesystem error).
66    /// Use [`TextDocument::try_new`] for a fallible alternative.
67    pub fn new() -> Self {
68        Self::try_new().expect("failed to initialize document")
69    }
70
71    /// Create a new, empty document, returning an error on failure.
72    pub fn try_new() -> Result<Self> {
73        let ctx = frontend::AppContext::new();
74        let doc_inner = TextDocumentInner::initialize(ctx)?;
75        let inner = Arc::new(Mutex::new(doc_inner));
76
77        // Bridge backend long-operation events to public DocumentEvent.
78        Self::subscribe_long_operation_events(&inner);
79
80        Ok(Self { inner })
81    }
82
83    /// Subscribe to backend long-operation events and bridge them to DocumentEvent.
84    fn subscribe_long_operation_events(inner: &Arc<Mutex<TextDocumentInner>>) {
85        use frontend::common::event::{LongOperationEvent as LOE, Origin};
86
87        let weak = Arc::downgrade(inner);
88        let mut locked = inner.lock();
89
90        // Progress
91        let w = weak.clone();
92        let progress_tok =
93            locked
94                .event_client
95                .subscribe(Origin::LongOperation(LOE::Progress), move |event| {
96                    if let Some(inner) = w.upgrade() {
97                        let (op_id, percent, message) = parse_progress_data(&event.data);
98                        let mut inner = inner.lock();
99                        inner.queue_event(DocumentEvent::LongOperationProgress {
100                            operation_id: op_id,
101                            percent,
102                            message,
103                        });
104                    }
105                });
106
107        // Completed
108        let w = weak.clone();
109        let completed_tok =
110            locked
111                .event_client
112                .subscribe(Origin::LongOperation(LOE::Completed), move |event| {
113                    if let Some(inner) = w.upgrade() {
114                        let op_id = parse_id_data(&event.data);
115                        let mut inner = inner.lock();
116                        inner.queue_event(DocumentEvent::DocumentReset);
117                        inner.check_block_count_changed();
118                        inner.reset_cached_child_order();
119                        inner.queue_event(DocumentEvent::LongOperationFinished {
120                            operation_id: op_id,
121                            success: true,
122                            error: None,
123                        });
124                    }
125                });
126
127        // Cancelled
128        let w = weak.clone();
129        let cancelled_tok =
130            locked
131                .event_client
132                .subscribe(Origin::LongOperation(LOE::Cancelled), move |event| {
133                    if let Some(inner) = w.upgrade() {
134                        let op_id = parse_id_data(&event.data);
135                        let mut inner = inner.lock();
136                        inner.queue_event(DocumentEvent::LongOperationFinished {
137                            operation_id: op_id,
138                            success: false,
139                            error: Some("cancelled".into()),
140                        });
141                    }
142                });
143
144        // Failed
145        let failed_tok =
146            locked
147                .event_client
148                .subscribe(Origin::LongOperation(LOE::Failed), move |event| {
149                    if let Some(inner) = weak.upgrade() {
150                        let (op_id, error) = parse_failed_data(&event.data);
151                        let mut inner = inner.lock();
152                        inner.queue_event(DocumentEvent::LongOperationFinished {
153                            operation_id: op_id,
154                            success: false,
155                            error: Some(error),
156                        });
157                    }
158                });
159
160        locked.long_op_subscriptions.extend([
161            progress_tok,
162            completed_tok,
163            cancelled_tok,
164            failed_tok,
165        ]);
166    }
167
168    // ── Whole-document content ────────────────────────────────
169
170    /// Replace the entire document with plain text. Clears undo history.
171    pub fn set_plain_text(&self, text: &str) -> Result<()> {
172        let queued = {
173            let mut inner = self.inner.lock();
174            let dto = frontend::document_io::ImportPlainTextDto {
175                plain_text: text.into(),
176            };
177            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
178            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
179            inner.invalidate_text_cache();
180            inner.rehighlight_all();
181            inner.queue_event(DocumentEvent::DocumentReset);
182            inner.check_block_count_changed();
183            inner.reset_cached_child_order();
184            inner.queue_event(DocumentEvent::UndoRedoChanged {
185                can_undo: false,
186                can_redo: false,
187            });
188            inner.take_queued_events()
189        };
190        crate::inner::dispatch_queued_events(queued);
191        Ok(())
192    }
193
194    /// Export the entire document as plain text, in reading order.
195    ///
196    /// This is the **human-readable** view: prose only. Embedded objects (a table) contribute
197    /// their content but not the `U+FFFC` anchor the document holds where they sit — which is
198    /// what you want for a `cat`-style export, and is why the crate's fast path bails the
199    /// moment a table exists.
200    ///
201    /// **Do not compute offsets from this string.** It is deliberately not
202    /// character-for-character the text a search runs against: that text carries the object
203    /// anchors, so a position taken here is short by two characters per preceding table. For
204    /// an addressable view — one whose offsets [`find_all`](Self::find_all),
205    /// [`replace_text`](Self::replace_text), a block's
206    /// [`position()`](crate::TextBlock::position) and a cursor all agree with — use
207    /// [`to_addressable_text`](Self::to_addressable_text) on a live document, or
208    /// [`djot_to_plain_text`](crate::djot_to_plain_text) when all you hold is Djot source.
209    ///
210    /// The two are allowed to differ in that one respect and no other; in particular they
211    /// agree on **order**. They did not always: this export used to hoist every blockquote's
212    /// prose to the end of the document (`"> a0\n\na"` came back as `"a\na0"`), because it
213    /// concatenated frames in creation order instead of sorting all blocks by
214    /// `document_position`. See `plain_text_order_tests`.
215    pub fn to_plain_text(&self) -> Result<String> {
216        let mut inner = self.inner.lock();
217        Ok(inner.plain_text()?.to_string())
218    }
219
220    /// [`to_plain_text`](Self::to_plain_text) for writing an actual `.txt` file: quoted
221    /// blocks are indented four spaces per blockquote level, so an epigraph or a block
222    /// quotation still reads as set-off matter in a format with no markup to say so.
223    ///
224    /// **Not** interchangeable with [`to_plain_text`](Self::to_plain_text), and not cached.
225    /// That one is pinned to the document's addressable text — the text
226    /// [`find_all`](Self::find_all) and [`replace_text`](Self::replace_text) compute
227    /// offsets against — in everything but the object anchors, so indenting it would shift
228    /// every offset inside a quote and desynchronise search from the document. Use this
229    /// only for output nobody addresses back into the document.
230    pub fn to_plain_text_indented(&self) -> Result<String> {
231        let inner = self.inner.lock();
232        let dto = document_io_commands::export_plain_text_indented(&inner.ctx)?;
233        Ok(dto.plain_text)
234    }
235
236    /// [`to_plain_text`](Self::to_plain_text) with every presentation option chosen
237    /// explicitly — quoted-block indentation, and a `U+000C` form feed before each block
238    /// that asks to start a new page.
239    ///
240    /// Subject to the same warning as [`to_plain_text_indented`](Self::to_plain_text_indented):
241    /// anything other than [`PlainTextExportOptions::addressable`] shifts offsets, so this is
242    /// for files being written out, never for text anyone addresses back into the document.
243    pub fn to_plain_text_with(&self, options: PlainTextExportOptions) -> Result<String> {
244        let inner = self.inner.lock();
245        let dto = document_io_commands::export_plain_text_with(&inner.ctx, options)?;
246        Ok(dto.plain_text)
247    }
248
249    /// The document's **addressable text**: the exact string every offset this document
250    /// deals out is an index into.
251    ///
252    /// One char space runs through the whole API — [`find_all`](Self::find_all) match
253    /// positions, [`replace_ranges`](Self::replace_ranges) ranges, a block's
254    /// [`position()`](crate::TextBlock::position), a cursor, an editor widget's selection.
255    /// This is the string that space addresses, character for character: an embedded
256    /// table occupies its `U+FFFC` [`TABLE_ANCHOR`](crate::TABLE_ANCHOR) here (plus its
257    /// `\n` separator), exactly as the document holds it.
258    ///
259    /// Use it whenever a document offset and a document string travel together — capturing
260    /// the quoted text under a selection, pairing block starts with the text they index,
261    /// slicing context around a search hit. Pairing an offset with
262    /// [`to_plain_text`](Self::to_plain_text) instead is the classic form of this bug: that
263    /// is the human-readable **export**, it omits the anchors, and every offset after a
264    /// table lands two characters off in it.
265    ///
266    /// Built by the same code path [`find_all`](Self::find_all) uses to build the text it
267    /// searches, so the two cannot diverge. For the same view of bare Djot source — no live
268    /// document at hand — use [`djot_to_plain_text`](crate::djot_to_plain_text), which is
269    /// pinned to produce this very string for the same content. Not cached; it is a fresh
270    /// read of the document each call.
271    pub fn to_addressable_text(&self) -> Result<String> {
272        let inner = self.inner.lock();
273        let dto = document_search_commands::addressable_text(&inner.ctx)?;
274        Ok(dto.text)
275    }
276
277    /// Replace the entire document with Markdown. Clears undo history.
278    ///
279    /// This is a **long operation**. Returns a typed [`Operation`] handle.
280    pub fn set_markdown(&self, markdown: &str) -> Result<Operation<MarkdownImportResult>> {
281        let mut inner = self.inner.lock();
282        inner.invalidate_text_cache();
283        let dto = frontend::document_io::ImportMarkdownDto {
284            markdown_text: markdown.into(),
285        };
286        let op_id = document_io_commands::import_markdown(&inner.ctx, &dto)?;
287        Ok(Operation::new(
288            op_id,
289            &inner.ctx,
290            Box::new(|ctx, id| {
291                document_io_commands::get_import_markdown_result(ctx, id)
292                    .ok()
293                    .flatten()
294                    .map(|r| {
295                        Ok(MarkdownImportResult {
296                            block_count: to_usize(r.block_count),
297                        })
298                    })
299            }),
300        ))
301    }
302
303    /// Export the entire document as Markdown.
304    pub fn to_markdown(&self) -> Result<String> {
305        let inner = self.inner.lock();
306        let dto = document_io_commands::export_markdown(&inner.ctx)?;
307        Ok(dto.markdown_text)
308    }
309
310    /// [`to_markdown`](Self::to_markdown) with the presentation opt-ins — today, whether a
311    /// block that asks to start a new page gets a raw-HTML page break emitted above it.
312    /// Off by default, because raw HTML is not Markdown.
313    pub fn to_markdown_with(&self, options: MarkdownExportOptions) -> Result<String> {
314        let inner = self.inner.lock();
315        let dto = document_io_commands::export_markdown_with(&inner.ctx, options)?;
316        Ok(dto.markdown_text)
317    }
318
319    /// Replace the entire document with djot markup. Clears undo history.
320    ///
321    /// This is a **long operation**. Returns a typed [`Operation`] handle.
322    pub fn set_djot(&self, djot: &str) -> Result<Operation<DjotImportResult>> {
323        self.set_djot_with_options(djot, DjotImportOptions::default())
324    }
325
326    /// Replace the entire document with djot markup, selecting which optional
327    /// block attributes (alignment, line height, direction, non-breakable
328    /// lines, background color) are applied via `options`. Clears undo history.
329    ///
330    /// This is a **long operation**. Returns a typed [`Operation`] handle.
331    pub fn set_djot_with_options(
332        &self,
333        djot: &str,
334        options: DjotImportOptions,
335    ) -> Result<Operation<DjotImportResult>> {
336        let mut inner = self.inner.lock();
337        inner.invalidate_text_cache();
338        let dto = frontend::document_io::ImportDjotDto {
339            djot_text: djot.into(),
340            options,
341        };
342        let op_id = document_io_commands::import_djot(&inner.ctx, &dto)?;
343        Ok(Operation::new(
344            op_id,
345            &inner.ctx,
346            Box::new(|ctx, id| {
347                document_io_commands::get_import_djot_result(ctx, id)
348                    .ok()
349                    .flatten()
350                    .map(|r| {
351                        Ok(DjotImportResult {
352                            block_count: to_usize(r.block_count),
353                        })
354                    })
355            }),
356        ))
357    }
358
359    /// Replace the entire document with djot markup, **synchronously**, on the
360    /// calling thread. Clears undo history.
361    ///
362    /// This is the right call for *loading* a document's initial content — the
363    /// case where the caller is going to block for the result anyway.
364    /// [`set_djot`](Self::set_djot) starts a long operation: it spawns a thread,
365    /// and the caller then blocks in [`Operation::wait`] until that thread
366    /// publishes. That round trip is pure overhead when there is no frame loop to
367    /// keep responsive, and it does not shrink with the input — an *empty*
368    /// document costs the same thread spawn and hand-off as a full one. Loading N
369    /// documents in a loop paid it N times.
370    ///
371    /// Prefer [`set_djot`](Self::set_djot) when the import is genuinely long and
372    /// the caller must stay responsive (it reports progress and can be
373    /// cancelled); prefer this when the caller just wants the content in.
374    ///
375    /// Observationally equivalent to `set_djot(..).wait()` — same import, same
376    /// `DocumentReset`, same cache/block bookkeeping — except that, having no
377    /// operation, it emits no `LongOperation*` events and cannot be cancelled.
378    pub fn set_djot_sync(&self, djot: &str) -> Result<DjotImportResult> {
379        self.set_djot_sync_with_options(djot, DjotImportOptions::default())
380    }
381
382    /// As [`set_djot_sync`](Self::set_djot_sync), selecting which optional block
383    /// attributes are applied via `options`.
384    pub fn set_djot_sync_with_options(
385        &self,
386        djot: &str,
387        options: DjotImportOptions,
388    ) -> Result<DjotImportResult> {
389        let (queued, block_count) = {
390            let mut inner = self.inner.lock();
391            inner.invalidate_text_cache();
392            let dto = frontend::document_io::ImportDjotDto {
393                djot_text: djot.into(),
394                options,
395            };
396            let result = document_io_commands::import_djot_sync(&inner.ctx, &dto)?;
397            // The same settling the async path performs when its operation
398            // completes (see `subscribe_long_operation_events`), done inline here
399            // because there is no completion event to hang it off.
400            inner.queue_event(DocumentEvent::DocumentReset);
401            inner.check_block_count_changed();
402            inner.reset_cached_child_order();
403            (inner.take_queued_events(), result.block_count)
404        };
405        // Dispatch outside the lock — a subscriber is free to call back in.
406        crate::inner::dispatch_queued_events(queued);
407        Ok(DjotImportResult {
408            block_count: to_usize(block_count),
409        })
410    }
411
412    /// Export the entire document as djot markup.
413    pub fn to_djot(&self) -> Result<String> {
414        self.to_djot_with_options(DjotExportOptions::default())
415    }
416
417    /// Export the entire document as djot markup, selecting which optional block
418    /// attributes (alignment, line height, direction, non-breakable lines,
419    /// background color) are emitted via `options`.
420    pub fn to_djot_with_options(&self, options: DjotExportOptions) -> Result<String> {
421        let inner = self.inner.lock();
422        let dto = document_io_commands::export_djot(&inner.ctx, &options)?;
423        Ok(dto.djot_text)
424    }
425
426    /// Replace the entire document with HTML. Clears undo history.
427    ///
428    /// This is a **long operation**. Returns a typed [`Operation`] handle.
429    pub fn set_html(&self, html: &str) -> Result<Operation<HtmlImportResult>> {
430        let mut inner = self.inner.lock();
431        inner.invalidate_text_cache();
432        let dto = frontend::document_io::ImportHtmlDto {
433            html_text: html.into(),
434        };
435        let op_id = document_io_commands::import_html(&inner.ctx, &dto)?;
436        Ok(Operation::new(
437            op_id,
438            &inner.ctx,
439            Box::new(|ctx, id| {
440                document_io_commands::get_import_html_result(ctx, id)
441                    .ok()
442                    .flatten()
443                    .map(|r| {
444                        Ok(HtmlImportResult {
445                            block_count: to_usize(r.block_count),
446                        })
447                    })
448            }),
449        ))
450    }
451
452    /// Export the entire document as HTML.
453    ///
454    /// Inline images keep whatever `src` the document stores; placing the files
455    /// those point at is the caller's business. Use
456    /// [`to_html_with_options`](Self::to_html_with_options) to inline them
457    /// instead, or to drop them.
458    pub fn to_html(&self) -> Result<String> {
459        let inner = self.inner.lock();
460        let dto = document_io_commands::export_html(&inner.ctx)?;
461        Ok(dto.html_text)
462    }
463
464    /// Export as HTML, choosing how inline images are represented.
465    pub fn to_html_with_options(&self, options: HtmlExportOptions) -> Result<String> {
466        let inner = self.inner.lock();
467        let dto = document_io_commands::export_html_with_options(&inner.ctx, options)?;
468        Ok(dto.html_text)
469    }
470
471    /// Export the entire document as LaTeX.
472    ///
473    /// Images are emitted as `\includegraphics{src}`, which LaTeX resolves
474    /// against the filesystem at compile time — so the caller is responsible for
475    /// placing those files beside the `.tex`. Use
476    /// [`to_latex_with_options`](Self::to_latex_with_options) to drop them
477    /// instead.
478    pub fn to_latex(&self, document_class: &str, include_preamble: bool) -> Result<String> {
479        self.to_latex_with_options(crate::LatexExportOptions {
480            document_class: document_class.into(),
481            include_preamble,
482            omit_images: false,
483        })
484    }
485
486    /// As [`to_latex`](Self::to_latex), but taking the full
487    /// [`LatexExportOptions`](crate::LatexExportOptions) — the same document class and preamble
488    /// knobs `to_latex` takes positionally, plus the choice of dropping inline images instead of
489    /// emitting `\includegraphics{…}` for them.
490    pub fn to_latex_with_options(&self, options: crate::LatexExportOptions) -> Result<String> {
491        let inner = self.inner.lock();
492        let dto = frontend::document_io::ExportLatexDto { options };
493        let result = document_io_commands::export_latex(&inner.ctx, &dto)?;
494        Ok(result.latex_text)
495    }
496
497    /// Export the entire document as DOCX to a file path.
498    ///
499    /// This is a **long operation**. Returns a typed [`Operation`] handle.
500    pub fn to_docx(&self, output_path: &str) -> Result<Operation<DocxExportResult>> {
501        self.to_docx_with_options(output_path, crate::DocxExportOptions::default())
502    }
503
504    /// As [`to_docx`](Self::to_docx), but with page geometry + base typography overrides — a
505    /// *manuscript* style (page size, margins, body font, line spacing, first-line indent,
506    /// alignment, and an optional page-number header). Per-block RTL is emitted automatically
507    /// from each block's own direction, independent of these options.
508    pub fn to_docx_with_options(
509        &self,
510        output_path: &str,
511        options: crate::DocxExportOptions,
512    ) -> Result<Operation<DocxExportResult>> {
513        let inner = self.inner.lock();
514        let dto = frontend::document_io::ExportDocxDto {
515            output_path: output_path.into(),
516            options,
517        };
518        let op_id = document_io_commands::export_docx(&inner.ctx, &dto)?;
519        Ok(Operation::new(
520            op_id,
521            &inner.ctx,
522            Box::new(|ctx, id| {
523                document_io_commands::get_export_docx_result(ctx, id)
524                    .ok()
525                    .flatten()
526                    .map(|r| {
527                        Ok(DocxExportResult {
528                            file_path: r.file_path,
529                            paragraph_count: to_usize(r.paragraph_count),
530                        })
531                    })
532            }),
533        ))
534    }
535
536    /// Export the entire document as an EPUB 3 file to a file path.
537    ///
538    /// This is a **long operation**. Returns a typed [`Operation`] handle.
539    pub fn to_epub(&self, output_path: &str) -> Result<Operation<EpubExportResult>> {
540        self.to_epub_with_options(output_path, crate::EpubExportOptions::default())
541    }
542
543    /// As [`to_epub`](Self::to_epub), but with book-level metadata (title, author, language,
544    /// reading direction). The document is split into chapters at the shallowest heading level
545    /// present (e.g. every top-level `# Chapter` heading) — see
546    /// [`EpubExportOptions`](crate::EpubExportOptions) for details.
547    pub fn to_epub_with_options(
548        &self,
549        output_path: &str,
550        options: crate::EpubExportOptions,
551    ) -> Result<Operation<EpubExportResult>> {
552        let inner = self.inner.lock();
553        let dto = frontend::document_io::ExportEpubDto {
554            output_path: output_path.into(),
555            options,
556        };
557        let op_id = document_io_commands::export_epub(&inner.ctx, &dto)?;
558        Ok(Operation::new(
559            op_id,
560            &inner.ctx,
561            Box::new(|ctx, id| {
562                document_io_commands::get_export_epub_result(ctx, id)
563                    .ok()
564                    .flatten()
565                    .map(|r| {
566                        Ok(EpubExportResult {
567                            file_path: r.file_path,
568                            chapter_count: to_usize(r.chapter_count),
569                        })
570                    })
571            }),
572        ))
573    }
574
575    /// Export the entire document as ODT (OpenDocument Text) to a file path.
576    ///
577    /// This is a **long operation**. Returns a typed [`Operation`] handle.
578    pub fn to_odt(&self, output_path: &str) -> Result<Operation<OdtExportResult>> {
579        self.to_odt_with_options(output_path, crate::OdtExportOptions::default())
580    }
581
582    /// As [`to_odt`](Self::to_odt), but with page geometry + base typography overrides — the ODT
583    /// analog of [`to_docx_with_options`](Self::to_docx_with_options), same units and same
584    /// per-block-RTL-is-automatic behaviour (see [`OdtExportOptions`](crate::OdtExportOptions)'s
585    /// own doc comment).
586    pub fn to_odt_with_options(
587        &self,
588        output_path: &str,
589        options: crate::OdtExportOptions,
590    ) -> Result<Operation<OdtExportResult>> {
591        let inner = self.inner.lock();
592        let dto = frontend::document_io::ExportOdtDto {
593            output_path: output_path.into(),
594            options,
595        };
596        let op_id = document_io_commands::export_odt(&inner.ctx, &dto)?;
597        Ok(Operation::new(
598            op_id,
599            &inner.ctx,
600            Box::new(|ctx, id| {
601                document_io_commands::get_export_odt_result(ctx, id)
602                    .ok()
603                    .flatten()
604                    .map(|r| {
605                        Ok(OdtExportResult {
606                            file_path: r.file_path,
607                            paragraph_count: to_usize(r.paragraph_count),
608                        })
609                    })
610            }),
611        ))
612    }
613
614    /// Export the entire document as a PDF file, using the given options (page geometry,
615    /// typography, embedded font bytes, base language/direction).
616    ///
617    /// This is a **long operation**. Returns a typed [`Operation`] handle.
618    ///
619    /// Requires the `pdf` cargo feature on `text-document` (which forwards to `frontend`'s and
620    /// `document_io`'s own `pdf` features). If it was not enabled at compile time, this returns
621    /// `Err(DocumentError::Unsupported(..))` immediately rather than attempting the export — no
622    /// `#[cfg]` is needed at the call site either way.
623    pub fn to_pdf(
624        &self,
625        output_path: &str,
626        options: crate::PdfExportOptions,
627    ) -> Result<Operation<PdfExportResult>> {
628        self.to_pdf_with_options(output_path, options)
629    }
630
631    /// As [`to_pdf`](Self::to_pdf) — the two are identical; `to_pdf` is the plain entry point,
632    /// `to_pdf_with_options` exists (like [`to_docx_with_options`](Self::to_docx_with_options)
633    /// and [`to_epub_with_options`](Self::to_epub_with_options)) so the naming stays consistent
634    /// across the three file-based exporters, all of which take a mandatory options struct.
635    #[cfg(feature = "pdf")]
636    pub fn to_pdf_with_options(
637        &self,
638        output_path: &str,
639        options: crate::PdfExportOptions,
640    ) -> Result<Operation<PdfExportResult>> {
641        let inner = self.inner.lock();
642        let dto = frontend::document_io::ExportPdfDto {
643            output_path: output_path.into(),
644            options,
645        };
646        let op_id = document_io_commands::export_pdf(&inner.ctx, &dto)?;
647        Ok(Operation::new(
648            op_id,
649            &inner.ctx,
650            Box::new(|ctx, id| {
651                document_io_commands::get_export_pdf_result(ctx, id)
652                    .ok()
653                    .flatten()
654                    .map(|r| {
655                        Ok(PdfExportResult {
656                            file_path: r.file_path,
657                            page_count: to_usize(r.page_count),
658                        })
659                    })
660            }),
661        ))
662    }
663
664    /// As [`to_pdf`](Self::to_pdf), when the `pdf` cargo feature was not enabled at compile
665    /// time — returns [`DocumentError::Unsupported`] immediately, without starting an operation
666    /// or touching the backend at all.
667    #[cfg(not(feature = "pdf"))]
668    pub fn to_pdf_with_options(
669        &self,
670        _output_path: &str,
671        _options: crate::PdfExportOptions,
672    ) -> Result<Operation<PdfExportResult>> {
673        Err(DocumentError::Unsupported(
674            "PDF export requires the `pdf` cargo feature on the `text-document` crate".into(),
675        ))
676    }
677
678    /// Clear all document content and reset to an empty state.
679    pub fn clear(&self) -> Result<()> {
680        let queued = {
681            let mut inner = self.inner.lock();
682            let dto = frontend::document_io::ImportPlainTextDto {
683                plain_text: String::new(),
684            };
685            document_io_commands::import_plain_text(&inner.ctx, &dto)?;
686            undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
687            inner.invalidate_text_cache();
688            inner.rehighlight_all();
689            inner.queue_event(DocumentEvent::DocumentReset);
690            inner.check_block_count_changed();
691            inner.reset_cached_child_order();
692            inner.queue_event(DocumentEvent::UndoRedoChanged {
693                can_undo: false,
694                can_redo: false,
695            });
696            inner.take_queued_events()
697        };
698        crate::inner::dispatch_queued_events(queued);
699        Ok(())
700    }
701
702    // ── Cursor factory ───────────────────────────────────────
703
704    /// Create a cursor at position 0.
705    pub fn cursor(&self) -> TextCursor {
706        self.cursor_at(0)
707    }
708
709    /// Create a cursor at the given position. If `position` falls
710    /// inside an extended grapheme cluster (decomposed accents, ZWJ
711    /// emoji, skin-tone sequences, flag pairs), the cursor snaps
712    /// forward to the end of the containing cluster so subsequent
713    /// `NextCharacter`/`PreviousCharacter` round-trips remain identity.
714    pub fn cursor_at(&self, position: usize) -> TextCursor {
715        let data = {
716            let mut inner = self.inner.lock();
717            inner.register_cursor(position)
718        };
719        let cursor = TextCursor {
720            doc: self.inner.clone(),
721            data,
722        };
723        cursor.snap_position_to_grapheme_boundary();
724        cursor
725    }
726
727    // ── Document queries ─────────────────────────────────────
728
729    /// Get document statistics. O(1) — reads cached values.
730    pub fn stats(&self) -> DocumentStats {
731        let inner = self.inner.lock();
732        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
733            .expect("get_document_stats should not fail");
734        DocumentStats::from(&dto)
735    }
736
737    /// Tell the document what each footnote label should print.
738    ///
739    /// Presentation only: never stored, never exported, never part of the text. A
740    /// reference occupies one character whatever its marker says.
741    ///
742    /// Set it when the numbers are a fact about something larger than this
743    /// document — a host compiling one chapter of a book knows the chapter's notes
744    /// continue a sequence this document cannot see. Leave it unset and the
745    /// document numbers its own references in reading order, which is right when
746    /// the document *is* the whole text.
747    /// Storing the map is only half of it: a marker is **shaped text**, so a
748    /// document already laid out keeps drawing the old one until something tells
749    /// it to reshape. Nothing else will — the map is presentation state and
750    /// changing it edits no block, so it emits no edit event of its own. Without
751    /// the notification below, a host that numbers a note the instant it is
752    /// created watches the raw label sit in the writer's prose until an unrelated
753    /// keystroke happens to force a relayout.
754    ///
755    /// `FormatChanged` over the whole document rather than a paint-only event:
756    /// the marker's width changes with its text (`9` and `10` are not the same
757    /// size), so the line has to be reshaped, not recoloured. Guarded on the map
758    /// actually differing, because a host pushes this on every refresh and a
759    /// full relayout per keystroke is not a thing to do by accident.
760    pub fn set_footnote_markers(&self, markers: std::collections::HashMap<String, String>) {
761        let queued = {
762            let mut inner = self.inner.lock();
763            {
764                let store = inner.ctx.db_context.get_store();
765                let mut current = store.footnote_markers.write();
766                if *current == markers {
767                    return;
768                }
769                *current = markers;
770            }
771            inner.queue_event(DocumentEvent::FormatChanged {
772                position: 0,
773                length: 0,
774                kind: crate::flow::FormatChangeKind::Character,
775            });
776            inner.take_queued_events()
777        };
778        crate::inner::dispatch_queued_events(queued);
779    }
780
781    /// Every footnote reference in the document, as `(position, label)`, in
782    /// reading order.
783    ///
784    /// The seam a host uses to tie its own note storage to the prose: Skribisto
785    /// keeps note bodies in its store, so what it needs from the document is
786    /// only *where* the references are and *which* note each names.
787    ///
788    /// Positions are document-absolute character offsets — the same space a
789    /// cursor and a search hit use — so a caller can go straight from a caret to
790    /// the note under it without a second lookup.
791    pub fn footnote_references(&self) -> Vec<(usize, String)> {
792        let inner = self.inner.lock();
793        let store = inner.ctx.db_context.get_store();
794
795        let refs = store.block_footnote_refs.read();
796        if refs.is_empty() {
797            return Vec::new();
798        }
799
800        // Block order, then byte order within a block — the order they are read.
801        let mut blocks: Vec<(i64, u64)> = store
802            .blocks
803            .read()
804            .values()
805            .map(|b| (b.document_position, b.id))
806            .collect();
807        blocks.sort_unstable();
808
809        let mut out = Vec::new();
810        for (position, block_id) in blocks {
811            let Some(anchors) = refs.get(&block_id) else {
812                continue;
813            };
814            let Some(block) = store.blocks.read().get(&block_id).cloned() else {
815                continue;
816            };
817            let text =
818                frontend::common::database::rope_helpers::block_content_via_store(&block, store);
819            let mut ordered: Vec<_> = anchors.iter().collect();
820            ordered.sort_by_key(|a| a.byte_offset);
821            for anchor in ordered {
822                // Byte offset within the block → character offset within the
823                // document. The two differ the moment the block holds anything
824                // outside ASCII, which for prose is immediately.
825                let chars_before = text
826                    .get(..anchor.byte_offset as usize)
827                    .map(|s| s.chars().count())
828                    .unwrap_or(0);
829                out.push((position as usize + chars_before, anchor.label.clone()));
830            }
831        }
832        out
833    }
834
835    /// The label of the footnote reference at `position`, if one sits there.
836    ///
837    /// What "the caret is on a footnote" means, for a host wiring a two-way
838    /// selection between its notes list and the prose.
839    pub fn footnote_reference_at(&self, position: usize) -> Option<String> {
840        self.footnote_references()
841            .into_iter()
842            .find(|(at, _)| *at == position)
843            .map(|(_, label)| label)
844    }
845
846    /// Get the total character count. O(1) — reads cached value.
847    pub fn character_count(&self) -> usize {
848        let inner = self.inner.lock();
849        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
850            .expect("get_document_stats should not fail");
851        to_usize(dto.character_count)
852    }
853
854    /// Get the number of blocks (paragraphs). O(1) — reads cached value.
855    pub fn block_count(&self) -> usize {
856        let inner = self.inner.lock();
857        let dto = document_inspection_commands::get_document_stats(&inner.ctx)
858            .expect("get_document_stats should not fail");
859        to_usize(dto.block_count)
860    }
861
862    /// Returns true if the document has no text content.
863    pub fn is_empty(&self) -> bool {
864        self.character_count() == 0
865    }
866
867    /// Get text at a position for a given length.
868    pub fn text_at(&self, position: usize, length: usize) -> Result<String> {
869        let inner = self.inner.lock();
870        let dto = frontend::document_inspection::GetTextAtPositionDto {
871            position: to_i64(position),
872            length: to_i64(length),
873        };
874        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
875        Ok(result.text)
876    }
877
878    /// Find the inline segment containing `position` and return its
879    /// stable element id (synthesized from `(block_id, byte_start)`
880    /// via [`common::format_runs::synth_element_id`]) together with the
881    /// segment's absolute start position and the character offset of
882    /// `position` within the segment. Used by accessibility layers to
883    /// convert a document-absolute character position into the
884    /// `(element_id, character_index_in_run)` coordinate space
885    /// AccessKit's `TextPosition` expects.
886    ///
887    /// Returns `None` when the position is outside the document.
888    /// Returns the element at position `position - 1` when `position`
889    /// falls exactly on an element boundary, matching the "cursor
890    /// belongs to the preceding element at a boundary" convention
891    /// used throughout text-document.
892    pub fn find_element_at_position(&self, position: usize) -> Option<(u64, usize, usize)> {
893        // Caret semantics, per the boundary convention documented just above: with the
894        // character-index `block_at`, a position at the end of a paragraph resolved to the
895        // *next* block and the `checked_sub` below then failed, so the last element of every
896        // paragraph was unreachable.
897        let block_info = self.block_at_caret(position).ok()?;
898        let block_start = block_info.start;
899        let offset_in_block = position.checked_sub(block_start)?;
900        let block = crate::text_block::TextBlock {
901            doc: std::sync::Arc::clone(&self.inner),
902            block_id: block_info.block_id,
903        };
904        let frags = block.fragments();
905        // Walk fragments; match the fragment that contains
906        // `offset_in_block`. For a boundary position shared with the
907        // next fragment, prefer the preceding fragment (boundary
908        // belongs to the end of the previous element).
909        let mut last_text: Option<(u64, usize, usize, usize)> = None; // (id, abs_start, frag_offset, frag_length)
910        for frag in &frags {
911            match frag {
912                crate::flow::FragmentContent::Text {
913                    offset,
914                    length,
915                    element_id,
916                    ..
917                } => {
918                    let frag_start = *offset;
919                    let frag_end = frag_start + *length;
920                    if offset_in_block >= frag_start && offset_in_block < frag_end {
921                        let abs_start = block_start + frag_start;
922                        let offset_within = offset_in_block - frag_start;
923                        return Some((*element_id, abs_start, offset_within));
924                    }
925                    // Record as a candidate for the "end-of-element"
926                    // boundary fallback (offset_in_block == frag_end).
927                    if offset_in_block == frag_end {
928                        last_text =
929                            Some((*element_id, block_start + frag_start, frag_start, *length));
930                    }
931                }
932                // Both objects occupy exactly one position and answer for it
933                // whole — there is no offset *inside* either to report.
934                crate::flow::FragmentContent::Image {
935                    offset, element_id, ..
936                }
937                | crate::flow::FragmentContent::FootnoteReference {
938                    offset, element_id, ..
939                } => {
940                    if offset_in_block == *offset {
941                        return Some((*element_id, block_start + offset, 0));
942                    }
943                }
944            }
945        }
946        // Boundary fallback: position was at the end of the last text
947        // fragment we saw.
948        last_text.map(|(id, abs_start, _, length)| (id, abs_start, length))
949    }
950
951    /// Get info about the block at a position. O(log n).
952    ///
953    /// `position` is read as a **character index**, so the inter-block separator belongs to
954    /// the block that *follows* it: in `"abc\ndef"`, position 3 is the newline and reports the
955    /// second block. For a **caret** offset — where 3 means "after the c", the last place the
956    /// caret can sit in the first paragraph — use [`block_at_caret`](Self::block_at_caret).
957    pub fn block_at(&self, position: usize) -> Result<BlockInfo> {
958        let inner = self.inner.lock();
959        let dto = frontend::document_inspection::GetBlockAtPositionDto {
960            position: to_i64(position),
961        };
962        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
963        Ok(BlockInfo::from(&result))
964    }
965
966    /// The block a **caret** at `position` sits in. O(log n).
967    ///
968    /// Differs from [`block_at`](Self::block_at) at exactly one place: the end of a paragraph.
969    /// A character index and a caret offset disagree there — the character at that index is the
970    /// separator, which belongs to the next block, but a caret there is still in the paragraph
971    /// it just finished typing. `block_at` answers the first question (and callers that walk
972    /// text depend on it — moving the caret across a separator, reading the character under an
973    /// offset); this answers the second.
974    ///
975    /// Ask this one whenever the position came from a cursor. Asking `block_at` instead is why
976    /// the caret-band highlight lit the *next* paragraph the moment the caret reached the end of
977    /// one.
978    pub fn block_at_caret(&self, position: usize) -> Result<BlockInfo> {
979        let inner = self.inner.lock();
980        let info = crate::inner::block_at_caret_dto(&inner.ctx, position)?;
981        Ok(BlockInfo::from(&info))
982    }
983
984    /// The sentence containing `position`, as absolute char offsets `(start, end)` — the
985    /// granularity between [`word`](TextCursor::select) and [`block_at`](Self::block_at).
986    ///
987    /// `content_locale` is a BCP-47-ish tag (`"en"`, `"en-US"`, `"pt_BR"`) naming the language
988    /// the text is written in. It selects the sentence tailoring for that language —
989    /// abbreviations that do not end a sentence, French spaced guillemets, the Greek question
990    /// mark. Pass it **fresh on every call**, like [`FindOptions::language`](crate::FindOptions):
991    /// only the caller knows what language the text is in, and it is not document state.
992    /// `None`, or a language with no tailoring, falls back to plain UAX #29.
993    ///
994    /// A sentence never crosses a block: a paragraph break always ends one. Returns `None` when
995    /// the block holds no sentence to point at (empty, or whitespace only). Trailing whitespace
996    /// is trimmed off the end, so the range covers the sentence and not the gap after it.
997    ///
998    /// The trailing edge is inclusive of the caret: a `position` at the very end of the block
999    /// resolves to the last sentence of *that* block rather than to the first sentence of the
1000    /// next one. `position` is a caret offset, so the block is resolved with
1001    /// [`block_at_caret`](Self::block_at_caret) and not with the character-index
1002    /// [`block_at`](Self::block_at).
1003    pub fn sentence_at(
1004        &self,
1005        position: usize,
1006        content_locale: Option<&str>,
1007    ) -> Option<(usize, usize)> {
1008        // Resolved before the lock: `block_at_caret` takes it itself.
1009        let block = self.block_at_caret(position).ok()?;
1010        let inner = self.inner.lock();
1011        let block_start = block.start;
1012        let block_length = block.length;
1013        if block_length == 0 {
1014            return None;
1015        }
1016        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
1017            position: to_i64(block_start),
1018            length: to_i64(block_length),
1019        };
1020        let text = document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto)
1021            .ok()?
1022            .text;
1023        drop(inner);
1024
1025        let offset = position.saturating_sub(block_start);
1026        let (start, end) =
1027            frontend::common::parser_tools::sentence_bounds(&text, offset, content_locale)?;
1028        Some((block_start + start, block_start + end))
1029    }
1030
1031    /// Get the block format at a position.
1032    ///
1033    /// `position` is read with **caret** semantics ([`block_at_caret`](Self::block_at_caret)):
1034    /// at the end of a paragraph this reports that paragraph's format, not the next one's.
1035    /// Formatting queries are asked about a cursor, never about a character index.
1036    pub fn block_format_at(&self, position: usize) -> Result<BlockFormat> {
1037        let inner = self.inner.lock();
1038        let block_info = crate::inner::block_at_caret_dto(&inner.ctx, position)?;
1039        let block_id = block_info.block_id;
1040        let block_id = block_id as u64;
1041        let block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
1042            .ok_or_else(|| DocumentError::NotFound("block not found".into()))?;
1043        Ok(BlockFormat::from(&block_dto))
1044    }
1045
1046    // ── Flow traversal (layout engine API) ─────────────────
1047
1048    /// Walk the main frame's visual flow in document order.
1049    ///
1050    /// Returns the top-level flow elements — blocks, tables, and
1051    /// sub-frames — in the order defined by the main frame's
1052    /// `child_order`. Table cell contents are NOT included here;
1053    /// access them through [`TextTableCell::blocks()`](crate::TextTableCell::blocks).
1054    ///
1055    /// This is the primary entry point for layout initialization.
1056    pub fn flow(&self) -> Vec<crate::flow::FlowElement> {
1057        let inner = self.inner.lock();
1058        let main_frame_id = get_main_frame_id(&inner);
1059        crate::text_frame::build_flow_elements(&inner, &self.inner, main_frame_id)
1060    }
1061
1062    /// Get a read-only handle to a block by its entity ID.
1063    ///
1064    /// Entity IDs are stable across insertions and deletions.
1065    /// Returns `None` if no block with this ID exists.
1066    pub fn block_by_id(&self, block_id: usize) -> Option<crate::text_block::TextBlock> {
1067        let inner = self.inner.lock();
1068        let exists = frontend::commands::block_commands::get_block(&inner.ctx, &(block_id as u64))
1069            .ok()
1070            .flatten()
1071            .is_some();
1072
1073        if exists {
1074            Some(crate::text_block::TextBlock {
1075                doc: self.inner.clone(),
1076                block_id,
1077            })
1078        } else {
1079            None
1080        }
1081    }
1082
1083    /// Build a single `BlockSnapshot` for the block at the given position.
1084    ///
1085    /// This is O(k) where k = format runs + image anchors in that block,
1086    /// compared to `snapshot_flow()` which is O(n) over the entire document.
1087    /// Use for incremental layout updates after single-block edits.
1088    pub fn snapshot_block_at_position(
1089        &self,
1090        position: usize,
1091    ) -> Option<crate::flow::BlockSnapshot> {
1092        self.snapshot_block_at_position_masked(position, &crate::highlight::HighlightMask::all())
1093    }
1094
1095    /// Like [`snapshot_block_at_position`](Self::snapshot_block_at_position)
1096    /// but with **no highlights applied** — base fragments and empty
1097    /// `paint_highlights`, regardless of the active sessions. Used by the
1098    /// incremental relayout path of a view that has opted out of highlights.
1099    pub fn snapshot_block_at_position_without_highlights(
1100        &self,
1101        position: usize,
1102    ) -> Option<crate::flow::BlockSnapshot> {
1103        self.snapshot_block_at_position_masked(position, &crate::highlight::HighlightMask::none())
1104    }
1105
1106    /// Like [`snapshot_block_at_position`](Self::snapshot_block_at_position) but rendering
1107    /// only the sessions `mask` admits — the per-view incremental path (two panes over one
1108    /// document can carry different find sessions). `all()` = the plain method; `none()` = the
1109    /// without-highlights method.
1110    pub fn snapshot_block_at_position_masked(
1111        &self,
1112        position: usize,
1113        mask: &crate::highlight::HighlightMask,
1114    ) -> Option<crate::flow::BlockSnapshot> {
1115        let inner = self.inner.lock();
1116        // Effective kind resolved once here (the join over the mask's sessions), then threaded
1117        // down with the mask itself.
1118        let hl = crate::highlight::SnapshotHighlights {
1119            kind: inner.highlights.effective_kind(mask),
1120            mask,
1121            suppress_paint: false,
1122        };
1123        let main_frame_id = get_main_frame_id(&inner);
1124        let store = inner.ctx.db_context.get_store();
1125
1126        // Rope-authoritative fast path. When every block is mirrored to the
1127        // rope (now true with tables — see `rope_positions_match_flow`), the
1128        // rope IS the position space the snapshot reports in, so we must also
1129        // *locate* the block via the rope. Walking a hand-rolled `running_pos`
1130        // here instead would search in the old cells-inline-no-sentinel space
1131        // and then report the rope position — an off-by-the-sentinel mismatch
1132        // for any block after a table.
1133        if common::database::rope_helpers::rope_positions_match_flow(store)
1134            && let Some((block_id, _, _)) =
1135                common::database::rope_helpers::find_block_at_char_position(store, position as i64)
1136        {
1137            return crate::text_block::build_block_snapshot(&inner, block_id, hl);
1138        }
1139
1140        // Collect all block IDs in document order, traversing into nested frames
1141        let ordered_block_ids = collect_frame_block_ids(&inner, main_frame_id)?;
1142
1143        // Walk blocks computing positions on the fly
1144        let pos = position as i64;
1145        let mut running_pos: i64 = 0;
1146        for &block_id in &ordered_block_ids {
1147            let block_dto = block_commands::get_block(&inner.ctx, &block_id)
1148                .ok()
1149                .flatten()?;
1150            let entity: common::entities::Block = block_dto.clone().into();
1151            let block_end =
1152                running_pos + common::database::rope_helpers::block_char_length(&entity, store);
1153            if pos >= running_pos && pos <= block_end {
1154                return crate::text_block::build_block_snapshot_with_position(
1155                    &inner,
1156                    block_id,
1157                    Some(running_pos as usize),
1158                    hl,
1159                );
1160            }
1161            running_pos = block_end + 1;
1162        }
1163
1164        // Fallback to last block
1165        if let Some(&last_id) = ordered_block_ids.last() {
1166            return crate::text_block::build_block_snapshot(&inner, last_id, hl);
1167        }
1168        None
1169    }
1170
1171    /// Get a read-only handle to the block containing the given
1172    /// character position. Returns `None` if position is out of range.
1173    pub fn block_at_position(&self, position: usize) -> Option<crate::text_block::TextBlock> {
1174        let inner = self.inner.lock();
1175        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1176            position: to_i64(position),
1177        };
1178        let result = document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1179        Some(crate::text_block::TextBlock {
1180            doc: self.inner.clone(),
1181            block_id: result.block_id as usize,
1182        })
1183    }
1184
1185    /// Get a read-only handle to a block by its 0-indexed global
1186    /// block number.
1187    ///
1188    /// **O(n)**: requires scanning all blocks sorted by
1189    /// `document_position` to find the nth one. Prefer
1190    /// [`block_at_position()`](TextDocument::block_at_position) or
1191    /// [`block_by_id()`](TextDocument::block_by_id) in
1192    /// performance-sensitive paths.
1193    pub fn block_by_number(&self, block_number: usize) -> Option<crate::text_block::TextBlock> {
1194        let inner = self.inner.lock();
1195        let all_blocks = frontend::commands::block_commands::get_all_block(&inner.ctx).ok()?;
1196        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1197        let store = inner.ctx.db_context.get_store();
1198        crate::inner::refresh_block_positions(&mut sorted, store);
1199        sorted.sort_by_key(|b| b.document_position);
1200
1201        sorted
1202            .get(block_number)
1203            .map(|b| crate::text_block::TextBlock {
1204                doc: self.inner.clone(),
1205                block_id: b.id as usize,
1206            })
1207    }
1208
1209    /// All blocks in the document, sorted by `document_position`. **O(n)**.
1210    ///
1211    /// Returns blocks from all frames, including those inside table cells.
1212    /// This is the efficient way to iterate all blocks — avoids the O(n^2)
1213    /// cost of calling `block_by_number(i)` in a loop.
1214    pub fn blocks(&self) -> Vec<crate::text_block::TextBlock> {
1215        let inner = self.inner.lock();
1216        let all_blocks =
1217            frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1218        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1219        let store = inner.ctx.db_context.get_store();
1220        crate::inner::refresh_block_positions(&mut sorted, store);
1221        sorted.sort_by_key(|b| b.document_position);
1222        sorted
1223            .iter()
1224            .map(|b| crate::text_block::TextBlock {
1225                doc: self.inner.clone(),
1226                block_id: b.id as usize,
1227            })
1228            .collect()
1229    }
1230
1231    /// All blocks whose character range intersects `[position, position + length)`.
1232    ///
1233    /// **O(n)**: scans all blocks once. Returns them sorted by `document_position`.
1234    /// A block intersects if its range `[block.position, block.position + block.length)`
1235    /// overlaps the query range. An empty query range (`length == 0`) returns the
1236    /// block containing that position, if any.
1237    pub fn blocks_in_range(
1238        &self,
1239        position: usize,
1240        length: usize,
1241    ) -> Vec<crate::text_block::TextBlock> {
1242        let inner = self.inner.lock();
1243        let all_blocks =
1244            frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
1245        let mut sorted: Vec<_> = all_blocks.into_iter().collect();
1246        let store = inner.ctx.db_context.get_store();
1247        crate::inner::refresh_block_positions(&mut sorted, store);
1248        sorted.sort_by_key(|b| b.document_position);
1249
1250        let range_start = position;
1251        let range_end = position + length;
1252        sorted
1253            .iter()
1254            .filter(|b| {
1255                let block_start = b.document_position.max(0) as usize;
1256                let entity: common::entities::Block = (*b).clone().into();
1257                let block_end = block_start
1258                    + common::database::rope_helpers::block_char_length(&entity, store).max(0)
1259                        as usize;
1260                // Overlap check: block intersects [range_start, range_end)
1261                if length == 0 {
1262                    // Point query: block contains the position
1263                    range_start >= block_start && range_start < block_end
1264                } else {
1265                    block_start < range_end && block_end > range_start
1266                }
1267            })
1268            .map(|b| crate::text_block::TextBlock {
1269                doc: self.inner.clone(),
1270                block_id: b.id as usize,
1271            })
1272            .collect()
1273    }
1274
1275    /// Snapshot the entire main flow in a single lock acquisition.
1276    ///
1277    /// Returns a [`FlowSnapshot`](crate::FlowSnapshot) containing snapshots
1278    /// for every element in the flow.
1279    pub fn snapshot_flow(&self) -> crate::flow::FlowSnapshot {
1280        self.snapshot_flow_masked(&crate::highlight::HighlightMask::all())
1281    }
1282
1283    /// Snapshot the entire main flow with **no highlights applied** — base
1284    /// fragments and empty `paint_highlights` on every block, regardless of
1285    /// the active sessions.
1286    ///
1287    /// This is the per-view opt-out: a read-only viewer that should stay
1288    /// free of search / spell / syntax highlighting pulls *this* snapshot
1289    /// instead of [`snapshot_flow`](Self::snapshot_flow). Because suppression
1290    /// happens at build time, it works for metric-affecting sessions too
1291    /// (whose highlights are otherwise merged into `fragments` irreversibly).
1292    pub fn snapshot_flow_without_highlights(&self) -> crate::flow::FlowSnapshot {
1293        self.snapshot_flow_masked(&crate::highlight::HighlightMask::none())
1294    }
1295
1296    /// Snapshot the entire main flow rendering only the sessions `mask` admits.
1297    ///
1298    /// The generalization of the plain / without-highlights pair: `all()` shows every session,
1299    /// `none()` shows none, and `only([...])` shows a chosen set — which is how two panes over
1300    /// one shared document carry different find sessions. The effective
1301    /// `HighlighterKind` is resolved **once here**, at the snapshot root,
1302    /// and threaded down, so a view showing only paint-only sessions never pays the reshape
1303    /// path for a metric session it does not show.
1304    pub fn snapshot_flow_masked(
1305        &self,
1306        mask: &crate::highlight::HighlightMask,
1307    ) -> crate::flow::FlowSnapshot {
1308        let inner = self.inner.lock();
1309        let main_frame_id = get_main_frame_id(&inner);
1310        let hl = crate::highlight::SnapshotHighlights {
1311            kind: inner.highlights.effective_kind(mask),
1312            mask,
1313            suppress_paint: false,
1314        };
1315        let elements = crate::text_frame::build_flow_snapshot(&inner, main_frame_id, hl);
1316        crate::flow::FlowSnapshot { elements }
1317    }
1318
1319    /// Snapshot the main flow like [`snapshot_flow_masked`](Self::snapshot_flow_masked),
1320    /// but **without computing the paint-only overlay** (`paint_highlights` is
1321    /// empty on every block). Fragments are identical — metric sessions still
1322    /// split them — so a consumer that reads only the fragments and their
1323    /// geometry gets the exact same tree, minus the `extract_paint_spans` work.
1324    ///
1325    /// This is the accessibility path's snapshot: the AT tree reads fragments,
1326    /// never the paint overlay, so paying to compute a per-block paint span for
1327    /// each of a spell-checker's tens of thousands of ranges is pure waste (it
1328    /// dominated the a11y rebuild on a large mis-dictionaried document). Render
1329    /// and layout keep using [`snapshot_flow_masked`](Self::snapshot_flow_masked),
1330    /// which they must — they draw the overlay.
1331    pub fn snapshot_flow_masked_no_paint(
1332        &self,
1333        mask: &crate::highlight::HighlightMask,
1334    ) -> crate::flow::FlowSnapshot {
1335        let inner = self.inner.lock();
1336        let main_frame_id = get_main_frame_id(&inner);
1337        let hl = crate::highlight::SnapshotHighlights {
1338            kind: inner.highlights.effective_kind(mask),
1339            mask,
1340            suppress_paint: true,
1341        };
1342        let elements = crate::text_frame::build_flow_snapshot(&inner, main_frame_id, hl);
1343        crate::flow::FlowSnapshot { elements }
1344    }
1345
1346    // ── Search ───────────────────────────────────────────────
1347
1348    /// Find the next (or previous) occurrence. Returns `None` if not found.
1349    pub fn find(
1350        &self,
1351        query: &str,
1352        from: usize,
1353        options: &FindOptions,
1354    ) -> Result<Option<FindMatch>> {
1355        let inner = self.inner.lock();
1356        let dto = options.to_find_text_dto(query, from);
1357        let result = document_search_commands::find_text(&inner.ctx, &dto)?;
1358        Ok(convert::find_result_to_match(&result))
1359    }
1360
1361    /// Find all occurrences.
1362    pub fn find_all(&self, query: &str, options: &FindOptions) -> Result<Vec<FindMatch>> {
1363        let inner = self.inner.lock();
1364        let dto = options.to_find_all_dto(query);
1365        let result = document_search_commands::find_all(&inner.ctx, &dto)?;
1366        Ok(convert::find_all_to_matches(&result))
1367    }
1368
1369    /// Replace occurrences. Returns the number of replacements. Undoable.
1370    ///
1371    /// `options` carries both how to find the text and — via
1372    /// [`crate::ReplaceOptions::format_policy`] — what the replacement wears where it
1373    /// overwrites formatted prose. The default drops the formatting under the replaced
1374    /// range, which is fine for plain text and destructive for a rename that lands on a
1375    /// partly-bold name; pass a different policy when that matters.
1376    pub fn replace_text(
1377        &self,
1378        query: &str,
1379        replacement: &str,
1380        replace_all: bool,
1381        options: &crate::ReplaceOptions,
1382    ) -> Result<usize> {
1383        let (count, queued) = {
1384            let mut inner = self.inner.lock();
1385            let dto = options.to_replace_dto(query, replacement, replace_all);
1386            let result =
1387                document_search_commands::replace_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1388            let count = to_usize(result.replacements_count);
1389            inner.invalidate_text_cache();
1390            if count > 0 {
1391                inner.modified = true;
1392                inner.rehighlight_all();
1393                // Replacements are scattered across the document — we can't
1394                // provide a single position/chars delta. Signal "content changed
1395                // from position 0, affecting `count` sites" so the consumer
1396                // knows to re-read.
1397                inner.queue_event(DocumentEvent::ContentsChanged {
1398                    position: 0,
1399                    chars_removed: 0,
1400                    chars_added: 0,
1401                    blocks_affected: count,
1402                });
1403                inner.check_block_count_changed();
1404                inner.check_flow_changed();
1405                let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1406                let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1407                inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1408            }
1409            (count, inner.take_queued_events())
1410        };
1411        crate::inner::dispatch_queued_events(queued);
1412        Ok(count)
1413    }
1414
1415    /// Replace an explicit set of ranges, each with **its own** replacement text. Undoable
1416    /// as one action, however many ranges it touches.
1417    ///
1418    /// [`replace_text`](Self::replace_text) can only put the same string at every match.
1419    /// This is for the case where the caller decides *per occurrence* — a reviewed bulk
1420    /// rename where some occurrences are unticked, or one that preserves the case it found
1421    /// (`AURÉLIEN` → `AURÉLIAN`, not `aurélian`).
1422    ///
1423    /// ⚠ **Do not build the ranges with a separate `find_all` call.** The document can move
1424    /// between the two, and the ranges then address text that is no longer there — which
1425    /// does not fail, it rewrites *the wrong words*. Use
1426    /// [`find_and_replace`](Self::find_and_replace), which does both under one lock.
1427    ///
1428    /// Ranges that straddle a block boundary, or that overlap one another, are **skipped**;
1429    /// the returned count reflects only what was actually applied.
1430    pub fn replace_ranges(
1431        &self,
1432        ranges: &[ReplaceRange],
1433        options: &crate::ReplaceOptions,
1434    ) -> Result<usize> {
1435        let (count, queued) = {
1436            let mut inner = self.inner.lock();
1437            let count = Self::replace_ranges_locked(&mut inner, ranges, options)?;
1438            (count, inner.take_queued_events())
1439        };
1440        crate::inner::dispatch_queued_events(queued);
1441        Ok(count)
1442    }
1443
1444    /// Find every match of `query` and let `decide` choose what each becomes — **atomically**.
1445    ///
1446    /// `decide` is handed the matched text and the index of the match, and returns the
1447    /// replacement, or `None` to leave that occurrence alone. So a rename that preserves case
1448    /// and skips the occurrences a writer unticked is one call:
1449    ///
1450    /// ```no_run
1451    /// # use text_document::{TextDocument, FindOptions, ReplaceOptions};
1452    /// # let doc = TextDocument::new();
1453    /// # let excluded: Vec<usize> = vec![];
1454    /// doc.find_and_replace("Aurélien", &ReplaceOptions::new(FindOptions::default()), |matched, i| {
1455    ///     if excluded.contains(&i) {
1456    ///         return None; // the writer unticked this one
1457    ///     }
1458    ///     Some(if matched.chars().all(char::is_uppercase) { "AURÉLIAN".into() } else { "Aurélian".into() })
1459    /// })?;
1460    /// # Ok::<(), text_document::DocumentError>(())
1461    /// ```
1462    ///
1463    /// **The scan and the splice happen under one lock**, which is the whole point. Calling
1464    /// `find_all` and then `replace_ranges` would drop the lock in between, and the document
1465    /// can be edited there — after which every range addresses text that has moved. That does
1466    /// not raise an error; it silently rewrites the wrong words. The document mutex is not
1467    /// reentrant, so composing the two public methods cannot close the gap; only doing both
1468    /// inside one can.
1469    pub fn find_and_replace(
1470        &self,
1471        query: &str,
1472        options: &crate::ReplaceOptions,
1473        mut decide: impl FnMut(&str, usize) -> Option<String>,
1474    ) -> Result<usize> {
1475        let (count, queued) = {
1476            let mut inner = self.inner.lock();
1477
1478            // Scan. The matched TEXT comes back with the offsets, sliced by the use case from
1479            // the very text it searched — deliberately, so this never has to slice a
1480            // whole-document string of its own. The only one reachable here is
1481            // `to_plain_text`, which is the human-readable view and carries no `U+FFFC` anchor
1482            // for an embedded table; slicing it with these offsets would be wrong by two
1483            // characters per preceding table, and the rename would rewrite the wrong words.
1484            let found = {
1485                let dto = options.find.to_find_all_dto(query);
1486                document_search_commands::find_all(&inner.ctx, &dto)?
1487            };
1488
1489            // …decide, against the document as it is RIGHT NOW…
1490            let mut ranges: Vec<ReplaceRange> = Vec::new();
1491            for (i, ((&position, &length), matched)) in found
1492                .positions
1493                .iter()
1494                .zip(found.lengths.iter())
1495                .zip(found.matched_texts.iter())
1496                .enumerate()
1497            {
1498                if let Some(replacement) = decide(matched, i) {
1499                    ranges.push(ReplaceRange {
1500                        position: to_usize(position),
1501                        length: to_usize(length),
1502                        replacement,
1503                    });
1504                }
1505            }
1506
1507            // …and splice — all without ever letting go of the lock.
1508            let count = if ranges.is_empty() {
1509                0
1510            } else {
1511                Self::replace_ranges_locked(&mut inner, &ranges, options)?
1512            };
1513            (count, inner.take_queued_events())
1514        };
1515        crate::inner::dispatch_queued_events(queued);
1516        Ok(count)
1517    }
1518
1519    /// The splice, with the lock already held. Shared by [`Self::replace_ranges`] and
1520    /// [`Self::find_and_replace`] so the second cannot drift from the first.
1521    fn replace_ranges_locked(
1522        inner: &mut crate::inner::TextDocumentInner,
1523        ranges: &[ReplaceRange],
1524        options: &crate::ReplaceOptions,
1525    ) -> Result<usize> {
1526        let dto = options.to_replace_ranges_dto(ranges);
1527        let result =
1528            document_search_commands::replace_ranges(&inner.ctx, Some(inner.stack_id), &dto)?;
1529        let count = to_usize(result.replacements_count);
1530
1531        inner.invalidate_text_cache();
1532        if count > 0 {
1533            inner.modified = true;
1534            inner.rehighlight_all();
1535            inner.queue_event(DocumentEvent::ContentsChanged {
1536                position: 0,
1537                chars_removed: 0,
1538                chars_added: 0,
1539                blocks_affected: count,
1540            });
1541            inner.check_block_count_changed();
1542            inner.check_flow_changed();
1543            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1544            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1545            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1546        }
1547        Ok(count)
1548    }
1549
1550    // ── Resources ────────────────────────────────────────────
1551
1552    /// Add a resource (image, stylesheet) to the document.
1553    pub fn add_resource(
1554        &self,
1555        resource_type: ResourceType,
1556        name: &str,
1557        mime_type: &str,
1558        data: &[u8],
1559    ) -> Result<()> {
1560        let mut inner = self.inner.lock();
1561        let dto = frontend::resource::dtos::CreateResourceDto {
1562            created_at: Default::default(),
1563            updated_at: Default::default(),
1564            resource_type,
1565            name: name.into(),
1566            url: String::new(),
1567            mime_type: mime_type.into(),
1568            data_base64: BASE64.encode(data),
1569        };
1570        let created = resource_commands::create_resource(
1571            &inner.ctx,
1572            Some(inner.stack_id),
1573            &dto,
1574            inner.document_id,
1575            -1,
1576        )?;
1577        inner.resource_cache.insert(name.to_string(), created.id);
1578        Ok(())
1579    }
1580
1581    /// Get a resource by name. Returns `None` if not found.
1582    ///
1583    /// Uses an internal cache to avoid scanning all resources on repeated lookups.
1584    pub fn resource(&self, name: &str) -> Result<Option<Vec<u8>>> {
1585        let mut inner = self.inner.lock();
1586
1587        // Fast path: check the name → ID cache.
1588        if let Some(&id) = inner.resource_cache.get(name) {
1589            if let Some(r) = resource_commands::get_resource(&inner.ctx, &id)? {
1590                let bytes = BASE64
1591                    .decode(&r.data_base64)
1592                    .map_err(|e| DocumentError::Internal(e.into()))?;
1593                return Ok(Some(bytes));
1594            }
1595            // ID was stale — fall through to full scan.
1596            inner.resource_cache.remove(name);
1597        }
1598
1599        // Slow path: linear scan, then populate cache for the match.
1600        let all = resource_commands::get_all_resource(&inner.ctx)?;
1601        for r in &all {
1602            if r.name == name {
1603                inner.resource_cache.insert(name.to_string(), r.id);
1604                let bytes = BASE64
1605                    .decode(&r.data_base64)
1606                    .map_err(|e| DocumentError::Internal(e.into()))?;
1607                return Ok(Some(bytes));
1608            }
1609        }
1610        Ok(None)
1611    }
1612
1613    // ── Undo / Redo ──────────────────────────────────────────
1614
1615    /// Undo the last operation.
1616    pub fn undo(&self) -> Result<()> {
1617        let queued = {
1618            let mut inner = self.inner.lock();
1619            let before = capture_block_state(&inner);
1620            let result = undo_redo_commands::undo(&inner.ctx, Some(inner.stack_id));
1621            inner.invalidate_text_cache();
1622            result?;
1623            inner.rehighlight_all();
1624            emit_content_change_events(&mut inner, &before);
1625            inner.check_block_count_changed();
1626            inner.check_flow_changed();
1627            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1628            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1629            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1630            inner.take_queued_events()
1631        };
1632        crate::inner::dispatch_queued_events(queued);
1633        Ok(())
1634    }
1635
1636    /// Redo the last undone operation.
1637    pub fn redo(&self) -> Result<()> {
1638        let queued = {
1639            let mut inner = self.inner.lock();
1640            let before = capture_block_state(&inner);
1641            let result = undo_redo_commands::redo(&inner.ctx, Some(inner.stack_id));
1642            inner.invalidate_text_cache();
1643            result?;
1644            inner.rehighlight_all();
1645            emit_content_change_events(&mut inner, &before);
1646            inner.check_block_count_changed();
1647            inner.check_flow_changed();
1648            let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
1649            let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
1650            inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
1651            inner.take_queued_events()
1652        };
1653        crate::inner::dispatch_queued_events(queued);
1654        Ok(())
1655    }
1656
1657    /// Returns true if there are operations that can be undone.
1658    pub fn can_undo(&self) -> bool {
1659        let inner = self.inner.lock();
1660        undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id))
1661    }
1662
1663    /// Returns true if there are operations that can be redone.
1664    pub fn can_redo(&self) -> bool {
1665        let inner = self.inner.lock();
1666        undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id))
1667    }
1668
1669    /// Clear all undo/redo history.
1670    pub fn clear_undo_redo(&self) {
1671        let inner = self.inner.lock();
1672        undo_redo_commands::clear_stack(&inner.ctx, inner.stack_id);
1673    }
1674
1675    // ── Modified state ───────────────────────────────────────
1676
1677    /// Returns true if the document has been modified since creation or last reset.
1678    pub fn is_modified(&self) -> bool {
1679        self.inner.lock().modified
1680    }
1681
1682    /// Set or clear the modified flag.
1683    pub fn set_modified(&self, modified: bool) {
1684        let queued = {
1685            let mut inner = self.inner.lock();
1686            if inner.modified != modified {
1687                inner.modified = modified;
1688                inner.queue_event(DocumentEvent::ModificationChanged(modified));
1689            }
1690            inner.take_queued_events()
1691        };
1692        crate::inner::dispatch_queued_events(queued);
1693    }
1694
1695    /// A monotonic counter, bumped once per [`DocumentEvent::ContentsChanged`]
1696    /// queued so far. Starts at `0`.
1697    ///
1698    /// Lets a caller answer "was this notification caused by exactly the
1699    /// most recent edit, with nothing else having happened since" precisely
1700    /// — snapshot the value when acting on a notification, and compare it
1701    /// against the current value later. This is deliberately *not* the same
1702    /// as [`is_modified`](Self::is_modified) (a flag, not a count) or the
1703    /// undo stack's depth (which does not grow when consecutive compatible
1704    /// edits merge into one entry — e.g. fast consecutive typing).
1705    pub fn content_revision(&self) -> u64 {
1706        self.inner.lock().content_revision
1707    }
1708
1709    // ── Document properties ──────────────────────────────────
1710
1711    /// Get the document title.
1712    pub fn title(&self) -> String {
1713        let inner = self.inner.lock();
1714        document_commands::get_document(&inner.ctx, &inner.document_id)
1715            .ok()
1716            .flatten()
1717            .map(|d| d.title)
1718            .unwrap_or_default()
1719    }
1720
1721    /// Set the document title.
1722    pub fn set_title(&self, title: &str) -> Result<()> {
1723        let inner = self.inner.lock();
1724        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1725            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1726        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1727        update.title = title.into();
1728        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1729        Ok(())
1730    }
1731
1732    /// Get the text direction.
1733    pub fn text_direction(&self) -> TextDirection {
1734        let inner = self.inner.lock();
1735        document_commands::get_document(&inner.ctx, &inner.document_id)
1736            .ok()
1737            .flatten()
1738            .map(|d| d.text_direction)
1739            .unwrap_or(TextDirection::LeftToRight)
1740    }
1741
1742    /// Set the text direction.
1743    pub fn set_text_direction(&self, direction: TextDirection) -> Result<()> {
1744        let inner = self.inner.lock();
1745        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1746            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1747        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1748        update.text_direction = direction;
1749        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1750        Ok(())
1751    }
1752
1753    /// Get the default wrap mode.
1754    pub fn default_wrap_mode(&self) -> WrapMode {
1755        let inner = self.inner.lock();
1756        document_commands::get_document(&inner.ctx, &inner.document_id)
1757            .ok()
1758            .flatten()
1759            .map(|d| d.default_wrap_mode)
1760            .unwrap_or(WrapMode::WordWrap)
1761    }
1762
1763    /// Set the default wrap mode.
1764    pub fn set_default_wrap_mode(&self, mode: WrapMode) -> Result<()> {
1765        let inner = self.inner.lock();
1766        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1767            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1768        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1769        update.default_wrap_mode = mode;
1770        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1771        Ok(())
1772    }
1773
1774    /// Get the document-wide default language (ISO 639-1 code, e.g. "en").
1775    /// This is the fallback hyphenation language for blocks that don't set
1776    /// their own `language`. Defaults to `"en"` when never set.
1777    pub fn default_language(&self) -> String {
1778        let inner = self.inner.lock();
1779        document_commands::get_document(&inner.ctx, &inner.document_id)
1780            .ok()
1781            .flatten()
1782            .and_then(|d| d.default_language)
1783            .unwrap_or_else(|| "en".to_string())
1784    }
1785
1786    /// Set the document-wide default language (ISO 639-1 code). Blocks
1787    /// without an explicit `language` inherit this for hyphenation.
1788    pub fn set_default_language(&self, language: &str) -> Result<()> {
1789        let inner = self.inner.lock();
1790        let doc = document_commands::get_document(&inner.ctx, &inner.document_id)?
1791            .ok_or_else(|| DocumentError::NotFound("document not found".into()))?;
1792        let mut update: frontend::document::dtos::UpdateDocumentDto = doc.into();
1793        update.default_language = Some(language.to_string());
1794        document_commands::update_document(&inner.ctx, Some(inner.stack_id), &update)?;
1795        Ok(())
1796    }
1797
1798    // ── Event subscription ───────────────────────────────────
1799
1800    /// Subscribe to document events via callback.
1801    ///
1802    /// Callbacks are invoked **outside** the document lock (after the editing
1803    /// operation completes and the lock is released). It is safe to call
1804    /// `TextDocument` or `TextCursor` methods from within the callback without
1805    /// risk of deadlock. However, keep callbacks lightweight — they run
1806    /// synchronously on the calling thread and block the caller until they
1807    /// return.
1808    ///
1809    /// Drop the returned [`Subscription`] to unsubscribe.
1810    ///
1811    /// # Breaking change (v0.0.6)
1812    ///
1813    /// The callback bound changed from `Send` to `Send + Sync` in v0.0.6
1814    /// to support `Arc`-based dispatch. Callbacks that capture non-`Sync`
1815    /// types (e.g., `Rc<T>`, `Cell<T>`) must be wrapped in a `Mutex`.
1816    pub fn on_change<F>(&self, callback: F) -> Subscription
1817    where
1818        F: Fn(DocumentEvent) + Send + Sync + 'static,
1819    {
1820        let mut inner = self.inner.lock();
1821        events::subscribe_inner(&mut inner, callback)
1822    }
1823
1824    /// Return events accumulated since the last `poll_events()` call.
1825    ///
1826    /// This delivery path is independent of callback dispatch via
1827    /// [`on_change`](Self::on_change) — using both simultaneously is safe
1828    /// and each path sees every event exactly once.
1829    pub fn poll_events(&self) -> Vec<DocumentEvent> {
1830        let mut inner = self.inner.lock();
1831        inner.drain_poll_events()
1832    }
1833
1834    // ── Syntax highlighting ──────────────────────────────────
1835
1836    /// Attach a single syntax highlighter to this document — the classic, one-highlighter
1837    /// entry point.
1838    ///
1839    /// Immediately re-highlights the entire document. **Replaces** the one highlighter this
1840    /// method manages, and *only* that one: a spell-checker or find layer registered
1841    /// independently via [`add_syntax_session`](Self::add_syntax_session) /
1842    /// [`add_range_session`](Self::add_range_session) is left untouched. Pass `None` to remove
1843    /// it.
1844    ///
1845    /// This is a convenience over the session registry — it owns exactly one "shim" session. A
1846    /// host that wants to manage several layers uses the session methods directly.
1847    pub fn set_syntax_highlighter(&self, highlighter: Option<Arc<dyn crate::SyntaxHighlighter>>) {
1848        let queued = {
1849            let mut inner = self.inner.lock();
1850            let prev_kind = inner.highlight_kind;
1851            let installed = highlighter.is_some();
1852            inner.highlights.set_shim(highlighter);
1853            if installed {
1854                inner.rehighlight_all(); // recomputes highlight_kind
1855            } else {
1856                inner.recompute_highlight_kind();
1857            }
1858            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1859            inner.take_queued_events()
1860        };
1861        crate::inner::dispatch_queued_events(queued);
1862    }
1863
1864    /// Register a **syntax session** — a [`SyntaxHighlighter`](crate::SyntaxHighlighter)
1865    /// callback with its own per-block state cascade — and return its [`crate::SessionId`].
1866    ///
1867    /// Unlike [`set_syntax_highlighter`](Self::set_syntax_highlighter), this **adds** rather
1868    /// than replaces: a document can carry a syntax highlighter and a spell-checker at once,
1869    /// each a session, merged in `(priority, registration)` order (a later session's field
1870    /// wins). Sessions remain visible only in views whose
1871    /// [`HighlightMask`](crate::highlight::HighlightMask) admits them.
1872    pub fn add_syntax_session(
1873        &self,
1874        highlighter: Arc<dyn crate::SyntaxHighlighter>,
1875    ) -> crate::highlight::SessionId {
1876        self.add_syntax_session_with_priority(highlighter, 0)
1877    }
1878
1879    /// [`add_syntax_session`](Self::add_syntax_session) at an explicit merge priority — see
1880    /// [`add_range_session_with_priority`](Self::add_range_session_with_priority).
1881    pub fn add_syntax_session_with_priority(
1882        &self,
1883        highlighter: Arc<dyn crate::SyntaxHighlighter>,
1884        priority: i32,
1885    ) -> crate::highlight::SessionId {
1886        let (id, queued) = {
1887            let mut inner = self.inner.lock();
1888            let prev_kind = inner.highlight_kind;
1889            let id = inner.highlights.add_syntax(highlighter, priority);
1890            inner.rehighlight_all();
1891            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1892            (id, inner.take_queued_events())
1893        };
1894        crate::inner::dispatch_queued_events(queued);
1895        id
1896    }
1897
1898    /// Register an empty **range session** — absolute-offset ranges set with
1899    /// [`set_session_ranges`](Self::set_session_ranges), the shape used for search and (later)
1900    /// an externally-driven spell-checker. Returns its [`crate::SessionId`].
1901    ///
1902    /// A view's own find session is a range session it alone admits; that is how two panes
1903    /// over one document highlight different queries.
1904    pub fn add_range_session(&self) -> crate::highlight::SessionId {
1905        self.add_range_session_with_priority(0)
1906    }
1907
1908    /// [`add_range_session`](Self::add_range_session) at an explicit **merge priority**.
1909    ///
1910    /// Where two sessions format the same character, the higher priority wins field by field;
1911    /// equal priorities fall back to registration order, which is what every session gets by
1912    /// default (`0`).
1913    ///
1914    /// Reach for this when a layer must reliably lose — an ambient background band that every
1915    /// find match and spell squiggle should paint over. Registration order cannot express that:
1916    /// a per-view layer is registered when its view appears, so whether it lands before or
1917    /// after the find session depends on the order the user happened to open things in.
1918    pub fn add_range_session_with_priority(&self, priority: i32) -> crate::highlight::SessionId {
1919        let mut inner = self.inner.lock();
1920        inner.highlights.add_range(priority)
1921        // No repaint: an empty range session shows nothing until its ranges are set.
1922    }
1923
1924    /// Replace the ranges of a range session (absolute char offsets, the space
1925    /// [`FindMatch`] reports in). Returns `false` if `id` is not a range
1926    /// session.
1927    ///
1928    /// Fires a highlight-changed event so live views showing this session re-snapshot — the
1929    /// only signal there is, since the ranges do not mutate the document.
1930    pub fn set_session_ranges(
1931        &self,
1932        id: crate::highlight::SessionId,
1933        ranges: Vec<crate::highlight::RangeHighlight>,
1934    ) -> bool {
1935        let (ok, queued) = {
1936            let mut inner = self.inner.lock();
1937            let prev_kind = inner.highlight_kind;
1938            // The block layout the ranges are bucketed against — cheap (ids + positions, no
1939            // block text) and computed before the mutable borrow of `highlights`. This is what
1940            // lets `merged_spans_for_block` look up only a block's own ranges instead of
1941            // scanning the whole vector per block.
1942            let block_positions = crate::highlight::ordered_block_positions(&inner);
1943            let changed = inner.highlights.set_ranges(id, ranges, &block_positions);
1944            if let Some((position, length)) = changed {
1945                inner.recompute_highlight_kind();
1946                // The real extent, not `0, 0`: a view can then recolor just the block it covers
1947                // rather than re-snapshotting the whole document on every caret move.
1948                Self::queue_highlight_changed(&mut inner, position, length, prev_kind);
1949            }
1950            (changed.is_some(), inner.take_queued_events())
1951        };
1952        crate::inner::dispatch_queued_events(queued);
1953        ok
1954    }
1955
1956    /// Retire a session (of either kind). Returns whether it existed.
1957    pub fn remove_session(&self, id: crate::highlight::SessionId) -> bool {
1958        let (existed, queued) = {
1959            let mut inner = self.inner.lock();
1960            let prev_kind = inner.highlight_kind;
1961            let existed = inner.highlights.remove(id);
1962            if existed {
1963                inner.recompute_highlight_kind();
1964                Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1965            }
1966            (existed, inner.take_queued_events())
1967        };
1968        crate::inner::dispatch_queued_events(queued);
1969        existed
1970    }
1971
1972    /// Re-highlight the entire document.
1973    ///
1974    /// Call this when the highlighter's rules change (e.g., new keywords
1975    /// were added, spellcheck dictionary updated).
1976    pub fn rehighlight(&self) {
1977        let queued = {
1978            let mut inner = self.inner.lock();
1979            let prev_kind = inner.highlight_kind;
1980            inner.rehighlight_all();
1981            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1982            inner.take_queued_events()
1983        };
1984        crate::inner::dispatch_queued_events(queued);
1985    }
1986
1987    /// Re-highlight a single block and cascade to subsequent blocks if
1988    /// the block state changes.
1989    pub fn rehighlight_block(&self, block_id: usize) {
1990        let queued = {
1991            let mut inner = self.inner.lock();
1992            let prev_kind = inner.highlight_kind;
1993            inner.rehighlight_from_block(block_id);
1994            Self::queue_highlight_changed(&mut inner, 0, 0, prev_kind);
1995            inner.take_queued_events()
1996        };
1997        crate::inner::dispatch_queued_events(queued);
1998    }
1999
2000    /// Queue the relayout/repaint notification for a highlight-only change.
2001    ///
2002    /// Highlighting overlays the layout without touching stored formatting,
2003    /// so it emits no edit event on its own — subscribers (live editors)
2004    /// must be told to re-snapshot. The event kind depends on whether the
2005    /// shaping input (`fragments`) changed:
2006    ///
2007    /// - A change that leaves `fragments` BASE on both sides (paint-only ↔
2008    ///   paint-only / none) emits [`DocumentEvent::HighlightPaintChanged`],
2009    ///   which the editor handles by recoloring the cached layout without
2010    ///   reshaping.
2011    /// - Any transition involving a metric-affecting highlighter changes
2012    ///   `fragments` (highlights are merged in / removed), so it emits
2013    ///   [`DocumentEvent::FormatChanged`] (full relayout, caret/scroll
2014    ///   preserved).
2015    ///
2016    /// `position` / `length` name the extent that changed, so a live view can
2017    /// recolor just the blocks it covers instead of re-deriving the whole
2018    /// snapshot. **A `length` of `0` means "unknown — assume the whole
2019    /// document"**, which is what the genuinely document-wide operations pass
2020    /// (installing or retiring a highlighter, a full rehighlight). Only
2021    /// [`set_session_ranges`](Self::set_session_ranges) reports a real extent,
2022    /// its before/after range sets giving an exact answer.
2023    fn queue_highlight_changed(
2024        inner: &mut TextDocumentInner,
2025        position: usize,
2026        length: usize,
2027        prev_kind: crate::highlight::HighlighterKind,
2028    ) {
2029        use crate::highlight::HighlighterKind::{Metric, None as KNone, PaintOnly};
2030        let new_kind = inner.highlight_kind;
2031        let event = match (prev_kind, new_kind) {
2032            // No highlighter before or after — nothing changed.
2033            (KNone, KNone) => return,
2034            // Fragments are BASE on both sides: recolor-only.
2035            (PaintOnly, PaintOnly) | (KNone, PaintOnly) | (PaintOnly, KNone) => {
2036                DocumentEvent::HighlightPaintChanged { position, length }
2037            }
2038            // A metric highlighter is involved on one side: fragments change.
2039            (KNone, Metric)
2040            | (Metric, Metric)
2041            | (Metric, PaintOnly)
2042            | (Metric, KNone)
2043            | (PaintOnly, Metric) => DocumentEvent::FormatChanged {
2044                position,
2045                length,
2046                kind: crate::flow::FormatChangeKind::Character,
2047            },
2048        };
2049        inner.queue_event(event);
2050    }
2051}
2052
2053impl Default for TextDocument {
2054    fn default() -> Self {
2055        Self::new()
2056    }
2057}
2058
2059// ── Undo/redo change detection helpers ─────────────────────────
2060
2061/// Lightweight block state for before/after comparison.
2062///
2063/// Named for undo/redo because that is where it started; it is now also how a
2064/// structural table edit works out what it did to the text. See
2065/// [`emit_content_change_events`].
2066pub(crate) struct UndoBlockState {
2067    id: u64,
2068    position: i64,
2069    text_length: i64,
2070    plain_text: String,
2071    format: BlockFormat,
2072}
2073
2074/// Capture the state of all blocks, sorted by document_position.
2075///
2076/// Reads through the store rather than the plain-text cache, so a caller does
2077/// not have to have invalidated anything first.
2078pub(crate) fn capture_block_state(inner: &TextDocumentInner) -> Vec<UndoBlockState> {
2079    let mut all_blocks =
2080        frontend::commands::block_commands::get_all_block(&inner.ctx).unwrap_or_default();
2081    let store = inner.ctx.db_context.get_store();
2082    crate::inner::refresh_block_positions(&mut all_blocks, store);
2083    let mut states: Vec<UndoBlockState> = all_blocks
2084        .into_iter()
2085        .map(|b| {
2086            let format = BlockFormat::from(&b);
2087            let entity: common::entities::Block = b.clone().into();
2088            let plain_text =
2089                common::database::rope_helpers::block_content_via_store(&entity, store);
2090            let text_length = common::database::rope_helpers::block_char_length(&entity, store);
2091            UndoBlockState {
2092                id: b.id,
2093                position: b.document_position,
2094                text_length,
2095                plain_text,
2096                format,
2097            }
2098        })
2099        .collect();
2100    states.sort_by_key(|s| s.position);
2101    states
2102}
2103
2104/// Build the full document text from sorted block states (joined with newlines).
2105fn build_doc_text(states: &[UndoBlockState]) -> String {
2106    states
2107        .iter()
2108        .map(|s| s.plain_text.as_str())
2109        .collect::<Vec<_>>()
2110        .join("\n")
2111}
2112
2113/// Compute the precise edit between two strings by comparing common prefix and suffix.
2114/// Returns `(edit_offset, chars_removed, chars_added)`.
2115fn compute_text_edit(before: &str, after: &str) -> (usize, usize, usize) {
2116    let before_chars: Vec<char> = before.chars().collect();
2117    let after_chars: Vec<char> = after.chars().collect();
2118
2119    // Common prefix
2120    let prefix_len = before_chars
2121        .iter()
2122        .zip(after_chars.iter())
2123        .take_while(|(a, b)| a == b)
2124        .count();
2125
2126    // Common suffix (not overlapping with prefix)
2127    let before_remaining = before_chars.len() - prefix_len;
2128    let after_remaining = after_chars.len() - prefix_len;
2129    let suffix_len = before_chars
2130        .iter()
2131        .rev()
2132        .zip(after_chars.iter().rev())
2133        .take(before_remaining.min(after_remaining))
2134        .take_while(|(a, b)| a == b)
2135        .count();
2136
2137    let removed = before_remaining - suffix_len;
2138    let added = after_remaining - suffix_len;
2139
2140    (prefix_len, removed, added)
2141}
2142
2143/// Compare block state before and after an edit and emit
2144/// `ContentsChanged` / `FormatChanged` events for the affected regions.
2145///
2146/// ## Why anything else would be a guess
2147///
2148/// The delta this computes is a **real text diff**, and consumers rely on that
2149/// being true rather than approximate: a comment anchor shifts by
2150/// `(position, chars_removed, chars_added)`, so a figure that is merely
2151/// plausible moves an anchor to somewhere that was never right — which is
2152/// harder to notice than not moving it at all.
2153///
2154/// That is why the table primitives call this rather than describing their own
2155/// edit. A row insert knows how many rows it added; it does not know where in
2156/// the document's text that lands, and working it out by hand would be seven
2157/// separate opportunities to be subtly wrong.
2158///
2159/// Used by undo, redo, and every structural table edit.
2160pub(crate) fn emit_content_change_events(inner: &mut TextDocumentInner, before: &[UndoBlockState]) {
2161    let after = capture_block_state(inner);
2162
2163    // Build a map of block id → state for the "before" set.
2164    let before_map: std::collections::HashMap<u64, &UndoBlockState> =
2165        before.iter().map(|s| (s.id, s)).collect();
2166    let after_map: std::collections::HashMap<u64, &UndoBlockState> =
2167        after.iter().map(|s| (s.id, s)).collect();
2168
2169    // Track the affected content region (earliest position, total old/new length).
2170    let mut content_changed = false;
2171    let mut earliest_pos: Option<usize> = None;
2172    let mut old_end: usize = 0;
2173    let mut new_end: usize = 0;
2174    let mut blocks_affected: usize = 0;
2175
2176    let mut format_only_changes: Vec<(usize, usize)> = Vec::new(); // (position, length)
2177
2178    // Check blocks present in both before and after.
2179    for after_state in &after {
2180        if let Some(before_state) = before_map.get(&after_state.id) {
2181            let text_changed = before_state.plain_text != after_state.plain_text
2182                || before_state.text_length != after_state.text_length;
2183            let format_changed = before_state.format != after_state.format;
2184
2185            if text_changed {
2186                content_changed = true;
2187                blocks_affected += 1;
2188                let pos = after_state.position.max(0) as usize;
2189                earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2190                old_end = old_end.max(
2191                    before_state.position.max(0) as usize
2192                        + before_state.text_length.max(0) as usize,
2193                );
2194                new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
2195            } else if format_changed {
2196                let pos = after_state.position.max(0) as usize;
2197                let len = after_state.text_length.max(0) as usize;
2198                format_only_changes.push((pos, len));
2199            }
2200        } else {
2201            // Block exists in after but not in before — new block from undo/redo.
2202            content_changed = true;
2203            blocks_affected += 1;
2204            let pos = after_state.position.max(0) as usize;
2205            earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2206            new_end = new_end.max(pos + after_state.text_length.max(0) as usize);
2207        }
2208    }
2209
2210    // Check blocks that were removed (present in before but not after).
2211    for before_state in before {
2212        if !after_map.contains_key(&before_state.id) {
2213            content_changed = true;
2214            blocks_affected += 1;
2215            let pos = before_state.position.max(0) as usize;
2216            earliest_pos = Some(earliest_pos.map_or(pos, |p: usize| p.min(pos)));
2217            old_end = old_end.max(pos + before_state.text_length.max(0) as usize);
2218        }
2219    }
2220
2221    if content_changed {
2222        let position = earliest_pos.unwrap_or(0);
2223        let chars_removed = old_end.saturating_sub(position);
2224        let chars_added = new_end.saturating_sub(position);
2225
2226        // Use a precise text-level diff for cursor adjustment so cursors land
2227        // at the actual edit point rather than the end of the affected block.
2228        let before_text = build_doc_text(before);
2229        let after_text = build_doc_text(&after);
2230        let (edit_offset, precise_removed, precise_added) =
2231            compute_text_edit(&before_text, &after_text);
2232        if precise_removed > 0 || precise_added > 0 {
2233            inner.adjust_cursors(edit_offset, precise_removed, precise_added);
2234        }
2235
2236        inner.queue_event(DocumentEvent::ContentsChanged {
2237            position,
2238            chars_removed,
2239            chars_added,
2240            blocks_affected,
2241        });
2242        // **`Replayed`, hard-coded, and it cannot double-count.** Undo and redo
2243        // never re-enter the insertion API — they snapshot and diff, which is
2244        // why this function exists — so text restored by them is reported once,
2245        // under an origin that says it came back rather than arrived.
2246        //
2247        // ⚠ Measured as the document's **total** gain, not from `chars_added`.
2248        // That figure is the size of the restored region, which is non-zero for
2249        // an undo that removes text: reporting it would put `Replayed` on a
2250        // count of characters nothing brought back. A test caught exactly that.
2251        let before_len: i64 = before.iter().map(|b| b.text_length.max(0)).sum();
2252        let after_len: i64 = after.iter().map(|a| a.text_length.max(0)).sum();
2253        if after_len > before_len {
2254            inner.queue_event(DocumentEvent::TextInserted {
2255                position,
2256                chars_inserted: (after_len - before_len) as usize,
2257                origin: crate::InsertionOrigin::Replayed,
2258            });
2259        }
2260    }
2261
2262    // Emit FormatChanged for blocks where only formatting changed (not content).
2263    for (position, length) in format_only_changes {
2264        inner.queue_event(DocumentEvent::FormatChanged {
2265            position,
2266            length,
2267            kind: FormatChangeKind::Block,
2268        });
2269    }
2270}
2271
2272// ── Flow helpers ──────────────────────────────────────────────
2273
2274/// Get the main frame ID for the document.
2275/// Collect all block IDs in document order from a frame, recursing into nested
2276/// sub-frames (negative entries in child_order).
2277fn collect_frame_block_ids(
2278    inner: &TextDocumentInner,
2279    frame_id: frontend::common::types::EntityId,
2280) -> Option<Vec<u64>> {
2281    let frame_dto = frame_commands::get_frame(&inner.ctx, &frame_id)
2282        .ok()
2283        .flatten()?;
2284
2285    if !frame_dto.child_order.is_empty() {
2286        let mut block_ids = Vec::new();
2287        for &entry in &frame_dto.child_order {
2288            if entry > 0 {
2289                block_ids.push(entry as u64);
2290            } else if entry < 0 {
2291                let sub_frame_id = (-entry) as u64;
2292                let sub_frame = frame_commands::get_frame(&inner.ctx, &sub_frame_id)
2293                    .ok()
2294                    .flatten();
2295                if let Some(ref sf) = sub_frame {
2296                    if let Some(table_id) = sf.table {
2297                        // Table anchor frame: collect blocks from cell frames
2298                        // in row-major order, matching collect_block_ids_recursive.
2299                        if let Some(table_dto) = table_commands::get_table(&inner.ctx, &table_id)
2300                            .ok()
2301                            .flatten()
2302                        {
2303                            let mut cell_dtos: Vec<_> = table_dto
2304                                .cells
2305                                .iter()
2306                                .filter_map(|&cid| {
2307                                    table_cell_commands::get_table_cell(&inner.ctx, &cid)
2308                                        .ok()
2309                                        .flatten()
2310                                })
2311                                .collect();
2312                            cell_dtos
2313                                .sort_by(|a, b| a.row.cmp(&b.row).then(a.column.cmp(&b.column)));
2314                            for cell_dto in &cell_dtos {
2315                                if let Some(cf_id) = cell_dto.cell_frame
2316                                    && let Some(cf_ids) = collect_frame_block_ids(inner, cf_id)
2317                                {
2318                                    block_ids.extend(cf_ids);
2319                                }
2320                            }
2321                        }
2322                    } else if let Some(sub_ids) = collect_frame_block_ids(inner, sub_frame_id) {
2323                        block_ids.extend(sub_ids);
2324                    }
2325                }
2326            }
2327        }
2328        Some(block_ids)
2329    } else {
2330        Some(frame_dto.blocks.to_vec())
2331    }
2332}
2333
2334pub(crate) fn get_main_frame_id(inner: &TextDocumentInner) -> frontend::common::types::EntityId {
2335    // The document's first frame is the main frame.
2336    let frames = frontend::commands::document_commands::get_document_relationship(
2337        &inner.ctx,
2338        &inner.document_id,
2339        &frontend::document::dtos::DocumentRelationshipField::Frames,
2340    )
2341    .unwrap_or_default();
2342
2343    frames.first().copied().unwrap_or(0)
2344}
2345
2346// ── Long-operation event data helpers ─────────────────────────
2347
2348/// Parse progress JSON: `{"id":"...", "percentage": 50.0, "message": "..."}`
2349fn parse_progress_data(data: &Option<String>) -> (String, f64, String) {
2350    let Some(json) = data else {
2351        return (String::new(), 0.0, String::new());
2352    };
2353    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2354    let id = v["id"].as_str().unwrap_or_default().to_string();
2355    let pct = v["percentage"].as_f64().unwrap_or(0.0);
2356    let msg = v["message"].as_str().unwrap_or_default().to_string();
2357    (id, pct, msg)
2358}
2359
2360/// Parse completed/cancelled JSON: `{"id":"..."}`
2361fn parse_id_data(data: &Option<String>) -> String {
2362    let Some(json) = data else {
2363        return String::new();
2364    };
2365    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2366    v["id"].as_str().unwrap_or_default().to_string()
2367}
2368
2369/// Parse failed JSON: `{"id":"...", "error":"..."}`
2370fn parse_failed_data(data: &Option<String>) -> (String, String) {
2371    let Some(json) = data else {
2372        return (String::new(), "unknown error".into());
2373    };
2374    let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
2375    let id = v["id"].as_str().unwrap_or_default().to_string();
2376    let error = v["error"].as_str().unwrap_or("unknown error").to_string();
2377    (id, error)
2378}