1use std::path::Path;
4
5use quick_xml::Reader;
6use quick_xml::events::Event;
7
8use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
9use crate::error::{Error, Result};
10use crate::ir::{
11 IDENTITY, LineCap, LineJoin, Node, Page, Paint, SourceMeta, Stroke, TextAnchor, TextRun,
12};
13
14const MAX_DELIMITED_INPUT_BYTES: u64 = 64 * 1024 * 1024;
15const MAX_DELIMITED_RECORDS: usize = 50_000;
16const MAX_DELIMITED_COLUMNS: usize = 256;
17const MAX_DELIMITED_CELLS: usize = 1_000_000;
18const MAX_DELIMITED_FIELD_BYTES: usize = 64 * 1024;
19const MAX_DELIMITED_RECORD_BYTES: usize = 1024 * 1024;
20const DELIMITED_ROWS_PER_PAGE: usize = 100;
21const DELIMITED_COLUMNS_PER_PAGE: usize = 32;
22const MAX_EMBEDDED_DELIMITED_SOURCE_BYTES: usize = 8 * 1024 * 1024;
23const MAX_RENDERED_CELL_CHARS: usize = 512;
24
25#[derive(Clone, Debug, Default)]
26pub struct TableData {
27 pub headers: Vec<String>,
28 pub rows: Vec<Vec<String>>,
29 pub alignments: Vec<TableAlign>,
30 pub raw_source: String,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
34pub enum TableAlign {
35 #[default]
36 Left,
37 Center,
38 Right,
39}
40
41pub(crate) fn convert(
42 path: &Path,
43 options: &ConvertOptions,
44 sink: &mut dyn PageConsumer,
45) -> Result<Vec<String>> {
46 let bytes = read_limited_file(
47 path,
48 options.max_input_bytes.min(MAX_DELIMITED_INPUT_BYTES),
49 "table input",
50 )?;
51 let text = String::from_utf8(bytes)
52 .map_err(|e| Error::InvalidInput(format!("table file is not valid UTF-8: {e}")))?;
53
54 let ext = path
55 .extension()
56 .and_then(|e| e.to_str())
57 .map(str::to_ascii_lowercase)
58 .unwrap_or_default();
59
60 let (delimiter, format) = match ext.as_str() {
61 "csv" => (Some(','), "csv"),
62 "tsv" | "tab" => (Some('\t'), "tsv"),
63 _ if !text.contains('|') && text.contains('\t') => (Some('\t'), "tsv"),
64 _ if !text.contains('|') && text.contains(',') => (Some(','), "csv"),
65 _ => (None, "markdown_table"),
66 };
67 if let Some(delimiter) = delimiter {
68 let (mut table, mut warnings) = parse_delimited_table_with_warnings(&text, delimiter)?;
69 if table.raw_source.is_empty() {
70 warnings.push("the full CSV/TSV source was not embedded because it spans multiple preview pages or exceeds 8 MiB".into());
71 }
72 convert_table_pages(&mut table, format, options, sink)?;
73 return Ok(warnings);
74 }
75
76 let table = parse_markdown_table(&text)?;
77 let page = layout_and_render_table(&table, options)?;
78 sink.consume(page)?;
79 Ok(Vec::new())
80}
81
82pub fn parse_delimited_table(source: &str, delimiter: char) -> Result<TableData> {
84 parse_delimited_table_with_warnings(source, delimiter).map(|(table, _)| table)
85}
86
87fn parse_delimited_table_with_warnings(
88 source: &str,
89 delimiter: char,
90) -> Result<(TableData, Vec<String>)> {
91 if source.len() as u64 > MAX_DELIMITED_INPUT_BYTES {
92 return Err(Error::LimitExceeded(format!(
93 "delimited table input exceeds {MAX_DELIMITED_INPUT_BYTES} bytes"
94 )));
95 }
96 let parse_source = source.strip_prefix('\u{feff}').unwrap_or(source);
97 let mut rows: Vec<Vec<String>> = Vec::new();
98 let mut current_row: Vec<String> = Vec::new();
99 let mut current_field = String::new();
100 let mut in_quotes = false;
101 let mut after_quote = false;
102 let mut field_started = false;
103 let mut record_started = false;
104 let mut record_bytes = 0usize;
105 let mut records_seen = 0usize;
106 let mut total_cells = 0usize;
107 let mut blank_records = 0usize;
108 let mut chars = parse_source.chars().peekable();
109
110 while let Some(ch) = chars.next() {
111 record_bytes = record_bytes.saturating_add(ch.len_utf8());
112 if record_bytes > MAX_DELIMITED_RECORD_BYTES {
113 return Err(Error::LimitExceeded(format!(
114 "delimited table record exceeds {MAX_DELIMITED_RECORD_BYTES} bytes"
115 )));
116 }
117
118 if in_quotes {
119 if ch == '"' {
120 if chars.peek() == Some(&'"') {
121 chars.next();
122 record_bytes = record_bytes.saturating_add(1);
123 check_delimited_record_bytes(record_bytes)?;
124 push_delimited_character(&mut current_field, '"')?;
125 } else {
126 in_quotes = false;
127 after_quote = true;
128 }
129 } else {
130 validate_delimited_character(ch)?;
131 push_delimited_character(&mut current_field, ch)?;
132 }
133 } else if after_quote {
134 if ch == delimiter {
135 push_delimited_field(&mut current_row, &mut current_field)?;
136 field_started = false;
137 after_quote = false;
138 record_started = true;
139 } else if ch == '\n' || ch == '\r' {
140 if ch == '\r' && chars.peek() == Some(&'\n') {
141 chars.next();
142 record_bytes = record_bytes.saturating_add(1);
143 check_delimited_record_bytes(record_bytes)?;
144 }
145 push_delimited_field(&mut current_row, &mut current_field)?;
146 finish_delimited_record(
147 &mut rows,
148 &mut current_row,
149 &mut total_cells,
150 &mut records_seen,
151 &mut blank_records,
152 record_started,
153 )?;
154 field_started = false;
155 after_quote = false;
156 record_started = false;
157 record_bytes = 0;
158 } else {
159 return Err(Error::InvalidInput(
160 "unexpected character after a closing CSV/TSV quote".into(),
161 ));
162 }
163 } else if ch == '"' {
164 if field_started || !current_field.is_empty() {
165 return Err(Error::InvalidInput(
166 "a quote may only start at the beginning of a CSV/TSV field".into(),
167 ));
168 }
169 in_quotes = true;
170 field_started = true;
171 record_started = true;
172 } else if ch == delimiter {
173 push_delimited_field(&mut current_row, &mut current_field)?;
174 field_started = false;
175 record_started = true;
176 } else if ch == '\n' || ch == '\r' {
177 if ch == '\r' && chars.peek() == Some(&'\n') {
178 chars.next();
179 record_bytes = record_bytes.saturating_add(1);
180 check_delimited_record_bytes(record_bytes)?;
181 }
182 push_delimited_field(&mut current_row, &mut current_field)?;
183 finish_delimited_record(
184 &mut rows,
185 &mut current_row,
186 &mut total_cells,
187 &mut records_seen,
188 &mut blank_records,
189 record_started,
190 )?;
191 field_started = false;
192 record_started = false;
193 record_bytes = 0;
194 } else {
195 validate_delimited_character(ch)?;
196 push_delimited_character(&mut current_field, ch)?;
197 field_started = true;
198 record_started = true;
199 }
200 }
201
202 if in_quotes {
203 return Err(Error::InvalidInput(
204 "CSV/TSV input ended inside a quoted field".into(),
205 ));
206 }
207 if field_started || after_quote || record_started || !current_row.is_empty() {
208 push_delimited_field(&mut current_row, &mut current_field)?;
209 finish_delimited_record(
210 &mut rows,
211 &mut current_row,
212 &mut total_cells,
213 &mut records_seen,
214 &mut blank_records,
215 record_started,
216 )?;
217 }
218
219 if rows.is_empty() {
220 return Err(Error::InvalidInput(
221 "no rows found in delimited table".into(),
222 ));
223 }
224
225 let headers = rows.remove(0);
226 if headers.is_empty() {
227 return Err(Error::InvalidInput(
228 "delimited table has no header fields".into(),
229 ));
230 }
231 let col_count = headers
232 .len()
233 .max(rows.iter().map(Vec::len).max().unwrap_or(0));
234 let ragged_rows = rows.iter().filter(|row| row.len() != headers.len()).count();
235
236 let mut alignments = vec![TableAlign::Left; col_count];
237 for (column, alignment) in alignments.iter_mut().enumerate() {
238 let is_numeric = !rows.is_empty()
239 && rows
240 .iter()
241 .all(|row| row.get(column).is_none_or(|value| is_numeric_cell(value)));
242 if is_numeric {
243 *alignment = TableAlign::Right;
244 }
245 }
246
247 let row_groups = rows.len().max(1).div_ceil(DELIMITED_ROWS_PER_PAGE);
248 let column_groups = col_count.div_ceil(DELIMITED_COLUMNS_PER_PAGE);
249 let page_count = row_groups.saturating_mul(column_groups);
250 let raw_source = if page_count == 1 && source.len() <= MAX_EMBEDDED_DELIMITED_SOURCE_BYTES {
251 source.to_owned()
252 } else {
253 String::new()
254 };
255 let mut warnings = Vec::new();
256 if blank_records > 0 {
257 warnings.push(format!(
258 "{blank_records} empty CSV/TSV record(s) were skipped"
259 ));
260 }
261 if ragged_rows > 0 {
262 warnings.push(format!(
263 "{ragged_rows} CSV/TSV record(s) have a different field count than the header; missing cells are blank and extra columns have empty headers"
264 ));
265 }
266
267 Ok((
268 TableData {
269 headers,
270 rows,
271 alignments,
272 raw_source,
273 },
274 warnings,
275 ))
276}
277
278fn push_delimited_character(field: &mut String, character: char) -> Result<()> {
279 if field.len().saturating_add(character.len_utf8()) > MAX_DELIMITED_FIELD_BYTES {
280 return Err(Error::LimitExceeded(format!(
281 "delimited table field exceeds {MAX_DELIMITED_FIELD_BYTES} bytes"
282 )));
283 }
284 field.push(character);
285 Ok(())
286}
287
288fn check_delimited_record_bytes(record_bytes: usize) -> Result<()> {
289 if record_bytes > MAX_DELIMITED_RECORD_BYTES {
290 return Err(Error::LimitExceeded(format!(
291 "delimited table record exceeds {MAX_DELIMITED_RECORD_BYTES} bytes"
292 )));
293 }
294 Ok(())
295}
296
297fn validate_delimited_character(character: char) -> Result<()> {
298 let codepoint = character as u32;
299 if (character.is_control() && !matches!(character, '\t' | '\r' | '\n'))
300 || matches!(codepoint, 0xFFFE | 0xFFFF)
301 {
302 return Err(Error::InvalidInput(format!(
303 "delimited table contains a forbidden control character U+{:04X}",
304 codepoint
305 )));
306 }
307 Ok(())
308}
309
310fn push_delimited_field(row: &mut Vec<String>, field: &mut String) -> Result<()> {
311 if row.len() >= MAX_DELIMITED_COLUMNS {
312 return Err(Error::LimitExceeded(format!(
313 "delimited table exceeds {MAX_DELIMITED_COLUMNS} columns"
314 )));
315 }
316 row.push(std::mem::take(field));
317 Ok(())
318}
319
320fn finish_delimited_record(
321 rows: &mut Vec<Vec<String>>,
322 row: &mut Vec<String>,
323 total_cells: &mut usize,
324 records_seen: &mut usize,
325 blank_records: &mut usize,
326 record_started: bool,
327) -> Result<()> {
328 *records_seen = records_seen.saturating_add(1);
329 if *records_seen > MAX_DELIMITED_RECORDS {
330 return Err(Error::LimitExceeded(format!(
331 "delimited table exceeds {MAX_DELIMITED_RECORDS} records including blank lines"
332 )));
333 }
334 if !record_started && row.iter().all(String::is_empty) {
335 *blank_records = blank_records.saturating_add(1);
336 row.clear();
337 return Ok(());
338 }
339 *total_cells = total_cells
340 .checked_add(row.len())
341 .ok_or_else(|| Error::LimitExceeded("delimited table cell count overflowed".into()))?;
342 if *total_cells > MAX_DELIMITED_CELLS {
343 return Err(Error::LimitExceeded(format!(
344 "delimited table exceeds {MAX_DELIMITED_CELLS} cells"
345 )));
346 }
347 rows.push(std::mem::take(row));
348 Ok(())
349}
350
351pub(crate) fn convert_table_pages(
352 table: &mut TableData,
353 format: &str,
354 options: &ConvertOptions,
355 sink: &mut dyn PageConsumer,
356) -> Result<()> {
357 let col_count = table
358 .headers
359 .len()
360 .max(table.rows.iter().map(Vec::len).max().unwrap_or(0));
361 let row_groups = table.rows.len().max(1).div_ceil(DELIMITED_ROWS_PER_PAGE);
362 let column_groups = col_count.div_ceil(DELIMITED_COLUMNS_PER_PAGE);
363 let page_count = row_groups
364 .checked_mul(column_groups)
365 .ok_or_else(|| Error::LimitExceeded("delimited table page count overflowed".into()))?;
366 if page_count == 0 || page_count > options.max_pages {
367 return Err(Error::LimitExceeded(format!(
368 "delimited table requires {page_count} pages; maximum is {}",
369 options.max_pages
370 )));
371 }
372
373 let mut page_number = 1usize;
374 let row_chunks: Vec<&[Vec<String>]> = if table.rows.is_empty() {
375 vec![&[]]
376 } else {
377 table.rows.chunks(DELIMITED_ROWS_PER_PAGE).collect()
378 };
379 for (row_group, rows) in row_chunks.iter().enumerate() {
380 let first_row = row_group * DELIMITED_ROWS_PER_PAGE + 1;
381 let last_row = (first_row + rows.len().saturating_sub(1)).min(table.rows.len());
382 for column_group in 0..column_groups {
383 let column_start = column_group * DELIMITED_COLUMNS_PER_PAGE;
384 let column_end = (column_start + DELIMITED_COLUMNS_PER_PAGE).min(col_count);
385 let headers = (column_start..column_end)
386 .map(|column| table.headers.get(column).cloned().unwrap_or_default())
387 .collect();
388 let chunk_rows = rows
389 .iter()
390 .map(|row| {
391 (column_start..column_end)
392 .map(|column| row.get(column).cloned().unwrap_or_default())
393 .collect()
394 })
395 .collect();
396 let alignments = (column_start..column_end)
397 .map(|column| {
398 table
399 .alignments
400 .get(column)
401 .copied()
402 .unwrap_or(TableAlign::Left)
403 })
404 .collect();
405 let page_table = TableData {
406 headers,
407 rows: chunk_rows,
408 alignments,
409 raw_source: if page_number == 1 {
410 std::mem::take(&mut table.raw_source)
411 } else {
412 String::new()
413 },
414 };
415 let mut page = layout_and_render_table(&page_table, options)?;
416 page.number = page_number;
417 page.source_format = format.into();
418 page.title = if column_groups == 1 {
419 format!("{} Table", format.to_ascii_uppercase())
420 } else {
421 format!(
422 "{} Table — columns {}–{}",
423 format.to_ascii_uppercase(),
424 column_start + 1,
425 column_end
426 )
427 };
428 page.description = if rows.is_empty() {
429 format!(
430 "{} table with headers and no data rows",
431 format.to_ascii_uppercase()
432 )
433 } else {
434 format!(
435 "{} table rows {first_row}–{last_row} of {}, columns {}–{}",
436 format.to_ascii_uppercase(),
437 table.rows.len(),
438 column_start + 1,
439 column_end
440 )
441 };
442 sink.consume(page)?;
443 page_number += 1;
444 }
445 }
446 Ok(())
447}
448
449pub fn parse_markdown_table(source: &str) -> Result<TableData> {
451 let mut headers = Vec::new();
452 let mut alignments = Vec::new();
453 let mut rows = Vec::new();
454
455 let mut state = 0; for line in source.lines() {
458 let line = line.trim();
459 if line.is_empty() || !line.contains('|') {
460 continue;
461 }
462
463 let cells = split_markdown_row(line);
464 if cells.is_empty() {
465 continue;
466 }
467
468 match state {
469 0 => {
470 headers = cells;
471 state = 1;
472 }
473 1 => {
474 let is_separator = cells.iter().all(|c| {
475 let trimmed = c.trim();
476 !trimmed.is_empty()
477 && trimmed.contains('-')
478 && trimmed
479 .chars()
480 .all(|ch| ch == '-' || ch == ':' || ch == ' ')
481 });
482
483 if is_separator {
484 for cell in &cells {
485 let c = cell.trim();
486 let align = if c.starts_with(':') && c.ends_with(':') {
487 TableAlign::Center
488 } else if c.ends_with(':') {
489 TableAlign::Right
490 } else {
491 TableAlign::Left
492 };
493 alignments.push(align);
494 }
495 while alignments.len() < headers.len() {
496 alignments.push(TableAlign::Left);
497 }
498 state = 2;
499 } else {
500 alignments = vec![TableAlign::Left; headers.len()];
501 rows.push(cells);
502 state = 2;
503 }
504 }
505 2 => {
506 rows.push(cells);
507 }
508 _ => {}
509 }
510 }
511
512 if headers.is_empty() && rows.is_empty() {
513 return Err(Error::InvalidInput(
514 "no table content found in markdown".into(),
515 ));
516 }
517
518 for (c, align) in alignments.iter_mut().enumerate().take(headers.len()) {
520 if *align == TableAlign::Left {
521 let is_numeric = !rows.is_empty()
522 && rows.iter().all(|r| {
523 if let Some(val) = r.get(c) {
524 is_numeric_cell(val)
525 } else {
526 true
527 }
528 });
529 if is_numeric {
530 *align = TableAlign::Right;
531 }
532 }
533 }
534
535 Ok(TableData {
536 headers,
537 rows,
538 alignments,
539 raw_source: source.to_string(),
540 })
541}
542
543pub(crate) fn is_numeric_cell(val: &str) -> bool {
546 let t = val.trim();
547 if t.is_empty() {
548 return true;
549 }
550 let s = if t.starts_with('(') && t.ends_with(')') && t.len() > 2 {
552 &t[1..t.len() - 1]
553 } else {
554 t
555 };
556 let s = s.trim();
557
558 let s = s
560 .strip_prefix('$')
561 .or_else(|| s.strip_prefix('€'))
562 .or_else(|| s.strip_prefix('£'))
563 .or_else(|| s.strip_prefix('¥'))
564 .or_else(|| s.strip_prefix('₩'))
565 .or_else(|| s.strip_prefix('₹'))
566 .unwrap_or(s);
567 let s = s.trim();
568
569 let s = s
571 .strip_suffix('%')
572 .or_else(|| s.strip_suffix('$'))
573 .or_else(|| s.strip_suffix('€'))
574 .or_else(|| s.strip_suffix('£'))
575 .or_else(|| s.strip_suffix('¥'))
576 .unwrap_or(s);
577 let s = s.trim();
578
579 if !s.chars().any(|c| c.is_ascii_digit()) {
581 return false;
582 }
583
584 let cleaned: String = s.chars().filter(|&c| c != ',').collect();
586 cleaned.parse::<f64>().is_ok()
587}
588
589pub(crate) fn split_markdown_row(line: &str) -> Vec<String> {
590 let mut cells = Vec::new();
591 let mut current = String::new();
592 let mut chars = line.chars().peekable();
593
594 while let Some(&c) = chars.peek() {
596 if c.is_whitespace() {
597 chars.next();
598 } else if c == '|' {
599 chars.next();
600 break;
601 } else {
602 break;
603 }
604 }
605
606 let mut in_code = false;
607 while let Some(c) = chars.next() {
608 if c == '`' {
609 in_code = !in_code;
610 current.push(c);
611 } else if c == '\\' && chars.peek() == Some(&'|') {
612 chars.next();
614 current.push('|');
615 } else if c == '|' && !in_code {
616 cells.push(current.trim().to_string());
617 current.clear();
618 } else {
619 current.push(c);
620 }
621 }
622
623 let trimmed = current.trim();
624 if !trimmed.is_empty() {
625 cells.push(trimmed.to_string());
626 }
627
628 cells
629}
630
631pub fn layout_and_render_table(table: &TableData, _options: &ConvertOptions) -> Result<Page> {
633 let col_count = table
634 .headers
635 .len()
636 .max(table.rows.iter().map(|r| r.len()).max().unwrap_or(0))
637 .max(1);
638
639 let font_size = 12.0;
640 let header_font_size = 12.5;
641 let padding_x = 16.0;
642 let cell_height = 36.0;
643 let header_height = 40.0;
644 let page_margin = 32.0;
645
646 let mut col_widths = vec![80.0f64; col_count];
647 for (c, h) in table.headers.iter().enumerate() {
648 if c < col_count {
649 let width = estimate_cell_text_width(h, header_font_size * 0.65) + padding_x * 2.0;
650 col_widths[c] = col_widths[c].max(width);
651 }
652 }
653 for row in &table.rows {
654 for (c, cell) in row.iter().enumerate() {
655 if c < col_count {
656 let cell_clean = sanitize_cell_text(cell);
657 let width =
658 estimate_cell_text_width(&cell_clean, font_size * 0.62) + padding_x * 2.0;
659 col_widths[c] = col_widths[c].max(width);
660 }
661 }
662 }
663
664 let total_table_width: f64 = col_widths.iter().sum();
665 let total_table_height = header_height + (table.rows.len() as f64 * cell_height);
666 let page_width = total_table_width + page_margin * 2.0;
667 let page_height = total_table_height + page_margin * 2.0;
668
669 let mut page = Page::new(1, page_width, page_height, "markdown_table");
670 let cell_text_was_truncated = table
671 .headers
672 .iter()
673 .any(|text| cell_text_is_truncated(text))
674 || table
675 .rows
676 .iter()
677 .flatten()
678 .any(|text| cell_text_is_truncated(text));
679 if cell_text_was_truncated {
680 page.warn(format!(
681 "table cell text longer than {MAX_RENDERED_CELL_CHARS} characters was truncated in the visual preview"
682 ));
683 }
684 if !table.raw_source.is_empty() {
685 page.embedded_source = Some(table.raw_source.clone());
686 }
687
688 let start_x = page_margin;
689 let start_y = page_margin;
690
691 let header_d = format!(
693 "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
694 start_x,
695 start_y,
696 start_x + total_table_width,
697 start_y,
698 start_x + total_table_width,
699 start_y + header_height,
700 start_x,
701 start_y + header_height
702 );
703 page.nodes.push(Node::Path {
704 id: String::new(),
705 d: header_d,
706 fill_rule: String::new(),
707 fill: Paint::solid("#f8fafc"),
708 stroke: Stroke::default(),
709 transform: IDENTITY,
710 clip_id: None,
711 meta: SourceMeta::default(),
712 });
713
714 for (row_idx, _) in table.rows.iter().enumerate() {
716 if row_idx % 2 == 1 {
717 let row_y = start_y + header_height + (row_idx as f64 * cell_height);
718 let row_d = format!(
719 "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
720 start_x,
721 row_y,
722 start_x + total_table_width,
723 row_y,
724 start_x + total_table_width,
725 row_y + cell_height,
726 start_x,
727 row_y + cell_height
728 );
729 page.nodes.push(Node::Path {
730 id: String::new(),
731 d: row_d,
732 fill_rule: String::new(),
733 fill: Paint::solid("#f1f5f9"),
734 stroke: Stroke::default(),
735 transform: IDENTITY,
736 clip_id: None,
737 meta: SourceMeta::default(),
738 });
739 }
740 }
741
742 let mut grid_d = String::new();
744 grid_d.push_str(&format!(
746 "M {:.2},{:.2} L {:.2},{:.2} ",
747 start_x,
748 start_y,
749 start_x + total_table_width,
750 start_y
751 ));
752 grid_d.push_str(&format!(
753 "M {:.2},{:.2} L {:.2},{:.2} ",
754 start_x,
755 start_y + header_height,
756 start_x + total_table_width,
757 start_y + header_height
758 ));
759 for r in 1..=table.rows.len() {
760 let y = start_y + header_height + (r as f64 * cell_height);
761 grid_d.push_str(&format!(
762 "M {:.2},{:.2} L {:.2},{:.2} ",
763 start_x,
764 y,
765 start_x + total_table_width,
766 y
767 ));
768 }
769
770 let mut cur_col_x = start_x;
772 grid_d.push_str(&format!(
773 "M {:.2},{:.2} L {:.2},{:.2} ",
774 cur_col_x,
775 start_y,
776 cur_col_x,
777 start_y + total_table_height
778 ));
779 for w in &col_widths {
780 cur_col_x += w;
781 grid_d.push_str(&format!(
782 "M {:.2},{:.2} L {:.2},{:.2} ",
783 cur_col_x,
784 start_y,
785 cur_col_x,
786 start_y + total_table_height
787 ));
788 }
789
790 page.nodes.push(Node::Path {
791 id: String::new(),
792 d: grid_d,
793 fill_rule: String::new(),
794 fill: Paint::None,
795 stroke: Stroke {
796 paint: Paint::solid("#e2e8f0"),
797 width: 1.0,
798 line_cap: LineCap::Butt,
799 line_join: LineJoin::Miter,
800 ..Default::default()
801 },
802 transform: IDENTITY,
803 clip_id: None,
804 meta: SourceMeta::default(),
805 });
806
807 let mut col_x = start_x;
809 for (c, header) in table.headers.iter().enumerate() {
810 let width = col_widths[c];
811 let align = table.alignments.get(c).copied().unwrap_or(TableAlign::Left);
812 let (text_x, anchor) = match align {
813 TableAlign::Right => (col_x + width - padding_x, TextAnchor::End),
814 TableAlign::Center => (col_x + width / 2.0, TextAnchor::Middle),
815 TableAlign::Left => (col_x + padding_x, TextAnchor::Start),
816 };
817 let text_y = start_y + header_height / 2.0 + 4.5;
818
819 page.nodes.push(Node::Text {
820 id: String::new(),
821 x: text_x,
822 y: text_y,
823 runs: vec![TextRun {
824 text: sanitize_cell_text(header),
825 font_size: header_font_size,
826 font_family: "Helvetica, Arial, sans-serif".to_string(),
827 bold: true,
828 fill: Paint::solid("#0f172a"),
829 ..Default::default()
830 }],
831 anchor,
832 transform: IDENTITY,
833 opacity: 1.0,
834 stroke: Stroke::default(),
835 clip_id: None,
836 meta: SourceMeta::default(),
837 });
838 col_x += width;
839 }
840
841 for (row_idx, row) in table.rows.iter().enumerate() {
843 let row_y = start_y + header_height + (row_idx as f64 * cell_height);
844 let mut cur_x = start_x;
845 for (col_idx, cell) in row.iter().enumerate() {
846 if col_idx < col_widths.len() {
847 let width = col_widths[col_idx];
848 let align = table
849 .alignments
850 .get(col_idx)
851 .copied()
852 .unwrap_or(TableAlign::Left);
853 let (text_x, anchor) = match align {
854 TableAlign::Right => (cur_x + width - padding_x, TextAnchor::End),
855 TableAlign::Center => (cur_x + width / 2.0, TextAnchor::Middle),
856 TableAlign::Left => (cur_x + padding_x, TextAnchor::Start),
857 };
858 let text_y = row_y + cell_height / 2.0 + 4.0;
859
860 page.nodes.push(Node::Text {
861 id: String::new(),
862 x: text_x,
863 y: text_y,
864 runs: vec![TextRun {
865 text: sanitize_cell_text(cell),
866 font_size,
867 font_family: "Helvetica, Arial, sans-serif".to_string(),
868 fill: Paint::solid("#334155"),
869 ..Default::default()
870 }],
871 anchor,
872 transform: IDENTITY,
873 opacity: 1.0,
874 stroke: Stroke::default(),
875 clip_id: None,
876 meta: SourceMeta::default(),
877 });
878 cur_x += width;
879 }
880 }
881 }
882
883 Ok(page)
884}
885
886pub fn extract_markdown_table_from_svg(svg_bytes: &[u8]) -> Result<String> {
888 let svg_text = std::str::from_utf8(svg_bytes)
889 .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
890
891 if let Some(decoded) = crate::cad::svg_reader::extract_embedded_source(svg_bytes)
893 && !decoded.trim().is_empty()
894 {
895 return Ok(decoded);
896 }
897
898 let mut reader = Reader::from_str(svg_text);
900 reader.config_mut().trim_text(true);
901
902 struct CellEntry {
903 x: f64,
904 y: f64,
905 text: String,
906 }
907
908 let mut cells: Vec<CellEntry> = Vec::new();
909 let mut current_x = 0.0f64;
910 let mut current_y = 0.0f64;
911 let mut in_text = false;
912 let mut current_text = String::new();
913
914 while let Ok(event) = reader.read_event() {
915 match event {
916 Event::Start(e) if e.name().as_ref() == b"text" => {
917 in_text = true;
918 current_text.clear();
919 for attr in e.attributes().flatten() {
920 if let Ok(s) = std::str::from_utf8(&attr.value) {
921 if attr.key.as_ref() == b"x" {
922 current_x = s.parse().unwrap_or(0.0);
923 } else if attr.key.as_ref() == b"y" {
924 current_y = s.parse().unwrap_or(0.0);
925 }
926 }
927 }
928 }
929 Event::Text(e) if in_text => {
930 let bytes = e.as_ref();
931 if let Ok(s) = std::str::from_utf8(bytes) {
932 current_text.push_str(s);
933 }
934 }
935 Event::End(e) if e.name().as_ref() == b"text" => {
936 in_text = false;
937 let trimmed = current_text.trim();
938 if !trimmed.is_empty() {
939 cells.push(CellEntry {
940 x: current_x,
941 y: current_y,
942 text: trimmed.to_string(),
943 });
944 }
945 }
946 Event::Eof => break,
947 _ => {}
948 }
949 }
950
951 if cells.is_empty() {
952 return Ok(String::new());
953 }
954
955 cells.sort_by(|a, b| a.y.partial_cmp(&b.y).unwrap_or(std::cmp::Ordering::Equal));
956
957 let mut rows: Vec<Vec<CellEntry>> = Vec::new();
958 for cell in cells {
959 if let Some(last_row) = rows.last_mut().filter(|r| (r[0].y - cell.y).abs() < 12.0) {
960 last_row.push(cell);
961 continue;
962 }
963 rows.push(vec![cell]);
964 }
965
966 for row in &mut rows {
967 row.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
968 }
969
970 let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
972 if col_count == 0 {
973 return Ok(String::new());
974 }
975
976 let mut col_widths = vec![3usize; col_count];
977 for row in &rows {
978 for (i, c) in row.iter().enumerate() {
979 if i < col_count {
980 let escaped = c.text.replace('|', r#"\|"#);
981 col_widths[i] = col_widths[i].max(escaped.len());
982 }
983 }
984 }
985
986 let mut md = String::new();
987 if let Some(headers) = rows.first() {
988 md.push_str("| ");
989 for (i, &w) in col_widths.iter().enumerate() {
990 let text = headers.get(i).map(|c| c.text.as_str()).unwrap_or("");
991 let escaped = text.replace('|', r#"\|"#);
992 md.push_str(&format!("{escaped:<w$} | "));
993 }
994 md.push('\n');
995
996 md.push_str("| ");
997 for &w in &col_widths {
998 let sep = "-".repeat(w.max(3));
999 md.push_str(&format!("{sep} | "));
1000 }
1001 md.push('\n');
1002
1003 for row in rows.iter().skip(1) {
1004 md.push_str("| ");
1005 for (i, &w) in col_widths.iter().enumerate() {
1006 let text = row.get(i).map(|c| c.text.as_str()).unwrap_or("");
1007 let escaped = text.replace('|', r#"\|"#);
1008 md.push_str(&format!("{escaped:<w$} | "));
1009 }
1010 md.push('\n');
1011 }
1012 }
1013
1014 Ok(md)
1015}
1016
1017fn is_cjk_or_fullwidth(c: char) -> bool {
1018 matches!(c as u32,
1019 0x3000..=0x303F | 0x3040..=0x309F | 0x30A0..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0xAC00..=0xD7AF )
1028}
1029
1030fn estimate_cell_text_width(text: &str, char_width: f64) -> f64 {
1031 let mut width = 0.0;
1032 for c in text.chars() {
1033 if is_cjk_or_fullwidth(c) {
1034 width += char_width * 1.8;
1035 } else {
1036 width += char_width;
1037 }
1038 }
1039 width
1040}
1041
1042fn normalized_cell_text(s: &str) -> String {
1043 s.replace("\r\n", "\n")
1044 .chars()
1045 .map(|character| {
1046 if matches!(character, '\r' | '\n' | '\t') {
1047 ' '
1048 } else {
1049 character
1050 }
1051 })
1052 .collect()
1053}
1054
1055fn cell_text_is_truncated(text: &str) -> bool {
1056 normalized_cell_text(text)
1057 .chars()
1058 .take(MAX_RENDERED_CELL_CHARS + 1)
1059 .count()
1060 > MAX_RENDERED_CELL_CHARS
1061}
1062
1063fn sanitize_cell_text(text: &str) -> String {
1064 let normalized = normalized_cell_text(text);
1065 let mut characters = normalized.chars();
1066 let mut preview = characters
1067 .by_ref()
1068 .take(MAX_RENDERED_CELL_CHARS)
1069 .collect::<String>();
1070 if characters.next().is_some() {
1071 preview.push('…');
1072 }
1073 preview
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079
1080 #[test]
1081 fn parses_rfc4180_quotes_and_preserves_field_whitespace() {
1082 let source = "\u{feff}Name,Note\r\n\" Acme, Inc. \",\"She said \"\"hello\"\"\"\r\nAlice,\"line one\r\nline two\"\r\n\r\n";
1083 let (table, warnings) = parse_delimited_table_with_warnings(source, ',').unwrap();
1084 assert_eq!(table.headers, ["Name", "Note"]);
1085 assert_eq!(table.rows[0], [" Acme, Inc. ", "She said \"hello\""]);
1086 assert_eq!(table.rows[1][1], "line one\r\nline two");
1087 assert_eq!(warnings, ["1 empty CSV/TSV record(s) were skipped"]);
1088 }
1089
1090 #[test]
1091 fn rejects_malformed_quotes_and_forbidden_controls() {
1092 for malformed in [
1093 "Name,Note\nAlice,\"unfinished\n",
1094 "Name,Note\nAl\"ice,ok\n",
1095 "Name,Note\n\"Alice\"x,ok\n",
1096 "Name,Note\nAlice,\0bad\n",
1097 "Name,Note\nAlice,\u{FFFE}bad\n",
1098 ] {
1099 assert!(
1100 parse_delimited_table(malformed, ',').is_err(),
1101 "{malformed:?}"
1102 );
1103 }
1104 }
1105
1106 #[test]
1107 fn preserves_ragged_rows_and_reports_the_shape_difference() {
1108 let (table, warnings) =
1109 parse_delimited_table_with_warnings("A,B\n1\n2,3,extra\n", ',').unwrap();
1110 assert_eq!(table.headers, ["A", "B"]);
1111 assert_eq!(
1112 table.rows,
1113 [
1114 vec!["1".to_owned()],
1115 vec!["2".to_owned(), "3".to_owned(), "extra".to_owned()]
1116 ]
1117 );
1118 assert_eq!(table.alignments.len(), 3);
1119 assert!(warnings[0].contains("different field count"));
1120 }
1121
1122 #[test]
1123 fn enforces_row_column_field_and_cell_budgets() {
1124 let too_many_columns = format!("{}\n", vec!["x"; MAX_DELIMITED_COLUMNS + 1].join(","));
1125 assert!(parse_delimited_table(&too_many_columns, ',').is_err());
1126
1127 let too_long_field = format!("header\n{}\n", "x".repeat(MAX_DELIMITED_FIELD_BYTES + 1));
1128 assert!(parse_delimited_table(&too_long_field, ',').is_err());
1129
1130 let row = vec!["x"; MAX_DELIMITED_COLUMNS.min(21)].join(",");
1131 let too_many_cells = format!(
1132 "{row}\n{}",
1133 format!("{row}\n").repeat(MAX_DELIMITED_RECORDS - 1)
1134 );
1135 assert!(parse_delimited_table(&too_many_cells, ',').is_err());
1136 }
1137
1138 #[test]
1139 fn truncates_long_display_cells_and_keeps_a_reportable_boundary() {
1140 let text = "x".repeat(MAX_RENDERED_CELL_CHARS + 20);
1141 assert!(cell_text_is_truncated(&text));
1142 let preview = sanitize_cell_text(&text);
1143 assert_eq!(preview.chars().count(), MAX_RENDERED_CELL_CHARS + 1);
1144 assert!(preview.ends_with('…'));
1145 }
1146}