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}