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}