use crate::inline::Inline;
use crate::sink::Sink;
use crate::vocab::{Emph, Vocab};
use crate::width::str_width;
use crate::{Channel, Options, Repairs};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
None,
Para,
Heading,
List,
Quote,
Fence,
Table,
}
#[derive(Clone)]
pub(crate) struct Engine {
pub v: Vocab,
inline: Inline,
pending: Vec<char>,
line_open: bool,
kind: LineKind,
out: String,
state: State,
heading: usize,
wrote: bool,
blank: bool,
held: Option<String>,
hold: HoldMemo,
scratch: String,
table: Table,
fence: FenceState,
cr: bool,
closed_fence: usize,
lists: Vec<(usize, bool)>,
list_gap: bool,
}
#[derive(Default, Clone)]
struct FenceState {
ch: char,
len: usize,
info: String,
body: bool,
}
impl Engine {
pub fn new(channel: Channel, options: &Options) -> Self {
Self {
v: Vocab::from_options(channel, options),
inline: Inline::new(),
pending: Vec::new(),
line_open: false,
kind: LineKind::Para,
out: String::new(),
state: State::None,
heading: 0,
wrote: false,
blank: false,
held: None,
hold: HoldMemo::default(),
scratch: String::new(),
table: Table::default(),
fence: FenceState::default(),
cr: false,
closed_fence: 0,
lists: Vec::new(),
list_gap: false,
}
}
pub fn repairs(&self) -> Repairs {
let mut r = self.inline.repairs;
r.closed_fence += self.closed_fence;
r
}
pub fn feed<S: Sink>(&mut self, chunk: &str, sink: &mut S) {
if chunk.is_empty() {
return;
}
if std::mem::take(&mut self.cr) && !chunk.starts_with('\n') {
self.pending.push('\r');
}
for seg in chunk.split_inclusive('\n') {
match seg.strip_suffix('\n') {
Some(rest) => {
let rest = rest.strip_suffix('\r').unwrap_or(rest);
self.pending.extend(rest.chars());
self.progress(true, sink);
}
None => {
let seg = match seg.strip_suffix('\r') {
Some(s) => {
self.cr = true;
s
}
None => seg,
};
self.pending.extend(seg.chars());
self.progress(false, sink);
}
}
}
}
pub fn finish<S: Sink>(&mut self, sink: &mut S) {
if std::mem::take(&mut self.cr) {
self.pending.push('\r');
}
if !self.pending.is_empty() || self.line_open {
self.progress(true, sink);
}
if let Some(held) = self.held.take() {
self.whole_para(&held, sink);
}
if self.state == State::Fence {
self.closed_fence += 1;
}
self.close_block(sink);
self.flush_all(sink);
sink.boundary();
}
pub fn preview<S: Sink>(&self, sink: &mut S) {
let mut e = self.clone();
e.inline.preview = true;
e.finish(sink);
}
fn progress<S: Sink>(&mut self, eol: bool, sink: &mut S) {
if self.state == State::Fence || self.state == State::Table || self.held.is_some() {
if !eol {
return;
}
if self.consume_whole_line(sink) {
return;
}
}
if !self.line_open {
self.hold = HoldMemo::default();
match classify(&self.pending, eol, true) {
Decision::NeedMore => return,
Decision::Whole(kind) => {
self.whole(kind, sink);
self.pending.clear();
return;
}
Decision::Prefix(kind, len) => {
self.open_line(kind, len, sink);
}
}
}
let cut = if eol { trim_end(&self.pending) } else { safe_cut(&self.pending, &mut self.hold) };
if cut > 0 {
self.inline.render(&self.pending[..cut], &mut self.out, &self.v);
self.pending.drain(..cut);
self.hold.shift(cut);
}
if eol {
self.pending.clear();
self.end_line(sink);
}
self.flush_safe(sink);
}
fn consume_whole_line<S: Sink>(&mut self, sink: &mut S) -> bool {
if self.state == State::Fence {
let line = self.take_line();
let closing = fence_marker(line.trim_start())
.is_some_and(|(ch, len, _)| ch == self.fence.ch && len >= self.fence.len);
if closing {
self.close_block(sink);
} else {
self.fence_body(&line);
self.flush_all(sink);
}
self.scratch = line;
return true;
}
if let Some(held) = self.held.take() {
let line = self.take_line();
if is_delimiter_row(&line) && self.table.begin(&held, &line) {
self.pending.clear();
self.scratch = line;
self.close_block(sink);
self.state = State::Table;
return true;
}
self.pending.clear();
self.pending.extend(line.chars());
self.scratch = line;
self.whole_para(&held, sink);
return false;
}
if self.state == State::Table {
let line = self.take_line();
let row = !line.trim().is_empty() && line.contains('|');
if row {
self.pending.clear();
self.table.push(&line);
} else {
self.pending.clear();
self.pending.extend(line.chars());
self.close_block(sink);
}
self.scratch = line;
return row;
}
false
}
fn take_line(&mut self) -> String {
let mut line = std::mem::take(&mut self.scratch);
line.clear();
line.extend(self.pending.iter());
self.pending.clear();
line
}
fn whole<S: Sink>(&mut self, kind: WholeKind, sink: &mut S) {
match kind {
WholeKind::Blank => {
if self.v.is_html() && self.state == State::List {
self.inline.finish_block(&mut self.out, &self.v);
self.list_gap = true;
self.blank = self.wrote;
self.flush_all(sink);
return;
}
self.close_block(sink);
self.blank = self.wrote;
}
WholeKind::Rule => {
self.close_block(sink);
self.start_line();
self.out.push_str(self.v.rule());
self.close_block(sink);
}
WholeKind::Fence => {
let line = self.take_line();
let t = line.trim_start();
let (ch, len, info) = fence_marker(t).expect("분류가 펜스라고 했다");
self.close_block(sink);
self.start_line();
self.fence = FenceState { ch, len, info: info.to_string(), body: false };
self.state = State::Fence;
let info = std::mem::take(&mut self.fence.info);
self.v.verbatim_open(&info, &mut self.out);
self.fence.info = info;
self.flush_all(sink);
self.scratch = line;
}
WholeKind::TableCandidate => {
self.held = Some(self.pending.iter().collect());
}
}
}
fn open_line<S: Sink>(&mut self, kind: LineKind, prefix: usize, sink: &mut S) {
if std::mem::take(&mut self.list_gap) && !matches!(kind, LineKind::Bullet(_) | LineKind::Ordered(..)) {
self.close_block(sink);
}
match kind {
LineKind::Para if self.state == State::List => {
self.out.push_str(self.v.line_break());
self.inline.note_raw(self.v.line_break());
self.inline.end_line();
for _ in 0..prefix.min(8) {
self.out.push(' ');
self.inline.note_raw(" ");
}
}
LineKind::Para => {
if self.state != State::Para {
self.close_block(sink);
self.state = State::Para;
self.start_line();
if self.v.is_html() {
self.out.push_str("<p>");
}
} else {
self.out.push_str(self.v.line_break());
self.inline.note_raw(self.v.line_break());
self.inline.end_line();
}
}
LineKind::Heading(level) => {
self.close_block(sink);
self.state = State::Heading;
self.start_line();
self.open_heading(level);
}
LineKind::Quote => {
if self.state != State::Quote {
self.close_block(sink);
self.state = State::Quote;
self.start_line();
self.out.push_str(self.v.quote_open());
} else {
self.out.push_str(self.v.line_break());
self.inline.note_raw(self.v.line_break());
self.inline.end_line();
}
self.out.push_str(self.v.quote_prefix());
self.inline.note_raw(self.v.quote_prefix());
self.inline.set_prev(None);
}
LineKind::Bullet(indent) | LineKind::Ordered(indent, _) => {
if self.state != State::List {
self.close_block(sink);
self.state = State::List;
} else {
self.inline.finish_block(&mut self.out, &self.v);
}
self.start_line();
if self.v.is_html() {
let n = match kind {
LineKind::Ordered(_, n) => Some(n),
_ => None,
};
self.open_item(indent, n);
} else {
for _ in 0..indent.min(8) {
self.out.push(' ');
}
match kind {
LineKind::Ordered(_, n) => {
let at = self.out.len();
push_usize(&mut self.out, n);
self.out.push_str(". ");
let written = &self.out[at..];
let src = self.pending.get(indent..indent + written.chars().count());
if src.is_none_or(|s| !written.chars().eq(s.iter().copied())) {
self.inline.repairs.rewritten_bullet += 1;
}
}
_ => {
let bullet = self.v.bullet();
let src = self.pending.get(indent..indent + 2);
if src.is_none_or(|s| !bullet.chars().eq(s.iter().copied())) {
self.inline.repairs.rewritten_bullet += 1;
}
self.out.push_str(bullet);
}
}
}
self.inline.set_prev(None);
}
}
self.pending.drain(..prefix);
self.kind = kind;
self.line_open = true;
}
fn end_line<S: Sink>(&mut self, sink: &mut S) {
match self.kind {
LineKind::Heading(_) => self.close_block(sink),
LineKind::Bullet(_) | LineKind::Ordered(..) | LineKind::Para | LineKind::Quote => {
self.inline.end_line()
}
}
self.line_open = false;
}
fn whole_para<S: Sink>(&mut self, line: &str, sink: &mut S) {
let saved = std::mem::take(&mut self.pending);
self.pending.extend(line.chars());
self.line_open = false;
match classify(&self.pending, true, false) {
Decision::Prefix(kind, len) => {
self.open_line(kind, len, sink);
let cut = trim_end(&self.pending);
if cut > 0 {
self.inline.render(&self.pending[..cut], &mut self.out, &self.v);
}
self.end_line(sink);
}
_ => self.whole(WholeKind::Blank, sink),
}
self.pending = saved;
self.flush_safe(sink);
}
fn open_item(&mut self, indent: usize, number: Option<usize>) {
let ordered = number.is_some();
while self.lists.last().is_some_and(|&(i, _)| i > indent) {
let (_, o) = self.lists.pop().expect("방금 봤다");
self.out.push_str(if o { "</li></ol>" } else { "</li></ul>" });
}
match self.lists.last().copied() {
Some((i, o)) if i == indent && o == ordered => self.out.push_str("</li>"),
Some((i, o)) if i == indent => {
self.lists.pop();
self.out.push_str(if o { "</li></ol>" } else { "</li></ul>" });
self.open_list(indent, number);
}
_ => self.open_list(indent, number),
}
self.out.push_str("<li>");
}
fn open_list(&mut self, indent: usize, number: Option<usize>) {
match number {
Some(n) if n != 1 => {
self.out.push_str("<ol start=\"");
push_usize(&mut self.out, n);
self.out.push_str("\">");
}
Some(_) => self.out.push_str("<ol>"),
None => self.out.push_str("<ul>"),
}
self.lists.push((indent, number.is_some()));
}
fn open_heading(&mut self, level: usize) {
let level = if self.heading == 0 { level } else { level.min(self.heading + 1) };
self.heading = level;
let max = self.v.max_heading();
if self.v.is_html() {
self.out.push_str("<h");
push_usize(&mut self.out, level.min(max));
self.out.push('>');
} else if max > 0 {
for _ in 0..level.min(max) {
self.out.push('#');
}
self.out.push(' ');
} else if !self.v.is_plain() {
self.out.push_str(self.v.open(Emph::Bold));
}
self.inline.set_prev(None);
}
fn fence_body(&mut self, line: &str) {
if self.fence.body || self.v.verbatim_body_newline() {
self.out.push('\n');
}
self.fence.body = true;
self.v.escape(line, &mut self.out);
}
fn start_line(&mut self) {
if self.wrote {
self.out.push('\n');
if self.blank {
self.out.push('\n');
}
}
self.blank = false;
self.wrote = true;
}
pub fn close_open(&self, out: &mut String) {
self.block_close_markup(out);
}
fn block_close_markup(&self, out: &mut String) {
match self.state {
State::Heading if self.v.is_html() => {
out.push_str("</h");
push_usize(out, self.heading.min(self.v.max_heading()));
out.push('>');
}
State::Heading => {
if self.v.max_heading() == 0 && !self.v.is_plain() {
out.push_str(self.v.close(Emph::Bold));
}
}
State::Para if self.v.is_html() => out.push_str("</p>"),
State::List => {
for &(_, ordered) in self.lists.iter().rev() {
out.push_str(if ordered { "</li></ol>" } else { "</li></ul>" });
}
}
State::Quote => out.push_str(self.v.quote_close()),
State::Fence => self.v.verbatim_close(&self.fence.info, out),
State::None | State::Para | State::Table => {}
}
}
fn close_block<S: Sink>(&mut self, sink: &mut S) {
match self.state {
State::None => {}
State::Para | State::List | State::Heading | State::Quote => {
self.inline.finish_block(&mut self.out, &self.v);
let mut out = std::mem::take(&mut self.out);
self.block_close_markup(&mut out);
self.out = out;
}
State::Fence => {
let mut out = std::mem::take(&mut self.out);
self.block_close_markup(&mut out);
self.out = out;
}
State::Table => {
let mut table = std::mem::take(&mut self.table);
self.start_line();
let breaks = table.render(&self.v, &mut self.inline.repairs, &mut self.out);
table.clear();
self.table = table;
if !breaks.is_empty() {
let mut from = 0;
for at in breaks {
sink.text(&self.out[from..at]);
sink.boundary();
from = at;
}
self.out.drain(..from);
}
}
}
self.state = State::None;
self.lists.clear();
self.list_gap = false;
self.inline.reset();
self.flush_all(sink);
sink.boundary();
}
fn flush_safe<S: Sink>(&mut self, sink: &mut S) {
let safe = self.inline.safe_len(self.out.len());
if safe == 0 {
return;
}
sink.text(&self.out[..safe]);
self.out.drain(..safe);
self.inline.shift(safe);
}
fn flush_all<S: Sink>(&mut self, sink: &mut S) {
debug_assert!(!self.inline.is_open(), "열린 마크업이 있는 채로 전부 내보낼 수 없다");
if !self.out.is_empty() {
sink.text(&self.out);
self.out.clear();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineKind {
Para,
Heading(usize),
Quote,
Bullet(usize),
Ordered(usize, usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WholeKind {
Blank,
Rule,
Fence,
TableCandidate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
NeedMore,
Prefix(LineKind, usize),
Whole(WholeKind),
}
fn marker_space(c: char) -> bool {
c == ' ' || c == '\t'
}
fn classify(p: &[char], eol: bool, can_table: bool) -> Decision {
let indent = p.iter().take_while(|&&c| c == ' ' || c == '\t').count();
if indent == p.len() {
return if eol { Decision::Whole(WholeKind::Blank) } else { Decision::NeedMore };
}
let t = &p[indent..];
let para = Decision::Prefix(LineKind::Para, indent);
let at_end = |d: Decision| if eol { para } else { d };
match t[0] {
'#' => {
let n = run(t, '#');
if n > 6 {
return para;
}
match t.get(n) {
Some(c) if marker_space(*c) => {
Decision::Prefix(LineKind::Heading(n), indent + n + 1)
}
Some(_) => para,
None => at_end(Decision::NeedMore),
}
}
'>' => match t.get(1) {
Some(' ') => Decision::Prefix(LineKind::Quote, indent + 2),
Some(_) => Decision::Prefix(LineKind::Quote, indent + 1),
None => {
if eol {
Decision::Prefix(LineKind::Quote, indent + 1)
} else {
Decision::NeedMore
}
}
},
c @ ('`' | '~') => {
let n = run(t, c);
if n >= 3 {
return if eol { Decision::Whole(WholeKind::Fence) } else { Decision::NeedMore };
}
if t.len() > n {
para
} else {
at_end(Decision::NeedMore)
}
}
'|' if can_table => {
if eol {
Decision::Whole(WholeKind::TableCandidate)
} else {
Decision::NeedMore
}
}
'+' | '•' => match t.get(1) {
Some(c) if marker_space(*c) => Decision::Prefix(LineKind::Bullet(indent), indent + 2),
Some(_) => para,
None => at_end(Decision::NeedMore),
},
c @ ('-' | '*' | '_') => {
let n = run(t, c);
if n == 1 && c != '_' {
return match t.get(1) {
Some(c) if marker_space(*c) => {
Decision::Prefix(LineKind::Bullet(indent), indent + 2)
}
Some(_) => para,
None => at_end(Decision::NeedMore),
};
}
if t[n..].iter().any(|&x| x != c && !marker_space(x)) {
return para;
}
if eol {
if t.iter().filter(|&&x| x == c).count() >= 3 {
Decision::Whole(WholeKind::Rule)
} else {
para
}
} else {
Decision::NeedMore
}
}
'0'..='9' => {
let d = t.iter().take_while(|c| c.is_ascii_digit()).count();
if d > 9 {
return para;
}
match (t.get(d), t.get(d + 1)) {
(Some('.' | ')'), Some(c)) if marker_space(*c) => {
let mut n = 0usize;
for c in &t[..d] {
n = n * 10 + (*c as usize - '0' as usize);
}
Decision::Prefix(LineKind::Ordered(indent, n), indent + d + 2)
}
(Some('.' | ')'), None) | (None, _) => at_end(Decision::NeedMore),
(Some(_), _) => para,
}
}
_ => para,
}
}
fn safe_cut(p: &[char], memo: &mut HoldMemo) -> usize {
let mut k = p.len();
let mut i = memo.scanned.min(k);
while i < k {
if p[i] != '[' {
i += 1;
continue;
}
let from = match memo.link {
Some((at, scan)) if at == i => scan,
_ => LinkScan::Bracket { j: 1, depth: 0 },
};
match link_end_from(&p[i..k], from) {
Ok(end) => i += end,
Err(scan) => {
memo.link = Some((i, scan));
k = i;
break;
}
}
}
memo.scanned = i;
let mut i = memo.comments.min(k);
while i + 4 <= k {
if p[i..].starts_with(&['<', '!', '-', '-']) {
let body = &p[i + 4..k];
let from = match memo.comment {
Some((at, from)) if at == i => from.min(body.len()),
_ => 0,
};
match body[from..].windows(3).position(|w| w == ['-', '-', '>']) {
Some(end) => {
i += 4 + from + end + 3;
memo.comments = i;
}
None => {
memo.comment = Some((i, body.len().saturating_sub(2)));
k = i;
break;
}
}
} else {
i += 1;
memo.comments = memo.comments.max(i.min(k.saturating_sub(3)));
}
}
if let Some(at) = p[..k].iter().rposition(|&c| c == '<') {
let body = &p[at + 1..k];
if !p[at..k].contains(&'>') {
let autolink = starts_with_chars(body, "http://") || starts_with_chars(body, "https://");
if autolink {
let ws = body.iter().position(|c| c.is_whitespace());
let bar = body.iter().position(|&c| c == '|');
if ws.is_none_or(|w| bar.is_some_and(|b| b < w)) {
k = k.min(at);
}
} else {
let tagish = p.get(at + 1).is_none_or(|&c| c.is_ascii_alphabetic() || c == '/' || c == '!');
if tagish && k - at < 80 {
k = k.min(at);
}
}
}
}
while k > 0 && matches!(p[k - 1], '*' | '_' | '~' | '`' | '\\' | ' ' | '\t' | '!') {
k -= 1;
}
while k > 0 && p[k - 1] == '\\' {
k -= 1;
}
if tags_only(&p[..k], memo) {
k = 0;
}
k
}
fn tags_only(p: &[char], memo: &mut HoldMemo) -> bool {
if memo.not_tags {
return false;
}
let mut i = memo.tags.min(p.len());
loop {
while i < p.len() && matches!(p[i], ' ' | '\t') {
i += 1;
}
if i == p.len() {
return memo.tags > 0;
}
let comment = p[i..].starts_with(&['<', '!', '-', '-']);
if p[i] != '<' || !(comment || p.get(i + 1).is_some_and(|&c| c.is_ascii_alphabetic() || c == '/')) {
memo.not_tags = true;
return false;
}
let end = if comment {
p[i + 4..].windows(3).position(|w| w == ['-', '-', '>']).map(|e| i + 4 + e + 3)
} else {
p[i..].iter().position(|&c| c == '>').map(|e| i + e + 1)
};
let Some(end) = end else { return false };
i = end;
memo.tags = end;
}
}
#[derive(Default, Clone, Copy)]
pub(crate) struct HoldMemo {
scanned: usize,
link: Option<(usize, LinkScan)>,
comment: Option<(usize, usize)>,
comments: usize,
tags: usize,
not_tags: bool,
}
impl HoldMemo {
fn shift(&mut self, n: usize) {
self.scanned = self.scanned.saturating_sub(n);
self.link = self.link.and_then(|(at, s)| at.checked_sub(n).map(|at| (at, s)));
self.comment = self.comment.and_then(|(at, f)| at.checked_sub(n).map(|at| (at, f)));
if n > 0 {
self.not_tags = true;
}
self.tags = self.tags.saturating_sub(n);
self.comments = self.comments.saturating_sub(n);
}
}
#[derive(Clone, Copy)]
enum LinkScan {
Bracket { j: usize, depth: usize },
Closed { at: usize },
Paren { j: usize },
}
fn link_end_from(p: &[char], mut scan: LinkScan) -> Result<usize, LinkScan> {
loop {
scan = match scan {
LinkScan::Bracket { mut j, mut depth } => {
loop {
match p.get(j) {
None => return Err(LinkScan::Bracket { j, depth }),
Some('[') => depth += 1,
Some(']') if depth == 0 => break,
Some(']') => depth -= 1,
Some(_) => {}
}
j += 1;
}
LinkScan::Closed { at: j }
}
LinkScan::Closed { at } => match p.get(at + 1) {
None => return Err(LinkScan::Closed { at }),
Some('(') => LinkScan::Paren { j: at + 2 },
Some(_) => return Ok(at + 1),
},
LinkScan::Paren { j } => {
return match p[j.min(p.len())..].iter().position(|&c| c == ')') {
Some(c) => Ok(j + c + 1),
None => Err(LinkScan::Paren { j: p.len() }),
};
}
}
}
}
fn starts_with_chars(chars: &[char], s: &str) -> bool {
let mut it = chars.iter();
for c in s.chars() {
match it.next() {
Some(&x) if x == c => {}
Some(_) => return false,
None => return true,
}
}
true
}
fn trim_end(p: &[char]) -> usize {
let mut k = p.len();
while k > 0 && (p[k - 1] == ' ' || p[k - 1] == '\t') {
k -= 1;
}
k
}
fn run(t: &[char], c: char) -> usize {
t.iter().take_while(|&&x| x == c).count()
}
fn push_usize(out: &mut String, mut n: usize) {
let mut buf = [0u8; 20];
let mut i = buf.len();
loop {
i -= 1;
buf[i] = b'0' + (n % 10) as u8;
n /= 10;
if n == 0 {
break;
}
}
out.push_str(std::str::from_utf8(&buf[i..]).expect("숫자는 ASCII 다"));
}
fn fence_marker(t: &str) -> Option<(char, usize, &str)> {
for ch in ['`', '~'] {
let n = t.chars().take_while(|&c| c == ch).count();
if n >= 3 {
return Some((ch, n, t[n..].trim()));
}
}
None
}
fn is_delimiter_row(line: &str) -> bool {
let t = line.trim();
t.contains('-')
&& t.contains('|')
&& t.chars().all(|c| matches!(c, '-' | ':' | '|' | ' ' | '\t'))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum Align {
#[default]
Left,
Right,
Center,
}
#[derive(Default, Clone)]
pub(crate) struct Table {
rows: Vec<Vec<String>>,
align: Vec<Align>,
cell: String,
src: String,
}
impl Table {
fn begin(&mut self, header: &str, delim: &str) -> bool {
self.src.clear();
self.src.push_str(header);
self.src.push('\n');
self.src.push_str(delim);
let head = split_cells(header);
let delim = split_cells(delim);
if head.is_empty() || head.len() != delim.len() {
return false;
}
self.align = delim
.iter()
.map(|d| {
let d = d.trim();
match (d.starts_with(':'), d.ends_with(':')) {
(true, true) => Align::Center,
(false, true) => Align::Right,
_ => Align::Left,
}
})
.collect();
self.rows.clear();
self.rows.push(head);
true
}
fn push(&mut self, line: &str) {
self.rows.push(split_cells(line));
self.src.push('\n');
self.src.push_str(line);
}
fn clear(&mut self) {
self.rows.clear();
self.align.clear();
self.src.clear();
}
fn count_rewrite(&self, written: &str, repairs: &mut Repairs) {
if !written.lines().map(str::trim).eq(self.src.lines().map(str::trim)) {
repairs.rewritten_table += 1;
}
}
fn render(&mut self, v: &Vocab, repairs: &mut Repairs, out: &mut String) -> Vec<usize> {
let cols = self.align.len();
let plain = Vocab::new(Channel::Plain);
let cell_vocab = if v.tables_native() { v } else { &plain };
let rows = std::mem::take(&mut self.rows);
let mut cells: Vec<Vec<String>> = Vec::with_capacity(rows.len());
let mut inline = Inline::new();
inline.in_cell = true;
let mut chars: Vec<char> = Vec::new();
for row in &rows {
let mut line = Vec::with_capacity(cols);
for c in 0..cols {
self.cell.clear();
chars.clear();
if c + 1 == cols && row.len() > cols {
for (k, cell) in row[c..].iter().enumerate() {
if k > 0 {
chars.extend(" | ".chars());
}
chars.extend(cell.chars());
}
} else {
chars.extend(row.get(c).map(String::as_str).unwrap_or("").chars());
}
inline.render(&chars, &mut self.cell, cell_vocab);
inline.finish_block(&mut self.cell, cell_vocab);
inline.reset();
line.push(std::mem::take(&mut self.cell));
}
cells.push(line);
}
self.rows = rows;
repairs.add(inline.repairs);
let from = out.len();
if v.is_html() {
write_html_table(out, &cells, &self.align);
return Vec::new();
}
if v.xml_tables() {
return write_notion_table(out, &mut cells, v.limit);
}
if v.tables_native() {
write_gfm_table(out, &cells, &self.align);
self.count_rewrite(&out[from..], repairs);
return Vec::new();
}
let mut widths = vec![0usize; cols];
for row in &cells {
for (c, cell) in row.iter().enumerate() {
widths[c] = widths[c].max(str_width(cell));
}
}
let mut body = String::new();
for (r, row) in cells.iter().enumerate() {
if r > 0 {
body.push('\n');
}
write_row(&mut body, row, &widths, &self.align);
if r == 0 {
body.push('\n');
for (c, w) in widths.iter().enumerate() {
if c > 0 {
body.push_str(" | ");
}
for _ in 0..*w {
body.push('-');
}
}
}
}
v.verbatim_open("", out);
if v.verbatim_body_newline() {
out.push('\n');
}
v.escape(&body, out);
v.verbatim_close("", out);
self.count_rewrite(&out[from..], repairs);
Vec::new()
}
}
fn write_html_table(out: &mut String, cells: &[Vec<String>], align: &[Align]) {
out.push_str("<table>");
for (r, row) in cells.iter().enumerate() {
let tag = if r == 0 { "th" } else { "td" };
if r == 0 {
out.push_str("\n<thead>");
} else if r == 1 {
out.push_str("\n<tbody>");
}
out.push_str("<tr>");
for (c, cell) in row.iter().enumerate() {
out.push('<');
out.push_str(tag);
match align.get(c) {
Some(Align::Right) => out.push_str(" style=\"text-align:right\""),
Some(Align::Center) => out.push_str(" style=\"text-align:center\""),
_ => {}
}
out.push('>');
out.push_str(cell);
out.push_str("</");
out.push_str(tag);
out.push('>');
}
out.push_str("</tr>");
if r == 0 {
out.push_str("</thead>");
}
}
if cells.len() > 1 {
out.push_str("</tbody>");
}
out.push_str("\n</table>");
}
fn write_notion_table(out: &mut String, cells: &mut [Vec<String>], limit: usize) -> Vec<usize> {
const OPEN: &str = "<table header-row=\"true\">";
const CLOSE: &str = "\n</table>";
for cell in cells.iter_mut().flatten() {
if cell.contains('<') {
let mut e = String::with_capacity(cell.len() + 8);
push_notion_cell(cell, &mut e);
*cell = e;
}
}
let row_len = |row: &[String]| -> usize {
"\n<tr>\n</tr>".len() + row.iter().map(|c| "\n<td></td>".len() + c.chars().count()).sum::<usize>()
};
let write_row = |out: &mut String, row: &[String]| {
out.push_str("\n<tr>");
for cell in row {
out.push_str("\n<td>");
out.push_str(cell);
out.push_str("</td>");
}
out.push_str("\n</tr>");
};
let Some((head, body)) = cells.split_first() else { return Vec::new() };
let base = OPEN.len() + CLOSE.len() + row_len(head);
if base > limit {
for (r, row) in cells.iter().enumerate() {
if r > 0 {
out.push('\n');
}
write_text_row(out, row);
}
return Vec::new();
}
let mut breaks = Vec::new();
let mut wrote = false;
let mut head_shown = false;
let mut open = false;
let mut used = 0;
let mut piece = |out: &mut String, wrote: &mut bool| {
if *wrote {
breaks.push(out.len());
out.push_str("\n\n");
}
*wrote = true;
};
for row in body {
let n = row_len(row);
if base + n > limit {
if open {
out.push_str(CLOSE);
open = false;
}
piece(out, &mut wrote);
if !head_shown {
write_text_row(out, head);
out.push('\n');
head_shown = true;
}
write_text_row(out, row);
continue;
}
if open && used + n > limit {
out.push_str(CLOSE);
open = false;
}
if !open {
piece(out, &mut wrote);
out.push_str(OPEN);
write_row(out, head);
open = true;
head_shown = true;
used = base;
}
write_row(out, row);
used += n;
}
if open {
out.push_str(CLOSE);
} else if !wrote {
out.push_str(OPEN);
write_row(out, head);
out.push_str(CLOSE);
}
breaks
}
fn write_text_row(out: &mut String, row: &[String]) {
for (k, cell) in row.iter().enumerate() {
if k > 0 {
out.push_str(" | ");
} else {
let digits = cell.len() - cell.trim_start_matches(|c: char| c.is_ascii_digit()).len();
match cell.as_bytes().first() {
Some(b'#' | b'>' | b'-' | b'+' | b'*') => out.push('\\'),
Some(b'0'..=b'9') if matches!(cell.as_bytes().get(digits), Some(b'.' | b')')) => {
out.push_str(&cell[..digits]);
out.push('\\');
out.push_str(&cell[digits..]);
continue;
}
_ => {}
}
}
out.push_str(cell);
}
}
fn push_notion_cell(cell: &str, out: &mut String) {
let mut code: Option<usize> = None;
let mut close_at: Option<usize> = None;
let mut it = cell.char_indices().peekable();
while let Some((i, c)) = it.next() {
if close_at == Some(i) {
out.push_str("\\>");
close_at = None;
continue;
}
if c == '`' {
let mut run = 1;
while it.next_if(|&(_, x)| x == '`').is_some() {
run += 1;
}
code = match code {
Some(r) if r == run => None,
None => Some(run),
keep => keep,
};
out.extend(std::iter::repeat_n('`', run));
continue;
}
if c == '<' && close_at.is_none() {
let rest = &cell[i + 1..];
let slash = rest.starts_with('/');
let name = &rest[usize::from(slash)..];
let name_len = name.len() - name.trim_start_matches(|x: char| x.is_ascii_alphanumeric()).len();
let word = &name[..name_len];
let tag_like = word.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
&& !(!slash && word.eq_ignore_ascii_case("br") && name[name_len..].starts_with('>'));
let structural = ["td", "tr", "th", "table"].iter().any(|t| word.eq_ignore_ascii_case(t));
let gt = rest.find('>').filter(|&g| rest.find('<').is_none_or(|l| g < l));
if tag_like && gt.is_some() && (code.is_none() || structural) {
out.push_str(if slash { "\\<\\/" } else { "\\<" });
close_at = gt.map(|g| i + 1 + g);
if slash {
it.next();
}
continue;
}
}
out.push(c);
}
}
fn write_gfm_table(out: &mut String, cells: &[Vec<String>], align: &[Align]) {
let write_cells = |out: &mut String, row: &[String]| {
out.push('|');
for cell in row {
out.push(' ');
for c in cell.chars() {
if c == '|' {
out.push('\\');
}
out.push(c);
}
out.push_str(" |");
}
};
for (r, row) in cells.iter().enumerate() {
if r > 0 {
out.push('\n');
}
write_cells(out, row);
if r == 0 {
out.push_str("\n|");
for i in 0..row.len() {
out.push_str(match align.get(i).unwrap_or(&Align::Left) {
Align::Left => " --- |",
Align::Right => " ---: |",
Align::Center => " :---: |",
});
}
}
}
}
fn write_row(out: &mut String, row: &[String], widths: &[usize], align: &[Align]) {
let last = row.len().saturating_sub(1);
for (c, cell) in row.iter().enumerate() {
if c > 0 {
out.push_str(" | ");
}
let pad = widths[c].saturating_sub(str_width(cell));
let (before, after) = match align.get(c).copied().unwrap_or_default() {
Align::Left => (0, pad),
Align::Right => (pad, 0),
Align::Center => (pad / 2, pad - pad / 2),
};
for _ in 0..before {
out.push(' ');
}
out.push_str(cell);
if c != last {
for _ in 0..after {
out.push(' ');
}
}
}
}
fn split_cells(line: &str) -> Vec<String> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = t.strip_suffix('|').unwrap_or(t);
let mut cells = Vec::new();
let mut cur = String::new();
let mut escaped = false;
for c in t.chars() {
match c {
'\\' if !escaped => escaped = true,
'|' if !escaped => {
cells.push(cur.trim().to_string());
cur = String::new();
}
_ => {
if escaped && c != '|' {
cur.push('\\');
}
escaped = false;
cur.push(c);
}
}
}
cells.push(cur.trim().to_string());
cells
}