Skip to main content

document_svg/document/
markdown.rs

1//! Markdown document parser, typography typesetter, and paginated vector SVG renderer.
2//!
3//! Parses CommonMark / GFM markdown constructs (ATX headings, bullet/numbered lists,
4//! fenced code blocks, blockquotes, horizontal rules, tables, and paragraphs),
5//! layouting content into flowing multi-page vector SVG documents.
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{
13    HtmlBlock, InlineHtmlImage, load_local_image_sources, render_blocks_to_pages,
14};
15use crate::error::{Error, Result};
16use crate::table::{TableAlign, TableData};
17
18const MAX_MARKDOWN_IMAGE_REFERENCES: usize = 10_000;
19const MAX_MARKDOWN_LINES: usize = 1_000_000;
20const MAX_MARKDOWN_LINE_BYTES: usize = 1024 * 1024;
21
22pub(crate) fn convert(
23    path: &Path,
24    options: &ConvertOptions,
25    sink: &mut dyn PageConsumer,
26) -> Result<Vec<String>> {
27    let bytes = read_limited_file(path, options.max_input_bytes, "Markdown input")?;
28    let text = String::from_utf8(bytes)
29        .map_err(|e| Error::InvalidInput(format!("Markdown file is not valid UTF-8: {e}")))?;
30    validate_markdown_lines(&text)?;
31    let text = expand_markdown_reference_images(&text, options.max_input_bytes)?;
32
33    // If the file is exclusively a single standalone markdown table,
34    // preserve dedicated single-table layout and embedded source for exact roundtripping.
35    let non_empty: Vec<&str> = text
36        .lines()
37        .map(str::trim)
38        .filter(|l| !l.is_empty())
39        .collect();
40    let is_pure_table = !non_empty.is_empty()
41        && non_empty
42            .iter()
43            .all(|l| l.starts_with('|') && l.contains('|'));
44
45    if is_pure_table && let Ok(table) = crate::table::parse_markdown_table(&text) {
46        let page = crate::table::layout_and_render_table(&table, options)?;
47        sink.consume(page)?;
48        return Ok(Vec::new());
49    }
50
51    let parent = path
52        .parent()
53        .filter(|parent| !parent.as_os_str().is_empty())
54        .unwrap_or_else(|| Path::new("."));
55    let base_dir = fs::canonicalize(parent)?;
56    let (image_sources, too_many_images) = collect_markdown_image_sources(&text);
57    let (inline_images, mut warnings) =
58        load_local_image_sources(&base_dir, image_sources, too_many_images)?;
59    if !inline_images.is_empty() {
60        push_markdown_warning_once(
61            &mut warnings,
62            "Markdown images are rendered as centered flow blocks; inline styling and wrapping are approximated",
63        );
64    }
65    let (blocks, parser_warnings) = parse_markdown_blocks_inner(&text, &inline_images)?;
66    for warning in parser_warnings {
67        push_markdown_warning_once(&mut warnings, &warning);
68    }
69    render_blocks_to_pages(&blocks, sink, options)?;
70    Ok(warnings)
71}
72
73/// Parses Markdown text into a sequence of renderable [`HtmlBlock`] elements.
74pub fn parse_markdown_blocks(text: &str) -> Result<Vec<HtmlBlock>> {
75    let expanded = expand_markdown_reference_images(text, u64::MAX)?;
76    parse_markdown_blocks_with_images(&expanded, &HashMap::new()).map(|(blocks, _)| blocks)
77}
78
79pub(crate) fn parse_markdown_blocks_with_images(
80    text: &str,
81    inline_images: &HashMap<String, InlineHtmlImage>,
82) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
83    validate_markdown_lines(text)?;
84    parse_markdown_blocks_inner(text, inline_images)
85}
86
87fn parse_markdown_blocks_inner(
88    text: &str,
89    inline_images: &HashMap<String, InlineHtmlImage>,
90) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
91    let mut blocks = Vec::new();
92    let mut warnings = Vec::new();
93    let lines: Vec<&str> = text.lines().collect();
94    let mut i = 0;
95    let mut image_count = 0usize;
96
97    while i < lines.len() {
98        let line = lines[i];
99        let trimmed = line.trim();
100
101        if trimmed.is_empty() {
102            i += 1;
103            continue;
104        }
105
106        if let Some((alt, source)) = parse_markdown_image(trimmed) {
107            image_count = image_count.saturating_add(1);
108            if image_count > MAX_MARKDOWN_IMAGE_REFERENCES {
109                push_markdown_warning_once(
110                    &mut warnings,
111                    "Markdown image blocks exceeded the supported limit; remaining images were omitted",
112                );
113            } else if let Some(image) = inline_images.get(&source) {
114                blocks.push(HtmlBlock::Image {
115                    href: image.href.clone(),
116                    pixel_width: image.pixel_width,
117                    pixel_height: image.pixel_height,
118                    alt: clean_markdown_inline(&alt),
119                });
120            } else {
121                push_markdown_warning_once(
122                    &mut warnings,
123                    "Markdown image source was omitted because it was not a validated local PNG/JPEG resource",
124                );
125                if !alt.is_empty() {
126                    blocks.push(HtmlBlock::Paragraph {
127                        text: format!("[Image omitted: {}]", clean_markdown_inline(&alt)),
128                    });
129                }
130            }
131            i += 1;
132            continue;
133        }
134
135        // 1. Fenced code block (``` or ~~~)
136        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
137            let fence = if trimmed.starts_with("```") {
138                "```"
139            } else {
140                "~~~"
141            };
142            i += 1;
143            let mut code_lines = Vec::new();
144            while i < lines.len() {
145                let code_line = lines[i];
146                if code_line.trim().starts_with(fence) {
147                    i += 1;
148                    break;
149                }
150                code_lines.push(code_line);
151                i += 1;
152            }
153            blocks.push(HtmlBlock::CodeBlock {
154                text: code_lines.join("\n"),
155            });
156            continue;
157        }
158
159        // 2. ATX Headings (# Heading)
160        if trimmed.starts_with('#') {
161            let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
162            if hash_count <= 6 {
163                let rest = trimmed[hash_count..].trim();
164                if !rest.is_empty() || trimmed.chars().nth(hash_count) == Some(' ') {
165                    blocks.push(HtmlBlock::Heading {
166                        level: hash_count as u8,
167                        text: clean_markdown_inline(rest),
168                    });
169                    i += 1;
170                    continue;
171                }
172            }
173        }
174
175        // 3. Horizontal Rule (---, ***, ___)
176        if (trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-' || c == ' '))
177            || (trimmed.starts_with("***") && trimmed.chars().all(|c| c == '*' || c == ' '))
178            || (trimmed.starts_with("___") && trimmed.chars().all(|c| c == '_' || c == ' '))
179        {
180            blocks.push(HtmlBlock::HorizontalRule);
181            i += 1;
182            continue;
183        }
184
185        // 4. Markdown Table (| Col 1 | Col 2 |)
186        if trimmed.starts_with('|') && trimmed.contains('|') {
187            let mut table_lines = Vec::new();
188            while i < lines.len() {
189                let tl = lines[i].trim();
190                if tl.starts_with('|') && tl.contains('|') {
191                    table_lines.push(tl);
192                    i += 1;
193                } else {
194                    break;
195                }
196            }
197            if let Ok(table_data) = parse_embedded_table(&table_lines) {
198                blocks.push(HtmlBlock::Table(table_data));
199            }
200            continue;
201        }
202
203        // 5. Blockquote (> quote)
204        if trimmed.starts_with('>') {
205            let mut quote_lines = Vec::new();
206            while i < lines.len() {
207                let ql = lines[i].trim();
208                if let Some(stripped) = ql.strip_prefix('>') {
209                    quote_lines.push(stripped.trim());
210                    i += 1;
211                } else if !ql.is_empty() && !quote_lines.is_empty() {
212                    // Lazy continuation line
213                    quote_lines.push(ql);
214                    i += 1;
215                } else {
216                    break;
217                }
218            }
219            blocks.push(HtmlBlock::Paragraph {
220                text: clean_markdown_inline(&quote_lines.join(" ")),
221            });
222            continue;
223        }
224
225        // 6. Unordered, Task, or Ordered List Item (- item, * item, - [x] task)
226        let indent_spaces = line.chars().take_while(|&c| c == ' ' || c == '\t').count();
227        let depth = (indent_spaces / 2).min(4);
228        let indent_prefix = "  ".repeat(depth);
229
230        if let Some(rest) = trimmed
231            .strip_prefix("- ")
232            .or_else(|| trimmed.strip_prefix("* "))
233            .or_else(|| trimmed.strip_prefix("+ "))
234        {
235            let (bullet, text) = if let Some(t) = rest
236                .strip_prefix("[x] ")
237                .or_else(|| rest.strip_prefix("[X] "))
238            {
239                (format!("{indent_prefix}☑ "), t)
240            } else if let Some(t) = rest.strip_prefix("[ ] ") {
241                (format!("{indent_prefix}☐ "), t)
242            } else {
243                (format!("{indent_prefix}• "), rest)
244            };
245            blocks.push(HtmlBlock::ListItem {
246                bullet,
247                text: clean_markdown_inline(text),
248            });
249            i += 1;
250            continue;
251        }
252
253        if let Some(dot_pos) = trimmed.find(". ") {
254            let prefix = &trimmed[..dot_pos];
255            if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
256                let num = prefix;
257                let text = trimmed[dot_pos + 2..].trim();
258                blocks.push(HtmlBlock::ListItem {
259                    bullet: format!("{indent_prefix}{num}. "),
260                    text: clean_markdown_inline(text),
261                });
262                i += 1;
263                continue;
264            }
265        }
266
267        // 7. Definition list definition line (: Definition description)
268        if let Some(def_text) = trimmed.strip_prefix(": ") {
269            blocks.push(HtmlBlock::ListItem {
270                bullet: format!("{indent_prefix}  • "),
271                text: clean_markdown_inline(def_text),
272            });
273            i += 1;
274            continue;
275        }
276
277        // 8. Footnote definition ([^label]: Footnote text)
278        if trimmed.starts_with("[^")
279            && let Some(colon_pos) = trimmed.find("]:")
280        {
281            let label = &trimmed[2..colon_pos];
282            let note_body = trimmed[colon_pos + 2..].trim();
283            blocks.push(HtmlBlock::ListItem {
284                bullet: format!("[{label}] "),
285                text: clean_markdown_inline(note_body),
286            });
287            i += 1;
288            continue;
289        }
290
291        // 9. Regular Paragraph or Setext Heading
292        let mut para_lines = Vec::new();
293        let mut is_setext = false;
294        let mut setext_level = 0u8;
295
296        while i < lines.len() {
297            let pl = lines[i].trim();
298
299            // Check if current line is a Setext underline for preceding paragraph lines
300            if !para_lines.is_empty() {
301                if pl.chars().all(|c| c == '=') && pl.len() >= 2 {
302                    is_setext = true;
303                    setext_level = 1;
304                    i += 1;
305                    break;
306                } else if pl.chars().all(|c| c == '-') && pl.len() >= 2 {
307                    is_setext = true;
308                    setext_level = 2;
309                    i += 1;
310                    break;
311                }
312            }
313
314            if pl.is_empty()
315                || pl.starts_with('#')
316                || pl.starts_with("```")
317                || pl.starts_with("~~~")
318                || pl.starts_with('>')
319                || pl.starts_with("- ")
320                || pl.starts_with("* ")
321                || pl.starts_with("+ ")
322                || pl.starts_with(": ")
323                || (pl.starts_with("[^") && pl.contains("]:"))
324                || (pl.starts_with('|') && pl.contains('|'))
325            {
326                break;
327            }
328            para_lines.push(pl);
329            i += 1;
330        }
331
332        if is_setext {
333            blocks.push(HtmlBlock::Heading {
334                level: setext_level,
335                text: clean_markdown_inline(&para_lines.join(" ")),
336            });
337        } else if !para_lines.is_empty() {
338            push_markdown_paragraph_with_images(
339                &para_lines.join(" "),
340                inline_images,
341                &mut warnings,
342                &mut blocks,
343                &mut image_count,
344            );
345        }
346    }
347
348    if blocks.is_empty() && !text.trim().is_empty() {
349        blocks.push(HtmlBlock::Paragraph {
350            text: clean_markdown_inline(text.trim()),
351        });
352    }
353
354    Ok((blocks, warnings))
355}
356
357#[derive(Clone, Debug)]
358struct MarkdownImageToken {
359    start: usize,
360    end: usize,
361    alt: String,
362    source: String,
363}
364
365pub(crate) fn collect_markdown_image_sources(text: &str) -> (Vec<String>, bool) {
366    let mut sources = Vec::new();
367    let mut exceeded = false;
368    let lines = text.lines().collect::<Vec<_>>();
369    let mut fenced: Option<u8> = None;
370    for (index, line) in lines.iter().enumerate() {
371        let trimmed = line.trim_start();
372        let marker = trimmed.as_bytes().first().copied();
373        if let Some(fence_char) = fenced {
374            if marker == Some(fence_char)
375                && trimmed
376                    .as_bytes()
377                    .iter()
378                    .take_while(|byte| **byte == fence_char)
379                    .count()
380                    >= 3
381            {
382                fenced = None;
383            }
384            continue;
385        }
386        if let Some(fence_char @ (b'`' | b'~')) = marker
387            && trimmed
388                .as_bytes()
389                .iter()
390                .take_while(|byte| **byte == fence_char)
391                .count()
392                >= 3
393        {
394            fenced = Some(fence_char);
395            continue;
396        }
397        if markdown_images_are_text_only(line)
398            || lines
399                .get(index + 1)
400                .is_some_and(|next| is_setext_underline(next.trim()))
401        {
402            continue;
403        }
404        for token in markdown_image_tokens(line) {
405            if sources.len() < MAX_MARKDOWN_IMAGE_REFERENCES {
406                sources.push(token.source);
407            } else {
408                exceeded = true;
409            }
410        }
411    }
412    (sources, exceeded)
413}
414
415pub(crate) fn expand_markdown_reference_images(text: &str, max_bytes: u64) -> Result<String> {
416    let definitions = markdown_reference_definitions(text);
417    if definitions.is_empty() {
418        return Ok(text.to_owned());
419    }
420    let mut output = String::with_capacity(text.len());
421    let mut fenced = None::<u8>;
422    for line in text.lines() {
423        let trimmed = line.trim_start();
424        let marker = trimmed.as_bytes().first().copied();
425        if let Some(fence) = fenced {
426            if marker == Some(fence)
427                && trimmed
428                    .as_bytes()
429                    .iter()
430                    .take_while(|byte| **byte == fence)
431                    .count()
432                    >= 3
433            {
434                fenced = None;
435            }
436            output.push_str(line);
437        } else if let Some(fence @ (b'`' | b'~')) = marker
438            && trimmed
439                .as_bytes()
440                .iter()
441                .take_while(|byte| **byte == fence)
442                .count()
443                >= 3
444        {
445            fenced = Some(fence);
446            output.push_str(line);
447        } else if parse_markdown_reference_definition(trimmed).is_some() {
448            // Definitions are metadata, not visible paragraph text.
449        } else {
450            output.push_str(&replace_markdown_reference_images(line, &definitions));
451        }
452        output.push('\n');
453        if output.len() as u64 > max_bytes {
454            return Err(Error::LimitExceeded(format!(
455                "expanded Markdown reference images exceed maximum bytes ({max_bytes})"
456            )));
457        }
458    }
459    if !text.ends_with(['\n', '\r']) {
460        output.pop();
461    }
462    validate_markdown_lines(&output)?;
463    Ok(output)
464}
465
466fn markdown_reference_definitions(text: &str) -> HashMap<String, String> {
467    text.lines()
468        .filter_map(parse_markdown_reference_definition)
469        .collect()
470}
471
472fn parse_markdown_reference_definition(line: &str) -> Option<(String, String)> {
473    let line = line.trim();
474    let close = line.find("]:")?;
475    if !line.starts_with("[") || close <= 1 || line.as_bytes().get(close + 2) == Some(&b'[') {
476        return None;
477    }
478    let label = normalize_markdown_reference_label(&line[1..close]);
479    if label.starts_with('^') {
480        return None;
481    }
482    let mut destination = line[close + 2..].trim();
483    if destination.is_empty() {
484        return None;
485    }
486    if let Some(value) = destination.strip_prefix('<') {
487        let end = value.find('>')?;
488        destination = &value[..end];
489    } else {
490        destination = destination.split_whitespace().next()?;
491    }
492    (!label.is_empty() && !destination.is_empty()).then(|| (label, destination.to_owned()))
493}
494
495fn normalize_markdown_reference_label(label: &str) -> String {
496    label
497        .split_whitespace()
498        .collect::<Vec<_>>()
499        .join(" ")
500        .to_ascii_lowercase()
501}
502
503fn replace_markdown_reference_images(line: &str, definitions: &HashMap<String, String>) -> String {
504    let mut output = String::with_capacity(line.len());
505    let mut cursor = 0usize;
506    while let Some(relative_start) = line.get(cursor..).and_then(|rest| rest.find("![")) {
507        let start = cursor + relative_start;
508        output.push_str(&line[cursor..start]);
509        if is_escaped_markdown_marker(line, start) || is_inside_markdown_code_span(line, start) {
510            output.push_str("![");
511            cursor = start + 2;
512            continue;
513        }
514        let Some(alt_end_relative) = line.get(start + 2..).and_then(|rest| rest.find(']')) else {
515            output.push_str(&line[start..]);
516            break;
517        };
518        let alt_end = start + 2 + alt_end_relative;
519        let alt = &line[start + 2..alt_end];
520        let mut reference_end = alt_end + 1;
521        if line.as_bytes().get(reference_end) == Some(&b'(') {
522            output.push_str(&line[start..reference_end]);
523            cursor = reference_end;
524            continue;
525        }
526        let label = if line.as_bytes().get(reference_end) == Some(&b'[') {
527            let Some(close_relative) = line
528                .get(reference_end + 1..)
529                .and_then(|rest| rest.find(']'))
530            else {
531                output.push_str(&line[start..]);
532                break;
533            };
534            let close = reference_end + 1 + close_relative;
535            let label = &line[reference_end + 1..close];
536            reference_end = close + 1;
537            if label.is_empty() { alt } else { label }
538        } else {
539            alt
540        };
541        let key = normalize_markdown_reference_label(label);
542        if let Some(destination) = definitions.get(&key) {
543            output.push_str("![");
544            output.push_str(alt);
545            output.push_str("](<");
546            output.push_str(destination);
547            output.push_str(">)");
548            cursor = reference_end;
549        } else {
550            output.push_str(&line[start..reference_end]);
551            cursor = reference_end;
552        }
553    }
554    if cursor < line.len() {
555        output.push_str(&line[cursor..]);
556    }
557    output
558}
559
560fn validate_markdown_lines(text: &str) -> Result<()> {
561    for (index, line) in text.lines().enumerate() {
562        if index >= MAX_MARKDOWN_LINES {
563            return Err(Error::LimitExceeded(format!(
564                "Markdown input exceeds {MAX_MARKDOWN_LINES} lines"
565            )));
566        }
567        if line.len() > MAX_MARKDOWN_LINE_BYTES {
568            return Err(Error::LimitExceeded(format!(
569                "Markdown line {} exceeds {MAX_MARKDOWN_LINE_BYTES} bytes",
570                index + 1
571            )));
572        }
573    }
574    Ok(())
575}
576
577fn parse_markdown_image(line: &str) -> Option<(String, String)> {
578    let line = line.trim();
579    let tokens = markdown_image_tokens(line);
580    let token = tokens.first()?;
581    (tokens.len() == 1 && token.start == 0 && token.end == line.len())
582        .then(|| (token.alt.clone(), token.source.clone()))
583}
584
585fn markdown_image_tokens(text: &str) -> Vec<MarkdownImageToken> {
586    let mut tokens = Vec::new();
587    let mut cursor = 0usize;
588    while let Some(relative_start) = text.get(cursor..).and_then(|rest| rest.find("![")) {
589        let start = cursor + relative_start;
590        if is_escaped_markdown_marker(text, start) || is_inside_markdown_code_span(text, start) {
591            cursor = start + 2;
592            continue;
593        }
594        let Some(relative_alt_end) = text.get(start + 2..).and_then(|rest| rest.find("](")) else {
595            break;
596        };
597        let alt_end = start + 2 + relative_alt_end;
598        let destination_start = alt_end + 2;
599        let Some(relative_end) = text
600            .get(destination_start..)
601            .and_then(|rest| rest.find(')'))
602        else {
603            break;
604        };
605        let close = destination_start + relative_end;
606        let raw_destination = text[destination_start..close].trim();
607        let destination = if let Some(bracketed) = raw_destination.strip_prefix('<') {
608            let Some(close_angle) = bracketed.find('>') else {
609                cursor = start + 2;
610                continue;
611            };
612            bracketed[..close_angle].trim()
613        } else {
614            raw_destination
615                .split_whitespace()
616                .next()
617                .unwrap_or_default()
618        };
619        if destination.is_empty() || destination.contains(['(', ')']) {
620            cursor = start + 2;
621            continue;
622        }
623        tokens.push(MarkdownImageToken {
624            start,
625            end: close + 1,
626            alt: text[start + 2..alt_end].to_owned(),
627            source: destination.to_owned(),
628        });
629        if tokens.len() > MAX_MARKDOWN_IMAGE_REFERENCES {
630            break;
631        }
632        cursor = close + 1;
633    }
634    tokens
635}
636
637fn is_escaped_markdown_marker(text: &str, index: usize) -> bool {
638    let slashes = text[..index]
639        .bytes()
640        .rev()
641        .take_while(|byte| *byte == b'\\')
642        .count();
643    slashes % 2 == 1
644}
645
646fn is_inside_markdown_code_span(text: &str, index: usize) -> bool {
647    let bytes = text.as_bytes();
648    let mut open_length = None;
649    let mut cursor = 0usize;
650    while cursor < index {
651        if bytes[cursor] != b'`' {
652            cursor += 1;
653            continue;
654        }
655        let start = cursor;
656        while cursor < index && bytes[cursor] == b'`' {
657            cursor += 1;
658        }
659        let length = cursor - start;
660        if open_length == Some(length) {
661            open_length = None;
662        } else if open_length.is_none() {
663            open_length = Some(length);
664        }
665    }
666    open_length.is_some()
667}
668
669fn markdown_images_are_text_only(line: &str) -> bool {
670    let trimmed = line.trim_start();
671    trimmed.starts_with('#')
672        || trimmed.starts_with('>')
673        || trimmed.starts_with("- ")
674        || trimmed.starts_with("* ")
675        || trimmed.starts_with("+ ")
676        || trimmed.starts_with(": ")
677        || trimmed.starts_with('|')
678        || (trimmed.starts_with("[^") && trimmed.contains("]:"))
679        || trimmed
680            .find(". ")
681            .is_some_and(|dot| dot > 0 && trimmed[..dot].bytes().all(|byte| byte.is_ascii_digit()))
682}
683
684fn is_setext_underline(line: &str) -> bool {
685    line.len() >= 2
686        && (line.chars().all(|character| character == '=')
687            || line.chars().all(|character| character == '-'))
688}
689
690fn push_markdown_paragraph_with_images(
691    text: &str,
692    inline_images: &HashMap<String, InlineHtmlImage>,
693    warnings: &mut Vec<String>,
694    blocks: &mut Vec<HtmlBlock>,
695    image_count: &mut usize,
696) {
697    let mut cursor = 0usize;
698    for token in markdown_image_tokens(text) {
699        if token.start > cursor {
700            push_markdown_paragraph_fragment(&text[cursor..token.start], blocks);
701        }
702        *image_count = image_count.saturating_add(1);
703        if *image_count > MAX_MARKDOWN_IMAGE_REFERENCES {
704            push_markdown_warning_once(
705                warnings,
706                "Markdown image references exceeded the supported limit; remaining images were omitted",
707            );
708        } else if let Some(image) = inline_images.get(&token.source) {
709            blocks.push(HtmlBlock::Image {
710                href: image.href.clone(),
711                pixel_width: image.pixel_width,
712                pixel_height: image.pixel_height,
713                alt: clean_markdown_inline(&token.alt),
714            });
715        } else {
716            push_markdown_warning_once(
717                warnings,
718                "Markdown image source was omitted because it was not a validated local PNG/JPEG resource",
719            );
720            if !token.alt.is_empty() {
721                blocks.push(HtmlBlock::Paragraph {
722                    text: format!("[Image omitted: {}]", clean_markdown_inline(&token.alt)),
723                });
724            }
725        }
726        cursor = token.end;
727    }
728    if cursor < text.len() {
729        push_markdown_paragraph_fragment(&text[cursor..], blocks);
730    }
731}
732
733fn push_markdown_paragraph_fragment(text: &str, blocks: &mut Vec<HtmlBlock>) {
734    let text = clean_markdown_inline(text);
735    if !text.trim().is_empty() {
736        blocks.push(HtmlBlock::Paragraph { text });
737    }
738}
739
740fn push_markdown_warning_once(warnings: &mut Vec<String>, warning: &str) {
741    if !warnings.iter().any(|existing| existing == warning) {
742        warnings.push(warning.to_owned());
743    }
744}
745
746fn parse_embedded_table(lines: &[&str]) -> Result<TableData> {
747    if lines.is_empty() {
748        return Err(Error::InvalidInput("empty table lines".into()));
749    }
750
751    let headers = parse_pipe_row(lines[0]);
752    let mut alignments = vec![TableAlign::Left; headers.len()];
753    let mut rows = Vec::new();
754    let mut start_row = 1;
755
756    if lines.len() > 1 {
757        let sep_cells = parse_pipe_row(lines[1]);
758        let is_sep = !sep_cells.is_empty()
759            && sep_cells.iter().all(|c| {
760                let t = c.trim();
761                !t.is_empty() && t.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ')
762            });
763
764        if is_sep {
765            alignments.clear();
766            for cell in &sep_cells {
767                let t = cell.trim();
768                let align = if t.starts_with(':') && t.ends_with(':') {
769                    TableAlign::Center
770                } else if t.ends_with(':') {
771                    TableAlign::Right
772                } else {
773                    TableAlign::Left
774                };
775                alignments.push(align);
776            }
777            while alignments.len() < headers.len() {
778                alignments.push(TableAlign::Left);
779            }
780            start_row = 2;
781        }
782    }
783
784    for line in &lines[start_row..] {
785        let row = parse_pipe_row(line);
786        if !row.is_empty() {
787            rows.push(row);
788        }
789    }
790
791    Ok(TableData {
792        headers,
793        rows,
794        alignments,
795        raw_source: lines.join("\n"),
796    })
797}
798
799fn parse_pipe_row(line: &str) -> Vec<String> {
800    crate::table::split_markdown_row(line)
801        .into_iter()
802        .map(|cell| clean_markdown_inline(cell.trim()))
803        .collect()
804}
805
806pub fn clean_markdown_inline(text: &str) -> String {
807    let mut out = String::with_capacity(text.len());
808    let chars: Vec<char> = text.chars().collect();
809    let mut i = 0;
810
811    while i < chars.len() {
812        // Escaped characters: \* \_ \[ \] \( \) \# \| \~ \` \\ -> literal character
813        if chars[i] == '\\' && i + 1 < chars.len() && "*_[]()#|~`\\".contains(chars[i + 1]) {
814            out.push(chars[i + 1]);
815            i += 2;
816            continue;
817        }
818
819        // Image ![alt](url) -> alt
820        if chars[i] == '!'
821            && i + 1 < chars.len()
822            && chars[i + 1] == '['
823            && let Some(close_bracket) = chars[i + 1..].iter().position(|&c| c == ']')
824        {
825            let bracket_end = i + 1 + close_bracket;
826            if bracket_end + 1 < chars.len()
827                && chars[bracket_end + 1] == '('
828                && let Some(close_paren) = chars[bracket_end + 1..].iter().position(|&c| c == ')')
829            {
830                let paren_end = bracket_end + 1 + close_paren;
831                let label: String = chars[i + 2..bracket_end].iter().collect();
832                out.push_str(&clean_markdown_inline(&label));
833                i = paren_end + 1;
834                continue;
835            }
836        }
837
838        // Footnote reference [^1] or [^label] -> [1] or [label]
839        if chars[i] == '['
840            && i + 1 < chars.len()
841            && chars[i + 1] == '^'
842            && let Some(close_bracket) = chars[i..].iter().position(|&c| c == ']')
843        {
844            let bracket_end = i + close_bracket;
845            let note_label: String = chars[i + 2..bracket_end].iter().collect();
846            out.push_str(&format!("[{note_label}]"));
847            i = bracket_end + 1;
848            continue;
849        }
850
851        // Link [label](url) -> label
852        if chars[i] == '['
853            && let Some(close_bracket) = chars[i..].iter().position(|&c| c == ']')
854        {
855            let bracket_end = i + close_bracket;
856            if bracket_end + 1 < chars.len()
857                && chars[bracket_end + 1] == '('
858                && let Some(close_paren) = chars[bracket_end + 1..].iter().position(|&c| c == ')')
859            {
860                let paren_end = bracket_end + 1 + close_paren;
861                let label: String = chars[i + 1..bracket_end].iter().collect();
862                out.push_str(&clean_markdown_inline(&label));
863                i = paren_end + 1;
864                continue;
865            }
866        }
867
868        // HTML tags or autolinks <...>
869        if chars[i] == '<'
870            && let Some(close_gt) = chars[i..].iter().position(|&c| c == '>')
871        {
872            let tag_content: String = chars[i + 1..i + close_gt].iter().collect();
873            let tag_lower = tag_content.to_ascii_lowercase();
874            let tag_trimmed = tag_lower.trim();
875            if tag_trimmed == "br" || tag_trimmed == "br/" || tag_trimmed == "br /" {
876                out.push('\n');
877                i += close_gt + 1;
878                continue;
879            } else if tag_trimmed.starts_with("http://")
880                || tag_trimmed.starts_with("https://")
881                || tag_trimmed.starts_with("mailto:")
882            {
883                out.push_str(&tag_content);
884                i += close_gt + 1;
885                continue;
886            } else if tag_trimmed.chars().all(|c| {
887                c.is_ascii_alphanumeric()
888                    || c == '/'
889                    || c == ' '
890                    || c == '-'
891                    || c == '_'
892                    || c == '"'
893                    || c == '='
894                    || c == ':'
895            }) {
896                // Strip HTML tag
897                i += close_gt + 1;
898                continue;
899            }
900        }
901
902        // Common HTML entities: &amp;, &lt;, &gt;, &quot;, &#39;, &nbsp;, &copy;, &mdash;, &ndash;
903        if chars[i] == '&'
904            && let Some(semi) = chars[i..].iter().position(|&c| c == ';')
905            && semi <= 8
906        {
907            let entity: String = chars[i + 1..i + semi].iter().collect();
908            let decoded = match entity.as_str() {
909                "amp" => Some("&"),
910                "lt" => Some("<"),
911                "gt" => Some(">"),
912                "quot" => Some("\""),
913                "apos" | "#39" => Some("'"),
914                "nbsp" => Some(" "),
915                "copy" => Some("©"),
916                "mdash" => Some("—"),
917                "ndash" => Some("–"),
918                _ => None,
919            };
920            if let Some(dec) = decoded {
921                out.push_str(dec);
922                i += semi + 1;
923                continue;
924            }
925        }
926
927        // Bold: ** or __
928        if (chars[i] == '*' && i + 1 < chars.len() && chars[i + 1] == '*')
929            || (chars[i] == '_' && i + 1 < chars.len() && chars[i + 1] == '_')
930        {
931            i += 2;
932            continue;
933        }
934
935        // Strikethrough: ~~
936        if chars[i] == '~' && i + 1 < chars.len() && chars[i + 1] == '~' {
937            i += 2;
938            continue;
939        }
940
941        // Inline code: `
942        if chars[i] == '`' {
943            i += 1;
944            continue;
945        }
946
947        // Single italic delimiter: * or _ (when flanking words)
948        if (chars[i] == '*' || chars[i] == '_')
949            && (i == 0
950                || chars[i - 1].is_whitespace()
951                || i + 1 == chars.len()
952                || chars[i + 1].is_whitespace()
953                || ",.!?;:)\"".contains(chars[i + 1]))
954        {
955            i += 1;
956            continue;
957        }
958
959        out.push(chars[i]);
960        i += 1;
961    }
962
963    out
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn image_tokens_skip_escaped_markers_and_code_spans() {
972        let tokens = markdown_image_tokens(
973            r#"Escaped \![no](ignored.png), code `![no](ignored.png)`, real ![chart](assets/chart.png)."#,
974        );
975        assert_eq!(tokens.len(), 1);
976        assert_eq!(tokens[0].alt, "chart");
977        assert_eq!(tokens[0].source, "assets/chart.png");
978
979        let titled = parse_markdown_image("![photo](<assets/photo.png> \"title\")").unwrap();
980        assert_eq!(titled.1, "assets/photo.png");
981    }
982
983    #[test]
984    fn expands_reference_style_images_without_touching_inline_or_fenced_code() {
985        let markdown = "![Chart][diagram]\n\n[diagram]: assets/chart.png\n\n```\n![code][diagram]\n```\n\n![direct](assets/direct.png)";
986        let expanded = expand_markdown_reference_images(markdown, u64::MAX).unwrap();
987        assert!(expanded.contains("![Chart](<assets/chart.png>)"));
988        assert!(expanded.contains("![direct](assets/direct.png)"));
989        assert!(expanded.contains("![code][diagram]"));
990        assert!(!expanded.contains("[diagram]:"));
991    }
992
993    #[test]
994    fn markdown_line_byte_and_count_limits_are_enforced() {
995        assert!(matches!(
996            validate_markdown_lines(&"x".repeat(MAX_MARKDOWN_LINE_BYTES + 1)),
997            Err(Error::LimitExceeded(_))
998        ));
999        let many_lines = "\n".repeat(MAX_MARKDOWN_LINES + 1);
1000        assert!(matches!(
1001            validate_markdown_lines(&many_lines),
1002            Err(Error::LimitExceeded(_))
1003        ));
1004    }
1005}