Skip to main content

common/parser_tools/
mark_options.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Named marks a writer anchors into its output as **bookmarks** — the third export payload,
5//! beside [`comments`](super::comment_options) and [`images`](super::image_options).
6//!
7//! # What this is for
8//!
9//! A host that exports a document for someone else to edit, and later reads the edited file
10//! back, needs to know which part of the returned file corresponds to which part of its own
11//! model. Nothing in DOCX or ODF carries that natively, and the obvious answer — a private
12//! attribute on the host's own elements — does not survive: **both Word and LibreOffice discard
13//! unknown-namespace attributes when they save.** Measured, not assumed: a file this crate wrote
14//! with a `skrb:uid` on every `<office:annotation>` came back from LibreOffice 25.8 with the
15//! attribute gone and its namespace declaration gone with it.
16//!
17//! A bookmark does survive, because it is not an extension. `text:bookmark` and
18//! `w:bookmarkStart` are first-class in ODF and OOXML respectively, position-tracked as the
19//! editor moves text around, invisible in both readers, and preserved by every writer that
20//! claims to support either format. So identity travels as a bookmark, and this is the payload
21//! that carries it.
22//!
23//! # Point marks and range marks
24//!
25//! A mark with `start == end` is a **point**: it names a position and nothing else, and is
26//! written as a single self-closing element. A mark with `start < end` is a **range**, written
27//! as a start/end pair bracketing exactly those characters. Both are useful and the difference
28//! is not cosmetic — a point mark survives the text around it being rewritten wholesale, while
29//! a range mark tells the reader precisely which characters it covered.
30//!
31//! # Names are the payload
32//!
33//! The name is the only thing that comes back, so it is where the host's identity has to live.
34//! [`DocumentMark::validate`] enforces the intersection of what the two formats accept, which is
35//! really just what *Word* accepts — 40 characters, ASCII alphanumerics and underscore, leading
36//! letter. ODF is far more permissive, but a name legal in only one of the two would produce a
37//! file that round-trips through one editor and loses its identity in the other, which is worse
38//! than refusing it.
39
40use std::collections::BTreeMap;
41
42use serde::{Deserialize, Serialize};
43
44/// Word's cap on the length of a bookmark name. Names longer than this are truncated or dropped
45/// by Word without a diagnostic, which is exactly the failure this payload exists to avoid.
46pub const MAX_BOOKMARK_NAME_LEN: usize = 40;
47
48/// Bookmark names Word reserves for itself.
49///
50/// Legal by every syntactic rule above and unusable all the same: Word maintains these, so a
51/// mark carrying one is not stored where it was put. `_GoBack` is the one that matters — Word
52/// rewrites it to the last edit position on every save, so an identity parked there would be
53/// silently relocated by the very application the file was sent to be edited in.
54pub const RESERVED_BOOKMARK_NAMES: &[&str] = &["_GoBack", "_Toc", "_Ref", "_Hlk", "_MailAutoSig"];
55
56/// One named position or range in the document's addressable character space.
57#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
58pub struct DocumentMark {
59    /// `[start, end)` in the document's **addressable** character space — the same space
60    /// [`DocumentComment::start`](super::comment_options::DocumentComment::start) uses, and the
61    /// same one `TextDocument::to_addressable_text()` reports. `start == end` is a point mark.
62    pub start: u32,
63    pub end: u32,
64    /// The bookmark name, which is the whole message. See [`validate`](Self::validate).
65    pub name: String,
66    /// Which of several marks sharing this exact range comes first.
67    ///
68    /// Zero for every mark that does not share its range with another, which is almost all of
69    /// them, so it can be ignored by any caller that never emits two.
70    ///
71    /// It exists because ties have to break the **same way** on both payloads. A comment and
72    /// the mark carrying its identity are two objects the writers sort independently:
73    /// [`DocumentComments`](super::comment_options::DocumentComments) breaks a tie on the
74    /// comment's `uid`, and this on the mark's `name`. Where a name is derived from a uid by
75    /// hashing — which is what a 40-character bookmark limit forces — the two orders are
76    /// uncorrelated, so a document with two comments on the identical range can emit the
77    /// annotations in one order and their marks in the other. A reader matching them by
78    /// position then hands each comment the other's identity, and the editor's remark comes
79    /// home on the wrong thread.
80    ///
81    /// The producer knows the intended order and nothing else can recover it, so the producer
82    /// says: set this from the same sequence the comments are built in.
83    ///
84    /// `#[serde(default)]` because this field was added after the type shipped: a payload
85    /// serialized before it exists has no `ordinal`, and reading it back must give 0 rather
86    /// than fail. The default is also the correct answer for such a payload — it was written
87    /// by a producer that stated no order.
88    #[serde(default)]
89    pub ordinal: u32,
90}
91
92impl DocumentMark {
93    /// A point mark at `at`.
94    pub fn point(at: u32, name: impl Into<String>) -> Self {
95        Self {
96            start: at,
97            end: at,
98            name: name.into(),
99            ordinal: 0,
100        }
101    }
102
103    /// A range mark over `[start, end)`.
104    pub fn range(start: u32, end: u32, name: impl Into<String>) -> Self {
105        Self {
106            start,
107            end,
108            name: name.into(),
109            ordinal: 0,
110        }
111    }
112
113    /// This mark, ordered ahead of or behind others sharing its exact range.
114    ///
115    /// See [`ordinal`](Self::ordinal) — required only when two marks can span the same
116    /// characters, which for a host carrying comment identity means two comments on one
117    /// paragraph.
118    pub fn with_ordinal(mut self, ordinal: u32) -> Self {
119        self.ordinal = ordinal;
120        self
121    }
122
123    /// True when this mark names a position rather than a span.
124    pub fn is_point(&self) -> bool {
125        self.start == self.end
126    }
127
128    /// Check the name against the stricter of the two formats' rules, and the range against
129    /// itself.
130    ///
131    /// Returns a description of the problem rather than a bool, because every caller of this
132    /// wants to say what was wrong: these names are minted by the host from its own data, so a
133    /// rejected one is a programming error and deserves to be reported as one — not silently
134    /// dropped, which would leave an export that looks complete and cannot be read back.
135    pub fn validate(&self) -> Result<(), String> {
136        if self.end < self.start {
137            return Err(format!(
138                "mark '{}' ends ({}) before it starts ({})",
139                self.name, self.end, self.start
140            ));
141        }
142        if self.name.is_empty() {
143            return Err("a mark with an empty name carries no identity".to_string());
144        }
145        if self.name.len() > MAX_BOOKMARK_NAME_LEN {
146            return Err(format!(
147                "mark name '{}' is {} characters; Word drops anything over {MAX_BOOKMARK_NAME_LEN}",
148                self.name,
149                self.name.len()
150            ));
151        }
152        if !self
153            .name
154            .starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
155        {
156            return Err(format!(
157                "mark name '{}' must begin with a letter or underscore",
158                self.name
159            ));
160        }
161        if let Some(bad) = self
162            .name
163            .chars()
164            .find(|c| !c.is_ascii_alphanumeric() && *c != '_')
165        {
166            return Err(format!(
167                "mark name '{}' contains {bad:?}; only ASCII letters, digits and underscore \
168                 survive both formats",
169                self.name
170            ));
171        }
172        // Names Word owns. Syntactically fine and semantically taken: Word rewrites `_GoBack`
173        // to wherever the last edit was, every save, so a mark of that name is not merely
174        // unreliable — it is actively moved by the application the file was sent to. The rest
175        // are its own field/TOC bookmarks. A host minting names from its own data will never
176        // produce one, which is exactly why it would be missed if it ever did.
177        if RESERVED_BOOKMARK_NAMES
178            .iter()
179            .any(|r| r.eq_ignore_ascii_case(&self.name))
180        {
181            return Err(format!(
182                "mark name '{}' is reserved by Word, which rewrites it on save",
183                self.name
184            ));
185        }
186        Ok(())
187    }
188}
189
190/// Every mark supplied to one export, keyed by name.
191///
192/// A `BTreeMap` for the same reason [`DocumentComments`](super::comment_options::DocumentComments)
193/// is one: two exports of the same document must be byte-comparable, and a randomised iteration
194/// order would quietly break that. Keying by name also makes the uniqueness the formats require
195/// structural — a bookmark name may appear only once in a document, and a `Vec` would let a
196/// caller supply the same name twice and produce a file neither editor can open cleanly.
197#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
198pub struct DocumentMarks(BTreeMap<String, DocumentMark>);
199
200impl DocumentMarks {
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// Register a mark, keyed by its own name. A second insert under the same name replaces the
206    /// first.
207    pub fn insert(&mut self, mark: DocumentMark) -> &mut Self {
208        self.0.insert(mark.name.clone(), mark);
209        self
210    }
211
212    pub fn get(&self, name: &str) -> Option<&DocumentMark> {
213        self.0.get(name)
214    }
215
216    pub fn iter(&self) -> impl Iterator<Item = &DocumentMark> {
217        self.0.values()
218    }
219
220    pub fn is_empty(&self) -> bool {
221        self.0.is_empty()
222    }
223
224    pub fn len(&self) -> usize {
225        self.0.len()
226    }
227
228    /// Every mark, sorted by `(start, end, name)` — the order a writer walking the document
229    /// front-to-back opens and closes them in. `name` breaks an exact positional tie
230    /// deterministically.
231    pub fn in_document_order(&self) -> Vec<&DocumentMark> {
232        let mut out: Vec<&DocumentMark> = self.0.values().collect();
233        out.sort_by(|a, b| {
234            a.start
235                .cmp(&b.start)
236                .then(a.end.cmp(&b.end))
237                // Before the name, so a producer that states the order gets it. See
238                // [`DocumentMark::ordinal`] — the name is the last resort, and for a hashed
239                // name it is an arbitrary one.
240                .then(a.ordinal.cmp(&b.ordinal))
241                .then(a.name.cmp(&b.name))
242        });
243        out
244    }
245
246    /// Validate every mark, reporting all the problems rather than the first — a caller fixing
247    /// a name-generation bug wants the whole list, not one round trip per offender.
248    pub fn validate(&self) -> Result<(), String> {
249        let problems: Vec<String> = self.0.values().filter_map(|m| m.validate().err()).collect();
250        if problems.is_empty() {
251            Ok(())
252        } else {
253            Err(problems.join("; "))
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn a_point_mark_is_its_own_start_and_end() {
264        let m = DocumentMark::point(12, "skrb_r0000000000000001_aaaaaaaaaaaa");
265        assert!(m.is_point());
266        assert_eq!((m.start, m.end), (12, 12));
267        assert_eq!(m.validate(), Ok(()));
268    }
269
270    #[test]
271    fn a_range_mark_spans_characters() {
272        let m = DocumentMark::range(4, 9, "skrb_c000000000000c001");
273        assert!(!m.is_point());
274        assert_eq!(m.validate(), Ok(()));
275    }
276
277    /// Every rejection is Word's rule, not ODF's — see the module doc for why the stricter of
278    /// the two is the one that applies.
279    #[test]
280    fn a_name_word_would_mangle_is_refused_with_a_reason() {
281        let too_long = DocumentMark::point(0, "a".repeat(MAX_BOOKMARK_NAME_LEN + 1));
282        assert!(too_long.validate().unwrap_err().contains("41 characters"));
283
284        let hyphenated = DocumentMark::point(0, "skrb-row-1");
285        assert!(hyphenated.validate().unwrap_err().contains("only ASCII"));
286
287        let leading_digit = DocumentMark::point(0, "1row");
288        assert!(
289            leading_digit
290                .validate()
291                .unwrap_err()
292                .contains("begin with a letter")
293        );
294
295        assert!(
296            DocumentMark::point(0, "")
297                .validate()
298                .unwrap_err()
299                .contains("empty name")
300        );
301    }
302
303    /// Syntactically perfect and unusable: Word maintains these itself.
304    ///
305    /// `_GoBack` is the one with teeth — Word moves it to the last edit position on every
306    /// save, so an identity parked there is relocated by the very application the file was
307    /// sent to. Nothing in this repo mints such a name today; that is the point of checking.
308    /// Two marks over the identical range come back in the order the producer stated, not in
309    /// the order their names happen to sort.
310    ///
311    /// This is what keeps a mark payload lined up with the comment payload it carries the
312    /// identity of. The two are sorted independently and a hashed name sorts arbitrarily, so
313    /// without this a document with two comments on one paragraph could emit the annotations
314    /// in one order and their marks in the other — and a reader matching by position would
315    /// hand each comment the other's identity.
316    #[test]
317    fn marks_sharing_a_range_come_back_in_the_order_the_producer_stated() {
318        let mut marks = DocumentMarks::default();
319        // `zzz` sorts after `aaa` by name, and is stated first.
320        marks.insert(DocumentMark::range(4, 9, "zzz_first").with_ordinal(0));
321        marks.insert(DocumentMark::range(4, 9, "aaa_second").with_ordinal(1));
322
323        let order: Vec<&str> = marks
324            .in_document_order()
325            .iter()
326            .map(|m| m.name.as_str())
327            .collect();
328        assert_eq!(order, vec!["zzz_first", "aaa_second"]);
329    }
330
331    /// A payload written before `ordinal` existed still reads back.
332    ///
333    /// The field was added to a type that had already shipped in a public crate, so a stored
334    /// payload has no `ordinal` key. Without `#[serde(default)]` that is a hard
335    /// `missing field` error rather than the 0 it should be.
336    #[test]
337    fn a_mark_serialized_before_the_ordinal_existed_still_deserializes() {
338        let old = r#"{"start":4,"end":9,"name":"skrb_c0000000000000001"}"#;
339        let mark: DocumentMark = serde_json::from_str(old).expect("an older payload still reads");
340        assert_eq!(mark.ordinal, 0);
341        assert_eq!(mark.start, 4);
342        assert_eq!(mark.name, "skrb_c0000000000000001");
343    }
344
345    /// With no ordinal stated, the name still decides — so a producer that never emits two
346    /// marks over one range needs to know nothing about any of this.
347    #[test]
348    fn marks_without_an_ordinal_still_fall_back_to_the_name() {
349        let mut marks = DocumentMarks::default();
350        marks.insert(DocumentMark::range(4, 9, "zzz"));
351        marks.insert(DocumentMark::range(4, 9, "aaa"));
352
353        let order: Vec<&str> = marks
354            .in_document_order()
355            .iter()
356            .map(|m| m.name.as_str())
357            .collect();
358        assert_eq!(order, vec!["aaa", "zzz"]);
359    }
360
361    #[test]
362    fn a_name_word_reserves_for_itself_is_refused() {
363        for name in RESERVED_BOOKMARK_NAMES {
364            let err = DocumentMark::point(0, *name)
365                .validate()
366                .expect_err("a reserved name must not validate");
367            assert!(err.contains("reserved by Word"), "{name}: {err}");
368        }
369        // Case-insensitively, the way Word compares them.
370        assert!(
371            DocumentMark::point(0, "_goback")
372                .validate()
373                .unwrap_err()
374                .contains("reserved")
375        );
376        // And a name that merely starts the same way is fine — the host's own names must not
377        // be caught by a prefix rule that was never intended.
378        assert!(DocumentMark::point(0, "_Toc_skrb_r00").validate().is_ok());
379    }
380
381    #[test]
382    fn an_inverted_range_is_refused() {
383        let m = DocumentMark::range(9, 4, "skrb_c000000000000c001");
384        assert!(m.validate().unwrap_err().contains("ends (4) before"));
385    }
386
387    #[test]
388    fn marks_come_back_in_document_order_not_name_order() {
389        let mut marks = DocumentMarks::new();
390        marks.insert(DocumentMark::point(30, "zzz_later"));
391        marks.insert(DocumentMark::point(10, "aaa_earlier"));
392        marks.insert(DocumentMark::range(10, 20, "mmm_same_start"));
393
394        let order: Vec<&str> = marks
395            .in_document_order()
396            .iter()
397            .map(|m| m.name.as_str())
398            .collect();
399        assert_eq!(order, ["aaa_earlier", "mmm_same_start", "zzz_later"]);
400    }
401
402    #[test]
403    fn one_name_can_only_be_registered_once() {
404        let mut marks = DocumentMarks::new();
405        marks.insert(DocumentMark::point(
406            10,
407            "skrb_r0000000000000001_aaaaaaaaaaaa",
408        ));
409        marks.insert(DocumentMark::point(
410            99,
411            "skrb_r0000000000000001_aaaaaaaaaaaa",
412        ));
413        assert_eq!(marks.len(), 1, "a name is a key, not a label");
414        assert_eq!(
415            marks
416                .get("skrb_r0000000000000001_aaaaaaaaaaaa")
417                .map(|m| m.start),
418            Some(99),
419            "the later registration wins"
420        );
421    }
422
423    #[test]
424    fn validation_reports_every_offender_at_once() {
425        let mut marks = DocumentMarks::new();
426        marks.insert(DocumentMark::point(0, "1bad"));
427        marks.insert(DocumentMark::point(0, "also-bad"));
428        marks.insert(DocumentMark::point(0, "fine_one"));
429        let err = marks.validate().unwrap_err();
430        assert!(err.contains("1bad"), "{err}");
431        assert!(err.contains("also-bad"), "{err}");
432        assert!(!err.contains("fine_one"), "{err}");
433    }
434}