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