text-document 1.9.0

Rich text document editing library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! # text-document
//!
//! A rich text document model for Rust.
//!
//! Provides a [`TextDocument`] as the main entry point and [`TextCursor`] for
//! cursor-based editing, inspired by Qt's QTextDocument/QTextCursor API.
//!
//! ```rust,no_run
//! use text_document::{TextDocument, MoveMode, MoveOperation};
//!
//! let doc = TextDocument::new();
//! doc.set_plain_text("Hello world").unwrap();
//!
//! let cursor = doc.cursor();
//! cursor.move_position(MoveOperation::EndOfWord, MoveMode::KeepAnchor, 1);
//! cursor.insert_text("Goodbye").unwrap(); // replaces "Hello"
//!
//! // Multiple cursors on the same document
//! let c1 = doc.cursor();
//! let c2 = doc.cursor_at(5);
//! c1.insert_text("A").unwrap();
//! // c2's position is automatically adjusted
//!
//! doc.undo().unwrap();
//! ```

mod batch;
mod convert;
mod cursor;
mod document;
mod error;
mod events;
mod flow;
mod fragment;
mod highlight;
mod inner;
mod operation;

mod streaming;
mod text_block;
mod text_frame;
mod text_list;
mod text_table;

// ── Re-exports from entity DTOs (enums that consumers need) ──────
pub use frontend::block::dtos::{Alignment, MarkerType};
pub use frontend::block::dtos::{CharVerticalAlignment, InlineContent, UnderlineStyle};
pub use frontend::common::format_runs::ReplaceFormatPolicy;
pub use frontend::common::parser_tools::{
    CountMethod, DjotExportOptions, DjotImportOptions, DocxExportOptions, DocxHeadingStyle,
    EpubExportOptions, ExportImage, ExportImages, HtmlExportOptions, HtmlImageMode,
    MarkdownExportOptions, PdfExportOptions, PlainTextExportOptions, Sentence, TABLE_ANCHOR,
    WordCharCounts, count, count_djot, djot_to_plain_text, sentence_bounds, sentences,
};

/// The matcher, as a pure function over `&str` — no document, no store, no threads.
///
/// A host app searching a whole project cannot afford to build a document per row just
/// to ask "does this contain that": it would parse every scene in the manuscript on
/// every keystroke. It extracts the prose cheaply and matches it here instead.
///
/// Exposing it is what keeps there being **one** definition of a match. An app that
/// rolled its own would disagree with this crate's in-document find about whole-word
/// rules and case folding, and a writer would meet that as "the editor found it but the
/// search panel didn't".
/// The same goes for **folding** and for **case preservation**: an app that lowercased its
/// own corpus would miss `Straße` and half-rename a Turkish manuscript. `FoldLocale` is how
/// a per-scene language reaches the fold.
/// [`FoldedText`](matching::FoldedText) is the *prepared* form: a haystack folded once and
/// searched many times. A search box re-searches the same corpus on every keystroke, and
/// folding it costs several times what scanning it does — so an app that searches a whole
/// project keeps one of these per scene rather than rebuilding the fold per character typed.
pub mod matching {
    pub use frontend::document_search::matching::{
        FoldLocale, FoldSpec, FoldedText, Match, MatchOptions, find_all, preserve_case,
    };
}
pub use frontend::document::dtos::{TextDirection, WrapMode};
pub use frontend::frame::dtos::FramePosition;
pub use frontend::list::dtos::ListStyle;
pub use frontend::resource::dtos::ResourceType;

// ── Error type ───────────────────────────────────────────────────
pub use batch::BatchDocument;
pub use error::{DocumentError, Result};

// ── Public API types ─────────────────────────────────────────────
pub use cursor::TextCursor;
pub use document::TextDocument;
pub use events::{DocumentEvent, Subscription};
pub use fragment::DocumentFragment;
pub use highlight::{
    HighlightContext, HighlightFormat, HighlightMask, HighlightSpan, RangeHighlight, SessionId,
    SyntaxHighlighter,
};
pub use operation::{
    DocxExportResult, EpubExportResult, HtmlImportResult, MarkdownImportResult, Operation,
    PdfExportResult,
};

// ── Layout engine API types ─────────────────────────────────────
pub use flow::{
    BlockSnapshot, CellFormat, CellRange, CellSnapshot, CellVerticalAlignment, FlowElement,
    FlowElementSnapshot, FlowSnapshot, FormatChangeKind, FragmentContent, FrameRef, FrameSnapshot,
    ListInfo, PaintHighlightSpan, SelectionKind, TableCellContext, TableCellRef, TableFormat,
    TableSnapshot,
};
pub use text_block::TextBlock;
pub use text_frame::TextFrame;
pub use text_list::TextList;
pub use text_table::{TextTable, TextTableCell};

// All public handle types are Send + Sync (all fields are Arc<Mutex<...>> + Copy).
const _: () = {
    #[allow(dead_code)]
    fn assert_send_sync<T: Send + Sync>() {}
    fn _assert_all() {
        assert_send_sync::<TextDocument>();
        assert_send_sync::<TextCursor>();
        assert_send_sync::<TextBlock>();
        assert_send_sync::<TextFrame>();
        assert_send_sync::<TextTable>();
        assert_send_sync::<TextTableCell>();
        assert_send_sync::<TextList>();
    }
};

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Color
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

/// An RGBA color value. Each component is 0–255.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Color {
    pub red: u8,
    pub green: u8,
    pub blue: u8,
    pub alpha: u8,
}

impl Color {
    /// Create an opaque color (alpha = 255).
    pub fn rgb(red: u8, green: u8, blue: u8) -> Self {
        Self {
            red,
            green,
            blue,
            alpha: 255,
        }
    }

    /// Create a color with explicit alpha.
    pub fn rgba(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
        Self {
            red,
            green,
            blue,
            alpha,
        }
    }
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Public format types
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

/// Character/text formatting. All fields are optional: `None` means
/// "not set — inherit from the block's default or the document's default."
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TextFormat {
    pub font_family: Option<String>,
    pub font_point_size: Option<u32>,
    pub font_weight: Option<u32>,
    pub font_bold: Option<bool>,
    pub font_italic: Option<bool>,
    pub font_underline: Option<bool>,
    pub font_overline: Option<bool>,
    pub font_strikeout: Option<bool>,
    pub letter_spacing: Option<i32>,
    pub word_spacing: Option<i32>,
    pub underline_style: Option<UnderlineStyle>,
    pub vertical_alignment: Option<CharVerticalAlignment>,
    pub anchor_href: Option<String>,
    pub anchor_names: Vec<String>,
    pub is_anchor: Option<bool>,
    pub tooltip: Option<String>,
    pub foreground_color: Option<Color>,
    pub background_color: Option<Color>,
    pub underline_color: Option<Color>,
}

/// Block (paragraph) formatting. All fields are optional.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct BlockFormat {
    pub alignment: Option<Alignment>,
    pub top_margin: Option<i32>,
    pub bottom_margin: Option<i32>,
    pub left_margin: Option<i32>,
    pub right_margin: Option<i32>,
    pub heading_level: Option<u8>,
    pub indent: Option<u8>,
    pub text_indent: Option<i32>,
    pub marker: Option<MarkerType>,
    pub tab_positions: Vec<i32>,
    pub line_height: Option<f32>,
    pub non_breakable_lines: Option<bool>,
    /// Start this block on a new page, where the target format can paginate.
    pub page_break_before: Option<bool>,
    pub direction: Option<TextDirection>,
    /// Unset the block's direction rather than setting one.
    ///
    /// Every other field merges (`None` = "don't change this"), so this
    /// is the only way to take a paragraph back to automatic direction
    /// detection once a direction has been stored. Wins over
    /// `direction` if both are set.
    pub clear_direction: bool,
    pub background_color: Option<String>,
    pub is_code_block: Option<bool>,
    pub code_language: Option<String>,
    /// Enable automatic + soft-hyphen hyphenation for this block.
    pub hyphenate: Option<bool>,
    /// Block natural language as an ISO 639-1 code (e.g. "en", "fr").
    /// Selects the hyphenation dictionary.
    pub language: Option<String>,
}

/// List formatting. All fields are optional: `None` means
/// "not set — don't change this property."
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListFormat {
    pub style: Option<ListStyle>,
    pub indent: Option<u8>,
    pub prefix: Option<String>,
    pub suffix: Option<String>,
}

/// Frame formatting. All fields are optional.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FrameFormat {
    pub height: Option<i32>,
    pub width: Option<i32>,
    pub top_margin: Option<i32>,
    pub bottom_margin: Option<i32>,
    pub left_margin: Option<i32>,
    pub right_margin: Option<i32>,
    pub padding: Option<i32>,
    pub border: Option<i32>,
    pub position: Option<FramePosition>,
    pub is_blockquote: Option<bool>,
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Enums for cursor movement
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

/// Controls whether a movement collapses or extends the selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MoveMode {
    /// Move both position and anchor — collapses selection.
    MoveAnchor,
    /// Move only position, keep anchor — creates or extends selection.
    KeepAnchor,
}

/// Semantic cursor movement operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MoveOperation {
    NoMove,
    Start,
    End,
    StartOfLine,
    EndOfLine,
    StartOfBlock,
    EndOfBlock,
    StartOfWord,
    EndOfWord,
    PreviousBlock,
    NextBlock,
    PreviousCharacter,
    NextCharacter,
    PreviousWord,
    NextWord,
    Up,
    Down,
    Left,
    Right,
    WordLeft,
    WordRight,
    /// The start of the sentence the cursor is in. Already there → the previous sentence's
    /// start, so repeating it walks backwards.
    StartOfSentence,
    /// The end of the sentence the cursor is in, at its terminator rather than at the space
    /// after it. Already there → the next sentence's end.
    EndOfSentence,
    /// The start of the previous sentence — [`StartOfSentence`](Self::StartOfSentence) applied
    /// from just before the current one.
    PreviousSentence,
    /// The start of the next sentence.
    NextSentence,
}

/// Quick-select a region around the cursor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionType {
    WordUnderCursor,
    /// The sentence the cursor is in, tailored to the cursor's
    /// [`content_locale`](crate::TextCursor::set_content_locale). Trailing whitespace is
    /// excluded, so the selection ends at the terminator.
    SentenceUnderCursor,
    LineUnderCursor,
    BlockUnderCursor,
    Document,
}

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Read-only info types
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

/// Document-level statistics. O(1) cached.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentStats {
    pub character_count: usize,
    pub word_count: usize,
    pub block_count: usize,
    pub frame_count: usize,
    pub image_count: usize,
    pub list_count: usize,
    pub table_count: usize,
}

/// Info about a block at a given position.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockInfo {
    pub block_id: usize,
    pub block_number: usize,
    pub start: usize,
    pub length: usize,
}

/// A single search match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FindMatch {
    pub position: usize,
    pub length: usize,
    /// The text that was actually matched, sliced from the document's own search text.
    ///
    /// Carried here so no caller ever slices it themselves — and with folding on, that is no
    /// longer a convenience. A search for `cafe` matches `café`; a search for `strasse`
    /// matches `straße`. The query is **not** the matched text, `length` is not the query's
    /// length, and the only other whole-document string a caller can reach
    /// ([`TextDocument::to_plain_text`]) does not even use the same offset space — it drops
    /// the `U+FFFC` anchor an embedded table occupies.
    pub matched_text: String,
}

/// Options for find / find_all / replace operations.
///
/// Both folding toggles default to **off = folded**, which is what a writer means by
/// "search": `aurelien` finds `Aurélien`, `strasse` finds `Straße`, `احمد` finds `أَحْمَد`.
/// Turn one on to be literal about it.
#[derive(Debug, Clone, Default)]
pub struct FindOptions {
    pub case_sensitive: bool,
    pub whole_word: bool,
    /// `false` (the default) folds diacritics, ligatures and Arabic orthography.
    pub diacritic_sensitive: bool,
    /// The BCP-47 tag of the text being searched — **per document**, not per search.
    ///
    /// Only Turkish and Azerbaijani (`tr`, `az`) change how text folds: there the dotted
    /// and dotless `i` are different letters, and merging them turns one word into another.
    /// Every other tag — including an empty or malformed one — folds untailored, so this is
    /// safe to leave alone and safe to feed a user's raw project setting.
    ///
    /// It decides *how* to fold, never *whether* to: the toggles above stay global across a
    /// search, or the same checkbox would mean different things in different chapters.
    pub language: String,
    pub use_regex: bool,
    pub search_backward: bool,
}

/// Options for a replace: how to *find* the text, and what the replacement wears where
/// it overwrites formatted prose.
///
/// The format policy is deliberately not on [`FindOptions`] — it means nothing to a
/// find, and a search option that silently only applies to half the calls that take it
/// is how dead toggles are born.
#[derive(Debug, Clone, Default)]
pub struct ReplaceOptions {
    pub find: FindOptions,
    /// Defaults to [`ReplaceFormatPolicy::InheritPreceding`] — the behaviour that has
    /// always shipped, which drops the formatting under the replaced range. Choose
    /// another policy when the range may be formatted and losing that would be wrong
    /// (a character rename landing on a partly-bold name).
    pub format_policy: ReplaceFormatPolicy,
}

/// One range to replace, with **its own** replacement text.
///
/// `position` and `length` are **char** offsets into the document's text — the same space
/// [`FindMatch`] reports in, so a match can be turned into a range directly.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplaceRange {
    pub position: usize,
    pub length: usize,
    pub replacement: String,
}

impl ReplaceOptions {
    /// A replace that finds the text exactly as `find` describes and keeps the default
    /// (historical) format policy.
    pub fn new(find: FindOptions) -> Self {
        Self {
            find,
            format_policy: ReplaceFormatPolicy::default(),
        }
    }

    pub fn with_format_policy(mut self, policy: ReplaceFormatPolicy) -> Self {
        self.format_policy = policy;
        self
    }
}