mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 13:24:35 +00:00
179 lines
8.3 KiB
Rust
179 lines
8.3 KiB
Rust
use super::as_html::allowed;
|
|
use super::layout::{Cell, Item, MAX_COLS, Page, items};
|
|
|
|
/// Markdown's deepest heading.
|
|
const MAX_HEADING: u8 = 6;
|
|
|
|
/// `s` as Markdown text on one line: line breaks become spaces, control characters are dropped,
|
|
/// and every character Markdown would read as markup is escaped with a backslash.
|
|
fn md_text(s: &str) -> String {
|
|
let mut o = String::with_capacity(s.len());
|
|
for c in s.chars().filter(|c| allowed(*c)) {
|
|
match c {
|
|
'\n' | '\r' | '\t' => o.push(' '),
|
|
'\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '|' | '~' | '!' | '&' => {
|
|
o.push('\\');
|
|
o.push(c);
|
|
}
|
|
c => o.push(c),
|
|
}
|
|
}
|
|
o
|
|
}
|
|
|
|
/// `line` with its start escaped where Markdown would read a list item there (`- `, `+ `,
|
|
/// `1. `, `1) `); the other block markers (`#`, `>`) are escaped by [`md_text`] already.
|
|
fn block_start(line: String) -> String {
|
|
let digits = line.bytes().take_while(u8::is_ascii_digit).count();
|
|
match line.as_bytes().get(digits) {
|
|
Some(b'.' | b')') if digits > 0 => format!("{}\\{}", line.get(..digits).unwrap_or_default(), line.get(digits..).unwrap_or_default()),
|
|
Some(b'-' | b'+') if digits == 0 => format!("\\{line}"),
|
|
_ => line,
|
|
}
|
|
}
|
|
|
|
/// `text` (already escaped) in bold and/or italic, its outer spaces kept outside. `open` and
|
|
/// `close` say whether `*` markers would be read as emphasis on that side (see [`marks`]);
|
|
/// where they would not, as in `foo` + bold `.` + `bar`, the run is wrapped in HTML tags, which
|
|
/// CommonMark takes inline. The text's own outer spaces count as a side that marks.
|
|
pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close: bool) -> String {
|
|
let core = text.trim();
|
|
if core.is_empty() || !(bold || italic) {
|
|
return text.to_string();
|
|
}
|
|
let lead = text.get(..text.len().saturating_sub(text.trim_start().len())).unwrap_or_default();
|
|
let trail = text.get(text.trim_end().len()..).unwrap_or_default();
|
|
let markers = (open || !lead.is_empty()) && (close || !trail.is_empty());
|
|
let (before, after) = match (markers, bold, italic) {
|
|
(true, true, true) => ("***", "***"),
|
|
(true, true, false) => ("**", "**"),
|
|
(true, false, _) => ("*", "*"),
|
|
(false, true, true) => ("<strong><em>", "</em></strong>"),
|
|
(false, true, false) => ("<strong>", "</strong>"),
|
|
(false, false, _) => ("<em>", "</em>"),
|
|
};
|
|
format!("{lead}{before}{core}{after}{trail}")
|
|
}
|
|
|
|
/// A paragraph's runs (text, bold, italic) as one line of Markdown. Neighbouring runs in the
|
|
/// same style are joined first, so two bold runs never meet as `****`.
|
|
pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String {
|
|
let mut merged: Vec<(String, bool, bool)> = Vec::new();
|
|
for &(text, bold, italic) in runs {
|
|
let text = md_text(text);
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
match merged.last_mut() {
|
|
Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(&text),
|
|
_ => merged.push((text, bold, italic)),
|
|
}
|
|
}
|
|
let mut out = String::new();
|
|
for (k, (text, bold, italic)) in merged.iter().enumerate() {
|
|
let core = text.trim();
|
|
let open = marks(core.chars().next(), out.chars().last());
|
|
// Markers of a styled neighbour right next to these would join them into one run.
|
|
let close = match merged.get(k.saturating_add(1)) {
|
|
Some((next, b, i)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false,
|
|
next => marks(core.chars().last(), next.and_then(|(n, ..)| n.chars().next())),
|
|
};
|
|
out.push_str(&emphasis(text, *bold, *italic, open, close));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Whether a `*` marker between the text's `edge` character and the `outside` one is read as
|
|
/// emphasis (CommonMark's flanking rules for `*`): always next to a space or the line's edge,
|
|
/// never next to another `*`, and otherwise unless the text's edge is punctuation while the
|
|
/// outside is a letter or digit.
|
|
fn marks(edge: Option<char>, outside: Option<char>) -> bool {
|
|
match (edge, outside) {
|
|
(_, None) => true,
|
|
(_, Some(o)) if o.is_whitespace() => true,
|
|
(_, Some('*')) => false,
|
|
(Some(e), Some(o)) => e.is_alphanumeric() || o.is_ascii_punctuation(),
|
|
(None, Some(_)) => false,
|
|
}
|
|
}
|
|
|
|
fn cell(c: &Cell) -> String {
|
|
// A cell sits between `| ` and ` |`, so its markers always have a space outside.
|
|
emphasis(&md_text(&c.text), c.bold, c.italic, true, true)
|
|
}
|
|
|
|
/// Markdown (CommonMark, with GitHub's pipe tables): headings, paragraphs with bold and italic,
|
|
/// tables with their first row as the header, and a rule between pages. Images are left out.
|
|
pub fn markdown(pages: &[Page]) -> String {
|
|
let mut blocks: Vec<String> = Vec::new();
|
|
for it in items(pages) {
|
|
match it {
|
|
Item::Para(runs, lvl) => {
|
|
// A heading's weight is the heading's own, as in the HTML export.
|
|
let runs: Vec<(&str, bool, bool)> = runs.iter().map(|r| (r.text.as_str(), r.block.bold && lvl == 0, r.block.italic)).collect();
|
|
let text = paragraph(&runs);
|
|
let text = text.trim();
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
blocks.push(match lvl {
|
|
0 => block_start(text.to_string()),
|
|
l => format!("{} {text}", "#".repeat(usize::from(l.min(MAX_HEADING)))),
|
|
});
|
|
}
|
|
Item::Table(t) => {
|
|
// Each row as wide as the widest; a cell spanning columns is followed by empty ones.
|
|
// Detected tables have at most MAX_COLS columns, and a span never counts for more.
|
|
let span = |c: &Cell| c.span.clamp(1, MAX_COLS);
|
|
let width = t.rows.iter().map(|r| r.iter().map(span).fold(0usize, usize::saturating_add)).max().unwrap_or(0);
|
|
if width == 0 {
|
|
continue;
|
|
}
|
|
let mut lines = Vec::with_capacity(t.rows.len().saturating_add(1));
|
|
for (i, row) in t.rows.iter().enumerate() {
|
|
let mut cells: Vec<String> = Vec::with_capacity(width);
|
|
for c in row {
|
|
cells.push(cell(c));
|
|
cells.extend(std::iter::repeat_n(String::new(), span(c).saturating_sub(1)));
|
|
}
|
|
cells.resize(width, String::new());
|
|
lines.push(format!("| {} |", cells.join(" | ")));
|
|
if i == 0 {
|
|
lines.push(format!("|{}", " --- |".repeat(width)));
|
|
}
|
|
}
|
|
blocks.push(lines.join("\n"));
|
|
}
|
|
Item::Img(_) => {}
|
|
Item::PageBreak => blocks.push("---".into()),
|
|
}
|
|
}
|
|
let mut s = blocks.join("\n\n");
|
|
s.push('\n');
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::paragraph;
|
|
|
|
#[test]
|
|
fn empty_or_control_run_does_not_split_styled_runs() {
|
|
assert_eq!(paragraph(&[("hello", true, false), ("", false, false), ("world", true, false)]), "**helloworld**");
|
|
assert_eq!(paragraph(&[("hello", true, false), ("\u{1}", false, false), ("world", true, false)]), "**helloworld**");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_styled_run_does_not_affect_neighbor_emphasis() {
|
|
assert_eq!(paragraph(&[("x", true, false), ("", false, true)]), "**x**");
|
|
assert_eq!(paragraph(&[("foo", false, false), (".", true, false), ("", true, true), ("bar", false, false)]), "foo<strong>.</strong>bar");
|
|
// Empty/control runs between differently styled runs still look ahead to the next non-empty run.
|
|
assert_eq!(paragraph(&[("bold", true, false), ("", false, false), ("italic", false, true)]), "<strong>bold</strong>*italic*");
|
|
assert_eq!(paragraph(&[("bold", true, false), ("\u{1}", false, false), (" italic", false, true)]), "**bold** *italic*");
|
|
// Leading empty/control runs do not affect the opening delimiter.
|
|
assert_eq!(paragraph(&[("", false, true), ("\u{1}", true, false), ("lead", true, false)]), "**lead**");
|
|
// All-empty/control paragraphs produce empty output.
|
|
assert_eq!(paragraph(&[("", true, false), ("\u{1}", false, true), ("\u{7f}", false, false)]), "");
|
|
}
|
|
}
|