From 7e9e51cdb07f04cd9a3cccc2925738bdbfd9694c Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Thu, 12 Nov 2026 10:19:13 -0700 Subject: [PATCH 1/2] fix(formats): read CSV cells as literal values, not Markdown `csv::to_table` ran every cell through the inline Markdown parser, so a spreadsheet value such as `*literal*` came back as an italic `literal` and `[a](b)` as a link. Writing the table out again lost the characters (#110). A cell is now one plain span holding its value as written. Fixes #110 Co-Authored-By: Claude Opus 5.5 --- crates/formats/src/csv.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/formats/src/csv.rs b/crates/formats/src/csv.rs index 71db9543..a10848e2 100644 --- a/crates/formats/src/csv.rs +++ b/crates/formats/src/csv.rs @@ -3,6 +3,9 @@ //! The marks inside a cell are dropped, because a spreadsheet cell holds a value, not a //! marked-up line. Quotes are doubled and a cell is wrapped when it holds a comma, a //! quote or a line break, which is the rule everything that reads CSV agrees on. +//! +//! Read back, a cell is its value as written: `*literal*` in a spreadsheet is those nine +//! characters, not a word in italics. use crate::markdown::{Block, Span}; @@ -36,11 +39,17 @@ fn quote(cell: &str) -> String { pub fn to_table(text: &str) -> Option { let rows = rows_of(text); let mut rows = rows.into_iter().filter(|r| !(r.len() == 1 && r[0].trim().is_empty())); - let head: Vec> = rows.next()?.iter().map(|c| crate::markdown::parse_inline(c)).collect(); - let body: Vec>> = rows.map(|r| r.iter().map(|c| crate::markdown::parse_inline(c)).collect()).collect(); + let head: Vec> = rows.next()?.iter().map(|c| literal(c)).collect(); + let body: Vec>> = rows.map(|r| r.iter().map(|c| literal(c)).collect()).collect(); Some(Block::Table { head, rows: body }) } +/// A cell's value as plain text. A spreadsheet cell holds a value, so its asterisks, +/// brackets and backticks are characters of that value, not marks for the page. +fn literal(cell: &str) -> Vec { + if cell.is_empty() { Vec::new() } else { vec![Span { text: cell.to_string(), ..Span::default() }] } +} + /// The cells of each line, with quotes undone. fn rows_of(text: &str) -> Vec> { let mut rows = Vec::new(); @@ -129,6 +138,21 @@ lines", assert_eq!(text(&rows[1][1]), "last"); } + /// Issue #110: a cell is read as the value it holds. Read as Markdown, `*literal*` came + /// back as an italic `literal` and `[a](b)` as a link, and writing the table out again + /// lost the characters. + #[test] + fn a_cell_that_looks_like_markdown_is_read_as_it_is_written() { + for input in ["Value\n*literal*\n", "Value\n[a](b)\n", "Value\n`x` **y** _z_ w\n", "# Heading,- item\n> quote,1. one\n"] { + let table = to_table(input).expect("a table"); + let Block::Table { head, rows } = &table else { panic!("a table") }; + let plain_only = + std::iter::once(head).chain(rows).flatten().flatten().all(|span| span == &Span { text: span.text.clone(), ..Span::default() }); + assert!(plain_only, "{input:?} is read without marks"); + assert_eq!(from_table(&table).as_deref(), Some(input), "{input:?} comes back character for character"); + } + } + #[test] fn nothing_in_nothing_out() { assert_eq!(to_table(""), None); From ff8413bdfb6a1c321e3a99cccd0d2d4de309a356 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Thu, 12 Nov 2026 10:19:33 -0700 Subject: [PATCH 2/2] fix(formats): keep blank rows of a one-column table in CSV The CSV reader skipped every line whose one cell was blank, which also dropped a blank row of a one-column table, so every row under it moved up (#112). The writer now quotes a blank cell that is alone on its row, and the reader keeps a line that held a quoted cell. Lines with nothing on them are still skipped. Fixes #112 Co-Authored-By: Claude Opus 5.5 --- crates/formats/src/csv.rs | 45 +++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/formats/src/csv.rs b/crates/formats/src/csv.rs index a10848e2..6b68bb11 100644 --- a/crates/formats/src/csv.rs +++ b/crates/formats/src/csv.rs @@ -14,7 +14,7 @@ pub fn from_table(block: &Block) -> Option { let Block::Table { head, rows } = block else { return None }; let mut out = String::new(); for row in std::iter::once(head).chain(rows.iter()) { - let line: Vec = row.iter().map(|cell| quote(&plain(cell))).collect(); + let line: Vec = row.iter().map(|cell| quote(&plain(cell), row.len() == 1)).collect(); out.push_str(&line.join(",")); out.push('\n'); } @@ -26,8 +26,14 @@ fn plain(spans: &[Span]) -> String { spans.iter().map(|s| s.text.as_str()).collect() } -fn quote(cell: &str) -> String { - if cell.contains([',', '"', '\n', '\r']) { format!("\"{}\"", cell.replace('"', "\"\"")) } else { cell.to_string() } +/// The cell as CSV. The only cell of a row is wrapped when it is blank too: written bare, its +/// line would be a blank line, which a reader skips, and the rows under it would move up. +fn quote(cell: &str, alone: bool) -> String { + if cell.contains([',', '"', '\n', '\r']) || (alone && cell.trim().is_empty()) { + format!("\"{}\"", cell.replace('"', "\"\"")) + } else { + cell.to_string() + } } /// A table read back from CSV: the first row is the heading, as it was written out. @@ -36,9 +42,12 @@ fn quote(cell: &str) -> String { /// doubled quote inside one is a quote, and a wrapped cell may hold commas and line /// breaks. Anything else is taken as it stands rather than refused, because a spreadsheet /// that wrote it is not going to be argued with. +/// +/// A line with nothing on it is skipped, but a row whose one cell was written as `""` is a +/// blank row of the table and is kept, so the rows under it stay where they were. pub fn to_table(text: &str) -> Option { let rows = rows_of(text); - let mut rows = rows.into_iter().filter(|r| !(r.len() == 1 && r[0].trim().is_empty())); + let mut rows = rows.into_iter().filter(|(r, quoted)| *quoted || !(r.len() == 1 && r[0].trim().is_empty())).map(|(r, _)| r); let head: Vec> = rows.next()?.iter().map(|c| literal(c)).collect(); let body: Vec>> = rows.map(|r| r.iter().map(|c| literal(c)).collect()).collect(); Some(Block::Table { head, rows: body }) @@ -50,12 +59,13 @@ fn literal(cell: &str) -> Vec { if cell.is_empty() { Vec::new() } else { vec![Span { text: cell.to_string(), ..Span::default() }] } } -/// The cells of each line, with quotes undone. -fn rows_of(text: &str) -> Vec> { +/// The cells of each line, with quotes undone, and whether any cell on the line was quoted. +fn rows_of(text: &str) -> Vec<(Vec, bool)> { let mut rows = Vec::new(); let mut row: Vec = Vec::new(); let mut cell = String::new(); let mut quoted = false; + let mut any_quoted = false; let mut chars = text.chars().peekable(); while let Some(c) = chars.next() { match c { @@ -70,19 +80,20 @@ fn rows_of(text: &str) -> Vec> { '"' if cell.trim().is_empty() => { cell.clear(); quoted = true; + any_quoted = true; } ',' if !quoted => row.push(std::mem::take(&mut cell)), '\r' if !quoted => {} '\n' if !quoted => { row.push(std::mem::take(&mut cell)); - rows.push(std::mem::take(&mut row)); + rows.push((std::mem::take(&mut row), std::mem::take(&mut any_quoted))); } other => cell.push(other), } } - if !cell.is_empty() || !row.is_empty() { + if !cell.is_empty() || !row.is_empty() || any_quoted { row.push(cell); - rows.push(row); + rows.push((row, any_quoted)); } rows } @@ -153,6 +164,22 @@ lines", } } + /// Issue #112: a blank row of a one-column table survives the trip through CSV, and so + /// do the rows under it. + #[test] + fn a_blank_row_of_a_one_column_table_is_kept() { + let cell = |text: &str| if text.is_empty() { Vec::new() } else { vec![Span { text: text.into(), ..Span::default() }] }; + let table = Block::Table { head: vec![cell("Value")], rows: vec![vec![cell("")], vec![cell("value")], vec![cell(" ")]] }; + let csv = from_table(&table).expect("a table"); + assert_eq!(csv, "Value\n\"\"\nvalue\n\" \"\n", "a blank only cell is written quoted"); + assert_eq!(to_table(&csv), Some(table), "and read back as a row"); + + // What a spreadsheet writes for a blank row, with blank lines between the rows that + // are not rows. + let Some(Block::Table { rows, .. }) = to_table("Value\n\"\"\n\nvalue\n\n") else { panic!("a table") }; + assert_eq!(rows, vec![vec![cell("")], vec![cell("value")]]); + } + #[test] fn nothing_in_nothing_out() { assert_eq!(to_table(""), None);