Skip to main content

quillmark_core/
normalize.rs

1//! # Input Normalization
2//!
3//! Preprocessing for markdown content before parsing. Handles invisible Unicode
4//! control characters (especially from copy-paste) that interfere with delimiter
5//! recognition, and HTML comment fences that would silently drop trailing text.
6//!
7//! Double chevrons (`<<` and `>>`) are passed through unchanged.
8//!
9//! ## Why normalize bidi characters?
10//!
11//! Unicode bidirectional formatting characters (LRO, RLO, LRE, RLE, etc.) are invisible
12//! and when placed adjacent to markdown delimiters like `**` prevent parsers from
13//! recognizing them:
14//!
15//! ```text
16//! **bold** or <U+202D>**(1234**
17//!             ^^^^^^^^ invisible LRO prevents second ** from being recognized as bold
18//! ```
19//!
20//! These appear commonly when copying from web pages with mixed LTR/RTL content,
21//! PDFs, and word processors.
22
23use crate::document::Card;
24use unicode_normalization::UnicodeNormalization;
25
26#[inline]
27fn is_bidi_char(c: char) -> bool {
28    matches!(
29        c,
30        '\u{061C}' // ARABIC LETTER MARK (ALM)
31        | '\u{200E}' // LEFT-TO-RIGHT MARK (LRM)
32        | '\u{200F}' // RIGHT-TO-LEFT MARK (RLM)
33        | '\u{202A}' // LEFT-TO-RIGHT EMBEDDING (LRE)
34        | '\u{202B}' // RIGHT-TO-LEFT EMBEDDING (RLE)
35        | '\u{202C}' // POP DIRECTIONAL FORMATTING (PDF)
36        | '\u{202D}' // LEFT-TO-RIGHT OVERRIDE (LRO)
37        | '\u{202E}' // RIGHT-TO-LEFT OVERRIDE (RLO)
38        | '\u{2066}' // LEFT-TO-RIGHT ISOLATE (LRI)
39        | '\u{2067}' // RIGHT-TO-LEFT ISOLATE (RLI)
40        | '\u{2068}' // FIRST STRONG ISOLATE (FSI)
41        | '\u{2069}' // POP DIRECTIONAL ISOLATE (PDI)
42    )
43}
44
45/// Strips Unicode bidirectional formatting characters that can interfere with markdown parsing.
46///
47/// Removes all of ALM (U+061C), LRM/RLM (U+200E/F), LRE/RLE/PDF/LRO/RLO
48/// (U+202A–202E), and LRI/RLI/FSI/PDI (U+2066–2069).
49pub fn strip_bidi_formatting(s: &str) -> String {
50    if !s.chars().any(is_bidi_char) {
51        return s.to_string();
52    }
53
54    s.chars().filter(|c| !is_bidi_char(*c)).collect()
55}
56
57/// Inserts a newline after `-->` when followed by non-whitespace content.
58///
59/// CommonMark HTML block type 2 ends with the line containing `-->`, so any
60/// text on the same line after `-->` would be swallowed. This function is
61/// context-aware: only closing fences inside a `<!-- ... -->` pair are fixed;
62/// bare `-->` outside a comment is left untouched.
63pub fn fix_html_comment_fences(s: &str) -> String {
64    if !s.contains("-->") {
65        return s.to_string();
66    }
67
68    let mut result = String::with_capacity(s.len() + 16);
69    let mut current_pos = 0;
70
71    while let Some(open_idx) = s[current_pos..].find("<!--") {
72        let abs_open = current_pos + open_idx;
73
74        if let Some(close_idx) = s[abs_open..].find("-->") {
75            let abs_close = abs_open + close_idx;
76            let mut after_fence = abs_close + 3;
77
78            // Handle `<!--- ... --->` style fences: the extra hyphen is part of
79            // the fence, not leaked trailing text.
80            let opener_has_extra_hyphen = s
81                .get(abs_open + 4..)
82                .is_some_and(|rest| rest.starts_with('-'));
83            if opener_has_extra_hyphen
84                && s.get(after_fence..)
85                    .is_some_and(|rest| rest.starts_with('-'))
86            {
87                after_fence += 1;
88            }
89
90            result.push_str(&s[current_pos..after_fence]);
91
92            let after_content = &s[after_fence..];
93
94            let needs_newline = if after_content.is_empty()
95                || after_content.starts_with('\n')
96                || after_content.starts_with("\r\n")
97            {
98                false
99            } else {
100                let next_newline = after_content.find('\n');
101                let until_newline = match next_newline {
102                    Some(pos) => &after_content[..pos],
103                    None => after_content,
104                };
105                !until_newline.trim().is_empty()
106            };
107
108            if needs_newline {
109                result.push('\n');
110            }
111
112            current_pos = after_fence;
113        } else {
114            // Unclosed comment — append the rest and stop.
115            result.push_str(&s[current_pos..]);
116            current_pos = s.len();
117            break;
118        }
119    }
120
121    if current_pos < s.len() {
122        result.push_str(&s[current_pos..]);
123    }
124
125    result
126}
127
128/// Applies all markdown normalizations in order: CRLF → LF, bidi strip,
129/// HTML comment fence repair.
130pub fn normalize_markdown(markdown: &str) -> String {
131    let cleaned = normalize_line_endings(markdown);
132    let cleaned = strip_bidi_formatting(&cleaned);
133    fix_html_comment_fences(&cleaned)
134}
135
136/// Convert CRLF (`\r\n`) and bare CR (`\r`) line endings to LF (`\n`).
137///
138/// Applied only to the Markdown body (spec §7); YAML scalars are unaffected.
139/// Necessary because YAML parsing normalizes its own scalars but passes the
140/// body verbatim, and some Windows/clipboard sources leave bare `\r` bytes.
141fn normalize_line_endings(s: &str) -> String {
142    if !s.contains('\r') {
143        return s.to_string();
144    }
145    let mut out = String::with_capacity(s.len());
146    let mut chars = s.chars().peekable();
147    while let Some(c) = chars.next() {
148        if c == '\r' {
149            if chars.peek() == Some(&'\n') {
150                chars.next();
151            }
152            out.push('\n');
153        } else {
154            out.push(c);
155        }
156    }
157    out
158}
159
160/// Normalize field name to Unicode NFC, so visually identical keys
161/// (e.g., composed `"café"` vs decomposed `"cafe\u{0301}"`) are treated as equal.
162pub fn normalize_field_name(name: &str) -> String {
163    name.nfc().collect()
164}
165
166/// Primary entry point for normalizing a [`crate::document::Document`] after parsing.
167///
168/// Per-card normalization:
169/// 1. Payload field names → Unicode NFC.
170/// 2. Card body → bidi-stripped + HTML comment fence repair (spec §7).
171///    YAML field *values* pass through verbatim.
172///
173/// Idempotent — calling multiple times produces the same result.
174pub fn normalize_document(
175    doc: crate::document::Document,
176) -> Result<crate::document::Document, crate::error::ParseError> {
177    use crate::document::Document;
178
179    let main = normalize_card(doc.main());
180    let normalized_cards: Vec<Card> = doc.cards().iter().map(normalize_card).collect();
181
182    Ok(Document::from_main_and_cards(
183        main,
184        normalized_cards,
185        doc.warnings().to_vec(),
186    ))
187}
188
189/// Build a new `Card` with NFC-normalized field names and a normalized body.
190fn normalize_card(card: &Card) -> Card {
191    use crate::document::PayloadItem;
192    let mut payload = card.payload().clone();
193    for item in payload.items_mut() {
194        if let PayloadItem::Field { key, .. } = item {
195            let normalized = normalize_field_name(key);
196            if normalized != *key {
197                *key = normalized;
198            }
199        }
200    }
201    Card::from_parts(payload, normalize_markdown(card.body()))
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_strip_bidi_no_change() {
210        assert_eq!(strip_bidi_formatting("hello world"), "hello world");
211        assert_eq!(strip_bidi_formatting(""), "");
212        assert_eq!(strip_bidi_formatting("**bold** text"), "**bold** text");
213    }
214
215    #[test]
216    fn test_strip_bidi_lro() {
217        assert_eq!(strip_bidi_formatting("he\u{202D}llo"), "hello");
218        assert_eq!(
219            strip_bidi_formatting("**asdf** or \u{202D}**(1234**"),
220            "**asdf** or **(1234**"
221        );
222    }
223
224    #[test]
225    fn test_strip_bidi_rlo() {
226        assert_eq!(strip_bidi_formatting("he\u{202E}llo"), "hello");
227    }
228
229    #[test]
230    fn test_strip_bidi_marks() {
231        assert_eq!(strip_bidi_formatting("a\u{200E}b\u{200F}c"), "abc");
232    }
233
234    #[test]
235    fn test_strip_bidi_embeddings() {
236        assert_eq!(
237            strip_bidi_formatting("\u{202A}text\u{202B}more\u{202C}"),
238            "textmore"
239        );
240    }
241
242    #[test]
243    fn test_strip_bidi_isolates() {
244        assert_eq!(
245            strip_bidi_formatting("\u{2066}a\u{2067}b\u{2068}c\u{2069}"),
246            "abc"
247        );
248    }
249
250    #[test]
251    fn test_strip_bidi_all_chars() {
252        let all_bidi = "\u{061C}\u{200E}\u{200F}\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}\u{2066}\u{2067}\u{2068}\u{2069}";
253        assert_eq!(strip_bidi_formatting(all_bidi), "");
254    }
255
256    #[test]
257    fn test_strip_bidi_arabic_letter_mark() {
258        assert_eq!(strip_bidi_formatting("hello\u{061C}world"), "helloworld");
259        assert_eq!(strip_bidi_formatting("\u{061C}**bold**"), "**bold**");
260    }
261
262    #[test]
263    fn test_strip_bidi_unicode_preserved() {
264        assert_eq!(strip_bidi_formatting("你好世界"), "你好世界");
265        assert_eq!(strip_bidi_formatting("مرحبا"), "مرحبا");
266        assert_eq!(strip_bidi_formatting("🎉"), "🎉");
267    }
268
269    #[test]
270    fn test_normalize_markdown_basic() {
271        assert_eq!(normalize_markdown("hello"), "hello");
272        assert_eq!(
273            normalize_markdown("**bold** \u{202D}**more**"),
274            "**bold** **more**"
275        );
276    }
277
278    #[test]
279    fn test_normalize_markdown_html_comment() {
280        assert_eq!(
281            normalize_markdown("<!-- comment -->Some text"),
282            "<!-- comment -->\nSome text"
283        );
284    }
285
286    #[test]
287    fn test_fix_html_comment_no_comment() {
288        assert_eq!(fix_html_comment_fences("hello world"), "hello world");
289        assert_eq!(fix_html_comment_fences("**bold** text"), "**bold** text");
290        assert_eq!(fix_html_comment_fences(""), "");
291    }
292
293    #[test]
294    fn test_fix_html_comment_single_line_trailing_text() {
295        assert_eq!(
296            fix_html_comment_fences("<!-- comment -->Same line text"),
297            "<!-- comment -->\nSame line text"
298        );
299    }
300
301    #[test]
302    fn test_fix_html_comment_already_newline() {
303        assert_eq!(
304            fix_html_comment_fences("<!-- comment -->\nNext line text"),
305            "<!-- comment -->\nNext line text"
306        );
307    }
308
309    #[test]
310    fn test_fix_html_comment_only_whitespace_after() {
311        assert_eq!(
312            fix_html_comment_fences("<!-- comment -->   \nSome text"),
313            "<!-- comment -->   \nSome text"
314        );
315    }
316
317    #[test]
318    fn test_fix_html_comment_multiline_trailing_text() {
319        assert_eq!(
320            fix_html_comment_fences("<!--\nmultiline\ncomment\n-->Trailing text"),
321            "<!--\nmultiline\ncomment\n-->\nTrailing text"
322        );
323    }
324
325    #[test]
326    fn test_fix_html_comment_multiline_proper() {
327        assert_eq!(
328            fix_html_comment_fences("<!--\nmultiline\n-->\n\nParagraph text"),
329            "<!--\nmultiline\n-->\n\nParagraph text"
330        );
331    }
332
333    #[test]
334    fn test_fix_html_comment_multiple_comments() {
335        assert_eq!(
336            fix_html_comment_fences("<!-- first -->Text\n\n<!-- second -->More text"),
337            "<!-- first -->\nText\n\n<!-- second -->\nMore text"
338        );
339    }
340
341    #[test]
342    fn test_fix_html_comment_end_of_string() {
343        assert_eq!(
344            fix_html_comment_fences("Some text before <!-- comment -->"),
345            "Some text before <!-- comment -->"
346        );
347    }
348
349    #[test]
350    fn test_fix_html_comment_only_comment() {
351        assert_eq!(
352            fix_html_comment_fences("<!-- comment -->"),
353            "<!-- comment -->"
354        );
355    }
356
357    #[test]
358    fn test_fix_html_comment_arrow_not_comment() {
359        assert_eq!(fix_html_comment_fences("-->some text"), "-->some text");
360    }
361
362    #[test]
363    fn test_fix_html_comment_nested_opener() {
364        // The first <!-- opens, the first --> closes; inner <!-- is just text.
365        assert_eq!(
366            fix_html_comment_fences("<!-- <!-- -->Trailing"),
367            "<!-- <!-- -->\nTrailing"
368        );
369    }
370
371    #[test]
372    fn test_fix_html_comment_unmatched_closer() {
373        assert_eq!(
374            fix_html_comment_fences("text --> more text"),
375            "text --> more text"
376        );
377    }
378
379    #[test]
380    fn test_fix_html_comment_multiple_valid_invalid() {
381        let input = "<!-- valid -->FixMe\ntext --> Ignore\n<!-- valid2 -->FixMe2";
382        let expected = "<!-- valid -->\nFixMe\ntext --> Ignore\n<!-- valid2 -->\nFixMe2";
383        assert_eq!(fix_html_comment_fences(input), expected);
384    }
385
386    #[test]
387    fn test_fix_html_comment_crlf() {
388        assert_eq!(
389            fix_html_comment_fences("<!-- comment -->\r\nSome text"),
390            "<!-- comment -->\r\nSome text"
391        );
392    }
393
394    #[test]
395    fn test_fix_html_comment_triple_hyphen_single_line() {
396        assert_eq!(
397            fix_html_comment_fences("<!--- comment --->Trailing text"),
398            "<!--- comment --->\nTrailing text"
399        );
400    }
401
402    #[test]
403    fn test_fix_html_comment_triple_hyphen_multiline() {
404        assert_eq!(
405            fix_html_comment_fences("<!---\ncomment\n--->Trailing text"),
406            "<!---\ncomment\n--->\nTrailing text"
407        );
408    }
409
410    #[test]
411    fn test_normalize_document_basic() {
412        use crate::document::Document;
413
414        let doc = Document::from_markdown(
415            "~~~card-yaml\n$quill: test\n$kind: main\ntitle: <<placeholder>>\n~~~\n\n<<content>> \u{202D}**bold**",
416        )
417        .unwrap();
418        let normalized = super::normalize_document(doc).unwrap();
419
420        assert_eq!(
421            normalized
422                .main()
423                .payload()
424                .get("title")
425                .unwrap()
426                .as_str()
427                .unwrap(),
428            "<<placeholder>>"
429        );
430
431        assert_eq!(normalized.main().body(), "\n<<content>> **bold**");
432    }
433
434    #[test]
435    fn test_normalize_document_preserves_quill_tag() {
436        use crate::document::Document;
437
438        let doc = Document::from_markdown("~~~card-yaml\n$quill: custom_quill\n$kind: main\n~~~\n")
439            .unwrap();
440        let normalized = super::normalize_document(doc).unwrap();
441
442        assert_eq!(normalized.quill_reference().name, "custom_quill");
443    }
444
445    #[test]
446    fn test_normalize_document_idempotent() {
447        use crate::document::Document;
448
449        let doc =
450            Document::from_markdown("~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<<content>>")
451                .unwrap();
452        let normalized_once = super::normalize_document(doc).unwrap();
453        let normalized_twice = super::normalize_document(normalized_once.clone()).unwrap();
454
455        assert_eq!(
456            normalized_once.main().body(),
457            normalized_twice.main().body()
458        );
459    }
460
461    #[test]
462    fn test_normalize_document_body_bidi_stripped() {
463        use crate::document::Document;
464
465        let doc = Document::from_markdown(
466            "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\nhello\u{202D}world",
467        )
468        .unwrap();
469        let normalized = super::normalize_document(doc).unwrap();
470        assert_eq!(normalized.main().body(), "\nhelloworld");
471    }
472
473    #[test]
474    fn test_normalize_document_yaml_field_bidi_preserved() {
475        use crate::document::Document;
476
477        let doc = Document::from_markdown(
478            "~~~card-yaml\n$quill: test\n$kind: main\ntitle: a\u{202D}b\n~~~\n",
479        )
480        .unwrap();
481        let normalized = super::normalize_document(doc).unwrap();
482        assert_eq!(
483            normalized
484                .main()
485                .payload()
486                .get("title")
487                .unwrap()
488                .as_str()
489                .unwrap(),
490            "a\u{202D}b"
491        );
492    }
493
494    #[test]
495    fn test_normalize_document_card_body_bidi_stripped() {
496        use crate::document::Document;
497
498        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\nbody\n\n~~~card-yaml\n$kind: note\n~~~\ncard\u{202D}body\n";
499        let doc = Document::from_markdown(md).unwrap();
500        assert_eq!(doc.cards().len(), 1, "expected 1 card");
501        let normalized = super::normalize_document(doc).unwrap();
502        assert_eq!(normalized.cards()[0].body(), "cardbody\n");
503    }
504
505    #[test]
506    fn test_normalize_document_card_field_bidi_preserved() {
507        use crate::document::Document;
508
509        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\nbody\n\n~~~card-yaml\n$kind: note\nname: Ali\u{202D}ce\n~~~\n";
510        let doc = Document::from_markdown(md).unwrap();
511        assert_eq!(doc.cards().len(), 1, "expected 1 card");
512        let normalized = super::normalize_document(doc).unwrap();
513        assert_eq!(
514            normalized.cards()[0]
515                .payload()
516                .get("name")
517                .unwrap()
518                .as_str()
519                .unwrap(),
520            "Ali\u{202D}ce"
521        );
522    }
523
524    #[test]
525    fn test_normalize_document_card_body_html_comment_repair() {
526        use crate::document::Document;
527
528        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n~~~card-yaml\n$kind: note\n~~~\n<!-- comment -->Trailing text\n";
529        let doc = Document::from_markdown(md).unwrap();
530        let normalized = super::normalize_document(doc).unwrap();
531        assert_eq!(
532            normalized.cards()[0].body(),
533            "<!-- comment -->\nTrailing text\n"
534        );
535    }
536
537    #[test]
538    fn test_normalize_document_toplevel_body_html_comment_repair() {
539        use crate::document::Document;
540
541        let md = "~~~card-yaml\n$quill: test\n$kind: main\n~~~\n\n<!-- note -->Content here";
542        let doc = Document::from_markdown(md).unwrap();
543        let normalized = super::normalize_document(doc).unwrap();
544        assert_eq!(normalized.main().body(), "\n<!-- note -->\nContent here");
545    }
546}