From 05a8ee3932fb83073a0db28cc70f462566257e39 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Thu, 12 Nov 2026 17:43:27 -0700 Subject: [PATCH] fix(formats): preserve multiline CSV cells in both page formats --- crates/formats/src/html.rs | 13 ++++++--- crates/formats/src/markdown.rs | 39 ++++++++++++++++++++------- crates/formats/src/render.rs | 26 +++++++++++++++--- crates/formats/tests/csv_multiline.rs | 31 +++++++++++++++++++++ docs/folder-format.md | 4 +++ 5 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 crates/formats/tests/csv_multiline.rs diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 37844d0b..602ab6f1 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -416,9 +416,14 @@ impl Writer { } } - fn line_break(&mut self) { + fn line_break(&mut self, attrs: &str) { if self.in_cell { - self.cell.push(' '); + let newline = match attr(attrs, "data-nc-break").as_deref() { + Some("crlf") => "\r\n", + Some("cr") => "\r", + _ => "\n", + }; + self.cell.push_str(&crate::render::table_line_breaks(newline)); } else if !self.href.is_empty() { // An attachment card uses
between its icon and file name while // keeping both inside one link. Markdown links cannot contain a @@ -513,7 +518,7 @@ impl Writer { self.quote = true; self.block_break(); } - ("br", _) => self.line_break(), + ("br", _) => self.line_break(attrs), ("hr", _) => { self.block_break(); self.push("---"); @@ -552,7 +557,7 @@ impl Writer { ("li", true) => { let trimmed = self.out.trim_end_matches([' ', '\t']).len(); self.out.truncate(trimmed); - self.line_break(); + self.line_break(""); } ("b" | "strong", _) => self.push("**"), ("i" | "em", _) => self.push("*"), diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index edb32167..42f8b7cf 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -74,7 +74,7 @@ struct Style { /// Parses inline Markdown into styled spans. pub fn parse_inline(text: &str) -> Vec { let chars: Vec = text.chars().collect(); - inline(&chars, Style::default(), None, 0) + inline(&chars, Style::default(), None, 0, false) } const MAX_DEPTH: usize = 8; @@ -282,13 +282,22 @@ fn inline_tag_close(chars: &[char], start: usize, open: &[char], close: &[char]) None } -fn inline(chars: &[char], st: Style, link: Option, depth: usize) -> Vec { +fn inline(chars: &[char], st: Style, link: Option, depth: usize, cell_breaks: bool) -> Vec { let mut out = Vec::new(); let mut buf = String::new(); let mut i = 0; while i < chars.len() { let c = chars.get(i).copied().unwrap_or(' '); let next = chars.get(i + 1).copied(); + // A table row stays on one source line; an explicit break is part of its cell. + if cell_breaks + && c == '<' + && let Some((len, newline)) = table_break(chars, i) + { + buf.push_str(newline); + i += len; + continue; + } if c == '<' && depth < MAX_DEPTH && let Some((font_size, font_family, content_start)) = font_style_open(chars, i) @@ -302,7 +311,7 @@ fn inline(chars: &[char], st: Style, link: Option, depth: usize) -> Vec< if font_family.is_some() { sized.font_family = font_family; } - out.extend(inline(slice(chars, content_start, content_end), sized, link.clone(), depth + 1)); + out.extend(inline(slice(chars, content_start, content_end), sized, link.clone(), depth + 1, cell_breaks)); i = content_end + "".chars().count(); continue; } @@ -323,7 +332,7 @@ fn inline(chars: &[char], st: Style, link: Option, depth: usize) -> Vec< } else { inner.underline = true; } - out.extend(inline(slice(chars, i + open_len, end), inner, link.clone(), depth + 1)); + out.extend(inline(slice(chars, i + open_len, end), inner, link.clone(), depth + 1, cell_breaks)); i = end + closing.len(); continue; } @@ -378,7 +387,7 @@ fn inline(chars: &[char], st: Style, link: Option, depth: usize) -> Vec< if let Some(end) = find(chars, i + 3, &['*', '*', '*']) { push_text(&mut out, &mut buf, &st, &link); let inner = Style { bold: true, italic: true, ..st.clone() }; - out.extend(inline(slice(chars, i + 3, end), inner, link.clone(), depth + 1)); + out.extend(inline(slice(chars, i + 3, end), inner, link.clone(), depth + 1, cell_breaks)); i = end + 3; } } @@ -393,7 +402,7 @@ fn inline(chars: &[char], st: Style, link: Option, depth: usize) -> Vec< } else { inner.strike = true; } - out.extend(inline(slice(chars, i + 2, end), inner, link.clone(), depth + 1)); + out.extend(inline(slice(chars, i + 2, end), inner, link.clone(), depth + 1, cell_breaks)); i = end + 2; } _ => { @@ -407,7 +416,7 @@ fn inline(chars: &[char], st: Style, link: Option, depth: usize) -> Vec< push_text(&mut out, &mut buf, &st, &link); let mut inner = st.clone(); inner.italic = true; - out.extend(inline(slice(chars, i + 1, end), inner, link.clone(), depth + 1)); + out.extend(inline(slice(chars, i + 1, end), inner, link.clone(), depth + 1, cell_breaks)); i = end + 1; } _ => { @@ -461,7 +470,7 @@ fn inline(chars: &[char], st: Style, link: Option, depth: usize) -> Vec< Some((label, url, after)) => { push_text(&mut out, &mut buf, &st, &link); let label_chars: Vec = label.chars().collect(); - out.extend(inline(&label_chars, st.clone(), Some(url), depth + 1)); + out.extend(inline(&label_chars, st.clone(), Some(url), depth + 1, cell_breaks)); i = after; } None => { @@ -552,6 +561,16 @@ fn is_table_separator(line: &str) -> bool { t.contains('-') && t.contains('|') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) } +fn table_break(chars: &[char], at: usize) -> Option<(usize, &'static str)> { + crate::render::TABLE_BREAKS + .iter() + .find_map(|&(newline, tag)| chars.get(at..at + tag.len()).filter(|slice| slice.iter().copied().eq(tag.chars())).map(|_| (tag.len(), newline))) +} + +fn parse_table_cell(text: &str) -> Vec { + inline(&text.chars().collect::>(), Style::default(), None, 0, true) +} + fn cells(line: &str) -> Vec { let t = line.trim(); let t = t.strip_prefix('|').unwrap_or(t); @@ -691,11 +710,11 @@ pub fn parse(src: &str) -> Vec { } out.push(Block::Quote(parse_inline(&parts.join(" ")))); } else if line.contains('|') && lines.get(i + 1).is_some_and(|l| is_table_separator(l)) { - let head: Vec> = cells(line).iter().map(|c| parse_inline(c)).collect(); + let head: Vec> = cells(line).iter().map(|c| parse_table_cell(c)).collect(); i += 2; let mut rows = Vec::new(); while i < lines.len() && lines.get(i).is_some_and(|l| l.contains('|') && !l.trim().is_empty()) { - rows.push(cells(lines.get(i).copied().unwrap_or("")).iter().map(|c| parse_inline(c)).collect()); + rows.push(cells(lines.get(i).copied().unwrap_or("")).iter().map(|c| parse_table_cell(c)).collect()); i += 1; } out.push(Block::Table { head, rows }); diff --git a/crates/formats/src/render.rs b/crates/formats/src/render.rs index 696134b2..3fa1470a 100644 --- a/crates/formats/src/render.rs +++ b/crates/formats/src/render.rs @@ -147,7 +147,11 @@ fn row_to_markdown(cells: &[Vec]) -> String { let mut s = String::from("|"); for c in cells { s.push(' '); - s.push_str(&escape_table_cell(&spans_to_markdown(c))); + let mut literal = Literal::for_spans(c); + let words: String = c.iter().filter(|span| plain_text(span)).map(|span| span.text.as_str()).collect(); + let lower = words.to_ascii_lowercase(); + literal.tag |= lower.contains("
") || lower.contains("
String { text.replace('\\', "\\\\").replace('|', "\\|") } +/// Explicit breaks keep each table row on one source line. HTML folds bare carriage +/// returns, so the optional attribute preserves the original CSV line-ending bytes. +pub(crate) const TABLE_BREAKS: [(&str, &str); 3] = [("\r\n", "
"), ("\r", "
"), ("\n", "
")]; + +pub(crate) fn table_line_breaks(text: &str) -> String { + TABLE_BREAKS.iter().fold(text.to_string(), |text, (newline, tag)| text.replace(newline, tag)) +} + /// Live-editor text and a character mask identifying literal punctuation. Formatting marks /// remain editable, while persistence-only backslashes never enter the text widget. pub fn editable_spans(spans: &[Span]) -> (String, Vec) { @@ -468,14 +480,14 @@ pub fn blocks_to_html(blocks: &[Block]) -> String { if !head.is_empty() { out.push_str(""); for c in head { - out.push_str(&format!("{}", spans_to_html(c))); + out.push_str(&format!("{}", spans_to_html_with_breaks(c, true))); } out.push_str("\n"); } for r in rows { out.push_str(""); for c in r { - out.push_str(&format!("{}", spans_to_html(c))); + out.push_str(&format!("{}", spans_to_html_with_breaks(c, true))); } out.push_str("\n"); } @@ -525,6 +537,11 @@ fn render_html_list(blocks: &[Block], at: &mut usize, depth: usize, ordered: boo /// Inline spans as HTML. pub fn spans_to_html(spans: &[Span]) -> String { + spans_to_html_with_breaks(spans, false) +} + +/// Table cell line breaks are content, rather than whitespace between HTML words. +fn spans_to_html_with_breaks(spans: &[Span], cell: bool) -> String { let mut out = String::new(); for s in spans { if let Some(src) = &s.image { @@ -543,6 +560,9 @@ pub fn spans_to_html(spans: &[Span]) -> String { continue; } let mut text = escape(&s.text); + if cell && !s.code { + text = table_line_breaks(&text); + } if s.code { text = format!("{text}"); } else { diff --git a/crates/formats/tests/csv_multiline.rs b/crates/formats/tests/csv_multiline.rs new file mode 100644 index 00000000..978f66df --- /dev/null +++ b/crates/formats/tests/csv_multiline.rs @@ -0,0 +1,31 @@ +use notecraft_formats::{csv, html, markdown, render}; + +#[test] +fn multiline_csv_cells_remain_in_one_row_in_both_page_formats() { + for value in + ["a\nb", "a\r\nb", "a\rb", "a\r\nb\rc\nd", "\na\n\nb\n", "
\nsecond", "caf\u{e9}\n*literal* | `code`
\\path"] + { + let quoted = value.replace('"', "\"\""); + let source = format!("Value,Other\n\"{quoted}\",untouched\nlast,row\n"); + let original = csv::to_table(&source).expect("CSV table"); + for format in ["cmgfm", "html"] { + let mut blocks = vec![original.clone()]; + for _ in 0..3 { + let written = + if format == "html" { html::to_markdown(&render::blocks_to_html(&blocks)) } else { render::blocks_to_markdown(&blocks) }; + blocks = markdown::parse(&written); + assert_eq!(blocks, vec![original.clone()], "{format}: {value:?}\n{written}"); + assert_eq!(csv::from_table(&blocks[0]).expect("table"), source); + } + } + } +} + +#[test] +fn a_literal_break_tag_and_inline_code_are_not_changed() { + assert_eq!(render::spans_to_markdown(&markdown::parse_inline("a
b")), "a
b"); + let original = markdown::parse("a
b\n\n| Value |\n| --- |\n| `
` |\n"); + assert_eq!(render::blocks_to_text(&original), "a
b\nValue\n
"); + assert_eq!(markdown::parse(&render::blocks_to_markdown(&original)), original); + assert_eq!(markdown::parse(&html::to_markdown(&render::blocks_to_html(&original))), original); +} diff --git a/docs/folder-format.md b/docs/folder-format.md index 275a7bf8..05404c8c 100644 --- a/docs/folder-format.md +++ b/docs/folder-format.md @@ -140,6 +140,10 @@ tags: [] `text` form, where N is 8 through 72. This is a subset of CommonMark and GFM, not a full implementation. See `crates/formats/src/markdown.rs` for the exact list. +- Table cell line breaks use `
` in both page formats, so a multiline CSV value stays + in one cell. LF is the default. `
` and + `
` preserve CRLF and CR bytes without HTML whitespace folding. + Literal break-tag text in a Markdown table is backslash-quoted; inline code stays literal. - Font-family spans in the in-progress font-formatting model use the `font-family` CSS property. Names that need CSS quoting are written as single-quoted strings, with quotes and backslashes escaped, and HTML attribute characters encoded. This representation preserves