diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs
index 37844d0..602ab6f 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 edb3216..42f8b7c 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 696134b..3fa1470 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 0000000..978f66d
--- /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 275a7bf..05404c8 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