Skip to main content

day_spec/
styled_codec.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Import and export for [`StyledText`]: Markdown, HTML, and RTF (docs/texteditor.md).
5//!
6//! All three are lossy, in one direction each way. Reading, anything Day's model cannot hold is
7//! dropped rather than approximated: a table, an image, a stylesheet. Writing, Day emits only
8//! what it can read back, so `parse(write(doc)) == doc` for every document Day itself produced.
9//! That round-trip is the contract these are tested against; matching Word or a browser byte for
10//! byte is not one.
11//!
12//! Hand-rolled, with no new dependency. Each format is a few hundred lines because the subset is
13//! kept small (docs/texteditor.md §RTF scope), and because a text codec that panics or
14//! loops on hostile input is worse than one that misses a control word.
15
16use crate::{
17    Color, Font, FontWeight, ListStyle, ParagraphAlign, ParagraphRun, ParagraphStyle, RunStyle,
18    StyledText, TextRun, Underline, coalesce_runs, paragraph_bounds,
19};
20
21/// A document's base style: the font a run says nothing about, and the size everything else is
22/// relative to. Import needs it to place headings and code; export needs it to know what not to
23/// write.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct DocStyle {
26    pub base: Font,
27    /// The point size `base` resolves to, for formats that speak in absolute sizes (RTF's
28    /// half-points, HTML's `px`). Only used to convert their sizes to a [`FontSpec::scale`](crate::FontSpec::scale) and
29    /// back, so an imprecise value costs relative accuracy, not correctness.
30    pub base_points: f64,
31}
32
33impl Default for DocStyle {
34    fn default() -> Self {
35        DocStyle {
36            base: Font::Body,
37            base_points: 16.0,
38        }
39    }
40}
41
42impl DocStyle {
43    pub fn new(base: Font, base_points: f64) -> Self {
44        DocStyle { base, base_points }
45    }
46    fn plain(&self) -> RunStyle {
47        RunStyle::plain(self.base)
48    }
49}
50
51/// The scale a heading level takes, so Markdown and HTML agree on what `#`/`<h1>` means. Roughly
52/// the browser default ramp, which is also what a reader expects a heading to look like.
53fn heading_scale(level: u8) -> f64 {
54    match level {
55        1 => 2.0,
56        2 => 1.5,
57        3 => 1.17,
58        4 => 1.0,
59        5 => 0.83,
60        _ => 0.67,
61    }
62}
63
64/// Which heading level a whole-line scale came from, if any.
65///
66/// `1.0` answers `None`: h4 is body-sized in every browser's default ramp, so a run at
67/// scale 1.0 is indistinguishable from ordinary bold text. An imported `#### x` therefore comes
68/// back out as `**x**`, the documented loss (docs/texteditor.md); the alternative would be
69/// exporting every bold word in the document as a heading.
70fn heading_level_for(scale: f64) -> Option<u8> {
71    if (scale - 1.0).abs() < 1e-6 {
72        return None;
73    }
74    (1u8..=6).find(|level| (heading_scale(*level) - scale).abs() < 1e-6)
75}
76
77// ===========================================================================
78// Markdown
79// ===========================================================================
80
81/// Parse markdown into a styled document: the inline grammar
82/// ([`crate::markdown`]) plus the block constructs a document editor needs:
83/// ATX headings, `>` quotes, and `-`/`1.` lists.
84///
85/// Headings become a **run** (bold at the level-dependent heading scale) rather than a paragraph attribute,
86/// because a heading is a size and a weight, and Day's paragraph attributes are alignment,
87/// indent and list decoration. Quotes and list items become paragraph runs, which is what they
88/// are.
89pub fn markdown_to_styled(md: &str, style: DocStyle) -> StyledText {
90    let mut doc = StyledText::default();
91    let mut paragraphs: Vec<ParagraphRun> = Vec::new();
92    let mut ordinal = 1u32;
93    let mut prev_ordered = false;
94
95    for raw in md.lines() {
96        let start = doc.text.len();
97        let (body, para, heading) = split_block_markers(raw);
98
99        // An ordered list numbers itself across consecutive items; anything else resets it.
100        let para = match para.list {
101            ListStyle::Ordered(_) => {
102                if !prev_ordered {
103                    ordinal = 1;
104                }
105                prev_ordered = true;
106                let p = ParagraphStyle {
107                    list: ListStyle::Ordered(ordinal),
108                    ..para
109                };
110                ordinal += 1;
111                p
112            }
113            _ => {
114                prev_ordered = false;
115                para
116            }
117        };
118
119        let (text, mut runs) = crate::markdown::parse(body, style.base);
120        // A heading scales and bolds the whole line, under whatever inline styling it carries.
121        if let Some(level) = heading {
122            let scale = heading_scale(level);
123            for r in &mut runs {
124                r.font.scale = scale;
125                if r.font.weight.is_none() {
126                    r.font.weight = Some(FontWeight::Bold);
127                }
128            }
129            let mut head = RunStyle::plain(style.base);
130            head.font.scale = scale;
131            head.set_bold(true);
132            merge_base_run(&mut runs, text.len(), head);
133        }
134        for r in &mut runs {
135            r.range = (r.range.start + start)..(r.range.end + start);
136        }
137        doc.runs.extend(runs);
138        doc.text.push_str(&text);
139        doc.text.push('\n');
140        let end = doc.text.len();
141        if para != ParagraphStyle::default() {
142            paragraphs.push(ParagraphRun::new(start..end, para));
143        }
144    }
145    // `lines()` drops the final terminator; a document that did not end in one should not gain it.
146    if !md.ends_with('\n') && doc.text.ends_with('\n') {
147        doc.text.pop();
148        doc.clamp();
149    }
150    doc.paragraphs = paragraphs;
151    doc.runs = coalesce_runs(std::mem::take(&mut doc.runs), style.base);
152    doc.clamp();
153    doc
154}
155
156/// Strip a line's block markers, returning the remaining inline text, the paragraph style they
157/// imply, and the heading level if it is one.
158fn split_block_markers(line: &str) -> (&str, ParagraphStyle, Option<u8>) {
159    let mut s = line;
160    let mut para = ParagraphStyle::default();
161    // Blockquotes nest, and each level indents.
162    while let Some(rest) = s.strip_prefix('>') {
163        para.indent += 24.0;
164        s = rest.strip_prefix(' ').unwrap_or(rest);
165    }
166    // One level of list nesting per two leading spaces, which is the CommonMark-ish rule and the
167    // one a person typing into an editor expects.
168    let indented = s.len() - s.trim_start_matches(' ').len();
169    let trimmed = s.trim_start_matches(' ');
170    if let Some(rest) = trimmed
171        .strip_prefix("- ")
172        .or_else(|| trimmed.strip_prefix("* "))
173    {
174        para.list = ListStyle::Bullet;
175        para.list_level = (indented / 2).min(u8::MAX as usize) as u8;
176        return (rest, para, None);
177    }
178    if let Some((num, rest)) = split_ordered_marker(trimmed) {
179        para.list = ListStyle::Ordered(num);
180        para.list_level = (indented / 2).min(u8::MAX as usize) as u8;
181        return (rest, para, None);
182    }
183    // ATX heading, up to six `#`.
184    let hashes = s.bytes().take_while(|b| *b == b'#').count();
185    if (1..=6).contains(&hashes)
186        && let Some(rest) = s[hashes..].strip_prefix(' ')
187    {
188        return (rest, para, Some(hashes as u8));
189    }
190    (s, para, None)
191}
192
193/// `"12. text"` → `(12, "text")`.
194fn split_ordered_marker(s: &str) -> Option<(u32, &str)> {
195    let digits = s.bytes().take_while(|b| b.is_ascii_digit()).count();
196    if digits == 0 || digits > 9 {
197        return None;
198    }
199    let rest = s[digits..].strip_prefix(". ")?;
200    s[..digits].parse().ok().map(|n| (n, rest))
201}
202
203/// Cover the whole line with `style`, folding it under any inline runs already there.
204fn merge_base_run(runs: &mut Vec<TextRun>, len: usize, style: RunStyle) {
205    let mut out = Vec::with_capacity(runs.len() * 2 + 1);
206    let mut at = 0usize;
207    for r in runs.iter() {
208        if r.range.start > at {
209            out.push(TextRun::styled(at..r.range.start, style.clone()));
210        }
211        out.push(r.clone());
212        at = r.range.end;
213    }
214    if at < len {
215        out.push(TextRun::styled(at..len, style));
216    }
217    *runs = out;
218}
219
220/// Write a styled document as markdown.
221///
222/// Lossy where markdown has no spelling: color, highlight, underline, alignment and relative
223/// sizes that are not one of the six heading steps all vanish. What survives round-trips.
224pub fn styled_to_markdown(doc: &StyledText, style: DocStyle) -> String {
225    let mut out = String::with_capacity(doc.text.len() + 32);
226    for (start, end) in paragraph_bounds(&doc.text) {
227        let line_end = end - usize::from(doc.text[start..end].ends_with('\n'));
228        let para = doc
229            .paragraphs
230            .iter()
231            .find(|p| p.range.start <= start && p.range.end >= line_end.max(start + 1))
232            .map(|p| p.style())
233            .unwrap_or_default();
234        // A whole-line scale that matches a heading step becomes `#`s.
235        let heading = doc
236            .style_of(start..line_end.max(start), style.base)
237            .font
238            .scale;
239        let heading = if line_end > start {
240            heading_level_for(heading)
241        } else {
242            None
243        };
244        for _ in 0..(para.indent / 24.0).round().max(0.0) as usize {
245            out.push_str("> ");
246        }
247        for _ in 0..para.list_level {
248            out.push_str("  ");
249        }
250        match para.list {
251            ListStyle::Bullet => out.push_str("- "),
252            ListStyle::Ordered(n) => out.push_str(&format!("{n}. ")),
253            ListStyle::None => {}
254        }
255        if let Some(level) = heading {
256            for _ in 0..level {
257                out.push('#');
258            }
259            out.push(' ');
260        }
261        write_markdown_inline(doc, start..line_end, style, heading.is_some(), &mut out);
262        if end < doc.text.len() || doc.text[start..end].ends_with('\n') {
263            out.push('\n');
264        }
265    }
266    out
267}
268
269fn write_markdown_inline(
270    doc: &StyledText,
271    range: std::ops::Range<usize>,
272    style: DocStyle,
273    in_heading: bool,
274    out: &mut String,
275) {
276    for (seg, s) in doc.segments(style.base) {
277        let lo = seg.start.max(range.start);
278        let hi = seg.end.min(range.end);
279        if lo >= hi {
280            continue;
281        }
282        let Some(text) = doc.text.get(lo..hi) else {
283            continue;
284        };
285        // A heading's own bold is the `#`, not `**`.
286        let bold = s.bold() && !in_heading;
287        let (open, close) = markdown_markers(&s, bold);
288        if let Some(url) = &s.link {
289            out.push('[');
290            out.push_str(&open);
291            out.push_str(text);
292            out.push_str(&close);
293            out.push_str("](");
294            out.push_str(url);
295            out.push(')');
296            continue;
297        }
298        out.push_str(&open);
299        out.push_str(&escape_markdown(text));
300        out.push_str(&close);
301    }
302}
303
304fn markdown_markers(s: &RunStyle, bold: bool) -> (String, String) {
305    let mut open = String::new();
306    let mut close = String::new();
307    if bold {
308        open.push_str("**");
309        close.insert_str(0, "**");
310    }
311    if s.font.italic {
312        open.push('*');
313        close.insert(0, '*');
314    }
315    if s.strikethrough {
316        open.push_str("~~");
317        close.insert_str(0, "~~");
318    }
319    if s.font.monospace {
320        open.push('`');
321        close.insert(0, '`');
322    }
323    (open, close)
324}
325
326fn escape_markdown(s: &str) -> String {
327    let mut out = String::with_capacity(s.len());
328    for c in s.chars() {
329        if matches!(c, '*' | '_' | '`' | '~' | '[' | ']' | '\\') {
330            out.push('\\');
331        }
332        out.push(c);
333    }
334    out
335}
336
337// ===========================================================================
338// HTML
339// ===========================================================================
340
341/// Write a styled document as an HTML fragment: one `<p>` per paragraph, `<span style="…">` per
342/// run, with `<b>`/`<i>`/`<u>`/`<s>`/`<code>`/`<a>` where a tag says it more plainly.
343pub fn styled_to_html(doc: &StyledText, style: DocStyle) -> String {
344    let mut out = String::with_capacity(doc.text.len() * 2);
345    for (start, end) in paragraph_bounds(&doc.text) {
346        let line_end = end - usize::from(doc.text[start..end].ends_with('\n'));
347        let para = doc
348            .paragraphs
349            .iter()
350            .find(|p| p.range.start <= start && p.range.end >= line_end.max(start + 1))
351            .map(|p| p.style())
352            .unwrap_or_default();
353        let (open_tag, close_tag) = match para.list {
354            ListStyle::None => ("p", "p"),
355            _ => ("li", "li"),
356        };
357        out.push('<');
358        out.push_str(open_tag);
359        let mut css = String::new();
360        match para.align {
361            ParagraphAlign::Natural => {}
362            ParagraphAlign::Center => css.push_str("text-align:center;"),
363            ParagraphAlign::Trailing => css.push_str("text-align:end;"),
364            ParagraphAlign::Justified => css.push_str("text-align:justify;"),
365        }
366        let indent = para.indent + f64::from(para.list_level) * 24.0;
367        if indent > 0.0 {
368            css.push_str(&format!("margin-inline-start:{indent}px;"));
369        }
370        if para.space_before > 0.0 {
371            css.push_str(&format!("margin-block-start:{}px;", para.space_before));
372        }
373        if para.space_after > 0.0 {
374            css.push_str(&format!("margin-block-end:{}px;", para.space_after));
375        }
376        if !css.is_empty() {
377            out.push_str(&format!(" style=\"{css}\""));
378        }
379        out.push('>');
380        for (seg, s) in doc.segments(style.base) {
381            let lo = seg.start.max(start);
382            let hi = seg.end.min(line_end);
383            if lo >= hi {
384                continue;
385            }
386            let Some(text) = doc.text.get(lo..hi) else {
387                continue;
388            };
389            write_html_run(&s, text, &mut out);
390        }
391        out.push_str("</");
392        out.push_str(close_tag);
393        out.push_str(">\n");
394    }
395    out
396}
397
398fn write_html_run(s: &RunStyle, text: &str, out: &mut String) {
399    let mut css = String::new();
400    if let Some(c) = s.color {
401        css.push_str(&format!("color:{};", css_hex(c)));
402    }
403    if let Some(c) = s.background {
404        css.push_str(&format!("background-color:{};", css_hex(c)));
405    }
406    if s.font.scale != 1.0 {
407        css.push_str(&format!("font-size:{}em;", s.font.scale));
408    }
409    if let Font::System(pt) | Font::Custom(_, pt) = s.font.style {
410        css.push_str(&format!("font-size:{pt}px;"));
411    }
412    let mut tags: Vec<&str> = Vec::new();
413    if let Some(url) = &s.link {
414        out.push_str("<a href=\"");
415        escape_html(url, out);
416        out.push_str("\">");
417    }
418    if !css.is_empty() {
419        out.push_str(&format!("<span style=\"{css}\">"));
420    }
421    if s.bold() {
422        tags.push("b");
423    }
424    if s.font.italic {
425        tags.push("i");
426    }
427    if s.underline.is_on() {
428        tags.push("u");
429    }
430    if s.strikethrough {
431        tags.push("s");
432    }
433    if s.font.monospace {
434        tags.push("code");
435    }
436    for t in &tags {
437        out.push('<');
438        out.push_str(t);
439        out.push('>');
440    }
441    escape_html(text, out);
442    for t in tags.iter().rev() {
443        out.push_str("</");
444        out.push_str(t);
445        out.push('>');
446    }
447    if !css.is_empty() {
448        out.push_str("</span>");
449    }
450    if s.link.is_some() {
451        out.push_str("</a>");
452    }
453}
454
455fn css_hex(c: Color) -> String {
456    let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
457    format!("#{:02x}{:02x}{:02x}", q(c.r), q(c.g), q(c.b))
458}
459
460fn escape_html(s: &str, out: &mut String) {
461    for c in s.chars() {
462        match c {
463            '&' => out.push_str("&amp;"),
464            '<' => out.push_str("&lt;"),
465            '>' => out.push_str("&gt;"),
466            '"' => out.push_str("&quot;"),
467            _ => out.push(c),
468        }
469    }
470}
471
472/// Parse an HTML fragment into a styled document.
473///
474/// A tag-soup reader, not a browser: it tracks the inline tags and inline `style` properties Day
475/// can express and ignores everything else, including `<script>`/`<style>` contents, which it
476/// skips wholesale. Unknown tags are transparent: their text survives, their meaning does not.
477pub fn html_to_styled(html: &str, style: DocStyle) -> StyledText {
478    let mut p = HtmlParser {
479        src: html.as_bytes(),
480        at: 0,
481        doc: StyledText::default(),
482        stack: vec![style.plain()],
483        para_stack: vec![ParagraphStyle::default()],
484        style,
485        para_start: 0,
486        list_ordinal: Vec::new(),
487    };
488    p.run();
489    p.finish()
490}
491
492struct HtmlParser<'a> {
493    src: &'a [u8],
494    at: usize,
495    doc: StyledText,
496    stack: Vec<RunStyle>,
497    para_stack: Vec<ParagraphStyle>,
498    style: DocStyle,
499    para_start: usize,
500    list_ordinal: Vec<u32>,
501}
502
503impl HtmlParser<'_> {
504    fn run(&mut self) {
505        let mut text_start = 0usize;
506        while self.at < self.src.len() {
507            if self.src[self.at] == b'<' {
508                self.flush(text_start);
509                self.tag();
510                text_start = self.doc.text.len();
511            } else {
512                let c = self.next_char();
513                self.push_char(c);
514            }
515        }
516        self.flush(text_start);
517    }
518
519    fn next_char(&mut self) -> char {
520        // Entities first, then a whole UTF-8 scalar so a multi-byte character is never split.
521        if self.src[self.at] == b'&'
522            && let Some(end) = self.src[self.at..].iter().take(12).position(|b| *b == b';')
523        {
524            let ent = std::str::from_utf8(&self.src[self.at + 1..self.at + end]).unwrap_or("");
525            let decoded = match ent {
526                "amp" => Some('&'),
527                "lt" => Some('<'),
528                "gt" => Some('>'),
529                "quot" => Some('"'),
530                "apos" | "#39" => Some('\''),
531                "nbsp" => Some('\u{a0}'),
532                e => e
533                    .strip_prefix('#')
534                    .and_then(|n| {
535                        n.strip_prefix('x')
536                            .and_then(|h| u32::from_str_radix(h, 16).ok())
537                            .or_else(|| n.parse().ok())
538                    })
539                    .and_then(char::from_u32),
540            };
541            if let Some(c) = decoded {
542                self.at += end + 1;
543                return c;
544            }
545        }
546        let rest = &self.src[self.at..];
547        let s = std::str::from_utf8(rest).unwrap_or("\u{fffd}");
548        let c = s.chars().next().unwrap_or('\u{fffd}');
549        self.at += c.len_utf8().min(rest.len());
550        c
551    }
552
553    fn push_char(&mut self, c: char) {
554        // Collapse runs of whitespace, the way HTML does; a `<br>` or block boundary is what
555        // makes a line break.
556        if c.is_whitespace() {
557            if self.doc.text.ends_with(' ') || self.doc.text.ends_with('\n') {
558                return;
559            }
560            self.doc.text.push(' ');
561            return;
562        }
563        self.doc.text.push(c);
564    }
565
566    /// Emit everything since `from` as a run under the current style.
567    fn flush(&mut self, from: usize) {
568        let end = self.doc.text.len();
569        let style = self
570            .stack
571            .last()
572            .cloned()
573            .unwrap_or_else(|| self.style.plain());
574        if end > from && style != self.style.plain() {
575            self.doc.runs.push(TextRun::styled(from..end, style));
576        }
577    }
578
579    fn tag(&mut self) {
580        let Some(close) = self.src[self.at..].iter().position(|b| *b == b'>') else {
581            self.at = self.src.len();
582            return;
583        };
584        let raw = std::str::from_utf8(&self.src[self.at + 1..self.at + close]).unwrap_or("");
585        self.at += close + 1;
586        let closing = raw.starts_with('/');
587        let body = raw.trim_start_matches('/').trim_end_matches('/');
588        let name: String = body
589            .split([' ', '\t', '\n'])
590            .next()
591            .unwrap_or("")
592            .to_ascii_lowercase();
593        // `<script>`/`<style>` contents are not text.
594        if !closing && matches!(name.as_str(), "script" | "style") {
595            let needle = format!("</{name}");
596            if let Some(end) = find_ci(&self.src[self.at..], needle.as_bytes()) {
597                self.at += end;
598            } else {
599                self.at = self.src.len();
600            }
601            return;
602        }
603        if name == "br" {
604            self.end_paragraph();
605            return;
606        }
607        if closing {
608            self.close(&name);
609        } else {
610            self.open(&name, body);
611        }
612    }
613
614    fn open(&mut self, name: &str, body: &str) {
615        if is_block(name) {
616            self.end_paragraph();
617            let mut para = ParagraphStyle::default();
618            if let Some(level) = heading_tag_level(name) {
619                let mut s = self.style.plain();
620                s.font.scale = heading_scale(level);
621                s.set_bold(true);
622                self.stack.push(s);
623                self.para_stack.push(para);
624                return;
625            }
626            if name == "li" {
627                let ordered = self.list_ordinal.last().copied();
628                para.list = match ordered {
629                    Some(n) => ListStyle::Ordered(n),
630                    None => ListStyle::Bullet,
631                };
632                para.list_level = self.list_ordinal.len().saturating_sub(1).min(255) as u8;
633                if let Some(n) = self.list_ordinal.last_mut() {
634                    *n += 1;
635                }
636            }
637            if name == "ul" {
638                self.list_ordinal.push(0);
639                // A bullet list is marked by a zero ordinal slot, which `li` reads as "no number".
640                if let Some(last) = self.list_ordinal.last_mut() {
641                    *last = 0;
642                }
643                self.para_stack.push(para);
644                self.stack.push(self.current_run());
645                return;
646            }
647            if name == "ol" {
648                self.list_ordinal.push(1);
649                self.para_stack.push(para);
650                self.stack.push(self.current_run());
651                return;
652            }
653            if name == "blockquote" {
654                para.indent += 24.0;
655            }
656            apply_block_css(&mut para, body);
657            self.para_stack.push(para);
658            self.stack.push(self.current_run());
659            return;
660        }
661        let mut s = self.current_run();
662        match name {
663            "b" | "strong" => s.set_bold(true),
664            "i" | "em" => s.set_italic(true),
665            "u" | "ins" => s.underline = Underline::Single,
666            "s" | "del" | "strike" => s.strikethrough = true,
667            "code" | "kbd" | "samp" | "tt" => s.font.monospace = true,
668            "a" => s.link = attr(body, "href").map(str::to_string),
669            _ => {}
670        }
671        apply_inline_css(&mut s, body, self.style);
672        self.stack.push(s);
673    }
674
675    fn close(&mut self, name: &str) {
676        if is_block(name) {
677            self.end_paragraph();
678            if self.para_stack.len() > 1 {
679                self.para_stack.pop();
680            }
681            if matches!(name, "ul" | "ol") {
682                self.list_ordinal.pop();
683            }
684        }
685        if self.stack.len() > 1 {
686            self.stack.pop();
687        }
688    }
689
690    fn current_run(&self) -> RunStyle {
691        self.stack
692            .last()
693            .cloned()
694            .unwrap_or_else(|| self.style.plain())
695    }
696
697    /// Close the paragraph in progress, recording its style and starting the next.
698    fn end_paragraph(&mut self) {
699        // Trim the collapsed trailing space a block boundary leaves behind.
700        while self.doc.text.ends_with(' ') {
701            self.doc.text.pop();
702        }
703        if self.doc.text.len() <= self.para_start {
704            return;
705        }
706        self.doc.text.push('\n');
707        let style = self.para_stack.last().copied().unwrap_or_default();
708        if style != ParagraphStyle::default() {
709            self.doc.paragraphs.push(ParagraphRun::new(
710                self.para_start..self.doc.text.len(),
711                style,
712            ));
713        }
714        self.para_start = self.doc.text.len();
715    }
716
717    fn finish(mut self) -> StyledText {
718        self.end_paragraph();
719        // The last paragraph's terminator is an artifact of closing it, not part of the text.
720        if self.doc.text.ends_with('\n') {
721            self.doc.text.pop();
722        }
723        let base = self.style.base;
724        self.doc.runs = coalesce_runs(std::mem::take(&mut self.doc.runs), base);
725        self.doc.clamp();
726        self.doc
727    }
728}
729
730fn is_block(name: &str) -> bool {
731    matches!(
732        name,
733        "p" | "div"
734            | "li"
735            | "ul"
736            | "ol"
737            | "blockquote"
738            | "pre"
739            | "h1"
740            | "h2"
741            | "h3"
742            | "h4"
743            | "h5"
744            | "h6"
745    )
746}
747
748fn heading_tag_level(name: &str) -> Option<u8> {
749    let n = name.strip_prefix('h')?;
750    let level: u8 = n.parse().ok()?;
751    (1..=6).contains(&level).then_some(level)
752}
753
754/// The value of `name="…"` (or `name='…'`) in a tag body.
755fn attr<'a>(body: &'a str, name: &str) -> Option<&'a str> {
756    let lower = body.to_ascii_lowercase();
757    let at = lower.find(&format!("{name}="))? + name.len() + 1;
758    let rest = &body[at..];
759    let quote = rest.chars().next()?;
760    if quote == '"' || quote == '\'' {
761        rest[1..].find(quote).map(|e| &rest[1..1 + e])
762    } else {
763        Some(rest.split([' ', '\t']).next().unwrap_or(rest))
764    }
765}
766
767fn apply_inline_css(s: &mut RunStyle, body: &str, doc: DocStyle) {
768    let Some(css) = attr(body, "style") else {
769        return;
770    };
771    for decl in css.split(';') {
772        let Some((k, v)) = decl.split_once(':') else {
773            continue;
774        };
775        let (k, v) = (k.trim().to_ascii_lowercase(), v.trim());
776        match k.as_str() {
777            "color" => s.color = parse_css_color(v),
778            "background-color" | "background" => s.background = parse_css_color(v),
779            "font-weight" => {
780                let bold = v == "bold" || v.parse::<u32>().is_ok_and(|n| n >= 600);
781                s.set_bold(bold);
782            }
783            "font-style" => s.set_italic(v == "italic" || v == "oblique"),
784            "text-decoration" | "text-decoration-line" => {
785                if v.contains("underline") {
786                    s.underline = Underline::Single;
787                }
788                if v.contains("line-through") {
789                    s.strikethrough = true;
790                }
791            }
792            "font-family" => s.font.monospace = v.contains("monospace") || v.contains("Courier"),
793            "font-size" => {
794                if let Some(em) = v
795                    .strip_suffix("em")
796                    .and_then(|n| n.trim().parse::<f64>().ok())
797                {
798                    s.font.scale = em;
799                } else if let Some(px) = v
800                    .strip_suffix("px")
801                    .and_then(|n| n.trim().parse::<f64>().ok())
802                {
803                    // An absolute size becomes an absolute style, which is exactly what
804                    // `Font::System` is for (docs/texteditor.md).
805                    s.font.style = Font::System(px);
806                    s.font.scale = 1.0;
807                } else if let Some(pct) = v
808                    .strip_suffix('%')
809                    .and_then(|n| n.trim().parse::<f64>().ok())
810                {
811                    s.font.scale = pct / 100.0;
812                } else if let Some(pt) = v
813                    .strip_suffix("pt")
814                    .and_then(|n| n.trim().parse::<f64>().ok())
815                {
816                    s.font.style = Font::System(pt * 96.0 / 72.0);
817                    s.font.scale = 1.0;
818                }
819                let _ = doc;
820            }
821            _ => {}
822        }
823    }
824}
825
826fn apply_block_css(para: &mut ParagraphStyle, body: &str) {
827    let Some(css) = attr(body, "style") else {
828        return;
829    };
830    for decl in css.split(';') {
831        let Some((k, v)) = decl.split_once(':') else {
832            continue;
833        };
834        let (k, v) = (k.trim().to_ascii_lowercase(), v.trim());
835        let px = |v: &str| v.trim_end_matches("px").trim().parse::<f64>().ok();
836        match k.as_str() {
837            "text-align" => {
838                para.align = match v {
839                    "center" => ParagraphAlign::Center,
840                    "right" | "end" => ParagraphAlign::Trailing,
841                    "justify" => ParagraphAlign::Justified,
842                    _ => ParagraphAlign::Natural,
843                }
844            }
845            "margin-inline-start" | "margin-left" | "padding-inline-start" => {
846                para.indent = px(v).unwrap_or(para.indent)
847            }
848            "margin-block-start" | "margin-top" => {
849                para.space_before = px(v).unwrap_or(para.space_before)
850            }
851            "margin-block-end" | "margin-bottom" => {
852                para.space_after = px(v).unwrap_or(para.space_after)
853            }
854            _ => {}
855        }
856    }
857}
858
859/// `#rgb`, `#rrggbb`, or `rgb(r, g, b)`.
860fn parse_css_color(v: &str) -> Option<Color> {
861    let v = v.trim();
862    if let Some(rest) = v.strip_prefix("rgb(").and_then(|r| r.strip_suffix(')')) {
863        let mut it = rest.split(',').map(|n| n.trim().parse::<f64>().ok());
864        return Some(Color::rgb(
865            it.next()?? / 255.0,
866            it.next()?? / 255.0,
867            it.next()?? / 255.0,
868        ));
869    }
870    Color::parse(v)
871}
872
873/// Case-insensitive substring search, for finding a closing tag.
874fn find_ci(hay: &[u8], needle: &[u8]) -> Option<usize> {
875    hay.windows(needle.len())
876        .position(|w| w.eq_ignore_ascii_case(needle))
877}
878
879// ===========================================================================
880// RTF
881// ===========================================================================
882
883/// Write a styled document as RTF, in the subset [`rtf_to_styled`] reads back
884/// (docs/texteditor.md).
885pub fn styled_to_rtf(doc: &StyledText, style: DocStyle) -> String {
886    // The color table is 1-based in `\cfN`; index 0 is "the default", so the table starts with an
887    // empty entry and every real color is written after it.
888    let mut colors: Vec<Color> = Vec::new();
889    let color_index = |c: Color, colors: &mut Vec<Color>| -> usize {
890        match colors.iter().position(|x| *x == c) {
891            Some(i) => i + 1,
892            None => {
893                colors.push(c);
894                colors.len()
895            }
896        }
897    };
898    let mut body = String::new();
899    for (start, end) in paragraph_bounds(&doc.text) {
900        let line_end = end - usize::from(doc.text[start..end].ends_with('\n'));
901        let para = doc
902            .paragraphs
903            .iter()
904            .find(|p| p.range.start <= start && p.range.end >= line_end.max(start + 1))
905            .map(|p| p.style())
906            .unwrap_or_default();
907        body.push_str("\\pard");
908        match para.align {
909            ParagraphAlign::Natural => body.push_str("\\ql"),
910            ParagraphAlign::Center => body.push_str("\\qc"),
911            ParagraphAlign::Trailing => body.push_str("\\qr"),
912            ParagraphAlign::Justified => body.push_str("\\qj"),
913        }
914        let indent = para.indent + f64::from(para.list_level) * 24.0;
915        if indent > 0.0 {
916            // RTF measures in twips: 20 per point.
917            body.push_str(&format!("\\li{}", (indent * 20.0).round() as i64));
918        }
919        if para.space_before > 0.0 {
920            body.push_str(&format!(
921                "\\sb{}",
922                (para.space_before * 20.0).round() as i64
923            ));
924        }
925        if para.space_after > 0.0 {
926            body.push_str(&format!("\\sa{}", (para.space_after * 20.0).round() as i64));
927        }
928        match para.list {
929            ListStyle::Bullet => body.push_str("\\bullet\\tab "),
930            ListStyle::Ordered(n) => body.push_str(&format!(" {n}.\\tab ")),
931            // The paragraph words above always end in one, so this space is their delimiter and
932            // is consumed by the reader rather than becoming text.
933            ListStyle::None => body.push(' '),
934        }
935        for (seg, s) in doc.segments(style.base) {
936            let lo = seg.start.max(start);
937            let hi = seg.end.min(line_end);
938            if lo >= hi {
939                continue;
940            }
941            let Some(text) = doc.text.get(lo..hi) else {
942                continue;
943            };
944            body.push('{');
945            // Every control word below needs a delimiter, and in RTF a single space after one is
946            // that delimiter. But a run with no control words must not get one, or the space
947            // becomes literal text, which is how "bold italic" came back as "bold  italic".
948            let attrs_at = body.len();
949            if s.bold() {
950                body.push_str("\\b");
951            }
952            if s.font.italic {
953                body.push_str("\\i");
954            }
955            match s.underline {
956                Underline::None => {}
957                Underline::Double => body.push_str("\\uldb"),
958                Underline::Dotted => body.push_str("\\uld"),
959                Underline::Wavy => body.push_str("\\ulwave"),
960                Underline::Single => body.push_str("\\ul"),
961            }
962            if s.strikethrough {
963                body.push_str("\\strike");
964            }
965            // Half-points, which is RTF's unit.
966            let pts = s.font.resolved_points(style.base_points);
967            if (pts - style.base_points).abs() > 0.01 {
968                body.push_str(&format!("\\fs{}", (pts * 2.0).round() as i64));
969            }
970            if let Some(c) = s.color {
971                body.push_str(&format!("\\cf{}", color_index(c, &mut colors)));
972            }
973            if let Some(c) = s.background {
974                body.push_str(&format!("\\highlight{}", color_index(c, &mut colors)));
975            }
976            if s.font.monospace {
977                body.push_str("\\f1");
978            }
979            if body.len() > attrs_at {
980                body.push(' ');
981            }
982            escape_rtf(text, &mut body);
983            body.push('}');
984        }
985        body.push_str("\\par\n");
986    }
987
988    let mut out = String::with_capacity(body.len() + 256);
989    out.push_str("{\\rtf1\\ansi\\deff0");
990    out.push_str("{\\fonttbl{\\f0 ");
991    out.push_str(rtf_family(style.base));
992    out.push_str(";}{\\f1 Courier New;}}");
993    out.push_str("{\\colortbl;");
994    for c in &colors {
995        let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
996        out.push_str(&format!(
997            "\\red{}\\green{}\\blue{};",
998            q(c.r),
999            q(c.g),
1000            q(c.b)
1001        ));
1002    }
1003    out.push('}');
1004    out.push_str(&format!(
1005        "\\fs{}\n",
1006        (style.base_points * 2.0).round() as i64
1007    ));
1008    out.push_str(&body);
1009    out.push('}');
1010    out
1011}
1012
1013fn rtf_family(base: Font) -> &'static str {
1014    match base {
1015        Font::Custom(name, _) => {
1016            // A `&'static str` family name can go straight in; RTF has no escaping inside a
1017            // font-table entry beyond the ones `escape_rtf` handles, and a family with a brace
1018            // in it is not a thing.
1019            name
1020        }
1021        _ => "Helvetica",
1022    }
1023}
1024
1025fn escape_rtf(s: &str, out: &mut String) {
1026    for c in s.chars() {
1027        match c {
1028            '\\' | '{' | '}' => {
1029                out.push('\\');
1030                out.push(c);
1031            }
1032            '\n' => out.push_str("\\line "),
1033            c if (c as u32) < 128 => out.push(c),
1034            // Non-ASCII rides `\uN?`: a signed 16-bit code unit plus a replacement character for
1035            // readers that do not understand it. Astral characters take two units, which is why
1036            // this encodes UTF-16 rather than the scalar.
1037            c => {
1038                let mut buf = [0u16; 2];
1039                for unit in c.encode_utf16(&mut buf) {
1040                    out.push_str(&format!("\\u{}?", *unit as i16));
1041                }
1042            }
1043        }
1044    }
1045}
1046
1047/// Parse the RTF subset Day writes, plus the control words a real editor puts around it
1048/// (docs/texteditor.md).
1049///
1050/// Reads: `\b \i \ul \uldb \uld \ulwave \strike \fs \cf \highlight \f`, the `\fonttbl` and
1051/// `\colortbl` tables, `\par \line \tab`, `\pard \ql \qc \qr \qj \li \sb \sa`, `\'hh` and `\uN`
1052/// escapes, and group nesting for style scoping. Skips `{\*\…}` destinations whole. Everything
1053/// else is ignored, which is what keeps a Word document from producing garbage rather than
1054/// nothing.
1055pub fn rtf_to_styled(rtf: &str, style: DocStyle) -> StyledText {
1056    let mut p = RtfParser {
1057        src: rtf.as_bytes(),
1058        at: 0,
1059        doc: StyledText::default(),
1060        stack: vec![RtfState::new(style)],
1061        colors: Vec::new(),
1062        mono_fonts: Vec::new(),
1063        style,
1064        run_start: 0,
1065        para_start: 0,
1066        para: ParagraphStyle::default(),
1067        pending_utf16: Vec::new(),
1068    };
1069    p.run();
1070    p.finish()
1071}
1072
1073#[derive(Clone)]
1074struct RtfState {
1075    run: RunStyle,
1076    /// Skip this group's text entirely (a `\*` destination, a font or color table).
1077    skip: bool,
1078}
1079
1080impl RtfState {
1081    fn new(style: DocStyle) -> Self {
1082        RtfState {
1083            run: style.plain(),
1084            skip: false,
1085        }
1086    }
1087}
1088
1089struct RtfParser<'a> {
1090    src: &'a [u8],
1091    at: usize,
1092    doc: StyledText,
1093    stack: Vec<RtfState>,
1094    colors: Vec<Color>,
1095    mono_fonts: Vec<u32>,
1096    style: DocStyle,
1097    run_start: usize,
1098    para_start: usize,
1099    para: ParagraphStyle,
1100    pending_utf16: Vec<u16>,
1101}
1102
1103impl RtfParser<'_> {
1104    fn run(&mut self) {
1105        while self.at < self.src.len() {
1106            match self.src[self.at] {
1107                b'{' => {
1108                    self.at += 1;
1109                    let top = self
1110                        .stack
1111                        .last()
1112                        .cloned()
1113                        .unwrap_or(RtfState::new(self.style));
1114                    self.stack.push(top);
1115                }
1116                b'}' => {
1117                    self.at += 1;
1118                    self.flush_run();
1119                    if self.stack.len() > 1 {
1120                        self.stack.pop();
1121                    }
1122                }
1123                b'\\' => self.control(),
1124                b'\r' | b'\n' => self.at += 1, // literal newlines are formatting, not text
1125                _ => {
1126                    let b = self.src[self.at];
1127                    self.at += 1;
1128                    self.push_byte(b);
1129                }
1130            }
1131        }
1132    }
1133
1134    fn state(&self) -> RtfState {
1135        self.stack
1136            .last()
1137            .cloned()
1138            .unwrap_or_else(|| RtfState::new(self.style))
1139    }
1140
1141    fn skipping(&self) -> bool {
1142        self.stack.last().is_some_and(|s| s.skip)
1143    }
1144
1145    fn push_byte(&mut self, b: u8) {
1146        self.flush_utf16();
1147        if self.skipping() {
1148            return;
1149        }
1150        self.doc.text.push(b as char);
1151    }
1152
1153    fn push_char(&mut self, c: char) {
1154        if self.skipping() {
1155            return;
1156        }
1157        self.doc.text.push(c);
1158    }
1159
1160    /// A `\uN` escape may be half a surrogate pair; hold units until they decode.
1161    fn push_utf16(&mut self, unit: u16) {
1162        if self.skipping() {
1163            return;
1164        }
1165        self.pending_utf16.push(unit);
1166        let decoded: String = String::from_utf16_lossy(&self.pending_utf16);
1167        // A lone high surrogate decodes lossily, so hold it for its partner, but only for one
1168        // more unit, or an unpaired surrogate would swallow the rest of the document.
1169        if !decoded.contains('\u{fffd}') || self.pending_utf16.len() >= 2 {
1170            self.doc.text.push_str(&decoded);
1171            self.pending_utf16.clear();
1172        }
1173    }
1174
1175    fn flush_utf16(&mut self) {
1176        if !self.pending_utf16.is_empty() {
1177            let s = String::from_utf16_lossy(&self.pending_utf16);
1178            self.pending_utf16.clear();
1179            if !self.skipping() {
1180                self.doc.text.push_str(&s);
1181            }
1182        }
1183    }
1184
1185    /// Emit everything typed since the last style change as a run.
1186    fn flush_run(&mut self) {
1187        self.flush_utf16();
1188        let end = self.doc.text.len();
1189        let run = self.state().run;
1190        if end > self.run_start && run != self.style.plain() {
1191            self.doc
1192                .runs
1193                .push(TextRun::styled(self.run_start..end, run));
1194        }
1195        self.run_start = end;
1196    }
1197
1198    /// Consume the rest of the current group without producing text, for the tables, whose
1199    /// contents this reader parses from the source directly.
1200    fn skip_group(&mut self) {
1201        let mut depth = 1i32;
1202        while self.at < self.src.len() && depth > 0 {
1203            match self.src[self.at] {
1204                b'{' => depth += 1,
1205                b'}' => depth -= 1,
1206                // An escaped brace is text, not nesting.
1207                b'\\' => self.at += 1,
1208                _ => {}
1209            }
1210            self.at += 1;
1211        }
1212        if self.stack.len() > 1 {
1213            self.stack.pop();
1214        }
1215        self.run_start = self.doc.text.len();
1216    }
1217
1218    fn control(&mut self) {
1219        self.at += 1;
1220        if self.at >= self.src.len() {
1221            return;
1222        }
1223        let b = self.src[self.at];
1224        // `\'hh` is a raw byte in the document's codepage, read as Latin-1, which is what an
1225        // `\ansi` document means and the only codepage this subset promises.
1226        if b == b'\'' {
1227            let hex = self
1228                .src
1229                .get(self.at + 1..self.at + 3)
1230                .and_then(|h| std::str::from_utf8(h).ok())
1231                .and_then(|h| u8::from_str_radix(h, 16).ok());
1232            self.at += 3.min(self.src.len() - self.at);
1233            if let Some(v) = hex {
1234                self.flush_utf16();
1235                self.push_char(v as char);
1236            }
1237            return;
1238        }
1239        // `\*` is RTF's own "skip this destination if you do not know it", and knowing it is
1240        // exactly what a subset reader does not. Marking the group rather than guessing at the
1241        // control word after it is what keeps `{\*\expandedcolortbl;;}` and every future
1242        // destination from leaking punctuation into the text.
1243        if b == b'*' {
1244            self.at += 1;
1245            if let Some(st) = self.stack.last_mut() {
1246                st.skip = true;
1247            }
1248            return;
1249        }
1250        if !b.is_ascii_alphabetic() {
1251            // An escaped literal: `\\`, `\{`, `\}`, and the ones nothing else claims.
1252            self.at += 1;
1253            self.flush_utf16();
1254            self.push_char(b as char);
1255            return;
1256        }
1257        let word_start = self.at;
1258        while self.at < self.src.len() && self.src[self.at].is_ascii_alphabetic() {
1259            self.at += 1;
1260        }
1261        let word = std::str::from_utf8(&self.src[word_start..self.at])
1262            .unwrap_or("")
1263            .to_string();
1264        // An optional signed parameter, then one optional space that belongs to the control word.
1265        let num_start = self.at;
1266        if self.at < self.src.len() && (self.src[self.at] == b'-') {
1267            self.at += 1;
1268        }
1269        while self.at < self.src.len() && self.src[self.at].is_ascii_digit() {
1270            self.at += 1;
1271        }
1272        let param: Option<i64> = std::str::from_utf8(&self.src[num_start..self.at])
1273            .ok()
1274            .and_then(|s| s.parse().ok());
1275        if self.at < self.src.len() && self.src[self.at] == b' ' {
1276            self.at += 1;
1277        }
1278        self.word(&word, param);
1279    }
1280
1281    fn word(&mut self, word: &str, param: Option<i64>) {
1282        // A style change ends the run it was in.
1283        let styling = matches!(
1284            word,
1285            "b" | "i"
1286                | "ul"
1287                | "ulnone"
1288                | "uldb"
1289                | "uld"
1290                | "ulwave"
1291                | "strike"
1292                | "striked"
1293                | "fs"
1294                | "cf"
1295                | "highlight"
1296                | "f"
1297                | "plain"
1298        );
1299        if styling {
1300            self.flush_run();
1301        }
1302        let on = param != Some(0);
1303        let st = self.stack.last_mut();
1304        let Some(st) = st else { return };
1305        match word {
1306            // Destinations whose contents are not body text.
1307            "fonttbl" | "colortbl" | "stylesheet" | "info" | "generator" | "pict" | "object"
1308            | "listtable" | "listoverridetable" => st.skip = true,
1309            "b" => st.run.set_bold(on),
1310            "i" => st.run.set_italic(on),
1311            "ul" => {
1312                st.run.underline = if on {
1313                    Underline::Single
1314                } else {
1315                    Underline::None
1316                }
1317            }
1318            "ulnone" => st.run.underline = Underline::None,
1319            "uldb" => st.run.underline = Underline::Double,
1320            "uld" => st.run.underline = Underline::Dotted,
1321            "ulwave" => st.run.underline = Underline::Wavy,
1322            "strike" | "striked" => st.run.strikethrough = on,
1323            "plain" => st.run = self.style.plain(),
1324            "fs" => {
1325                if let Some(half) = param {
1326                    let pts = half as f64 / 2.0;
1327                    if (pts - self.style.base_points).abs() < 0.01 {
1328                        st.run.font.style = self.style.base;
1329                        st.run.font.scale = 1.0;
1330                    } else {
1331                        st.run.font.style = Font::System(pts);
1332                        st.run.font.scale = 1.0;
1333                    }
1334                }
1335            }
1336            "cf" => st.run.color = color_at(&self.colors, param),
1337            "highlight" => st.run.background = color_at(&self.colors, param),
1338            "f" => {
1339                st.run.font.monospace = param.is_some_and(|n| self.mono_fonts.contains(&(n as u32)))
1340            }
1341            "par" => {
1342                self.flush_run();
1343                self.push_char('\n');
1344                self.run_start = self.doc.text.len();
1345                self.close_paragraph();
1346            }
1347            "line" => {
1348                self.flush_run();
1349                self.push_char('\n');
1350                self.run_start = self.doc.text.len();
1351            }
1352            "tab" => {
1353                self.push_char('\t');
1354            }
1355            "bullet" => self.push_char('\u{2022}'),
1356            "emdash" => self.push_char('—'),
1357            "endash" => self.push_char('–'),
1358            "lquote" => self.push_char('\u{2018}'),
1359            "rquote" => self.push_char('\u{2019}'),
1360            "ldblquote" => self.push_char('\u{201c}'),
1361            "rdblquote" => self.push_char('\u{201d}'),
1362            "u" => {
1363                if let Some(n) = param {
1364                    self.push_utf16(n as i16 as u16);
1365                    // A `\uN` is followed by a replacement character for old readers; skip it.
1366                    if self.at < self.src.len() && self.src[self.at] == b'?' {
1367                        self.at += 1;
1368                    }
1369                }
1370            }
1371            "pard" => self.para = ParagraphStyle::default(),
1372            "ql" => self.para.align = ParagraphAlign::Natural,
1373            "qc" => self.para.align = ParagraphAlign::Center,
1374            "qr" => self.para.align = ParagraphAlign::Trailing,
1375            "qj" => self.para.align = ParagraphAlign::Justified,
1376            "li" => self.para.indent = param.unwrap_or(0) as f64 / 20.0,
1377            "sb" => self.para.space_before = param.unwrap_or(0) as f64 / 20.0,
1378            "sa" => self.para.space_after = param.unwrap_or(0) as f64 / 20.0,
1379            _ => {}
1380        }
1381        // The tables are read from the source rather than as body text, because a group marked
1382        // `skip` produces none. Each leaves the cursor inside its group; `skip_group` then walks
1383        // to the matching brace so `run` never sees the contents.
1384        if word == "colortbl" {
1385            self.read_color_table();
1386            self.skip_group();
1387        }
1388        if word == "fonttbl" {
1389            self.read_font_table();
1390            self.skip_group();
1391        }
1392    }
1393
1394    /// `\red0\green0\blue255;` entries up to the group's close. Index 0 is "the default", so an
1395    /// empty leading entry is normal and is recorded as black.
1396    fn read_color_table(&mut self) {
1397        let mut rgb = [0u8; 3];
1398        let mut seen = false;
1399        while self.at < self.src.len() && self.src[self.at] != b'}' {
1400            if self.src[self.at] == b'\\' {
1401                let start = self.at + 1;
1402                let mut e = start;
1403                while e < self.src.len() && self.src[e].is_ascii_alphabetic() {
1404                    e += 1;
1405                }
1406                let word = std::str::from_utf8(&self.src[start..e]).unwrap_or("");
1407                let ns = e;
1408                while e < self.src.len() && self.src[e].is_ascii_digit() {
1409                    e += 1;
1410                }
1411                let n: u8 = std::str::from_utf8(&self.src[ns..e])
1412                    .ok()
1413                    .and_then(|s| s.parse().ok())
1414                    .unwrap_or(0);
1415                match word {
1416                    "red" => {
1417                        rgb[0] = n;
1418                        seen = true;
1419                    }
1420                    "green" => {
1421                        rgb[1] = n;
1422                        seen = true;
1423                    }
1424                    "blue" => {
1425                        rgb[2] = n;
1426                        seen = true;
1427                    }
1428                    _ => {}
1429                }
1430                self.at = e;
1431                continue;
1432            }
1433            if self.src[self.at] == b';' {
1434                self.colors.push(if seen {
1435                    Color::rgb(
1436                        f64::from(rgb[0]) / 255.0,
1437                        f64::from(rgb[1]) / 255.0,
1438                        f64::from(rgb[2]) / 255.0,
1439                    )
1440                } else {
1441                    Color::BLACK
1442                });
1443                rgb = [0u8; 3];
1444                seen = false;
1445            }
1446            self.at += 1;
1447        }
1448    }
1449
1450    /// `{\f1 Courier New;}` entries: only which indices are monospaced, which is all the model
1451    /// can carry. Reads to the end of the enclosing group without consuming its closing brace;
1452    /// `skip_group` does that.
1453    fn read_font_table(&mut self) {
1454        let mut depth = 1i32;
1455        let mut current: Option<u32> = None;
1456        let mut name = String::new();
1457        let from = self.at;
1458        let mut at = self.at;
1459        while at < self.src.len() && depth > 0 {
1460            match self.src[at] {
1461                b'{' => {
1462                    depth += 1;
1463                    at += 1;
1464                }
1465                b'}' | b';' => {
1466                    if self.src[at] == b'}' {
1467                        depth -= 1;
1468                    }
1469                    if let Some(i) = current.take()
1470                        && is_mono_family(&name)
1471                    {
1472                        self.mono_fonts.push(i);
1473                    }
1474                    name.clear();
1475                    at += 1;
1476                }
1477                b'\\' => {
1478                    let s = at + 1;
1479                    let mut e = s;
1480                    while e < self.src.len() && self.src[e].is_ascii_alphabetic() {
1481                        e += 1;
1482                    }
1483                    let word = std::str::from_utf8(&self.src[s..e]).unwrap_or("");
1484                    let ns = e;
1485                    while e < self.src.len() && self.src[e].is_ascii_digit() {
1486                        e += 1;
1487                    }
1488                    let n: Option<u32> = std::str::from_utf8(&self.src[ns..e])
1489                        .ok()
1490                        .and_then(|s| s.parse().ok());
1491                    match word {
1492                        "f" => current = n,
1493                        // RTF's own marker for a fixed-pitch family, which is more reliable than
1494                        // the name for a font this reader has never heard of.
1495                        "fmodern" => {
1496                            if let Some(i) = current {
1497                                self.mono_fonts.push(i);
1498                            }
1499                        }
1500                        _ => {}
1501                    }
1502                    at = e;
1503                    if at < self.src.len() && self.src[at] == b' ' {
1504                        at += 1;
1505                    }
1506                }
1507                b => {
1508                    name.push(b as char);
1509                    at += 1;
1510                }
1511            }
1512        }
1513        // Leave the cursor where it started: `skip_group` walks the braces.
1514        self.at = from;
1515    }
1516
1517    fn close_paragraph(&mut self) {
1518        if self.doc.text.len() <= self.para_start {
1519            self.para_start = self.doc.text.len();
1520            return;
1521        }
1522        if self.para != ParagraphStyle::default() {
1523            self.doc.paragraphs.push(ParagraphRun::new(
1524                self.para_start..self.doc.text.len(),
1525                self.para,
1526            ));
1527        }
1528        self.para_start = self.doc.text.len();
1529    }
1530
1531    fn finish(mut self) -> StyledText {
1532        self.flush_run();
1533        self.close_paragraph();
1534        // The final `\par` is the document's terminator, not a blank last line.
1535        if self.doc.text.ends_with('\n') {
1536            self.doc.text.pop();
1537        }
1538        let base = self.style.base;
1539        self.doc.runs = coalesce_runs(std::mem::take(&mut self.doc.runs), base);
1540        self.doc.clamp();
1541        self.doc
1542    }
1543}
1544
1545fn color_at(colors: &[Color], param: Option<i64>) -> Option<Color> {
1546    // `\cf0` is "the document's default color", i.e. nothing to say. The table's leading `;`
1547    // terminates that default entry, so the parsed vector holds it at index 0 and `\cfN` indexes
1548    // straight in, which is the off-by-one this indexed past when it subtracted.
1549    let i = param?;
1550    if i <= 0 {
1551        return None;
1552    }
1553    colors.get(i as usize).copied()
1554}
1555
1556fn is_mono_family(name: &str) -> bool {
1557    let n = name.to_ascii_lowercase();
1558    ["courier", "consolas", "mono", "menlo", "monaco"]
1559        .iter()
1560        .any(|m| n.contains(m))
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::*;
1566
1567    fn style() -> DocStyle {
1568        DocStyle::new(Font::Body, 16.0)
1569    }
1570
1571    fn plain_doc(t: &str) -> StyledText {
1572        StyledText::plain(t)
1573    }
1574
1575    #[test]
1576    fn markdown_inline_round_trips() {
1577        let md = "Save **now** or *lose* it.";
1578        let doc = markdown_to_styled(md, style());
1579        assert_eq!(doc.text, "Save now or lose it.");
1580        assert!(doc.validate().is_ok());
1581        assert_eq!(styled_to_markdown(&doc, style()), md);
1582    }
1583
1584    #[test]
1585    fn markdown_headings_become_scaled_bold_runs() {
1586        let doc = markdown_to_styled("# Title\nbody", style());
1587        assert_eq!(doc.text, "Title\nbody");
1588        let s = doc.style_of(0..5, Font::Body);
1589        assert!(s.bold());
1590        assert_eq!(s.font.scale, 2.0);
1591        assert!(!doc.style_of(6..10, Font::Body).bold());
1592        assert_eq!(styled_to_markdown(&doc, style()), "# Title\nbody");
1593    }
1594
1595    #[test]
1596    fn markdown_lists_become_paragraph_runs() {
1597        let doc = markdown_to_styled("- one\n- two\n1. a\n2. b", style());
1598        assert_eq!(doc.text, "one\ntwo\na\nb");
1599        let kinds: Vec<_> = doc.paragraphs.iter().map(|p| p.list).collect();
1600        assert_eq!(
1601            kinds,
1602            vec![
1603                ListStyle::Bullet,
1604                ListStyle::Bullet,
1605                ListStyle::Ordered(1),
1606                ListStyle::Ordered(2)
1607            ]
1608        );
1609        assert_eq!(
1610            styled_to_markdown(&doc, style()),
1611            "- one\n- two\n1. a\n2. b"
1612        );
1613    }
1614
1615    #[test]
1616    fn markdown_quotes_indent() {
1617        let doc = markdown_to_styled("> quoted", style());
1618        assert_eq!(doc.text, "quoted");
1619        assert_eq!(doc.paragraphs[0].indent, 24.0);
1620        assert_eq!(styled_to_markdown(&doc, style()), "> quoted");
1621    }
1622
1623    #[test]
1624    fn markdown_escapes_what_would_reparse() {
1625        let doc = plain_doc("2 * 3 * 4 and _x_");
1626        let md = styled_to_markdown(&doc, style());
1627        assert_eq!(markdown_to_styled(&md, style()).text, doc.text);
1628    }
1629
1630    #[test]
1631    fn html_round_trips_the_attributes_it_can_write() {
1632        let mut doc = StyledText::plain("hello world");
1633        doc.apply(0..5, Font::Body, |s| {
1634            s.set_bold(true);
1635            s.underline = Underline::Single;
1636            s.color = Some(Color::hex(0xff0000));
1637        });
1638        doc.apply(6..11, Font::Body, |s| {
1639            s.strikethrough = true;
1640            s.background = Some(Color::hex(0x00ff00));
1641        });
1642        let html = styled_to_html(&doc, style());
1643        let back = html_to_styled(&html, style());
1644        assert_eq!(back.text, "hello world");
1645        let a = back.style_of(0..5, Font::Body);
1646        assert!(a.bold() && a.underline.is_on());
1647        assert_eq!(a.color, Some(Color::hex(0xff0000)));
1648        let b = back.style_of(6..11, Font::Body);
1649        assert!(b.strikethrough);
1650        assert_eq!(b.background, Some(Color::hex(0x00ff00)));
1651    }
1652
1653    #[test]
1654    fn html_paragraphs_and_headings() {
1655        let doc = html_to_styled(
1656            "<h2>Title</h2><p style=\"text-align:center\">mid</p>",
1657            style(),
1658        );
1659        assert_eq!(doc.text, "Title\nmid");
1660        assert_eq!(doc.style_of(0..5, Font::Body).font.scale, 1.5);
1661        assert_eq!(doc.paragraph_style_of(6..9).align, ParagraphAlign::Center);
1662    }
1663
1664    #[test]
1665    fn html_lists_number_themselves() {
1666        let doc = html_to_styled("<ol><li>a</li><li>b</li></ol>", style());
1667        assert_eq!(doc.text, "a\nb");
1668        assert_eq!(doc.paragraphs[0].list, ListStyle::Ordered(1));
1669        assert_eq!(doc.paragraphs[1].list, ListStyle::Ordered(2));
1670    }
1671
1672    #[test]
1673    fn html_ignores_scripts_and_unknown_tags() {
1674        let doc = html_to_styled(
1675            "<p>a<script>var x = '<b>';</script><weird>b</weird></p>",
1676            style(),
1677        );
1678        assert_eq!(doc.text, "ab", "script contents are not text");
1679    }
1680
1681    #[test]
1682    fn html_entities_and_multibyte_survive() {
1683        let doc = html_to_styled("<p>a &amp; b &#233; caf\u{e9} \u{1f600}</p>", style());
1684        assert_eq!(doc.text, "a & b é café 😀");
1685        assert!(doc.validate().is_ok());
1686    }
1687
1688    #[test]
1689    fn rtf_round_trips_every_attribute_day_writes() {
1690        let mut doc = StyledText::plain("bold italic under strike big red");
1691        doc.apply(0..4, Font::Body, |s| s.set_bold(true));
1692        doc.apply(5..11, Font::Body, |s| s.set_italic(true));
1693        doc.apply(12..17, Font::Body, |s| s.underline = Underline::Single);
1694        doc.apply(18..24, Font::Body, |s| s.strikethrough = true);
1695        doc.apply(25..28, Font::Body, |s| s.font.scale = 1.5);
1696        doc.apply(29..32, Font::Body, |s| s.color = Some(Color::hex(0xcc0000)));
1697
1698        let rtf = styled_to_rtf(&doc, style());
1699        let back = rtf_to_styled(&rtf, style());
1700        assert_eq!(back.text, doc.text);
1701        assert!(back.style_of(0..4, Font::Body).bold());
1702        assert!(back.style_of(5..11, Font::Body).font.italic);
1703        assert!(back.style_of(12..17, Font::Body).underline.is_on());
1704        assert!(back.style_of(18..24, Font::Body).strikethrough);
1705        assert_eq!(
1706            back.style_of(29..32, Font::Body).color,
1707            Some(Color::hex(0xcc0000))
1708        );
1709        // A relative scale becomes an absolute size in RTF and comes back as one.
1710        let big = back.style_of(25..28, Font::Body);
1711        assert_eq!(big.font.resolved_points(16.0), 24.0);
1712    }
1713
1714    #[test]
1715    fn rtf_paragraph_attributes_round_trip() {
1716        let mut doc = StyledText::plain("one\ntwo");
1717        doc.apply_paragraph(0..1, |p| p.align = ParagraphAlign::Center);
1718        doc.apply_paragraph(5..5, |p| p.indent = 36.0);
1719        let back = rtf_to_styled(&styled_to_rtf(&doc, style()), style());
1720        assert_eq!(back.text, "one\ntwo");
1721        assert_eq!(back.paragraph_style_of(0..1).align, ParagraphAlign::Center);
1722        assert_eq!(back.paragraph_style_of(5..5).indent, 36.0);
1723    }
1724
1725    #[test]
1726    fn rtf_escapes_and_unicode_survive() {
1727        let doc = plain_doc("a{b}c\\d é 😀");
1728        let back = rtf_to_styled(&styled_to_rtf(&doc, style()), style());
1729        assert_eq!(back.text, "a{b}c\\d é 😀");
1730    }
1731
1732    #[test]
1733    fn rtf_skips_destinations_and_tables() {
1734        // What TextEdit puts around a document: a `\*` destination and a font table with a name
1735        // that must not leak into the text.
1736        let rtf = "{\\rtf1\\ansi{\\fonttbl{\\f0\\fswiss Helvetica;}}{\\*\\expandedcolortbl;;}\
1737                   {\\*\\generator Riched20;}\\fs32 hello\\par}";
1738        let doc = rtf_to_styled(rtf, style());
1739        assert_eq!(doc.text, "hello", "got {:?}", doc.text);
1740    }
1741
1742    #[test]
1743    fn every_codec_survives_hostile_input_without_panicking() {
1744        for bad in [
1745            "",
1746            "{",
1747            "}",
1748            "\\",
1749            "{\\rtf1\\ansi\\u",
1750            "{\\rtf1{{{{{{",
1751            "{\\rtf1\\'zz}",
1752            "{\\colortbl",
1753            "<",
1754            "<p",
1755            "<p style=",
1756            "<a href=",
1757            "&#",
1758            "&#xZZ;",
1759            "<script>",
1760            "**unclosed",
1761            "# ",
1762            "- ",
1763            "1. ",
1764        ] {
1765            let _ = rtf_to_styled(bad, style());
1766            let _ = html_to_styled(bad, style());
1767            let d = markdown_to_styled(bad, style());
1768            assert!(d.validate().is_ok(), "markdown {bad:?} produced bad ranges");
1769        }
1770    }
1771}