Skip to main content

pdfboss_markdown/
report.rs

1//! Character replacement and sanitizing report for Markdown rendering.
2//! Tracks unencodable characters and HTML fragments skipped during composition.
3
4use pdfboss_write::Standard14;
5use std::collections::BTreeMap;
6
7/// Report of characters replaced and HTML fragments skipped during sanitizing.
8#[derive(Debug, Default, Clone, PartialEq)]
9pub struct Report {
10    /// Characters that were unencodable and replaced with '?', with their counts.
11    pub replaced: BTreeMap<char, u32>,
12    /// Number of raw HTML fragments that were skipped.
13    pub skipped_html: u32,
14}
15
16impl Report {
17    /// Whether this report is empty (no replacements, no HTML skipped).
18    pub fn is_empty(&self) -> bool {
19        self.replaced.is_empty() && self.skipped_html == 0
20    }
21
22    /// A summary describing all replacements and skipped HTML, or an empty
23    /// string if the report is empty. Deterministic order (BTreeMap).
24    pub fn summary(&self) -> String {
25        let has_replaced = !self.replaced.is_empty();
26        let has_html = self.skipped_html > 0;
27
28        if !has_replaced && !has_html {
29            return String::new();
30        }
31
32        let mut parts = Vec::new();
33
34        if has_replaced {
35            let total: u32 = self.replaced.values().sum();
36            let char_plural = if total == 1 {
37                "character"
38            } else {
39                "characters"
40            };
41            let chars_str = self
42                .replaced
43                .iter()
44                .map(|(ch, count)| format!("'{}'×{}", ch, count))
45                .collect::<Vec<_>>()
46                .join(", ");
47            parts.push(format!(
48                "replaced {} {} unavailable in the standard fonts: {}",
49                total, char_plural, chars_str
50            ));
51        }
52
53        if has_html {
54            let fragment_plural = if self.skipped_html == 1 {
55                "raw html fragment"
56            } else {
57                "raw html fragments"
58            };
59            parts.push(format!("skipped {} {}", self.skipped_html, fragment_plural));
60        }
61
62        parts.join("; ")
63    }
64}
65
66/// Replaces characters that cannot be encoded in the given font with '?',
67/// tallying them in the report. Newlines are preserved. All other unencodable
68/// or width-unmeasurable characters are replaced.
69pub(crate) fn sanitize(text: &str, font: Standard14, report: &mut Report) -> String {
70    text.chars()
71        .map(|ch| {
72            if ch == '\n' {
73                return ch;
74            }
75            let mut buffer = [0u8; 4];
76            let encoded = font.encode(ch.encode_utf8(&mut buffer)).is_ok();
77            if encoded && font.width(ch).is_some() {
78                return ch;
79            }
80            *report.replaced.entry(ch).or_insert(0) += 1;
81            '?'
82        })
83        .collect()
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn replaces_unencodable_chars_and_tallies() {
92        let mut report = Report::default();
93        let clean = sanitize("ok 🎉🎉 中", Standard14::Helvetica, &mut report);
94        assert_eq!(clean, "ok ?? ?");
95        assert_eq!(report.replaced.get(&'🎉'), Some(&2));
96        assert_eq!(report.replaced.get(&'中'), Some(&1));
97    }
98
99    #[test]
100    fn newline_survives_as_the_hard_break_marker() {
101        let mut report = Report::default();
102        assert_eq!(sanitize("a\nb", Standard14::Courier, &mut report), "a\nb");
103        assert!(report.is_empty());
104    }
105
106    #[test]
107    fn summary_names_chars_counts_and_html() {
108        let mut report = Report {
109            skipped_html: 1,
110            ..Report::default()
111        };
112        sanitize("中", Standard14::Helvetica, &mut report);
113        let summary = report.summary();
114        assert!(summary.contains("'中'"), "{summary}");
115        assert!(summary.contains("1 raw html fragment"), "{summary}");
116    }
117}