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