Skip to main content

document_svg/table/
mod.rs

1//! Markdown and structured table parsing, grid layout, SVG rendering, and reverse extraction.
2
3use 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
82/// Parses CSV or TSV text into a bounded table model.
83pub 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
449/// Parses GFM markdown table subset
450pub 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; // 0 = expecting header, 1 = expecting separator, 2 = rows
456
457    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    // Auto-detect numeric columns when no explicit alignment was specified (i.e. Left default)
519    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
543/// Determines if a cell value represents a numeric quantity (including formatted currencies,
544/// percentages, thousand-separated integers, and accounting parenthesized negatives).
545pub(crate) fn is_numeric_cell(val: &str) -> bool {
546    let t = val.trim();
547    if t.is_empty() {
548        return true;
549    }
550    // Accounting negative e.g. (1,234.56)
551    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    // Strip leading currency or sign
559    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    // Strip trailing percentage or currency sign
570    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    // Must contain at least one digit
580    if !s.chars().any(|c| c.is_ascii_digit()) {
581        return false;
582    }
583
584    // Remove commas if thousand separator (e.g. 1,000,000.50)
585    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    // Skip leading pipe if present
595    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            // Escaped pipe `\|` -> literal `|`
613            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
631/// Computes column widths, row heights, and generates clean Page IR for the table.
632pub 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    // 1. Header background
692    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    // 2. Alternating row background
715    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    // 3. Grid lines
743    let mut grid_d = String::new();
744    // Horizontal lines
745    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    // Vertical lines
771    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    // 4. Header text
808    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    // 5. Row cells
842    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
886/// Reverse extraction: reads SVG containing a table and reconstructs Markdown Table.
887pub 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    // 1. Check embedded source (content attribute)
892    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    // 2. Geometric fallback: scan text cells
899    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    // Determine max columns and per-column width
971    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 | // CJK Symbols and Punctuation
1020        0x3040..=0x309F | // Hiragana
1021        0x30A0..=0x30FF | // Katakana
1022        0x3400..=0x4DBF | // CJK Unified Ideographs Extension A
1023        0x4E00..=0x9FFF | // CJK Unified Ideographs
1024        0xF900..=0xFAFF | // CJK Compatibility Ideographs
1025        0xFF00..=0xFFEF | // Halfwidth and Fullwidth Forms
1026        0xAC00..=0xD7AF   // Hangul Syllables
1027    )
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}