use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const MAX_BOOKMARK_NAME_LEN: usize = 40;
pub const RESERVED_BOOKMARK_NAMES: &[&str] = &["_GoBack", "_Toc", "_Ref", "_Hlk", "_MailAutoSig"];
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DocumentMark {
pub start: u32,
pub end: u32,
pub name: String,
#[serde(default)]
pub ordinal: u32,
}
impl DocumentMark {
pub fn point(at: u32, name: impl Into<String>) -> Self {
Self {
start: at,
end: at,
name: name.into(),
ordinal: 0,
}
}
pub fn range(start: u32, end: u32, name: impl Into<String>) -> Self {
Self {
start,
end,
name: name.into(),
ordinal: 0,
}
}
pub fn with_ordinal(mut self, ordinal: u32) -> Self {
self.ordinal = ordinal;
self
}
pub fn is_point(&self) -> bool {
self.start == self.end
}
pub fn validate(&self) -> Result<(), String> {
if self.end < self.start {
return Err(format!(
"mark '{}' ends ({}) before it starts ({})",
self.name, self.end, self.start
));
}
if self.name.is_empty() {
return Err("a mark with an empty name carries no identity".to_string());
}
if self.name.len() > MAX_BOOKMARK_NAME_LEN {
return Err(format!(
"mark name '{}' is {} characters; Word drops anything over {MAX_BOOKMARK_NAME_LEN}",
self.name,
self.name.len()
));
}
if !self
.name
.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
{
return Err(format!(
"mark name '{}' must begin with a letter or underscore",
self.name
));
}
if let Some(bad) = self
.name
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '_')
{
return Err(format!(
"mark name '{}' contains {bad:?}; only ASCII letters, digits and underscore \
survive both formats",
self.name
));
}
if RESERVED_BOOKMARK_NAMES
.iter()
.any(|r| r.eq_ignore_ascii_case(&self.name))
{
return Err(format!(
"mark name '{}' is reserved by Word, which rewrites it on save",
self.name
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DocumentMarks(BTreeMap<String, DocumentMark>);
impl DocumentMarks {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, mark: DocumentMark) -> &mut Self {
self.0.insert(mark.name.clone(), mark);
self
}
pub fn get(&self, name: &str) -> Option<&DocumentMark> {
self.0.get(name)
}
pub fn iter(&self) -> impl Iterator<Item = &DocumentMark> {
self.0.values()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn in_document_order(&self) -> Vec<&DocumentMark> {
let mut out: Vec<&DocumentMark> = self.0.values().collect();
out.sort_by(|a, b| {
a.start
.cmp(&b.start)
.then(a.end.cmp(&b.end))
.then(a.ordinal.cmp(&b.ordinal))
.then(a.name.cmp(&b.name))
});
out
}
pub fn validate(&self) -> Result<(), String> {
let problems: Vec<String> = self.0.values().filter_map(|m| m.validate().err()).collect();
if problems.is_empty() {
Ok(())
} else {
Err(problems.join("; "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_point_mark_is_its_own_start_and_end() {
let m = DocumentMark::point(12, "skrb_r0000000000000001_aaaaaaaaaaaa");
assert!(m.is_point());
assert_eq!((m.start, m.end), (12, 12));
assert_eq!(m.validate(), Ok(()));
}
#[test]
fn a_range_mark_spans_characters() {
let m = DocumentMark::range(4, 9, "skrb_c000000000000c001");
assert!(!m.is_point());
assert_eq!(m.validate(), Ok(()));
}
#[test]
fn a_name_word_would_mangle_is_refused_with_a_reason() {
let too_long = DocumentMark::point(0, "a".repeat(MAX_BOOKMARK_NAME_LEN + 1));
assert!(too_long.validate().unwrap_err().contains("41 characters"));
let hyphenated = DocumentMark::point(0, "skrb-row-1");
assert!(hyphenated.validate().unwrap_err().contains("only ASCII"));
let leading_digit = DocumentMark::point(0, "1row");
assert!(
leading_digit
.validate()
.unwrap_err()
.contains("begin with a letter")
);
assert!(
DocumentMark::point(0, "")
.validate()
.unwrap_err()
.contains("empty name")
);
}
#[test]
fn marks_sharing_a_range_come_back_in_the_order_the_producer_stated() {
let mut marks = DocumentMarks::default();
marks.insert(DocumentMark::range(4, 9, "zzz_first").with_ordinal(0));
marks.insert(DocumentMark::range(4, 9, "aaa_second").with_ordinal(1));
let order: Vec<&str> = marks
.in_document_order()
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(order, vec!["zzz_first", "aaa_second"]);
}
#[test]
fn a_mark_serialized_before_the_ordinal_existed_still_deserializes() {
let old = r#"{"start":4,"end":9,"name":"skrb_c0000000000000001"}"#;
let mark: DocumentMark = serde_json::from_str(old).expect("an older payload still reads");
assert_eq!(mark.ordinal, 0);
assert_eq!(mark.start, 4);
assert_eq!(mark.name, "skrb_c0000000000000001");
}
#[test]
fn marks_without_an_ordinal_still_fall_back_to_the_name() {
let mut marks = DocumentMarks::default();
marks.insert(DocumentMark::range(4, 9, "zzz"));
marks.insert(DocumentMark::range(4, 9, "aaa"));
let order: Vec<&str> = marks
.in_document_order()
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(order, vec!["aaa", "zzz"]);
}
#[test]
fn a_name_word_reserves_for_itself_is_refused() {
for name in RESERVED_BOOKMARK_NAMES {
let err = DocumentMark::point(0, *name)
.validate()
.expect_err("a reserved name must not validate");
assert!(err.contains("reserved by Word"), "{name}: {err}");
}
assert!(
DocumentMark::point(0, "_goback")
.validate()
.unwrap_err()
.contains("reserved")
);
assert!(DocumentMark::point(0, "_Toc_skrb_r00").validate().is_ok());
}
#[test]
fn an_inverted_range_is_refused() {
let m = DocumentMark::range(9, 4, "skrb_c000000000000c001");
assert!(m.validate().unwrap_err().contains("ends (4) before"));
}
#[test]
fn marks_come_back_in_document_order_not_name_order() {
let mut marks = DocumentMarks::new();
marks.insert(DocumentMark::point(30, "zzz_later"));
marks.insert(DocumentMark::point(10, "aaa_earlier"));
marks.insert(DocumentMark::range(10, 20, "mmm_same_start"));
let order: Vec<&str> = marks
.in_document_order()
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(order, ["aaa_earlier", "mmm_same_start", "zzz_later"]);
}
#[test]
fn one_name_can_only_be_registered_once() {
let mut marks = DocumentMarks::new();
marks.insert(DocumentMark::point(
10,
"skrb_r0000000000000001_aaaaaaaaaaaa",
));
marks.insert(DocumentMark::point(
99,
"skrb_r0000000000000001_aaaaaaaaaaaa",
));
assert_eq!(marks.len(), 1, "a name is a key, not a label");
assert_eq!(
marks
.get("skrb_r0000000000000001_aaaaaaaaaaaa")
.map(|m| m.start),
Some(99),
"the later registration wins"
);
}
#[test]
fn validation_reports_every_offender_at_once() {
let mut marks = DocumentMarks::new();
marks.insert(DocumentMark::point(0, "1bad"));
marks.insert(DocumentMark::point(0, "also-bad"));
marks.insert(DocumentMark::point(0, "fine_one"));
let err = marks.validate().unwrap_err();
assert!(err.contains("1bad"), "{err}");
assert!(err.contains("also-bad"), "{err}");
assert!(!err.contains("fine_one"), "{err}");
}
}