Skip to main content

text_document/
lib.rs

1//! # text-document
2//!
3//! A rich text document model for Rust.
4//!
5//! Provides a [`TextDocument`] as the main entry point and [`TextCursor`] for
6//! cursor-based editing, inspired by Qt's QTextDocument/QTextCursor API.
7//!
8//! ```rust,no_run
9//! use text_document::{TextDocument, MoveMode, MoveOperation};
10//!
11//! let doc = TextDocument::new();
12//! doc.set_plain_text("Hello world").unwrap();
13//!
14//! let cursor = doc.cursor();
15//! cursor.move_position(MoveOperation::EndOfWord, MoveMode::KeepAnchor, 1);
16//! cursor.insert_text("Goodbye").unwrap(); // replaces "Hello"
17//!
18//! // Multiple cursors on the same document
19//! let c1 = doc.cursor();
20//! let c2 = doc.cursor_at(5);
21//! c1.insert_text("A").unwrap();
22//! // c2's position is automatically adjusted
23//!
24//! doc.undo().unwrap();
25//! ```
26
27mod backend;
28mod batch;
29mod convert;
30mod cursor;
31mod document;
32mod error;
33mod events;
34mod flow;
35mod fragment;
36mod highlight;
37mod inner;
38mod link_extent;
39mod operation;
40
41mod streaming;
42mod text_block;
43mod text_frame;
44mod text_list;
45mod text_table;
46
47// ── Re-exports from entity DTOs (enums that consumers need) ──────
48pub use backend::DocumentBackend;
49pub use frontend::block::dtos::{Alignment, MarkerType};
50pub use frontend::block::dtos::{CharVerticalAlignment, InlineContent, UnderlineStyle};
51pub use frontend::common::format_runs::ReplaceFormatPolicy;
52pub use frontend::common::parser_tools::{
53    CommentReply, CountMethod, DjotExportOptions, DjotImportOptions, DocumentComment,
54    DocumentComments, DocumentMark, DocumentMarks, DocxExportOptions, DocxHeadingStyle,
55    EpubExportOptions, ExportImage, ExportImages, HTML_FOOTNOTE_ATTR, HtmlExportOptions,
56    HtmlImageMode, LatexExportOptions, MAX_BOOKMARK_NAME_LEN, MarkdownExportOptions,
57    OdtExportOptions, OdtHeadingStyle, PdfExportOptions, PlainTextExportOptions, Sentence,
58    TABLE_ANCHOR, WordCharCounts, count, count_djot, djot_round_trip_is_lossy, djot_to_plain_text,
59    escape_djot_inline, guard_djot_block_start, needs_djot_escaping, plain_text_to_djot,
60    sentence_bounds, sentences,
61};
62
63/// The matcher, as a pure function over `&str` — no document, no store, no threads.
64///
65/// A host app searching a whole project cannot afford to build a document per row just
66/// to ask "does this contain that": it would parse every scene in the manuscript on
67/// every keystroke. It extracts the prose cheaply and matches it here instead.
68///
69/// Exposing it is what keeps there being **one** definition of a match. An app that
70/// rolled its own would disagree with this crate's in-document find about whole-word
71/// rules and case folding, and a writer would meet that as "the editor found it but the
72/// search panel didn't".
73/// The same goes for **folding** and for **case preservation**: an app that lowercased its
74/// own corpus would miss `Straße` and half-rename a Turkish manuscript. `FoldLocale` is how
75/// a per-scene language reaches the fold.
76/// [`FoldedText`](matching::FoldedText) is the *prepared* form: a haystack folded once and
77/// searched many times. A search box re-searches the same corpus on every keystroke, and
78/// folding it costs several times what scanning it does — so an app that searches a whole
79/// project keeps one of these per scene rather than rebuilding the fold per character typed.
80pub mod matching {
81    pub use frontend::document_search::matching::{
82        FoldLocale, FoldSpec, FoldedText, Match, MatchOptions, find_all, preserve_case,
83    };
84}
85pub use frontend::document::dtos::{TextDirection, WrapMode};
86pub use frontend::frame::dtos::FramePosition;
87pub use frontend::list::dtos::ListStyle;
88pub use frontend::resource::dtos::ResourceType;
89
90// ── Error type ───────────────────────────────────────────────────
91pub use batch::BatchDocument;
92pub use error::{DocumentError, Result};
93
94// ── Public API types ─────────────────────────────────────────────
95pub use cursor::TextCursor;
96pub use document::TextDocument;
97pub use events::{DocumentEvent, InsertionOrigin, Subscription};
98pub use fragment::DocumentFragment;
99pub use highlight::{
100    HighlightContext, HighlightFormat, HighlightMask, HighlightSpan, RangeHighlight, SessionId,
101    SessionVisibility, SyntaxHighlighter,
102};
103pub use operation::{
104    DocxExportResult, EpubExportResult, HtmlImportResult, MarkdownImportResult, OdtExportResult,
105    Operation, PdfExportResult,
106};
107
108// ── Layout engine API types ─────────────────────────────────────
109pub use flow::{
110    AddressablePiece, BlockSnapshot, CellFormat, CellRange, CellSnapshot, CellVerticalAlignment,
111    FlowElement, FlowElementSnapshot, FlowSnapshot, FormatChangeKind, FragmentContent, FrameRef,
112    FrameSnapshot, ListInfo, PaintHighlightSpan, SelectionKind, TableCellContext, TableCellRef,
113    TableFormat, TableSnapshot,
114};
115pub use link_extent::LinkExtent;
116pub use text_block::TextBlock;
117pub use text_frame::TextFrame;
118pub use text_list::TextList;
119pub use text_table::{TextTable, TextTableCell};
120
121// All public handle types are Send + Sync (all fields are Arc<Mutex<...>> + Copy).
122const _: () = {
123    #[allow(dead_code)]
124    fn assert_send_sync<T: Send + Sync>() {}
125    fn _assert_all() {
126        assert_send_sync::<TextDocument>();
127        assert_send_sync::<TextCursor>();
128        assert_send_sync::<TextBlock>();
129        assert_send_sync::<TextFrame>();
130        assert_send_sync::<TextTable>();
131        assert_send_sync::<TextTableCell>();
132        assert_send_sync::<TextList>();
133    }
134};
135
136// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
137// Color
138// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
139
140/// An RGBA color value. Each component is 0–255.
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
142pub struct Color {
143    pub red: u8,
144    pub green: u8,
145    pub blue: u8,
146    pub alpha: u8,
147}
148
149impl Color {
150    /// Create an opaque color (alpha = 255).
151    pub fn rgb(red: u8, green: u8, blue: u8) -> Self {
152        Self {
153            red,
154            green,
155            blue,
156            alpha: 255,
157        }
158    }
159
160    /// Create a color with explicit alpha.
161    pub fn rgba(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
162        Self {
163            red,
164            green,
165            blue,
166            alpha,
167        }
168    }
169}
170
171// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
172// Public format types
173// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
174
175/// Character/text formatting. All fields are optional: `None` means
176/// "not set — inherit from the block's default or the document's default."
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct TextFormat {
179    pub font_family: Option<String>,
180    pub font_point_size: Option<u32>,
181    pub font_weight: Option<u32>,
182    pub font_bold: Option<bool>,
183    pub font_italic: Option<bool>,
184    pub font_underline: Option<bool>,
185    pub font_overline: Option<bool>,
186    pub font_strikeout: Option<bool>,
187    pub letter_spacing: Option<i32>,
188    pub word_spacing: Option<i32>,
189    pub underline_style: Option<UnderlineStyle>,
190    pub vertical_alignment: Option<CharVerticalAlignment>,
191    pub anchor_href: Option<String>,
192    pub anchor_names: Vec<String>,
193    pub is_anchor: Option<bool>,
194    pub tooltip: Option<String>,
195    /// Remove the range's link, rather than pointing it somewhere else.
196    ///
197    /// Every other field here merges — `None` means "leave this alone" — so
198    /// without a flag there is no way to express removal at all. Takes
199    /// precedence over `anchor_href`. Mirrors [`BlockFormat::clear_direction`].
200    ///
201    /// Write-only: reading a format back never sets it, since it describes an
202    /// edit rather than a state.
203    pub clear_link: bool,
204    pub foreground_color: Option<Color>,
205    pub background_color: Option<Color>,
206    pub underline_color: Option<Color>,
207}
208
209/// Block (paragraph) formatting. All fields are optional.
210#[derive(Debug, Clone, Default, PartialEq)]
211pub struct BlockFormat {
212    pub alignment: Option<Alignment>,
213    pub top_margin: Option<i32>,
214    pub bottom_margin: Option<i32>,
215    pub left_margin: Option<i32>,
216    pub right_margin: Option<i32>,
217    pub heading_level: Option<u8>,
218    pub indent: Option<u8>,
219    pub text_indent: Option<i32>,
220    pub marker: Option<MarkerType>,
221    pub tab_positions: Vec<i32>,
222    pub line_height: Option<f32>,
223    pub non_breakable_lines: Option<bool>,
224    /// Start this block on a new page, where the target format can paginate.
225    pub page_break_before: Option<bool>,
226    pub direction: Option<TextDirection>,
227    /// Unset the block's direction rather than setting one.
228    ///
229    /// Every other field merges (`None` = "don't change this"), so this
230    /// is the only way to take a paragraph back to automatic direction
231    /// detection once a direction has been stored. Wins over
232    /// `direction` if both are set.
233    pub clear_direction: bool,
234    pub background_color: Option<String>,
235    pub is_code_block: Option<bool>,
236    pub code_language: Option<String>,
237    /// Enable automatic + soft-hyphen hyphenation for this block.
238    pub hyphenate: Option<bool>,
239    /// Block natural language as an ISO 639-1 code (e.g. "en", "fr").
240    /// Selects the hyphenation dictionary.
241    pub language: Option<String>,
242}
243
244/// List formatting. All fields are optional: `None` means
245/// "not set — don't change this property."
246#[derive(Debug, Clone, Default, PartialEq, Eq)]
247pub struct ListFormat {
248    pub style: Option<ListStyle>,
249    pub indent: Option<u8>,
250    pub prefix: Option<String>,
251    pub suffix: Option<String>,
252}
253
254/// Frame formatting. All fields are optional.
255#[derive(Debug, Clone, Default, PartialEq, Eq)]
256pub struct FrameFormat {
257    pub height: Option<i32>,
258    pub width: Option<i32>,
259    pub top_margin: Option<i32>,
260    pub bottom_margin: Option<i32>,
261    pub left_margin: Option<i32>,
262    pub right_margin: Option<i32>,
263    pub padding: Option<i32>,
264    pub border: Option<i32>,
265    pub position: Option<FramePosition>,
266    pub is_blockquote: Option<bool>,
267}
268
269// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
270// Enums for cursor movement
271// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
272
273/// Controls whether a movement collapses or extends the selection.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum MoveMode {
276    /// Move both position and anchor — collapses selection.
277    MoveAnchor,
278    /// Move only position, keep anchor — creates or extends selection.
279    KeepAnchor,
280}
281
282/// Semantic cursor movement operations.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum MoveOperation {
285    NoMove,
286    Start,
287    End,
288    StartOfLine,
289    EndOfLine,
290    StartOfBlock,
291    EndOfBlock,
292    StartOfWord,
293    EndOfWord,
294    PreviousBlock,
295    NextBlock,
296    PreviousCharacter,
297    NextCharacter,
298    PreviousWord,
299    NextWord,
300    Up,
301    Down,
302    Left,
303    Right,
304    WordLeft,
305    WordRight,
306    /// The start of the sentence the cursor is in. Already there → the previous sentence's
307    /// start, so repeating it walks backwards.
308    StartOfSentence,
309    /// The end of the sentence the cursor is in, at its terminator rather than at the space
310    /// after it. Already there → the next sentence's end.
311    EndOfSentence,
312    /// The start of the previous sentence — [`StartOfSentence`](Self::StartOfSentence) applied
313    /// from just before the current one.
314    PreviousSentence,
315    /// The start of the next sentence.
316    NextSentence,
317}
318
319/// Quick-select a region around the cursor.
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum SelectionType {
322    WordUnderCursor,
323    /// The sentence the cursor is in, tailored to the cursor's
324    /// [`content_locale`](crate::TextCursor::set_content_locale). Trailing whitespace is
325    /// excluded, so the selection ends at the terminator.
326    SentenceUnderCursor,
327    LineUnderCursor,
328    BlockUnderCursor,
329    Document,
330}
331
332// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
333// Read-only info types
334// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
335
336/// Document-level statistics. O(1) cached.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct DocumentStats {
339    pub character_count: usize,
340    pub word_count: usize,
341    pub block_count: usize,
342    pub frame_count: usize,
343    pub image_count: usize,
344    pub list_count: usize,
345    pub table_count: usize,
346}
347
348/// Info about a block at a given position.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct BlockInfo {
351    pub block_id: usize,
352    pub block_number: usize,
353    pub start: usize,
354    pub length: usize,
355}
356
357/// A single search match.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct FindMatch {
360    pub position: usize,
361    pub length: usize,
362    /// The text that was actually matched, sliced from the document's own search text.
363    ///
364    /// Carried here so no caller ever slices it themselves — and with folding on, that is no
365    /// longer a convenience. A search for `cafe` matches `café`; a search for `strasse`
366    /// matches `straße`. The query is **not** the matched text, `length` is not the query's
367    /// length, and the whole-document string a caller reaches for first
368    /// ([`TextDocument::to_plain_text`]) does not even use the same offset space — it drops
369    /// the `U+FFFC` anchor an embedded table occupies. (The string that does share this
370    /// offset space is [`TextDocument::to_addressable_text`].)
371    pub matched_text: String,
372}
373
374/// Options for find / find_all / replace operations.
375///
376/// Both folding toggles default to **off = folded**, which is what a writer means by
377/// "search": `aurelien` finds `Aurélien`, `strasse` finds `Straße`, `احمد` finds `أَحْمَد`.
378/// Turn one on to be literal about it.
379#[derive(Debug, Clone, Default)]
380pub struct FindOptions {
381    pub case_sensitive: bool,
382    pub whole_word: bool,
383    /// `false` (the default) folds diacritics, ligatures and Arabic orthography.
384    pub diacritic_sensitive: bool,
385    /// The BCP-47 tag of the text being searched — **per document**, not per search.
386    ///
387    /// Only Turkish and Azerbaijani (`tr`, `az`) change how text folds: there the dotted
388    /// and dotless `i` are different letters, and merging them turns one word into another.
389    /// Every other tag — including an empty or malformed one — folds untailored, so this is
390    /// safe to leave alone and safe to feed a user's raw project setting.
391    ///
392    /// It decides *how* to fold, never *whether* to: the toggles above stay global across a
393    /// search, or the same checkbox would mean different things in different chapters.
394    pub language: String,
395    pub use_regex: bool,
396    pub search_backward: bool,
397}
398
399/// Options for a replace: how to *find* the text, and what the replacement wears where
400/// it overwrites formatted prose.
401///
402/// The format policy is deliberately not on [`FindOptions`] — it means nothing to a
403/// find, and a search option that silently only applies to half the calls that take it
404/// is how dead toggles are born.
405#[derive(Debug, Clone, Default)]
406pub struct ReplaceOptions {
407    pub find: FindOptions,
408    /// Defaults to [`ReplaceFormatPolicy::InheritPreceding`] — the behaviour that has
409    /// always shipped, which drops the formatting under the replaced range. Choose
410    /// another policy when the range may be formatted and losing that would be wrong
411    /// (a character rename landing on a partly-bold name).
412    pub format_policy: ReplaceFormatPolicy,
413}
414
415/// One range to replace, with **its own** replacement text.
416///
417/// `position` and `length` are **char** offsets into the document's text — the same space
418/// [`FindMatch`] reports in, so a match can be turned into a range directly.
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct ReplaceRange {
421    pub position: usize,
422    pub length: usize,
423    pub replacement: String,
424}
425
426impl ReplaceOptions {
427    /// A replace that finds the text exactly as `find` describes and keeps the default
428    /// (historical) format policy.
429    pub fn new(find: FindOptions) -> Self {
430        Self {
431            find,
432            format_policy: ReplaceFormatPolicy::default(),
433        }
434    }
435
436    pub fn with_format_policy(mut self, policy: ReplaceFormatPolicy) -> Self {
437        self.format_policy = policy;
438        self
439    }
440}
441
442/// How many documents are alive in this process right now.
443///
444/// A [`TextDocument`] is a handle onto a shared body, so a single surviving clone
445/// keeps that document's rope, block table and store resident. A host that opens a
446/// document per scene and closes a project has no other way to ask whether it
447/// actually let go: the memory shows up under the rope and the block table
448/// whoever is holding them, and an allocation profile names the allocation site
449/// rather than the owner.
450///
451/// Counted on the body, not on the handle, so cloning a `TextDocument` does not
452/// move it. It rises by one for each document successfully created and falls by
453/// one when the last handle to it is dropped.
454pub fn live_document_count() -> usize {
455    inner::LIVE_DOCUMENTS.load(std::sync::atomic::Ordering::Relaxed)
456}