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