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