1use 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 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
73pub 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 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 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 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 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 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 quote_lines.push(ql);
214 i += 1;
215 } else {
216 break;
217 }
218 }
219 blocks.push(HtmlBlock::Paragraph {
220 text: clean_markdown_inline("e_lines.join(" ")),
221 });
222 continue;
223 }
224
225 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 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 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 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 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(¶_lines.join(" ")),
336 });
337 } else if !para_lines.is_empty() {
338 push_markdown_paragraph_with_images(
339 ¶_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 } 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(";
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(") 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 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 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 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 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 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 i += close_gt + 1;
898 continue;
899 }
900 }
901
902 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 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 if chars[i] == '~' && i + 1 < chars.len() && chars[i + 1] == '~' {
937 i += 2;
938 continue;
939 }
940
941 if chars[i] == '`' {
943 i += 1;
944 continue;
945 }
946
947 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 \, code ``, real ."#,
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("").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";
986 let expanded = expand_markdown_reference_images(markdown, u64::MAX).unwrap();
987 assert!(expanded.contains(""));
988 assert!(expanded.contains(""));
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}