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}