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}