Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions crates/formats/src/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br> between its icon and file name while
// keeping both inside one link. Markdown links cannot contain a
Expand Down Expand Up @@ -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("---");
Expand Down Expand Up @@ -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("*"),
Expand Down
39 changes: 29 additions & 10 deletions crates/formats/src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ struct Style {
/// Parses inline Markdown into styled spans.
pub fn parse_inline(text: &str) -> Vec<Span> {
let chars: Vec<char> = text.chars().collect();
inline(&chars, Style::default(), None, 0)
inline(&chars, Style::default(), None, 0, false)
}

const MAX_DEPTH: usize = 8;
Expand Down Expand Up @@ -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<String>, depth: usize) -> Vec<Span> {
fn inline(chars: &[char], st: Style, link: Option<String>, depth: usize, cell_breaks: bool) -> Vec<Span> {
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)
Expand All @@ -302,7 +311,7 @@ fn inline(chars: &[char], st: Style, link: Option<String>, 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 + "</span>".chars().count();
continue;
}
Expand All @@ -323,7 +332,7 @@ fn inline(chars: &[char], st: Style, link: Option<String>, 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;
}
Expand Down Expand Up @@ -378,7 +387,7 @@ fn inline(chars: &[char], st: Style, link: Option<String>, 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;
}
}
Expand All @@ -393,7 +402,7 @@ fn inline(chars: &[char], st: Style, link: Option<String>, 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;
}
_ => {
Expand All @@ -407,7 +416,7 @@ fn inline(chars: &[char], st: Style, link: Option<String>, 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;
}
_ => {
Expand Down Expand Up @@ -461,7 +470,7 @@ fn inline(chars: &[char], st: Style, link: Option<String>, depth: usize) -> Vec<
Some((label, url, after)) => {
push_text(&mut out, &mut buf, &st, &link);
let label_chars: Vec<char> = 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 => {
Expand Down Expand Up @@ -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<Span> {
inline(&text.chars().collect::<Vec<_>>(), Style::default(), None, 0, true)
}

fn cells(line: &str) -> Vec<String> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
Expand Down Expand Up @@ -691,11 +710,11 @@ pub fn parse(src: &str) -> Vec<Block> {
}
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<Vec<Span>> = cells(line).iter().map(|c| parse_inline(c)).collect();
let head: Vec<Vec<Span>> = 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 });
Expand Down
26 changes: 23 additions & 3 deletions crates/formats/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,11 @@ fn row_to_markdown(cells: &[Vec<Span>]) -> 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("<br>") || lower.contains("<br ");
s.push_str(&escape_table_cell(&table_line_breaks(&marked(c, 0, &literal))));
s.push_str(" |");
}
s.push('\n');
Expand All @@ -164,6 +168,14 @@ pub(crate) fn escape_table_cell(text: &str) -> 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", "<br data-nc-break=\"crlf\">"), ("\r", "<br data-nc-break=\"cr\">"), ("\n", "<br>")];

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<bool>) {
Expand Down Expand Up @@ -468,14 +480,14 @@ pub fn blocks_to_html(blocks: &[Block]) -> String {
if !head.is_empty() {
out.push_str("<tr>");
for c in head {
out.push_str(&format!("<th>{}</th>", spans_to_html(c)));
out.push_str(&format!("<th>{}</th>", spans_to_html_with_breaks(c, true)));
}
out.push_str("</tr>\n");
}
for r in rows {
out.push_str("<tr>");
for c in r {
out.push_str(&format!("<td>{}</td>", spans_to_html(c)));
out.push_str(&format!("<td>{}</td>", spans_to_html_with_breaks(c, true)));
}
out.push_str("</tr>\n");
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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!("<code>{text}</code>");
} else {
Expand Down
31 changes: 31 additions & 0 deletions crates/formats/tests/csv_multiline.rs
Original file line number Diff line number Diff line change
@@ -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", "<br data-nc-break=\"crlf\">\nsecond", "caf\u{e9}\n*literal* | `code` <br> \\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<br>b")), "a<br>b");
let original = markdown::parse("a<br>b\n\n| Value |\n| --- |\n| `<br>` |\n");
assert_eq!(render::blocks_to_text(&original), "a<br>b\nValue\n<br>");
assert_eq!(markdown::parse(&render::blocks_to_markdown(&original)), original);
assert_eq!(markdown::parse(&html::to_markdown(&render::blocks_to_html(&original))), original);
}
4 changes: 4 additions & 0 deletions docs/folder-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ tags: []
`<span style="font-size: Npt">text</span>` 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 `<br>` in both page formats, so a multiline CSV value stays
in one cell. LF is the default. `<br data-nc-break="crlf">` and
`<br data-nc-break="cr">` 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
Expand Down
Loading