mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 12:57:09 -06:00
refactor(formats): split latex.rs into latex/
This commit is contained in:
+20
-2806
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
use super::math::linear_to_latex;
|
||||
use super::*;
|
||||
|
||||
/// Text width of the exported article in points (`\linewidth` when reading widths).
|
||||
pub(super) const LINE_WIDTH: f32 = 345.0;
|
||||
|
||||
/// LaTeX's `\tabcolsep` (both sides of a cell), points.
|
||||
pub(super) const TABCOLSEP: f32 = 6.0;
|
||||
|
||||
// =============================================================================================
|
||||
// Export
|
||||
|
||||
/// Export `doc` as a LaTeX source file.
|
||||
pub fn export(doc: &Document) -> String {
|
||||
let flow = model::from_doc(doc);
|
||||
let mut w = Writer::default();
|
||||
let title_at = flow.blocks.iter().position(|b| matches!(b, FBlock::Para(p) if p.kind == Kind::Title && !p.is_empty()));
|
||||
let mut title = String::new();
|
||||
if let Some(FBlock::Para(p)) = title_at.and_then(|i| flow.blocks.get(i)) {
|
||||
title = w.inlines(&p.inlines);
|
||||
}
|
||||
w.title_at = title_at;
|
||||
w.blocks(&flow.blocks);
|
||||
let body = std::mem::take(&mut w.out);
|
||||
|
||||
let meta = &flow.meta;
|
||||
let mut o = String::new();
|
||||
o.push_str("% Written by WordCraft (https://getartcraft.com/apps/wordcraft).\n");
|
||||
o.push_str("\\documentclass{article}\n");
|
||||
o.push_str("\\usepackage[T1]{fontenc}\n");
|
||||
if w.uses.ulem {
|
||||
o.push_str("\\usepackage[normalem]{ulem}\n");
|
||||
}
|
||||
if w.uses.color {
|
||||
o.push_str("\\usepackage[table]{xcolor}\n");
|
||||
}
|
||||
if w.uses.multirow {
|
||||
o.push_str("\\usepackage{multirow}\n");
|
||||
}
|
||||
if w.uses.math {
|
||||
o.push_str("\\usepackage{amsmath}\n");
|
||||
o.push_str("\\usepackage{amssymb}\n");
|
||||
}
|
||||
o.push_str("\\usepackage{hyperref}\n");
|
||||
let pdf = [("pdftitle", &meta.title), ("pdfauthor", &meta.author), ("pdfsubject", &meta.subject), ("pdfkeywords", &meta.keywords)];
|
||||
let set: Vec<String> = pdf.iter().filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| format!("{k}={{{}}}", escape_text(v.trim()))).collect();
|
||||
if !set.is_empty() {
|
||||
o.push_str(&format!("\\hypersetup{{{}}}\n", set.join(", ")));
|
||||
}
|
||||
if title_at.is_some() {
|
||||
o.push_str(&format!("\\title{{{title}}}\n"));
|
||||
// `\maketitle` shows only the title: the document didn't show an author or a date.
|
||||
o.push_str("\\author{}\n\\date{}\n");
|
||||
}
|
||||
o.push_str("\n\\begin{document}\n\n");
|
||||
o.push_str(body.trim_end());
|
||||
o.push_str("\n\n\\end{document}\n");
|
||||
o
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Uses {
|
||||
ulem: bool,
|
||||
color: bool,
|
||||
multirow: bool,
|
||||
math: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Writer {
|
||||
pub(super) out: String,
|
||||
uses: Uses,
|
||||
/// Index of the paragraph `\maketitle` stands for (top level only).
|
||||
title_at: Option<usize>,
|
||||
/// Inside a table cell: no `verbatim`, paragraphs separated by `\par`.
|
||||
in_cell: bool,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
const SECTIONS: [&str; 6] = ["section", "subsection", "subsubsection", "paragraph", "subparagraph", "subparagraph"];
|
||||
|
||||
impl Writer {
|
||||
fn blocks(&mut self, blocks: &[FBlock]) {
|
||||
let mut lists: Vec<bool> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(b) = blocks.get(i) {
|
||||
let list = if let FBlock::Para(p) = b { p.list } else { None };
|
||||
match list {
|
||||
Some(li) => {
|
||||
let depth = (li.level.min(3) + 1) as usize;
|
||||
while lists.len() > depth || (lists.len() == depth && lists.last() != Some(&li.ordered)) {
|
||||
self.close_list(&mut lists);
|
||||
}
|
||||
while lists.len() < depth {
|
||||
let env = if li.ordered { "enumerate" } else { "itemize" };
|
||||
self.out.push_str(&format!("{}\\begin{{{env}}}\n", " ".repeat(lists.len())));
|
||||
lists.push(li.ordered);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
while !lists.is_empty() {
|
||||
self.close_list(&mut lists);
|
||||
}
|
||||
}
|
||||
}
|
||||
match b {
|
||||
FBlock::Para(p) if p.list.is_some() => {
|
||||
let text = self.inlines(&p.inlines);
|
||||
self.page_break(p);
|
||||
self.out.push_str(&format!("{}\\item {}\n", " ".repeat(lists.len()), text.trim()));
|
||||
}
|
||||
FBlock::Para(p) if p.kind == Kind::Code => {
|
||||
let mut j = i;
|
||||
let mut lines = Vec::new();
|
||||
while let Some(FBlock::Para(q)) = blocks.get(j) {
|
||||
if q.kind != Kind::Code || q.list.is_some() {
|
||||
break;
|
||||
}
|
||||
lines.push(q.text());
|
||||
j += 1;
|
||||
}
|
||||
self.page_break(p);
|
||||
self.code(&lines);
|
||||
i = j.max(i + 1);
|
||||
continue;
|
||||
}
|
||||
FBlock::Para(p) if p.kind == Kind::Quote => {
|
||||
let mut j = i;
|
||||
let mut paras = Vec::new();
|
||||
while let Some(FBlock::Para(q)) = blocks.get(j) {
|
||||
if q.kind != Kind::Quote || q.list.is_some() {
|
||||
break;
|
||||
}
|
||||
paras.push(self.inlines(&q.inlines));
|
||||
j += 1;
|
||||
}
|
||||
self.page_break(p);
|
||||
let body: Vec<&str> = paras.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
let sep = if self.in_cell { "\\par\n" } else { "\n\n" };
|
||||
self.out.push_str(&format!("\\begin{{quote}}\n{}\n\\end{{quote}}\n\n", body.join(sep)));
|
||||
i = j.max(i + 1);
|
||||
continue;
|
||||
}
|
||||
FBlock::Para(p) => {
|
||||
let top_title = self.depth == 0 && self.title_at == Some(i);
|
||||
self.para(p, top_title);
|
||||
}
|
||||
FBlock::Table(t) => self.table(t),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
while !lists.is_empty() {
|
||||
self.close_list(&mut lists);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_list(&mut self, lists: &mut Vec<bool>) {
|
||||
if let Some(ordered) = lists.pop() {
|
||||
let env = if ordered { "enumerate" } else { "itemize" };
|
||||
self.out.push_str(&format!("{}\\end{{{env}}}\n", " ".repeat(lists.len())));
|
||||
if lists.is_empty() && !self.in_cell {
|
||||
self.out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn page_break(&mut self, p: &Para) {
|
||||
if p.page_break && !self.in_cell {
|
||||
self.out.push_str("\\clearpage\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn para(&mut self, p: &Para, top_title: bool) {
|
||||
self.page_break(p);
|
||||
if p.kind == Kind::Rule {
|
||||
self.out.push_str("\\noindent\\rule{\\linewidth}{0.4pt}\n\n");
|
||||
return;
|
||||
}
|
||||
if top_title {
|
||||
self.out.push_str("\\maketitle\n\n");
|
||||
return;
|
||||
}
|
||||
let text = self.inlines(&p.inlines);
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let end = if self.in_cell { "\\par\n" } else { "\n\n" };
|
||||
match p.kind {
|
||||
Kind::Heading(n) if !self.in_cell => {
|
||||
let cmd = SECTIONS.get(usize::from(n.clamp(1, 6)) - 1).copied().unwrap_or("section");
|
||||
// `\\` (a line break) isn't allowed in a heading.
|
||||
let text = text.replace("\\\\\n", " ");
|
||||
self.out.push_str(&format!("\\{cmd}*{{{text}}}\n\n"));
|
||||
}
|
||||
Kind::Title => self.out.push_str(&format!("\\begin{{center}}\n{{\\LARGE {text}\\par}}\n\\end{{center}}\n\n")),
|
||||
Kind::Heading(_) => self.out.push_str(&format!("\\textbf{{{text}}}{end}")),
|
||||
_ => match p.align {
|
||||
Some(Align::Center) => self.out.push_str(&format!("\\begin{{center}}\n{text}\n\\end{{center}}\n\n")),
|
||||
Some(Align::Right) => self.out.push_str(&format!("\\begin{{flushright}}\n{text}\n\\end{{flushright}}\n\n")),
|
||||
_ => {
|
||||
self.out.push_str(text);
|
||||
self.out.push_str(end);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&mut self, lines: &[String]) {
|
||||
if self.in_cell {
|
||||
// `verbatim` can't go in a table cell: typewriter lines instead.
|
||||
let v: Vec<String> = lines.iter().map(|l| format!("\\texttt{{{}}}", escape_text(&l.replace(' ', "\u{A0}")))).collect();
|
||||
self.out.push_str(&v.join("\\\\\n"));
|
||||
self.out.push_str("\\par\n");
|
||||
return;
|
||||
}
|
||||
self.out.push_str("\\begin{verbatim}\n");
|
||||
for l in lines {
|
||||
// The environment ends at the first `\end{verbatim}`, so keep it from appearing. The
|
||||
// control characters go first: removing one afterwards could join a delimiter back up.
|
||||
let l: String = l.chars().filter(|c| *c == '\t' || !c.is_control()).collect();
|
||||
let l = l.replace("\\end{verbatim}", "\\end {verbatim}");
|
||||
self.out.push_str(&l);
|
||||
self.out.push('\n');
|
||||
}
|
||||
self.out.push_str("\\end{verbatim}\n\n");
|
||||
}
|
||||
|
||||
/// LaTeX for a run of inlines.
|
||||
fn inlines(&mut self, inlines: &[Inline]) -> String {
|
||||
let mut o = String::new();
|
||||
for i in inlines {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
let s = self.span(t, f);
|
||||
o.push_str(&s);
|
||||
}
|
||||
Inline::Image(img) => {
|
||||
let alt = if img.alt.trim().is_empty() { "Picture".to_string() } else { format!("Picture: {}", img.alt.trim()) };
|
||||
o.push_str(&format!("\\textit{{[{}]}}", escape_text(&alt)));
|
||||
}
|
||||
Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))),
|
||||
Inline::Figure(_) => {}
|
||||
// LaTeX keeps no ruby (no package): its base text, in the run's formatting.
|
||||
Inline::Ruby { base, fmt, .. } => {
|
||||
let s = self.span(base, fmt);
|
||||
o.push_str(&s);
|
||||
}
|
||||
Inline::Equation { linear, display } => {
|
||||
self.uses.math = true;
|
||||
let src = linear_to_latex(linear);
|
||||
if *display && !self.in_cell {
|
||||
o.push_str(&format!("\n\\[ {src} \\]\n"));
|
||||
} else {
|
||||
o.push_str(&format!("${src}$"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn span(&mut self, text: &str, f: &Fmt) -> String {
|
||||
let mut s = if f.code { escape_text(&text.replace(' ', "\u{A0}")) } else { escape_text(text) };
|
||||
if s.is_empty() {
|
||||
return s;
|
||||
}
|
||||
let wrap = |s: String, cmd: &str| format!("\\{cmd}{{{s}}}");
|
||||
if f.code {
|
||||
s = wrap(s, "texttt");
|
||||
}
|
||||
if f.sup {
|
||||
s = wrap(s, "textsuperscript");
|
||||
} else if f.sub {
|
||||
s = wrap(s, "textsubscript");
|
||||
}
|
||||
if f.bold {
|
||||
s = wrap(s, "textbf");
|
||||
}
|
||||
if f.italic {
|
||||
s = wrap(s, "textit");
|
||||
}
|
||||
if f.underline {
|
||||
self.uses.ulem = true;
|
||||
s = wrap(s, "uline");
|
||||
}
|
||||
if f.strike {
|
||||
self.uses.ulem = true;
|
||||
s = wrap(s, "sout");
|
||||
}
|
||||
if let Some(c) = f.color.filter(|c| *c != Rgb(0, 0, 0)) {
|
||||
self.uses.color = true;
|
||||
s = format!("\\textcolor[HTML]{{{}}}{{{s}}}", hex(c));
|
||||
}
|
||||
if let Some(size) = f.size.filter(|v| v.is_finite() && *v >= 1.0 && *v <= 1638.0) {
|
||||
s = format!("{{\\fontsize{{{}}}{{{}}}\\selectfont {s}}}", num(size), num(size * 1.2));
|
||||
}
|
||||
if let Some(l) = f.link.as_deref().filter(|l| !l.is_empty()) {
|
||||
s = match l.strip_prefix('#') {
|
||||
// An internal bookmark reference, not a URL.
|
||||
Some(target) => format!("\\hyperlink{{{}}}{{{s}}}", escape_url(target)),
|
||||
// A script, `file:`, UNC or other non-web target becomes plain text: a `\href`
|
||||
// compiles to a PDF `/URI` action the reader would otherwise follow on click.
|
||||
None if wordcraft_doc::link::is_safe_href(l) => format!("\\href{{{}}}{{{s}}}", escape_url(l)),
|
||||
None => s,
|
||||
};
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub(super) fn table(&mut self, t: &FTable) {
|
||||
let cols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS);
|
||||
let widths: Vec<f32> = if t.widths.len() == cols && t.widths.iter().all(|w| w.is_finite() && *w > 1.0) {
|
||||
let total: f32 = t.widths.iter().sum();
|
||||
// Scale a table wider than the page to fit.
|
||||
let k = if total > LINE_WIDTH { LINE_WIDTH / total } else { 1.0 };
|
||||
t.widths.iter().map(|w| w * k).collect()
|
||||
} else {
|
||||
vec![LINE_WIDTH / cols as f32; cols]
|
||||
};
|
||||
let col = |w: f32| format!("p{{{}pt}}", num((w - 2.0 * TABCOLSEP).max(12.0)));
|
||||
let spec: String = if t.borderless {
|
||||
widths.iter().map(|w| col(*w)).collect::<Vec<_>>().join(" ")
|
||||
} else {
|
||||
let s: String = widths.iter().map(|w| format!("{}|", col(*w))).collect();
|
||||
format!("|{s}")
|
||||
};
|
||||
let mut o = if t.borderless {
|
||||
format!("\\noindent\\begin{{tabular}}{{{spec}}}\n")
|
||||
} else {
|
||||
format!("\\noindent\\begin{{tabular}}{{{spec}}}\n\\hline\n")
|
||||
};
|
||||
for row in &t.rows {
|
||||
let mut cells = Vec::new();
|
||||
let mut g = 0usize;
|
||||
for c in row {
|
||||
let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1));
|
||||
let w: f32 = widths.get(g..(g + span).min(widths.len())).map(|s| s.iter().sum()).unwrap_or(72.0);
|
||||
let mut body = if c.covered { String::new() } else { self.cell(c) };
|
||||
if c.rowspan > 1 && !c.covered {
|
||||
self.uses.multirow = true;
|
||||
body = format!("\\multirow{{{}}}{{=}}{{{body}}}", c.rowspan.min(1000));
|
||||
}
|
||||
if let Some(sh) = c.shading {
|
||||
self.uses.color = true;
|
||||
body = format!("\\cellcolor[HTML]{{{}}}{body}", hex(sh));
|
||||
}
|
||||
if span > 1 {
|
||||
let col_spec = if t.borderless {
|
||||
col(w)
|
||||
} else {
|
||||
let left = if g == 0 { "|" } else { "" };
|
||||
format!("{left}{}|", col(w))
|
||||
};
|
||||
body = format!("\\multicolumn{{{span}}}{{{col_spec}}}{{{body}}}");
|
||||
}
|
||||
cells.push(body);
|
||||
g += span;
|
||||
}
|
||||
if cells.is_empty() {
|
||||
continue;
|
||||
}
|
||||
o.push_str(&cells.join(" & "));
|
||||
if t.borderless {
|
||||
o.push_str(" \\\\\n");
|
||||
} else {
|
||||
o.push_str(" \\\\\n\\hline\n");
|
||||
}
|
||||
}
|
||||
o.push_str("\\end{tabular}\n");
|
||||
self.out.push_str(&o);
|
||||
self.out.push_str(if self.in_cell { "\\par\n" } else { "\n" });
|
||||
}
|
||||
|
||||
fn cell(&mut self, c: &Cell) -> String {
|
||||
let mut sub = Writer { in_cell: true, depth: self.depth + 1, ..Default::default() };
|
||||
if self.depth < model::MAX_DEPTH {
|
||||
sub.blocks(&c.blocks);
|
||||
} else {
|
||||
sub.out = escape_text(&c.text());
|
||||
}
|
||||
self.uses.ulem |= sub.uses.ulem;
|
||||
self.uses.color |= sub.uses.color;
|
||||
self.uses.multirow |= sub.uses.multirow;
|
||||
self.uses.math |= sub.uses.math;
|
||||
let s = sub.out.trim().trim_end_matches("\\par").trim_end().to_string();
|
||||
s.lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(c: Rgb) -> String {
|
||||
format!("{:02X}{:02X}{:02X}", c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
/// A length without needless decimals.
|
||||
fn num(v: f32) -> String {
|
||||
let r = (v * 100.0).round() / 100.0;
|
||||
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r}") }
|
||||
}
|
||||
|
||||
/// Text with LaTeX's special characters escaped.
|
||||
pub fn escape_text(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 8);
|
||||
let mut prev = '\0';
|
||||
let mut line_has_text = false;
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => o.push_str("\\textbackslash{}"),
|
||||
'{' => o.push_str("\\{"),
|
||||
'}' => o.push_str("\\}"),
|
||||
'$' | '&' | '#' | '%' | '_' => {
|
||||
o.push('\\');
|
||||
o.push(c);
|
||||
}
|
||||
'~' => o.push_str("\\textasciitilde{}"),
|
||||
'^' => o.push_str("\\textasciicircum{}"),
|
||||
'<' => o.push_str("\\textless{}"),
|
||||
'>' => o.push_str("\\textgreater{}"),
|
||||
'|' => o.push_str("\\textbar{}"),
|
||||
'\u{A0}' => o.push('~'),
|
||||
// An opening bracket here would become an optional item or line-break argument.
|
||||
'[' if !line_has_text => o.push_str("{[}"),
|
||||
// Keep TeX from joining `--`, ``` `` ``` and `''` into dashes and quotes.
|
||||
'-' if prev == '-' => o.push_str("{}-"),
|
||||
'`' => o.push_str("\\textasciigrave{}"),
|
||||
'\'' if prev == '\'' => o.push_str("{}'"),
|
||||
'\t' => o.push_str("\\quad{}"),
|
||||
'\n' | '\u{000B}' => {
|
||||
// A line break; LaTeX refuses one with nothing before it on the line.
|
||||
o.push_str(if line_has_text { "\\\\\n" } else { "\\mbox{}\\\\\n" });
|
||||
line_has_text = false;
|
||||
prev = c;
|
||||
continue;
|
||||
}
|
||||
c if c.is_control() => {}
|
||||
c => o.push(c),
|
||||
}
|
||||
if !c.is_whitespace() {
|
||||
line_has_text = true;
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// A URL or anchor name for `\href`/`\hyperlink`: `%`, `#`, `\`, braces escaped.
|
||||
fn escape_url(s: &str) -> String {
|
||||
let mut o = String::new();
|
||||
for c in s.chars().filter(|c| !c.is_control()) {
|
||||
// Never `^^`: TeX reads `^^5c` as a backslash, which would start a command.
|
||||
if c == '^' && o.ends_with('^') {
|
||||
continue;
|
||||
}
|
||||
if matches!(c, '%' | '#' | '\\' | '{' | '}') {
|
||||
o.push('\\');
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Tok {
|
||||
/// `\name` (a starred form keeps its `*`) or a control symbol like `\&`.
|
||||
Cmd(String),
|
||||
Open,
|
||||
Close,
|
||||
/// Text (spaces collapsed to one).
|
||||
Text(String),
|
||||
/// A blank line.
|
||||
Par,
|
||||
Amp,
|
||||
/// `~`: a non-breaking space.
|
||||
Tie,
|
||||
/// Math source, and whether it is displayed.
|
||||
Math(String, bool),
|
||||
/// The content of a verbatim-like environment.
|
||||
Verbatim(String),
|
||||
/// `\verb|…|`.
|
||||
Verb(String),
|
||||
/// `\begin{name}` and `\end{name}`.
|
||||
Begin(String),
|
||||
End(String),
|
||||
}
|
||||
|
||||
const VERBATIM_ENVS: &[&str] = &["verbatim", "verbatim*", "Verbatim", "lstlisting", "minted", "alltt", "comment"];
|
||||
|
||||
const MATH_ENVS: &[&str] = &[
|
||||
"equation",
|
||||
"equation*",
|
||||
"align",
|
||||
"align*",
|
||||
"alignat",
|
||||
"alignat*",
|
||||
"gather",
|
||||
"gather*",
|
||||
"multline",
|
||||
"multline*",
|
||||
"flalign",
|
||||
"flalign*",
|
||||
"displaymath",
|
||||
"math",
|
||||
"eqnarray",
|
||||
"eqnarray*",
|
||||
];
|
||||
|
||||
struct Lexer<'a> {
|
||||
s: &'a str,
|
||||
i: usize,
|
||||
out: Vec<Tok>,
|
||||
}
|
||||
|
||||
impl Lexer<'_> {
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.s.get(self.i..).and_then(|r| r.chars().next())
|
||||
}
|
||||
fn rest(&self) -> &str {
|
||||
self.s.get(self.i..).unwrap_or("")
|
||||
}
|
||||
fn bump(&mut self) -> Option<char> {
|
||||
let c = self.peek()?;
|
||||
self.i += c.len_utf8();
|
||||
Some(c)
|
||||
}
|
||||
fn text(&mut self, t: &str) {
|
||||
if let Some(Tok::Text(s)) = self.out.last_mut() {
|
||||
s.push_str(t);
|
||||
} else {
|
||||
self.out.push(Tok::Text(t.to_string()));
|
||||
}
|
||||
}
|
||||
/// Skip the rest of a line (a comment), the line end and the next line's leading blanks.
|
||||
fn comment(&mut self) {
|
||||
while let Some(c) = self.bump() {
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while matches!(self.peek(), Some(' ' | '\t')) {
|
||||
self.bump();
|
||||
}
|
||||
}
|
||||
/// Read until `end` (not included; consumed) and return what came before.
|
||||
fn until(&mut self, end: &str) -> String {
|
||||
match self.rest().find(end) {
|
||||
Some(at) => {
|
||||
let s = self.rest().get(..at).unwrap_or("").to_string();
|
||||
self.i += at + end.len();
|
||||
s
|
||||
}
|
||||
None => {
|
||||
let s = self.rest().to_string();
|
||||
self.i = self.s.len();
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
/// After `\begin` / `\end`: the `{name}`, if well formed.
|
||||
fn env_name(&mut self) -> Option<String> {
|
||||
let save = self.i;
|
||||
while matches!(self.peek(), Some(' ' | '\t')) {
|
||||
self.bump();
|
||||
}
|
||||
if self.peek() != Some('{') {
|
||||
self.i = save;
|
||||
return None;
|
||||
}
|
||||
self.bump();
|
||||
// Only the next 65 bytes are searched: looking through the rest of the file for a `}`
|
||||
// made a run of unclosed `\\begin{` quadratic.
|
||||
let Some(end) = self.rest().bytes().take(65).position(|b| b == b'}') else {
|
||||
// Unclosed or overlong: leave the `{` to be read as ordinary input.
|
||||
self.i = save;
|
||||
return None;
|
||||
};
|
||||
let r = self.rest();
|
||||
let name = r.get(..end).unwrap_or("").trim().to_string();
|
||||
self.i += end + 1;
|
||||
Some(name)
|
||||
}
|
||||
|
||||
fn run(mut self) -> Vec<Tok> {
|
||||
while let Some(c) = self.peek() {
|
||||
match c {
|
||||
'\\' => {
|
||||
self.bump();
|
||||
let r = self.rest();
|
||||
let n = r.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(r.len());
|
||||
if n == 0 {
|
||||
let Some(sym) = self.bump() else { break };
|
||||
match sym {
|
||||
'(' => {
|
||||
let m = self.until("\\)");
|
||||
self.out.push(Tok::Math(m, false));
|
||||
}
|
||||
'[' => {
|
||||
let m = self.until("\\]");
|
||||
self.out.push(Tok::Math(m, true));
|
||||
}
|
||||
_ => self.out.push(Tok::Cmd(sym.to_string())),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut name = r.get(..n).unwrap_or("").to_string();
|
||||
self.i += n;
|
||||
if self.peek() == Some('*') {
|
||||
self.bump();
|
||||
name.push('*');
|
||||
}
|
||||
match name.as_str() {
|
||||
"begin" => match self.env_name() {
|
||||
Some(env) if VERBATIM_ENVS.contains(&env.as_str()) => {
|
||||
// `minted{lang}` / `lstlisting[options]`: skip to the line end.
|
||||
let _options = self.until("\n");
|
||||
let v = self.until(&format!("\\end{{{env}}}"));
|
||||
if env != "comment" {
|
||||
self.out.push(Tok::Verbatim(v));
|
||||
}
|
||||
}
|
||||
Some(env) if MATH_ENVS.contains(&env.as_str()) => {
|
||||
let m = self.until(&format!("\\end{{{env}}}"));
|
||||
self.out.push(Tok::Math(m, env != "math"));
|
||||
}
|
||||
Some(env) => self.out.push(Tok::Begin(env)),
|
||||
None => self.out.push(Tok::Cmd(name)),
|
||||
},
|
||||
"end" => match self.env_name() {
|
||||
Some(env) => self.out.push(Tok::End(env)),
|
||||
None => self.out.push(Tok::Cmd(name)),
|
||||
},
|
||||
"verb" | "verb*" => match self.bump() {
|
||||
Some(delim) if !delim.is_alphabetic() && !delim.is_whitespace() => {
|
||||
let v = self.until(&delim.to_string());
|
||||
self.out.push(Tok::Verb(v));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {
|
||||
self.out.push(Tok::Cmd(name));
|
||||
// A control word swallows the spaces (and one line end) after it.
|
||||
let mut newline = false;
|
||||
while let Some(c) = self.peek() {
|
||||
if c == ' ' || c == '\t' || (c == '\n' && !newline) {
|
||||
newline |= c == '\n';
|
||||
self.bump();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'%' => self.comment(),
|
||||
'{' => {
|
||||
self.bump();
|
||||
self.out.push(Tok::Open);
|
||||
}
|
||||
'}' => {
|
||||
self.bump();
|
||||
self.out.push(Tok::Close);
|
||||
}
|
||||
'&' => {
|
||||
self.bump();
|
||||
self.out.push(Tok::Amp);
|
||||
}
|
||||
'~' => {
|
||||
self.bump();
|
||||
self.out.push(Tok::Tie);
|
||||
}
|
||||
'$' => {
|
||||
self.bump();
|
||||
if self.peek() == Some('$') {
|
||||
self.bump();
|
||||
let m = self.until("$$");
|
||||
self.out.push(Tok::Math(m, true));
|
||||
} else {
|
||||
let m = self.until("$");
|
||||
self.out.push(Tok::Math(m, false));
|
||||
}
|
||||
}
|
||||
'\n' | '\r' | ' ' | '\t' => {
|
||||
// Whitespace: a blank line ends the paragraph, anything else is one space.
|
||||
let mut lines = 0;
|
||||
while let Some(c) = self.peek() {
|
||||
match c {
|
||||
'\n' => lines += 1,
|
||||
' ' | '\t' | '\r' => {}
|
||||
_ => break,
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
if lines >= 2 {
|
||||
self.out.push(Tok::Par);
|
||||
} else {
|
||||
self.text(" ");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let r = self.rest();
|
||||
let n = r.find(['\\', '%', '{', '}', '&', '~', '$', '\n', '\r', ' ', '\t']).unwrap_or(r.len()).max(c.len_utf8());
|
||||
let t = r.get(..n).unwrap_or("").to_string();
|
||||
self.i += n;
|
||||
self.text(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tokenize(src: &str) -> Vec<Tok> {
|
||||
Lexer { s: src, i: 0, out: Vec::new() }.run()
|
||||
}
|
||||
|
||||
/// TeX's text ligatures: dashes and quotes.
|
||||
pub(super) fn ligatures(s: &str) -> String {
|
||||
s.replace("---", "—").replace("--", "–").replace("``", "“").replace("''", "”").replace('`', "‘").replace("<<", "«").replace(">>", "»")
|
||||
}
|
||||
|
||||
/// Character for a symbol command, if it is one.
|
||||
pub(super) fn text_symbol(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"&" => "&",
|
||||
"%" => "%",
|
||||
"$" => "$",
|
||||
"#" => "#",
|
||||
"_" => "_",
|
||||
"{" => "{",
|
||||
"}" => "}",
|
||||
" " => " ",
|
||||
"," | ";" | ":" | "enspace" | "thinspace" => " ",
|
||||
"quad" => "\u{2003}",
|
||||
"qquad" => "\u{2003}\u{2003}",
|
||||
"-" | "/" | "@" | "!" | "noindent" | "indent" | "relax" | "protect" | "selectfont" | "nobreak" | "sloppy" | "fussy" | "centering"
|
||||
| "raggedright" | "raggedleft" | "normalfont" | "item" => "",
|
||||
"textbackslash" | "backslash" => "\\",
|
||||
"textasciitilde" => "~",
|
||||
"textasciicircum" => "^",
|
||||
"textless" => "<",
|
||||
"textgreater" => ">",
|
||||
"textbar" => "|",
|
||||
"textbraceleft" => "{",
|
||||
"textbraceright" => "}",
|
||||
"textunderscore" => "_",
|
||||
"textasciigrave" => "`",
|
||||
"textquotesingle" => "'",
|
||||
"textquotedbl" => "\"",
|
||||
"ldots" | "dots" | "textellipsis" => "…",
|
||||
"textendash" => "–",
|
||||
"textemdash" => "—",
|
||||
"textbullet" => "•",
|
||||
"textperiodcentered" => "·",
|
||||
"textquoteleft" => "‘",
|
||||
"textquoteright" => "’",
|
||||
"textquotedblleft" => "“",
|
||||
"textquotedblright" => "”",
|
||||
"guillemotleft" | "guillemetleft" => "«",
|
||||
"guillemotright" | "guillemetright" => "»",
|
||||
"copyright" | "textcopyright" => "©",
|
||||
"textregistered" => "®",
|
||||
"texttrademark" => "™",
|
||||
"S" | "textsection" => "§",
|
||||
"P" | "textparagraph" => "¶",
|
||||
"dag" | "textdagger" => "†",
|
||||
"ddag" | "textdaggerdbl" => "‡",
|
||||
"pounds" | "textsterling" => "£",
|
||||
"euro" | "texteuro" => "€",
|
||||
"textyen" => "¥",
|
||||
"textcent" => "¢",
|
||||
"textdegree" | "degree" => "°",
|
||||
"textmu" => "µ",
|
||||
"texttimes" => "×",
|
||||
"textdiv" => "÷",
|
||||
"textpm" => "±",
|
||||
"textonehalf" => "½",
|
||||
"textonequarter" => "¼",
|
||||
"textthreequarters" => "¾",
|
||||
"textexclamdown" => "¡",
|
||||
"textquestiondown" => "¿",
|
||||
"ss" => "ß",
|
||||
"SS" => "SS",
|
||||
"ae" => "æ",
|
||||
"AE" => "Æ",
|
||||
"oe" => "œ",
|
||||
"OE" => "Œ",
|
||||
"o" => "ø",
|
||||
"O" => "Ø",
|
||||
"aa" => "å",
|
||||
"AA" => "Å",
|
||||
"l" => "ł",
|
||||
"L" => "Ł",
|
||||
"i" => "ı",
|
||||
"j" => "ȷ",
|
||||
"LaTeX" => "LaTeX",
|
||||
"LaTeXe" => "LaTeX2ε",
|
||||
"TeX" => "TeX",
|
||||
"XeTeX" => "XeTeX",
|
||||
"LuaTeX" => "LuaTeX",
|
||||
"BibTeX" => "BibTeX",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Combining mark for an accent command.
|
||||
pub(super) fn accent_mark(name: &str) -> Option<char> {
|
||||
Some(match name {
|
||||
"'" => '\u{301}',
|
||||
"`" => '\u{300}',
|
||||
"^" => '\u{302}',
|
||||
"\"" => '\u{308}',
|
||||
"~" => '\u{303}',
|
||||
"=" => '\u{304}',
|
||||
"." => '\u{307}',
|
||||
"u" => '\u{306}',
|
||||
"v" => '\u{30C}',
|
||||
"H" => '\u{30B}',
|
||||
"c" => '\u{327}',
|
||||
"k" => '\u{328}',
|
||||
"r" => '\u{30A}',
|
||||
"d" => '\u{323}',
|
||||
"b" => '\u{331}',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Precomposed letters for the common accents (base + combining mark otherwise).
|
||||
pub(super) fn compose(base: char, mark: char) -> String {
|
||||
const TABLE: &[(char, char, char)] = &[
|
||||
('a', '\u{301}', 'á'),
|
||||
('e', '\u{301}', 'é'),
|
||||
('i', '\u{301}', 'í'),
|
||||
('ı', '\u{301}', 'í'),
|
||||
('o', '\u{301}', 'ó'),
|
||||
('u', '\u{301}', 'ú'),
|
||||
('y', '\u{301}', 'ý'),
|
||||
('c', '\u{301}', 'ć'),
|
||||
('n', '\u{301}', 'ń'),
|
||||
('s', '\u{301}', 'ś'),
|
||||
('z', '\u{301}', 'ź'),
|
||||
('A', '\u{301}', 'Á'),
|
||||
('E', '\u{301}', 'É'),
|
||||
('I', '\u{301}', 'Í'),
|
||||
('O', '\u{301}', 'Ó'),
|
||||
('U', '\u{301}', 'Ú'),
|
||||
('Y', '\u{301}', 'Ý'),
|
||||
('a', '\u{300}', 'à'),
|
||||
('e', '\u{300}', 'è'),
|
||||
('i', '\u{300}', 'ì'),
|
||||
('ı', '\u{300}', 'ì'),
|
||||
('o', '\u{300}', 'ò'),
|
||||
('u', '\u{300}', 'ù'),
|
||||
('A', '\u{300}', 'À'),
|
||||
('E', '\u{300}', 'È'),
|
||||
('I', '\u{300}', 'Ì'),
|
||||
('O', '\u{300}', 'Ò'),
|
||||
('U', '\u{300}', 'Ù'),
|
||||
('a', '\u{302}', 'â'),
|
||||
('e', '\u{302}', 'ê'),
|
||||
('i', '\u{302}', 'î'),
|
||||
('ı', '\u{302}', 'î'),
|
||||
('o', '\u{302}', 'ô'),
|
||||
('u', '\u{302}', 'û'),
|
||||
('A', '\u{302}', 'Â'),
|
||||
('E', '\u{302}', 'Ê'),
|
||||
('I', '\u{302}', 'Î'),
|
||||
('O', '\u{302}', 'Ô'),
|
||||
('U', '\u{302}', 'Û'),
|
||||
('a', '\u{308}', 'ä'),
|
||||
('e', '\u{308}', 'ë'),
|
||||
('i', '\u{308}', 'ï'),
|
||||
('ı', '\u{308}', 'ï'),
|
||||
('o', '\u{308}', 'ö'),
|
||||
('u', '\u{308}', 'ü'),
|
||||
('y', '\u{308}', 'ÿ'),
|
||||
('A', '\u{308}', 'Ä'),
|
||||
('E', '\u{308}', 'Ë'),
|
||||
('I', '\u{308}', 'Ï'),
|
||||
('O', '\u{308}', 'Ö'),
|
||||
('U', '\u{308}', 'Ü'),
|
||||
('a', '\u{303}', 'ã'),
|
||||
('n', '\u{303}', 'ñ'),
|
||||
('o', '\u{303}', 'õ'),
|
||||
('A', '\u{303}', 'Ã'),
|
||||
('N', '\u{303}', 'Ñ'),
|
||||
('O', '\u{303}', 'Õ'),
|
||||
('c', '\u{327}', 'ç'),
|
||||
('C', '\u{327}', 'Ç'),
|
||||
('s', '\u{327}', 'ş'),
|
||||
('S', '\u{327}', 'Ş'),
|
||||
('a', '\u{30A}', 'å'),
|
||||
('A', '\u{30A}', 'Å'),
|
||||
('u', '\u{30A}', 'ů'),
|
||||
('c', '\u{30C}', 'č'),
|
||||
('d', '\u{30C}', 'ď'),
|
||||
('e', '\u{30C}', 'ě'),
|
||||
('n', '\u{30C}', 'ň'),
|
||||
('r', '\u{30C}', 'ř'),
|
||||
('s', '\u{30C}', 'š'),
|
||||
('t', '\u{30C}', 'ť'),
|
||||
('z', '\u{30C}', 'ž'),
|
||||
('C', '\u{30C}', 'Č'),
|
||||
('D', '\u{30C}', 'Ď'),
|
||||
('E', '\u{30C}', 'Ě'),
|
||||
('N', '\u{30C}', 'Ň'),
|
||||
('R', '\u{30C}', 'Ř'),
|
||||
('S', '\u{30C}', 'Š'),
|
||||
('T', '\u{30C}', 'Ť'),
|
||||
('Z', '\u{30C}', 'Ž'),
|
||||
('g', '\u{306}', 'ğ'),
|
||||
('G', '\u{306}', 'Ğ'),
|
||||
('o', '\u{30B}', 'ő'),
|
||||
('u', '\u{30B}', 'ű'),
|
||||
('O', '\u{30B}', 'Ő'),
|
||||
('U', '\u{30B}', 'Ű'),
|
||||
('a', '\u{328}', 'ą'),
|
||||
('e', '\u{328}', 'ę'),
|
||||
('A', '\u{328}', 'Ą'),
|
||||
('E', '\u{328}', 'Ę'),
|
||||
('z', '\u{307}', 'ż'),
|
||||
('Z', '\u{307}', 'Ż'),
|
||||
('a', '\u{304}', 'ā'),
|
||||
('e', '\u{304}', 'ē'),
|
||||
('i', '\u{304}', 'ī'),
|
||||
('o', '\u{304}', 'ō'),
|
||||
('u', '\u{304}', 'ū'),
|
||||
];
|
||||
match TABLE.iter().find(|(b, m, _)| *b == base && *m == mark) {
|
||||
Some((_, _, c)) => c.to_string(),
|
||||
None => format!("{base}{mark}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// Largest `.tex` source imported (bytes); longer input is cut off.
|
||||
pub const MAX_SOURCE: usize = 32 << 20;
|
||||
|
||||
/// Nesting limit for groups, environments and arguments (hostile input).
|
||||
pub(super) const MAX_NEST: usize = 32;
|
||||
|
||||
/// Longest `tabular` column spec read (characters); the rest is ignored.
|
||||
pub(super) const MAX_SPEC: usize = 4096;
|
||||
|
||||
/// Most bookmarks kept in a row with no text between them.
|
||||
pub(super) const MAX_ANCHOR_RUN: usize = 64;
|
||||
@@ -0,0 +1,567 @@
|
||||
use super::limits::MAX_NEST;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Equations: WordCraft keeps them in the linear format (`x=(-b±√(b^2-4ac))/2a`).
|
||||
|
||||
/// Symbols with a LaTeX command: (character, command without the backslash).
|
||||
const MATH_SYMBOLS: &[(char, &str)] = &[
|
||||
('α', "alpha"),
|
||||
('β', "beta"),
|
||||
('γ', "gamma"),
|
||||
('δ', "delta"),
|
||||
('ϵ', "epsilon"),
|
||||
('ε', "varepsilon"),
|
||||
('ζ', "zeta"),
|
||||
('η', "eta"),
|
||||
('θ', "theta"),
|
||||
('ϑ', "vartheta"),
|
||||
('ι', "iota"),
|
||||
('κ', "kappa"),
|
||||
('λ', "lambda"),
|
||||
('μ', "mu"),
|
||||
('ν', "nu"),
|
||||
('ξ', "xi"),
|
||||
('π', "pi"),
|
||||
('ϖ', "varpi"),
|
||||
('ρ', "rho"),
|
||||
('ϱ', "varrho"),
|
||||
('σ', "sigma"),
|
||||
('ς', "varsigma"),
|
||||
('τ', "tau"),
|
||||
('υ', "upsilon"),
|
||||
('ϕ', "phi"),
|
||||
('φ', "varphi"),
|
||||
('χ', "chi"),
|
||||
('ψ', "psi"),
|
||||
('ω', "omega"),
|
||||
('Γ', "Gamma"),
|
||||
('Δ', "Delta"),
|
||||
('Θ', "Theta"),
|
||||
('Λ', "Lambda"),
|
||||
('Ξ', "Xi"),
|
||||
('Π', "Pi"),
|
||||
('Σ', "Sigma"),
|
||||
('Υ', "Upsilon"),
|
||||
('Φ', "Phi"),
|
||||
('Ψ', "Psi"),
|
||||
('Ω', "Omega"),
|
||||
('±', "pm"),
|
||||
('∓', "mp"),
|
||||
('×', "times"),
|
||||
('÷', "div"),
|
||||
('⋅', "cdot"),
|
||||
('·', "cdot"),
|
||||
('∘', "circ"),
|
||||
('∗', "ast"),
|
||||
('≤', "leq"),
|
||||
('≥', "geq"),
|
||||
('≠', "neq"),
|
||||
('≈', "approx"),
|
||||
('≡', "equiv"),
|
||||
('∼', "sim"),
|
||||
('≃', "simeq"),
|
||||
('≅', "cong"),
|
||||
('∝', "propto"),
|
||||
('≪', "ll"),
|
||||
('≫', "gg"),
|
||||
('∈', "in"),
|
||||
('∉', "notin"),
|
||||
('∋', "ni"),
|
||||
('⊂', "subset"),
|
||||
('⊃', "supset"),
|
||||
('⊆', "subseteq"),
|
||||
('⊇', "supseteq"),
|
||||
('∪', "cup"),
|
||||
('∩', "cap"),
|
||||
('∖', "setminus"),
|
||||
('∅', "emptyset"),
|
||||
('∀', "forall"),
|
||||
('∃', "exists"),
|
||||
('¬', "neg"),
|
||||
('∧', "wedge"),
|
||||
('∨', "vee"),
|
||||
('⊕', "oplus"),
|
||||
('⊗', "otimes"),
|
||||
('→', "to"),
|
||||
('←', "leftarrow"),
|
||||
('↔', "leftrightarrow"),
|
||||
('⇒', "Rightarrow"),
|
||||
('⇐', "Leftarrow"),
|
||||
('⇔', "Leftrightarrow"),
|
||||
('↦', "mapsto"),
|
||||
('↑', "uparrow"),
|
||||
('↓', "downarrow"),
|
||||
('∑', "sum"),
|
||||
('∏', "prod"),
|
||||
('∐', "coprod"),
|
||||
('∫', "int"),
|
||||
('∬', "iint"),
|
||||
('∭', "iiint"),
|
||||
('∮', "oint"),
|
||||
('⋃', "bigcup"),
|
||||
('⋂', "bigcap"),
|
||||
('∞', "infty"),
|
||||
('∂', "partial"),
|
||||
('∇', "nabla"),
|
||||
('ℏ', "hbar"),
|
||||
('ℓ', "ell"),
|
||||
('ℵ', "aleph"),
|
||||
('ℜ', "Re"),
|
||||
('ℑ', "Im"),
|
||||
('∠', "angle"),
|
||||
('⊥', "perp"),
|
||||
('∥', "parallel"),
|
||||
('′', "prime"),
|
||||
('…', "ldots"),
|
||||
('⋯', "cdots"),
|
||||
('⋮', "vdots"),
|
||||
('⋱', "ddots"),
|
||||
('⟨', "langle"),
|
||||
('⟩', "rangle"),
|
||||
('⌊', "lfloor"),
|
||||
('⌋', "rfloor"),
|
||||
('⌈', "lceil"),
|
||||
('⌉', "rceil"),
|
||||
('°', "circ"),
|
||||
('•', "bullet"),
|
||||
('⊤', "top"),
|
||||
];
|
||||
|
||||
/// Extra input spellings mapped to the same symbols.
|
||||
const MATH_ALIASES: &[(&str, char)] = &[
|
||||
("le", '≤'),
|
||||
("ge", '≥'),
|
||||
("ne", '≠'),
|
||||
("rightarrow", '→'),
|
||||
("gets", '←'),
|
||||
("land", '∧'),
|
||||
("lor", '∨'),
|
||||
("lnot", '¬'),
|
||||
("dots", '…'),
|
||||
("dotsc", '…'),
|
||||
("dotsb", '⋯'),
|
||||
("varnothing", '∅'),
|
||||
("implies", '⇒'),
|
||||
("iff", '⇔'),
|
||||
("degree", '°'),
|
||||
("lbrace", '{'),
|
||||
("rbrace", '}'),
|
||||
("vert", '|'),
|
||||
("mid", '|'),
|
||||
("Vert", '‖'),
|
||||
("lvert", '|'),
|
||||
("rvert", '|'),
|
||||
("bot", '⊥'),
|
||||
("hslash", 'ℏ'),
|
||||
("colon", ':'),
|
||||
];
|
||||
|
||||
/// Operator names written upright in both formats.
|
||||
const MATH_FUNCTIONS: &[&str] = &[
|
||||
"sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "coth", "log", "ln", "lg", "exp", "lim",
|
||||
"liminf", "limsup", "min", "max", "sup", "inf", "det", "dim", "gcd", "deg", "arg", "ker", "Pr", "hom",
|
||||
];
|
||||
|
||||
/// Control words an exported equation may carry besides the symbols and functions above (font
|
||||
/// switches, delimiters, spacing).
|
||||
const MATH_COMMANDS: &[&str] = &[
|
||||
"mathbb",
|
||||
"mathbf",
|
||||
"mathcal",
|
||||
"mathfrak",
|
||||
"mathit",
|
||||
"mathrm",
|
||||
"mathsf",
|
||||
"mathtt",
|
||||
"boldsymbol",
|
||||
"left",
|
||||
"right",
|
||||
"quad",
|
||||
"qquad",
|
||||
"displaystyle",
|
||||
"textstyle",
|
||||
"limits",
|
||||
"nolimits",
|
||||
"not",
|
||||
];
|
||||
|
||||
/// May `\\name` from an equation's text be written to the exported file as a command? Only the
|
||||
/// names known here. An equation's text comes from the document (a file someone else may have
|
||||
/// made), and `\\input`, `\\write` or `\\def` in it would act on the machine of whoever compiles
|
||||
/// the export.
|
||||
fn known_math_command(name: &str) -> bool {
|
||||
math_symbol(name).is_some() || MATH_FUNCTIONS.contains(&name) || MATH_COMMANDS.contains(&name)
|
||||
}
|
||||
|
||||
fn math_symbol(name: &str) -> Option<char> {
|
||||
MATH_SYMBOLS.iter().find(|(_, n)| *n == name).map(|(c, _)| *c).or_else(|| MATH_ALIASES.iter().find(|(n, _)| *n == name).map(|(_, c)| *c))
|
||||
}
|
||||
|
||||
/// For every `l` in `chars`, the index of the `r` that closes it, found in one stack pass.
|
||||
/// Looking groups up here keeps scanning linear: rescanning to the end of the input for every
|
||||
/// unmatched `(` made long equations quadratic.
|
||||
pub(super) fn closers(chars: &[char], l: char, r: char) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; chars.len()];
|
||||
let mut open = Vec::new();
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if *c == l {
|
||||
open.push(i);
|
||||
} else if *c == r
|
||||
&& let Some(o) = open.pop()
|
||||
&& let Some(slot) = out.get_mut(o)
|
||||
{
|
||||
*slot = Some(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The balanced group opening at `open` (see [`closers`]): its content and the index after it.
|
||||
pub(super) fn balanced(chars: &[char], closers: &[Option<usize>], open: usize) -> Option<(String, usize)> {
|
||||
let close = (*closers.get(open)?)?;
|
||||
Some((chars.get(open + 1..close).unwrap_or_default().iter().collect(), close + 1))
|
||||
}
|
||||
|
||||
/// A linear-format equation as LaTeX math.
|
||||
pub fn linear_to_latex(linear: &str) -> String {
|
||||
linear_to_latex_at(linear, 0)
|
||||
}
|
||||
|
||||
fn linear_to_latex_at(linear: &str, depth: usize) -> String {
|
||||
let chars: Vec<char> = linear.chars().collect();
|
||||
let parens = closers(&chars, '(', ')');
|
||||
let mut o = String::new();
|
||||
let mut i = 0;
|
||||
let sub = |s: &str| if depth < MAX_NEST { linear_to_latex_at(s, depth + 1) } else { escape_math_text(s) };
|
||||
while let Some(&c) = chars.get(i) {
|
||||
// (a)/(b) → \frac{a}{b}
|
||||
// (The `/` is looked for before the group is copied out: copying every group of a deeply
|
||||
// nested equation only to find no `/` after it was quadratic.)
|
||||
if c == '('
|
||||
&& let Some(Some(close)) = parens.get(i)
|
||||
&& chars.get(close + 1) == Some(&'/')
|
||||
&& let Some((num, after)) = balanced(&chars, &parens, i)
|
||||
&& let Some((den, end)) = balanced(&chars, &parens, after + 1)
|
||||
{
|
||||
o.push_str(&format!("\\frac{{{}}}{{{}}}", sub(&num), sub(&den)));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'^' | '_' => {
|
||||
// Never `^^`: TeX reads `^^5c` as a backslash, which would start a command.
|
||||
if c == '^' && o.ends_with('^') {
|
||||
o.push_str("{}");
|
||||
}
|
||||
o.push(c);
|
||||
match balanced(&chars, &parens, i + 1) {
|
||||
Some((inner, end)) => {
|
||||
o.push_str(&format!("{{{}}}", sub(&inner)));
|
||||
i = end;
|
||||
}
|
||||
None => i += 1,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
'√' => {
|
||||
match balanced(&chars, &parens, i + 1) {
|
||||
Some((inner, end)) => {
|
||||
match inner.split_once('&') {
|
||||
Some((n, x)) => o.push_str(&format!("\\sqrt[{}]{{{}}}", sub(n), sub(x))),
|
||||
None => o.push_str(&format!("\\sqrt{{{}}}", sub(&inner))),
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
None => {
|
||||
o.push_str("\\surd ");
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
'\\' => {
|
||||
// Linear format control words (`\alpha`) are LaTeX's too.
|
||||
let name: String = chars.iter().skip(i + 1).take_while(|c| c.is_ascii_alphabetic()).collect();
|
||||
if name.is_empty() {
|
||||
o.push_str("\\backslash ");
|
||||
i += 1;
|
||||
} else if !known_math_command(&name) {
|
||||
// Not a command we know: written as the text it is.
|
||||
o.push_str("\\backslash ");
|
||||
o.push_str(&name);
|
||||
i += 1 + name.len();
|
||||
} else {
|
||||
o.push('\\');
|
||||
o.push_str(&name);
|
||||
i += 1 + name.len();
|
||||
if chars.get(i).is_some_and(char::is_ascii_alphabetic) {
|
||||
o.push(' ');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
'#'..='&' => {
|
||||
o.push('\\');
|
||||
o.push(c);
|
||||
}
|
||||
'{' => o.push_str("\\{"),
|
||||
'}' => o.push_str("\\}"),
|
||||
'~' => o.push_str("\\sim "),
|
||||
c => match MATH_SYMBOLS.iter().find(|(s, _)| *s == c) {
|
||||
Some((_, name)) => {
|
||||
o.push('\\');
|
||||
o.push_str(name);
|
||||
// A control word needs a space only before a letter.
|
||||
if chars.get(i + 1).is_some_and(char::is_ascii_alphabetic) {
|
||||
o.push(' ');
|
||||
}
|
||||
}
|
||||
None if c.is_control() => {}
|
||||
None => o.push(c),
|
||||
},
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
o.trim().to_string()
|
||||
}
|
||||
|
||||
fn escape_math_text(s: &str) -> String {
|
||||
let mut o = String::new();
|
||||
for c in s.chars().filter(|c| !matches!(c, '{' | '}' | '\\' | '$' | '%' | '#' | '&') && !c.is_control()) {
|
||||
// Never `^^` (see `linear_to_latex_at`).
|
||||
if c == '^' && o.ends_with('^') {
|
||||
continue;
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// LaTeX math as a linear-format equation.
|
||||
pub fn latex_to_linear(src: &str) -> String {
|
||||
let toks = math_tokens(src);
|
||||
let mut pos = 0;
|
||||
let s = math_seq(&toks, &mut pos, 0, false);
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum MTok {
|
||||
Cmd(String),
|
||||
Open,
|
||||
Close,
|
||||
OptOpen,
|
||||
OptClose,
|
||||
Char(char),
|
||||
}
|
||||
|
||||
fn math_tokens(src: &str) -> Vec<MTok> {
|
||||
let chars: Vec<char> = src.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
match c {
|
||||
'\\' => {
|
||||
let name: String = chars.iter().skip(i + 1).take_while(|c| c.is_ascii_alphabetic()).collect();
|
||||
if name.is_empty() {
|
||||
if let Some(&n) = chars.get(i + 1) {
|
||||
out.push(MTok::Cmd(n.to_string()));
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1 + name.len();
|
||||
out.push(MTok::Cmd(name));
|
||||
}
|
||||
}
|
||||
'{' => {
|
||||
out.push(MTok::Open);
|
||||
i += 1;
|
||||
}
|
||||
'}' => {
|
||||
out.push(MTok::Close);
|
||||
i += 1;
|
||||
}
|
||||
'[' => {
|
||||
out.push(MTok::OptOpen);
|
||||
i += 1;
|
||||
}
|
||||
']' => {
|
||||
out.push(MTok::OptClose);
|
||||
i += 1;
|
||||
}
|
||||
'%' => {
|
||||
// A comment, to the end of the line.
|
||||
while chars.get(i).is_some_and(|c| *c != '\n') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
c => {
|
||||
out.push(MTok::Char(if c.is_whitespace() { ' ' } else { c }));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One argument: a `{…}` group or a single token.
|
||||
fn math_arg(t: &[MTok], pos: &mut usize, depth: usize) -> String {
|
||||
while t.get(*pos) == Some(&MTok::Char(' ')) {
|
||||
*pos += 1;
|
||||
}
|
||||
match t.get(*pos) {
|
||||
Some(MTok::Open) => {
|
||||
*pos += 1;
|
||||
let s = math_seq(t, pos, depth + 1, false);
|
||||
if t.get(*pos) == Some(&MTok::Close) {
|
||||
*pos += 1;
|
||||
}
|
||||
s
|
||||
}
|
||||
Some(MTok::Close) | None => String::new(),
|
||||
Some(_) => {
|
||||
let end = *pos + 1;
|
||||
let mut p = *pos;
|
||||
let s = math_seq(t.get(..end).unwrap_or_default(), &mut p, depth + 1, false);
|
||||
*pos = end;
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional `[…]` argument.
|
||||
fn math_opt(t: &[MTok], pos: &mut usize, depth: usize) -> Option<String> {
|
||||
if t.get(*pos) != Some(&MTok::OptOpen) {
|
||||
return None;
|
||||
}
|
||||
*pos += 1;
|
||||
let s = math_seq(t, pos, depth + 1, true);
|
||||
if t.get(*pos) == Some(&MTok::OptClose) {
|
||||
*pos += 1;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Parenthesise a fraction part or script unless it is a single symbol.
|
||||
fn wrap_part(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if s.chars().count() == 1 || (!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) { s.to_string() } else { format!("({s})") }
|
||||
}
|
||||
|
||||
fn math_seq(t: &[MTok], pos: &mut usize, depth: usize, in_opt: bool) -> String {
|
||||
let mut o = String::new();
|
||||
while let Some(tok) = t.get(*pos) {
|
||||
match tok {
|
||||
MTok::Close => break,
|
||||
MTok::OptClose if in_opt => break,
|
||||
MTok::Open => {
|
||||
*pos += 1;
|
||||
if depth < MAX_NEST {
|
||||
o.push_str(&math_seq(t, pos, depth + 1, false));
|
||||
}
|
||||
if t.get(*pos) == Some(&MTok::Close) {
|
||||
*pos += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
MTok::OptOpen => o.push('['),
|
||||
MTok::OptClose => o.push(']'),
|
||||
MTok::Char('^') | MTok::Char('_') => {
|
||||
let c = if *tok == MTok::Char('^') { '^' } else { '_' };
|
||||
*pos += 1;
|
||||
let a = if depth < MAX_NEST { math_arg(t, pos, depth) } else { String::new() };
|
||||
o.push(c);
|
||||
o.push_str(&wrap_part(&a));
|
||||
continue;
|
||||
}
|
||||
MTok::Char('&') => o.push(' '),
|
||||
MTok::Char('~') => o.push(' '),
|
||||
MTok::Char(c) => o.push(*c),
|
||||
MTok::Cmd(name) => {
|
||||
*pos += 1;
|
||||
let name = name.as_str();
|
||||
if depth >= MAX_NEST {
|
||||
continue;
|
||||
}
|
||||
match name {
|
||||
"frac" | "dfrac" | "tfrac" | "cfrac" => {
|
||||
let a = math_arg(t, pos, depth);
|
||||
let b = math_arg(t, pos, depth);
|
||||
o.push_str(&format!("{}/{}", wrap_part(&a), wrap_part(&b)));
|
||||
}
|
||||
"binom" | "dbinom" | "tbinom" => {
|
||||
let a = math_arg(t, pos, depth);
|
||||
let b = math_arg(t, pos, depth);
|
||||
o.push_str(&format!("({a}¦{b})"));
|
||||
}
|
||||
"sqrt" => {
|
||||
let n = math_opt(t, pos, depth);
|
||||
let a = math_arg(t, pos, depth);
|
||||
match n {
|
||||
Some(n) => o.push_str(&format!("√({n}&{a})")),
|
||||
None => o.push_str(&format!("√({a})")),
|
||||
}
|
||||
}
|
||||
"left" | "right" | "big" | "Big" | "bigg" | "Bigg" | "bigl" | "bigr" | "Bigl" | "Bigr" | "middle" => {
|
||||
// Keep the delimiter, drop the sizing; `\left.` is no delimiter.
|
||||
if t.get(*pos) == Some(&MTok::Char('.')) {
|
||||
*pos += 1;
|
||||
}
|
||||
}
|
||||
"text" | "textrm" | "textit" | "textbf" | "mathrm" | "mathit" | "mathbf" | "mathsf" | "mathtt" | "mathcal" | "mathbb"
|
||||
| "mathfrak" | "boldsymbol" | "bm" | "pmb" | "operatorname" | "mbox" | "hbox" | "displaystyle" | "textstyle" | "scriptstyle" => {
|
||||
if t.get(*pos) == Some(&MTok::Open) || !matches!(name, "displaystyle" | "textstyle" | "scriptstyle") {
|
||||
let a = math_arg(t, pos, depth);
|
||||
o.push_str(&a);
|
||||
}
|
||||
}
|
||||
"overline" | "bar" => o.push_str(&format!("{}¯", wrap_part(&math_arg(t, pos, depth)))),
|
||||
"hat" | "widehat" => o.push_str(&format!("{}̂", math_arg(t, pos, depth))),
|
||||
"tilde" | "widetilde" => o.push_str(&format!("{}̃", math_arg(t, pos, depth))),
|
||||
"vec" => o.push_str(&format!("{}⃗", math_arg(t, pos, depth))),
|
||||
"dot" => o.push_str(&format!("{}̇", math_arg(t, pos, depth))),
|
||||
"ddot" => o.push_str(&format!("{}̈", math_arg(t, pos, depth))),
|
||||
"acute" => o.push_str(&format!("{}́", math_arg(t, pos, depth))),
|
||||
"grave" => o.push_str(&format!("{}̀", math_arg(t, pos, depth))),
|
||||
"breve" => o.push_str(&format!("{}̆", math_arg(t, pos, depth))),
|
||||
"check" => o.push_str(&format!("{}̌", math_arg(t, pos, depth))),
|
||||
"pmod" => {
|
||||
let a = math_arg(t, pos, depth);
|
||||
o.push_str(&format!(" (mod {a})"));
|
||||
}
|
||||
"bmod" => o.push_str(" mod "),
|
||||
"begin" | "end" => {
|
||||
// Inner environments (`matrix`, `cases`, `aligned`…): keep the content.
|
||||
let _ = math_arg(t, pos, depth);
|
||||
if name == "begin" {
|
||||
let _ = math_opt(t, pos, depth);
|
||||
}
|
||||
}
|
||||
"," | ";" | ":" | "quad" | "qquad" | " " | "enspace" | "thinspace" => o.push(' '),
|
||||
"!" | "limits" | "nolimits" | "nonumber" | "notag" => {}
|
||||
"\\" | "cr" => o.push(' '),
|
||||
"{" | "}" | "%" | "#" | "&" | "$" | "_" => o.push_str(name),
|
||||
"label" | "tag" => {
|
||||
let _ = math_arg(t, pos, depth);
|
||||
}
|
||||
n if MATH_FUNCTIONS.contains(&n) => {
|
||||
o.push_str(n);
|
||||
o.push(' ');
|
||||
}
|
||||
n => match math_symbol(n) {
|
||||
Some(c) => o.push(c),
|
||||
// Linear format knows LaTeX-style control words too.
|
||||
None => {
|
||||
o.push('\\');
|
||||
o.push_str(n);
|
||||
o.push(' ');
|
||||
}
|
||||
},
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
*pos += 1;
|
||||
}
|
||||
o
|
||||
}
|
||||
@@ -0,0 +1,910 @@
|
||||
use super::lex::{Tok, accent_mark, compose, ligatures, text_symbol, tokenize};
|
||||
use super::limits::{MAX_ANCHOR_RUN, MAX_NEST, MAX_SOURCE, MAX_SPEC};
|
||||
use super::math::latex_to_linear;
|
||||
use super::table::{raw_text, source_text, table};
|
||||
use super::units::{length_pt, parse_color, size_of};
|
||||
use super::*;
|
||||
|
||||
// =============================================================================================
|
||||
// Import
|
||||
|
||||
/// Import a LaTeX source file.
|
||||
pub fn import(bytes: &[u8]) -> Document {
|
||||
model::begin_import();
|
||||
let mut d = model::to_doc(&parse(&crate::txt::decode(bytes.get(..MAX_SOURCE).unwrap_or(bytes))));
|
||||
d.ensure_nonempty();
|
||||
d
|
||||
}
|
||||
|
||||
/// Commands dropped together with their arguments (layout and definitions, not text).
|
||||
const DROP_WITH_ARGS: &[&str] = &[
|
||||
"documentclass",
|
||||
"usepackage",
|
||||
"RequirePackage",
|
||||
"newcommand",
|
||||
"renewcommand",
|
||||
"providecommand",
|
||||
"newcommand*",
|
||||
"renewcommand*",
|
||||
"providecommand*",
|
||||
"newenvironment",
|
||||
"renewenvironment",
|
||||
"DeclareMathOperator",
|
||||
"DeclareMathOperator*",
|
||||
"def",
|
||||
"let",
|
||||
"setlength",
|
||||
"addtolength",
|
||||
"setcounter",
|
||||
"addtocounter",
|
||||
"stepcounter",
|
||||
"refstepcounter",
|
||||
"vspace",
|
||||
"vspace*",
|
||||
"hspace",
|
||||
"hspace*",
|
||||
"vskip",
|
||||
"hskip",
|
||||
"pagestyle",
|
||||
"thispagestyle",
|
||||
"pagenumbering",
|
||||
"bibliographystyle",
|
||||
"bibliography",
|
||||
"addbibresource",
|
||||
"printbibliography",
|
||||
"input",
|
||||
"include",
|
||||
"includeonly",
|
||||
"graphicspath",
|
||||
"hypersetup",
|
||||
"definecolor",
|
||||
"geometry",
|
||||
"numberwithin",
|
||||
"newtheorem",
|
||||
"newtheorem*",
|
||||
"theoremstyle",
|
||||
"phantom",
|
||||
"hphantom",
|
||||
"vphantom",
|
||||
"index",
|
||||
"nocite",
|
||||
"addcontentsline",
|
||||
"markboth",
|
||||
"markright",
|
||||
"linespread",
|
||||
"setstretch",
|
||||
"fancyhf",
|
||||
"fancyhead",
|
||||
"fancyfoot",
|
||||
"lstset",
|
||||
"captionsetup",
|
||||
"setmainfont",
|
||||
"setsansfont",
|
||||
"setmonofont",
|
||||
"newfontfamily",
|
||||
"date",
|
||||
"thanks",
|
||||
"maketitle*",
|
||||
"tableofcontents",
|
||||
"listoffigures",
|
||||
"listoftables",
|
||||
"appendix",
|
||||
"frontmatter",
|
||||
"mainmatter",
|
||||
"backmatter",
|
||||
"cline",
|
||||
"hline",
|
||||
"toprule",
|
||||
"midrule",
|
||||
"bottomrule",
|
||||
"cmidrule",
|
||||
"addlinespace",
|
||||
"endhead",
|
||||
"endfirsthead",
|
||||
"endfoot",
|
||||
"endlastfoot",
|
||||
"rowcolor",
|
||||
"arraystretch",
|
||||
"smallskip",
|
||||
"medskip",
|
||||
"bigskip",
|
||||
"today",
|
||||
"and",
|
||||
];
|
||||
|
||||
/// Paragraph-level context from the enclosing environments.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Ctx {
|
||||
kind: Kind,
|
||||
align: Option<Align>,
|
||||
}
|
||||
|
||||
pub(super) struct Parser<'a> {
|
||||
pub(super) t: &'a [Tok],
|
||||
pub(super) pos: usize,
|
||||
blocks: Vec<FBlock>,
|
||||
para: Para,
|
||||
/// Character formatting in effect (groups save and restore it).
|
||||
fmt: Fmt,
|
||||
/// Paragraph context of the enclosing environments.
|
||||
ctx: Ctx,
|
||||
/// Text that followed an optional argument's `]` in the same token, emitted next.
|
||||
pending: Option<String>,
|
||||
/// Open lists: ordered?
|
||||
lists: Vec<bool>,
|
||||
page_break: bool,
|
||||
depth: usize,
|
||||
meta: model::Meta,
|
||||
/// Sectioning levels in use: `\part`/`\chapter` push `\section` down.
|
||||
top: u8,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
pub(super) fn new(t: &'a [Tok], depth: usize) -> Parser<'a> {
|
||||
Parser {
|
||||
t,
|
||||
pos: 0,
|
||||
blocks: Vec::new(),
|
||||
para: Para::default(),
|
||||
fmt: Fmt::default(),
|
||||
ctx: Ctx::default(),
|
||||
pending: None,
|
||||
lists: Vec::new(),
|
||||
page_break: false,
|
||||
depth,
|
||||
meta: Default::default(),
|
||||
top: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt(&self) -> Fmt {
|
||||
self.fmt.clone()
|
||||
}
|
||||
fn fmt_mut(&mut self) -> &mut Fmt {
|
||||
&mut self.fmt
|
||||
}
|
||||
fn ctx(&self) -> Ctx {
|
||||
self.ctx
|
||||
}
|
||||
|
||||
/// A bookmark here. `text` looks back past the bookmarks that end the paragraph to see what
|
||||
/// the last text was, so a run of them with nothing between is kept short: after
|
||||
/// [`MAX_ANCHOR_RUN`] in a row further ones are left out (thousands of labels with no text
|
||||
/// between them made reading a file take time quadratic in its size).
|
||||
fn anchor(&mut self, name: String) {
|
||||
let run = self.para.inlines.iter().rev().take(MAX_ANCHOR_RUN).take_while(|i| matches!(i, Inline::Anchor(_))).count();
|
||||
if run < MAX_ANCHOR_RUN {
|
||||
self.para.inlines.push(Inline::Anchor(name));
|
||||
}
|
||||
}
|
||||
|
||||
fn text(&mut self, s: &str) {
|
||||
// Spaces collapse, and none start a paragraph (as in TeX).
|
||||
let after_space = match self.para.inlines.iter().rev().find(|i| !matches!(i, Inline::Anchor(_))) {
|
||||
Some(Inline::Text(t, _)) => t.is_empty() || t.ends_with([' ', '\n']),
|
||||
Some(_) => false,
|
||||
None => true,
|
||||
};
|
||||
let s = if after_space { s.trim_start_matches(' ') } else { s };
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
let f = self.fmt();
|
||||
self.para.push_text(s, &f);
|
||||
}
|
||||
|
||||
/// End the current paragraph (if it has content).
|
||||
fn flush(&mut self) {
|
||||
let mut p = std::mem::take(&mut self.para);
|
||||
p.trim();
|
||||
if p.is_empty() && !p.inlines.iter().any(|i| matches!(i, Inline::Image(_) | Inline::Equation { .. })) {
|
||||
// Keep a list item's position even when empty.
|
||||
if p.list.is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let c = self.ctx();
|
||||
if p.kind == Kind::Normal {
|
||||
p.kind = c.kind;
|
||||
}
|
||||
if p.align.is_none() {
|
||||
p.align = c.align;
|
||||
}
|
||||
if self.page_break {
|
||||
p.page_break = true;
|
||||
self.page_break = false;
|
||||
}
|
||||
self.blocks.push(FBlock::Para(p));
|
||||
}
|
||||
|
||||
pub(super) fn skip_spaces(&mut self) {
|
||||
while let Some(Tok::Text(s)) = self.t.get(self.pos) {
|
||||
if !s.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The tokens of an optional `[…]` argument, if one follows.
|
||||
pub(super) fn opt_arg(&mut self) -> Option<String> {
|
||||
let save = self.pos;
|
||||
self.skip_spaces();
|
||||
let Some(Tok::Text(s)) = self.t.get(self.pos) else {
|
||||
self.pos = save;
|
||||
return None;
|
||||
};
|
||||
if !s.starts_with('[') {
|
||||
self.pos = save;
|
||||
return None;
|
||||
}
|
||||
// Collect raw text up to the matching `]` (no nested brackets across tokens).
|
||||
let mut out = String::new();
|
||||
let mut first = true;
|
||||
while let Some(tok) = self.t.get(self.pos) {
|
||||
match tok {
|
||||
Tok::Text(s) => {
|
||||
let s = if first { s.get(1..).unwrap_or("") } else { s.as_str() };
|
||||
first = false;
|
||||
if let Some(end) = s.find(']') {
|
||||
out.push_str(s.get(..end).unwrap_or(""));
|
||||
let rest = s.get(end + 1..).unwrap_or("").to_string();
|
||||
self.pos += 1;
|
||||
if !rest.trim().is_empty() {
|
||||
// The text after `]` comes next (tokens are shared, so it waits here).
|
||||
self.pending = Some(rest);
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
out.push_str(s);
|
||||
}
|
||||
Tok::Open => out.push('{'),
|
||||
Tok::Close => out.push('}'),
|
||||
Tok::Cmd(c) => {
|
||||
out.push('\\');
|
||||
out.push_str(c);
|
||||
}
|
||||
Tok::Par => break,
|
||||
_ => {}
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The tokens of a `{…}` argument (or a single token), consumed.
|
||||
pub(super) fn arg_tokens(&mut self) -> &'a [Tok] {
|
||||
self.skip_spaces();
|
||||
match self.t.get(self.pos) {
|
||||
Some(Tok::Open) => {
|
||||
let start = self.pos + 1;
|
||||
let mut depth = 0usize;
|
||||
let mut i = self.pos;
|
||||
while let Some(tok) = self.t.get(i) {
|
||||
match tok {
|
||||
Tok::Open => depth += 1,
|
||||
Tok::Close => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
self.pos = i + 1;
|
||||
return self.t.get(start..i).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
self.pos = self.t.len();
|
||||
self.t.get(start..).unwrap_or_default()
|
||||
}
|
||||
Some(Tok::Text(_)) => {
|
||||
// A single character argument (`\textbf x`) is rare: take the whole token.
|
||||
let i = self.pos;
|
||||
self.pos += 1;
|
||||
self.t.get(i..i + 1).unwrap_or_default()
|
||||
}
|
||||
Some(Tok::Cmd(_)) => {
|
||||
let i = self.pos;
|
||||
self.pos += 1;
|
||||
self.t.get(i..i + 1).unwrap_or_default()
|
||||
}
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// An argument as plain text (no formatting): labels, URLs, colour names.
|
||||
pub(super) fn arg_raw(&mut self) -> String {
|
||||
raw_text(self.arg_tokens())
|
||||
}
|
||||
|
||||
/// An argument as LaTeX source (braces kept): column specs, lengths.
|
||||
fn arg_source(&mut self) -> String {
|
||||
source_text(self.arg_tokens())
|
||||
}
|
||||
|
||||
/// An argument's text as it would be shown (formatting dropped): titles and authors.
|
||||
fn arg_text(&mut self) -> String {
|
||||
let toks = self.arg_tokens();
|
||||
let mut sub = Parser::new(toks, self.depth + 1);
|
||||
if self.depth < MAX_NEST {
|
||||
sub.run(None);
|
||||
}
|
||||
let (blocks, _) = sub.finish();
|
||||
let text: Vec<String> = blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
squash(&text.join(" "))
|
||||
}
|
||||
|
||||
/// Parse `toks` inline into the current paragraph with formatting `f` applied.
|
||||
fn inline_with(&mut self, toks: &'a [Tok], f: impl FnOnce(&mut Fmt)) {
|
||||
if self.depth >= MAX_NEST {
|
||||
let s = raw_text(toks);
|
||||
self.text(&s);
|
||||
return;
|
||||
}
|
||||
let saved_fmt = self.fmt.clone();
|
||||
f(&mut self.fmt);
|
||||
let (saved_t, saved_pos) = (self.t, self.pos);
|
||||
self.t = toks;
|
||||
self.pos = 0;
|
||||
self.depth += 1;
|
||||
self.run(None);
|
||||
self.flush_pending();
|
||||
self.depth -= 1;
|
||||
self.t = saved_t;
|
||||
self.pos = saved_pos;
|
||||
self.fmt = saved_fmt;
|
||||
}
|
||||
|
||||
/// Skip every `[…]` and `{…}` argument that follows (and, for definitions, the macro name).
|
||||
fn drop_args(&mut self, name: &str) {
|
||||
if matches!(name, "newcommand" | "renewcommand" | "providecommand" | "newcommand*" | "renewcommand*" | "providecommand*" | "def" | "let") {
|
||||
self.skip_spaces();
|
||||
if matches!(self.t.get(self.pos), Some(Tok::Cmd(_))) {
|
||||
self.pos += 1;
|
||||
}
|
||||
if name == "let" {
|
||||
self.skip_spaces();
|
||||
if matches!(self.t.get(self.pos), Some(Tok::Cmd(_))) {
|
||||
self.pos += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if name == "def" {
|
||||
// `\def\x#1{…}`: the parameter text, then the body.
|
||||
while matches!(self.t.get(self.pos), Some(Tok::Text(_))) {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let save = self.pos;
|
||||
self.skip_spaces();
|
||||
match self.t.get(self.pos) {
|
||||
Some(Tok::Open) => {
|
||||
let _ = self.arg_tokens();
|
||||
}
|
||||
Some(Tok::Text(s)) if s.starts_with('[') => {
|
||||
let _ = self.opt_arg();
|
||||
if self.pending.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.pos = save;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn heading(&mut self, level: u8) {
|
||||
let _ = self.opt_arg();
|
||||
let toks = self.arg_tokens();
|
||||
self.flush();
|
||||
self.para = Para::new(Kind::Heading(level.clamp(1, 6)));
|
||||
self.inline_with(toks, |_| {});
|
||||
self.flush();
|
||||
}
|
||||
|
||||
/// Parse until `\end{env}` (when given), a closing brace at this level, or the end.
|
||||
fn flush_pending(&mut self) {
|
||||
if let Some(p) = self.pending.take() {
|
||||
self.text(&ligatures(&p));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(&mut self, env: Option<&str>) {
|
||||
loop {
|
||||
self.flush_pending();
|
||||
let Some(tok) = self.t.get(self.pos) else { break };
|
||||
self.pos += 1;
|
||||
match tok {
|
||||
Tok::Text(s) => {
|
||||
let s = ligatures(s);
|
||||
self.text(&s);
|
||||
}
|
||||
Tok::Par => self.flush(),
|
||||
Tok::Tie => self.text("\u{A0}"),
|
||||
Tok::Amp => self.text(" "),
|
||||
Tok::Open => {
|
||||
// A group scopes declarations (`{\bfseries …}`, `{\centering …\par}`):
|
||||
// character formatting and paragraph alignment come back at its end.
|
||||
let toks = {
|
||||
self.pos -= 1;
|
||||
self.arg_tokens()
|
||||
};
|
||||
let saved_ctx = self.ctx;
|
||||
self.inline_with(toks, |_| {});
|
||||
self.ctx = saved_ctx;
|
||||
}
|
||||
Tok::Close => {}
|
||||
Tok::Math(m, display) => {
|
||||
let linear = latex_to_linear(m);
|
||||
if linear.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if *display {
|
||||
self.flush();
|
||||
self.para.inlines.push(Inline::Equation { linear, display: true });
|
||||
self.flush();
|
||||
} else {
|
||||
self.para.inlines.push(Inline::Equation { linear, display: false });
|
||||
}
|
||||
}
|
||||
Tok::Verbatim(v) => {
|
||||
self.flush();
|
||||
let v = v.strip_prefix('\n').unwrap_or(v);
|
||||
let v = v.strip_suffix('\n').unwrap_or(v);
|
||||
for line in v.lines() {
|
||||
let mut p = Para::new(Kind::Code);
|
||||
p.push_text(line.trim_end_matches('\r'), &Fmt::default());
|
||||
if line.trim().is_empty() {
|
||||
p.inlines.push(Inline::Text(String::new(), Fmt::default()));
|
||||
}
|
||||
self.blocks.push(FBlock::Para(p));
|
||||
}
|
||||
}
|
||||
Tok::Verb(v) => {
|
||||
let mut f = self.fmt();
|
||||
f.code = true;
|
||||
self.para.push_text(v, &f);
|
||||
}
|
||||
// Too deep: the environment's content goes on at this level.
|
||||
Tok::Begin(_) if self.depth >= MAX_NEST => {}
|
||||
Tok::Begin(name) => self.environment(name),
|
||||
Tok::End(name) => {
|
||||
if env == Some(name.as_str()) {
|
||||
return;
|
||||
}
|
||||
// A stray `\end`: ignore.
|
||||
}
|
||||
Tok::Cmd(name) => self.command(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, name: &str) {
|
||||
if let Some(sym) = text_symbol(name) {
|
||||
if name == "item" {
|
||||
self.item();
|
||||
} else if matches!(name, "centering" | "raggedleft" | "raggedright") {
|
||||
let a = match name {
|
||||
"centering" => Align::Center,
|
||||
"raggedleft" => Align::Right,
|
||||
_ => Align::Left,
|
||||
};
|
||||
self.ctx.align = Some(a);
|
||||
} else {
|
||||
self.text(sym);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(mark) = accent_mark(name) {
|
||||
// `\"o` takes one letter of the text after it; `\"{o}` the group.
|
||||
self.skip_spaces();
|
||||
let arg = match self.t.get(self.pos) {
|
||||
Some(Tok::Text(t)) => {
|
||||
self.pos += 1;
|
||||
let mut chars = t.chars();
|
||||
let first = chars.next().map(String::from).unwrap_or_default();
|
||||
if !chars.as_str().is_empty() {
|
||||
self.pending = Some(chars.as_str().to_string());
|
||||
}
|
||||
first
|
||||
}
|
||||
_ => self.arg_raw(),
|
||||
};
|
||||
let mut chars = arg.chars();
|
||||
let s = match chars.next() {
|
||||
Some(b) => format!("{}{}", compose(b, mark), chars.as_str()),
|
||||
// An accent over nothing prints the accent alone: the spacing form of the mark.
|
||||
None => match name {
|
||||
"`" | "^" | "~" => name.to_string(),
|
||||
"'" => "\u{B4}".to_string(),
|
||||
"\"" => "\u{A8}".to_string(),
|
||||
_ => mark.to_string(),
|
||||
},
|
||||
};
|
||||
self.text(&s);
|
||||
return;
|
||||
}
|
||||
if let Some(size) = size_of(name) {
|
||||
self.fmt_mut().size = (size > 0.0).then_some(size);
|
||||
return;
|
||||
}
|
||||
match name {
|
||||
"\\" | "newline" | "linebreak" | "break" => {
|
||||
let _ = self.opt_arg();
|
||||
self.text("\n");
|
||||
}
|
||||
"part" | "part*" => self.heading(1),
|
||||
"chapter" | "chapter*" => self.heading(self.top.max(1)),
|
||||
"section" | "section*" => self.heading(1 + self.top),
|
||||
"subsection" | "subsection*" => self.heading(2 + self.top),
|
||||
"subsubsection" | "subsubsection*" => self.heading(3 + self.top),
|
||||
"paragraph" | "paragraph*" => self.heading(4 + self.top),
|
||||
"subparagraph" | "subparagraph*" => self.heading(5 + self.top),
|
||||
"title" => self.meta.title = self.arg_text(),
|
||||
"author" => self.meta.author = self.arg_text(),
|
||||
"maketitle" => {
|
||||
self.flush();
|
||||
if !self.meta.title.is_empty() {
|
||||
let mut p = Para::new(Kind::Title);
|
||||
p.push_text(&self.meta.title, &Fmt::default());
|
||||
self.blocks.push(FBlock::Para(p));
|
||||
}
|
||||
if !self.meta.author.is_empty() {
|
||||
let mut p = Para { align: Some(Align::Center), ..Default::default() };
|
||||
p.push_text(&self.meta.author, &Fmt::default());
|
||||
self.blocks.push(FBlock::Para(p));
|
||||
}
|
||||
}
|
||||
"newpage" | "clearpage" | "cleardoublepage" | "pagebreak" | "eject" => {
|
||||
let _ = self.opt_arg();
|
||||
self.flush();
|
||||
self.page_break = true;
|
||||
}
|
||||
"par" => self.flush(),
|
||||
"hrule" | "hrulefill" | "rule" => {
|
||||
let _ = self.opt_arg();
|
||||
let full = if name == "rule" {
|
||||
let w = self.arg_source();
|
||||
let _ = self.arg_raw();
|
||||
w.contains("linewidth") || w.contains("textwidth") || w.contains("columnwidth")
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if full {
|
||||
self.flush();
|
||||
self.blocks.push(FBlock::Para(Para::new(Kind::Rule)));
|
||||
}
|
||||
}
|
||||
"textbf" => self.with_arg(|f| f.bold = true),
|
||||
"textit" | "textsl" => self.with_arg(|f| f.italic = true),
|
||||
"emph" => self.with_arg(|f| f.italic = !f.italic),
|
||||
"underline" | "uline" | "ul" | "uuline" => self.with_arg(|f| f.underline = true),
|
||||
"sout" | "st" | "xout" | "cancel" => self.with_arg(|f| f.strike = true),
|
||||
"texttt" | "code" | "path" => self.with_arg(|f| f.code = true),
|
||||
"textsuperscript" => self.with_arg(|f| f.sup = true),
|
||||
"textsubscript" => self.with_arg(|f| f.sub = true),
|
||||
"textmd" | "textup" | "textrm" | "textsf" | "textsc" | "textnormal" | "mbox" | "makebox" | "fbox" | "framebox" | "hbox" | "text"
|
||||
| "textls" | "foreignlanguage" | "enquote" | "MakeUppercase" | "MakeLowercase" | "uppercase" | "lowercase" => {
|
||||
if name == "foreignlanguage" {
|
||||
let _ = self.arg_raw();
|
||||
}
|
||||
if matches!(name, "makebox" | "framebox") {
|
||||
let _ = self.opt_arg();
|
||||
let _ = self.opt_arg();
|
||||
}
|
||||
let quote = name == "enquote";
|
||||
if quote {
|
||||
self.text("“");
|
||||
}
|
||||
self.with_arg(|f| {
|
||||
if name == "textmd" {
|
||||
f.bold = false;
|
||||
}
|
||||
if name == "textup" {
|
||||
f.italic = false;
|
||||
}
|
||||
if name == "textnormal" {
|
||||
*f = Fmt { link: f.link.clone(), ..Default::default() };
|
||||
}
|
||||
});
|
||||
if quote {
|
||||
self.text("”");
|
||||
}
|
||||
}
|
||||
"bfseries" | "bf" => self.fmt_mut().bold = true,
|
||||
"itshape" | "it" | "slshape" | "sl" | "em" => self.fmt_mut().italic = true,
|
||||
"ttfamily" | "tt" => self.fmt_mut().code = true,
|
||||
"mdseries" => self.fmt_mut().bold = false,
|
||||
"upshape" => self.fmt_mut().italic = false,
|
||||
"rmfamily" | "sffamily" | "scshape" | "sc" | "rm" | "sf" | "normalfont*" => {}
|
||||
"fontsize" => {
|
||||
let s = self.arg_source();
|
||||
let _ = self.arg_raw();
|
||||
self.fmt_mut().size = length_pt(&s).or_else(|| s.trim().parse().ok()).filter(|v: &f32| *v >= 1.0 && *v <= 1638.0);
|
||||
}
|
||||
"textcolor" | "color" | "colorbox" | "highlight" | "hl" | "cellcolor" => {
|
||||
let model = self.opt_arg();
|
||||
let spec = if matches!(name, "hl" | "highlight") { "yellow".to_string() } else { self.arg_raw() };
|
||||
let c = parse_color(model.as_deref(), &spec);
|
||||
match name {
|
||||
"color" => self.fmt_mut().color = c,
|
||||
"cellcolor" => {}
|
||||
"textcolor" => self.with_arg(move |f| f.color = c.or(f.color)),
|
||||
_ => self.with_arg(move |f| f.background = c.or(f.background)),
|
||||
}
|
||||
}
|
||||
"href" => {
|
||||
let url = model::kept_link(&self.arg_raw());
|
||||
self.with_arg(move |f| f.link = url);
|
||||
}
|
||||
"url" | "nolinkurl" => {
|
||||
let url = self.arg_raw();
|
||||
let mut f = self.fmt();
|
||||
if name == "url" {
|
||||
f.link = model::kept_link(&url);
|
||||
}
|
||||
self.para.push_text(&url, &f);
|
||||
}
|
||||
"hyperlink" => {
|
||||
let target = self.arg_raw();
|
||||
let link = model::kept_link(&format!("#{target}"));
|
||||
self.with_arg(move |f| f.link = link);
|
||||
}
|
||||
"hypertarget" => {
|
||||
let target = self.arg_raw();
|
||||
self.anchor(target);
|
||||
self.with_arg(|_| {});
|
||||
}
|
||||
"label" => {
|
||||
let l = self.arg_raw();
|
||||
self.anchor(l);
|
||||
}
|
||||
"ref" | "eqref" | "pageref" | "autoref" | "cref" | "Cref" | "nameref" => {
|
||||
let l = self.arg_raw();
|
||||
let mut f = self.fmt();
|
||||
f.link = model::kept_link(&format!("#{l}"));
|
||||
self.para.push_text(&l, &f);
|
||||
}
|
||||
"cite" | "citep" | "citet" | "parencite" | "textcite" | "autocite" => {
|
||||
let _ = self.opt_arg();
|
||||
let _ = self.opt_arg();
|
||||
let keys = self.arg_raw();
|
||||
let keys: Vec<&str> = keys.split(',').map(str::trim).filter(|k| !k.is_empty()).collect();
|
||||
self.text(&format!("[{}]", keys.join(", ")));
|
||||
}
|
||||
"footnote" | "footnotetext" | "marginpar" => {
|
||||
let _ = self.opt_arg();
|
||||
self.text(" (");
|
||||
self.with_arg(|_| {});
|
||||
self.text(")");
|
||||
}
|
||||
"includegraphics" => {
|
||||
let _ = self.opt_arg();
|
||||
let file = self.arg_raw();
|
||||
let mut f = self.fmt();
|
||||
f.italic = true;
|
||||
self.para.push_text(&format!("[Picture: {}]", file.trim()), &f);
|
||||
}
|
||||
"caption" | "caption*" => {
|
||||
let _ = self.opt_arg();
|
||||
let toks = self.arg_tokens();
|
||||
self.flush();
|
||||
self.para.align = Some(Align::Center);
|
||||
self.inline_with(toks, |f| f.italic = true);
|
||||
self.flush();
|
||||
}
|
||||
"bibitem" => {
|
||||
let label = self.opt_arg();
|
||||
let key = self.arg_raw();
|
||||
self.flush();
|
||||
self.text(&format!("[{}] ", label.unwrap_or(key)));
|
||||
}
|
||||
"multicolumn" | "multirow" => {
|
||||
// Outside a tabular: keep the content.
|
||||
let _ = self.arg_raw();
|
||||
let _ = self.opt_arg();
|
||||
let _ = self.arg_raw();
|
||||
self.with_arg(|_| {});
|
||||
}
|
||||
"begin" | "end" => {}
|
||||
n if DROP_WITH_ARGS.contains(&n) => self.drop_args(n),
|
||||
_ => {
|
||||
// Unknown: drop the command, keep its arguments' text.
|
||||
let _ = self.opt_arg();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_arg(&mut self, f: impl FnOnce(&mut Fmt)) {
|
||||
let toks = self.arg_tokens();
|
||||
self.inline_with(toks, f);
|
||||
}
|
||||
|
||||
fn item(&mut self) {
|
||||
self.flush();
|
||||
let level = self.lists.len().saturating_sub(1).min(8) as u8;
|
||||
self.para = Para::default();
|
||||
if let Some(&ordered) = self.lists.last() {
|
||||
self.para.list = Some(ListInfo { ordered, level, start: 1 });
|
||||
}
|
||||
let label = self.opt_arg();
|
||||
if let Some(l) = label {
|
||||
let mut f = self.fmt();
|
||||
f.bold = true;
|
||||
self.para.push_text(&format!("{} ", l.trim()), &f);
|
||||
}
|
||||
}
|
||||
|
||||
fn environment(&mut self, name: &str) {
|
||||
self.depth += 1;
|
||||
match name {
|
||||
"document" => {
|
||||
self.run(Some(name));
|
||||
}
|
||||
"itemize" | "enumerate" | "description" | "list" | "compactitem" | "compactenum" | "inparaenum" => {
|
||||
let _ = self.opt_arg();
|
||||
if name == "list" {
|
||||
let _ = self.arg_raw();
|
||||
let _ = self.arg_raw();
|
||||
}
|
||||
self.flush();
|
||||
self.lists.push(matches!(name, "enumerate" | "compactenum" | "inparaenum"));
|
||||
self.run(Some(name));
|
||||
self.flush();
|
||||
self.lists.pop();
|
||||
}
|
||||
"quote" | "quotation" | "verse" | "abstract" => {
|
||||
self.flush();
|
||||
if name == "abstract" {
|
||||
let mut p = Para { align: Some(Align::Center), ..Default::default() };
|
||||
p.push_text("Abstract", &Fmt { bold: true, ..Default::default() });
|
||||
self.blocks.push(FBlock::Para(p));
|
||||
}
|
||||
let saved = self.ctx;
|
||||
self.ctx.kind = Kind::Quote;
|
||||
self.run(Some(name));
|
||||
self.flush();
|
||||
self.ctx = saved;
|
||||
}
|
||||
"center" | "flushleft" | "flushright" | "centering" => {
|
||||
self.flush();
|
||||
let align = match name {
|
||||
"flushleft" => Align::Left,
|
||||
"flushright" => Align::Right,
|
||||
_ => Align::Center,
|
||||
};
|
||||
let saved = self.ctx;
|
||||
self.ctx.align = Some(align);
|
||||
self.run(Some(name));
|
||||
self.flush();
|
||||
self.ctx = saved;
|
||||
}
|
||||
"tabular" | "tabular*" | "tabularx" | "tabulary" | "longtable" | "longtable*" | "array" | "tblr" | "longtblr" => {
|
||||
let _ = self.opt_arg();
|
||||
if matches!(name, "tabular*" | "tabularx" | "tabulary") {
|
||||
let _ = self.arg_raw();
|
||||
}
|
||||
// A real column spec is short; capping it bounds the work on hostile input.
|
||||
let spec: String = self.arg_source().chars().take(MAX_SPEC).collect();
|
||||
let toks = self.until_end(name);
|
||||
self.flush();
|
||||
let t = table(toks, &spec, self.depth);
|
||||
if !t.rows.is_empty() {
|
||||
self.blocks.push(FBlock::Table(t));
|
||||
}
|
||||
}
|
||||
"thebibliography" => {
|
||||
let _ = self.arg_raw();
|
||||
self.flush();
|
||||
let mut p = Para::new(Kind::Heading(1 + self.top));
|
||||
p.push_text("References", &Fmt::default());
|
||||
self.blocks.push(FBlock::Para(p));
|
||||
self.run(Some(name));
|
||||
self.flush();
|
||||
}
|
||||
"minipage" | "figure" | "figure*" | "table" | "table*" | "wrapfigure" | "subfigure" | "titlepage" | "multicols" | "multicols*"
|
||||
| "small" | "footnotesize" | "large" | "theorem" | "lemma" | "proof" | "definition" | "example" | "remark" | "corollary"
|
||||
| "proposition" => {
|
||||
// Floats and boxes: their content, in order.
|
||||
let _ = self.opt_arg();
|
||||
if matches!(name, "minipage" | "subfigure" | "multicols" | "multicols*") {
|
||||
let _ = self.arg_raw();
|
||||
}
|
||||
if name == "wrapfigure" {
|
||||
let _ = self.arg_raw();
|
||||
let _ = self.arg_raw();
|
||||
}
|
||||
self.flush();
|
||||
if matches!(name, "theorem" | "lemma" | "proof" | "definition" | "example" | "remark" | "corollary" | "proposition") {
|
||||
let mut label: String = name.chars().take(1).flat_map(char::to_uppercase).chain(name.chars().skip(1)).collect();
|
||||
label.push_str(". ");
|
||||
self.para.push_text(&label, &Fmt { bold: name != "proof", italic: name == "proof", ..Default::default() });
|
||||
}
|
||||
let (saved_fmt, saved_ctx) = (self.fmt.clone(), self.ctx);
|
||||
if let Some(s) = size_of(name) {
|
||||
self.fmt.size = (s > 0.0).then_some(s);
|
||||
}
|
||||
self.run(Some(name));
|
||||
self.flush();
|
||||
self.fmt = saved_fmt;
|
||||
self.ctx = saved_ctx;
|
||||
}
|
||||
_ => {
|
||||
// Unknown environment: its content.
|
||||
let _ = self.opt_arg();
|
||||
let (saved_fmt, saved_ctx) = (self.fmt.clone(), self.ctx);
|
||||
self.run(Some(name));
|
||||
self.fmt = saved_fmt;
|
||||
self.ctx = saved_ctx;
|
||||
}
|
||||
}
|
||||
self.depth -= 1;
|
||||
}
|
||||
|
||||
/// The tokens up to the matching `\end{name}` (consumed).
|
||||
fn until_end(&mut self, name: &str) -> &'a [Tok] {
|
||||
let start = self.pos;
|
||||
let mut depth = 0usize;
|
||||
while let Some(tok) = self.t.get(self.pos) {
|
||||
match tok {
|
||||
Tok::Begin(n) if n == name => depth += 1,
|
||||
Tok::End(n) if n == name => {
|
||||
if depth == 0 {
|
||||
let toks = self.t.get(start..self.pos).unwrap_or_default();
|
||||
self.pos += 1;
|
||||
return toks;
|
||||
}
|
||||
depth -= 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
self.t.get(start..).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn finish(mut self) -> (Vec<FBlock>, model::Meta) {
|
||||
self.flush_pending();
|
||||
self.flush();
|
||||
(self.blocks, self.meta)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse LaTeX source into a flow.
|
||||
pub fn parse(src: &str) -> Flow {
|
||||
let toks = tokenize(src);
|
||||
// The preamble only contributes metadata; without `\begin{document}` it is all body.
|
||||
let body_at = toks.iter().position(|t| matches!(t, Tok::Begin(n) if n == "document"));
|
||||
let mut meta = model::Meta::default();
|
||||
let (body, preamble): (&[Tok], &[Tok]) = match body_at {
|
||||
Some(i) => (toks.get(i + 1..).unwrap_or_default(), toks.get(..i).unwrap_or_default()),
|
||||
None => (&toks[..], &[]),
|
||||
};
|
||||
let mut pre = Parser::new(preamble, 0);
|
||||
while let Some(t) = pre.t.get(pre.pos) {
|
||||
pre.pos += 1;
|
||||
if let Tok::Cmd(c) = t {
|
||||
match c.as_str() {
|
||||
"title" => meta.title = pre.arg_text(),
|
||||
"author" => meta.author = pre.arg_text(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut p = Parser::new(body, 0);
|
||||
p.meta = meta;
|
||||
// `\part` and `\chapter` put `\section` one level down each.
|
||||
let uses = |names: [&str; 2]| body.iter().any(|t| matches!(t, Tok::Cmd(c) if names.contains(&c.as_str())));
|
||||
p.top = u8::from(uses(["part", "part*"])) + u8::from(uses(["chapter", "chapter*"]));
|
||||
p.run(Some("document"));
|
||||
let (blocks, meta) = p.finish();
|
||||
Flow { blocks, meta }
|
||||
}
|
||||
|
||||
fn squash(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use super::lex::{Tok, text_symbol};
|
||||
use super::limits::MAX_NEST;
|
||||
use super::parser::Parser;
|
||||
use super::units::{parse_color, spec_widths};
|
||||
use super::*;
|
||||
|
||||
/// Plain text of tokens (arguments that are names, URLs, lengths).
|
||||
pub(super) fn raw_text(toks: &[Tok]) -> String {
|
||||
let mut s = String::new();
|
||||
for t in toks {
|
||||
match t {
|
||||
Tok::Text(x) => s.push_str(x),
|
||||
Tok::Tie => s.push('~'),
|
||||
Tok::Cmd(c) => match text_symbol(c) {
|
||||
Some(sym) => s.push_str(sym),
|
||||
None if c == "\\" => s.push(' '),
|
||||
None => {
|
||||
s.push('\\');
|
||||
s.push_str(c);
|
||||
}
|
||||
},
|
||||
Tok::Verb(v) | Tok::Math(v, _) => s.push_str(v),
|
||||
Tok::Amp => s.push('&'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
|
||||
/// Tokens back as LaTeX source (comments and layout whitespace normalised).
|
||||
pub(super) fn source_text(toks: &[Tok]) -> String {
|
||||
let mut s = String::new();
|
||||
for t in toks {
|
||||
match t {
|
||||
Tok::Text(x) => s.push_str(x),
|
||||
Tok::Open => s.push('{'),
|
||||
Tok::Close => s.push('}'),
|
||||
Tok::Tie => s.push('~'),
|
||||
Tok::Amp => s.push('&'),
|
||||
Tok::Par => s.push(' '),
|
||||
Tok::Cmd(c) => {
|
||||
s.push('\\');
|
||||
s.push_str(c);
|
||||
if c.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
Tok::Math(m, _) => {
|
||||
s.push('$');
|
||||
s.push_str(m);
|
||||
s.push('$');
|
||||
}
|
||||
Tok::Verb(v) | Tok::Verbatim(v) => s.push_str(v),
|
||||
Tok::Begin(n) => s.push_str(&format!("\\begin{{{n}}}")),
|
||||
Tok::End(n) => s.push_str(&format!("\\end{{{n}}}")),
|
||||
}
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
|
||||
/// A `tabular` body as a table.
|
||||
pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
|
||||
let (spec_cols, widths) = spec_widths(spec);
|
||||
let mut has_rules = spec.contains('|');
|
||||
// Split rows at `\\` and cells at `&`, outside braces and nested environments.
|
||||
let mut rows: Vec<Vec<&[Tok]>> = Vec::new();
|
||||
let mut row: Vec<&[Tok]> = Vec::new();
|
||||
let mut start = 0usize;
|
||||
let mut brace = 0usize;
|
||||
let mut env = 0usize;
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
match t {
|
||||
Tok::Open => brace += 1,
|
||||
Tok::Close => brace = brace.saturating_sub(1),
|
||||
Tok::Begin(_) => env += 1,
|
||||
Tok::End(_) => env = env.saturating_sub(1),
|
||||
Tok::Cmd(c) if brace == 0 && env == 0 && matches!(c.as_str(), "hline" | "cline" | "toprule" | "midrule" | "bottomrule") => {
|
||||
has_rules = true;
|
||||
}
|
||||
Tok::Amp if brace == 0 && env == 0 => {
|
||||
row.push(toks.get(start..i).unwrap_or_default());
|
||||
start = i + 1;
|
||||
}
|
||||
Tok::Cmd(c) if brace == 0 && env == 0 && (c == "\\" || c == "tabularnewline") => {
|
||||
row.push(toks.get(start..i).unwrap_or_default());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if rows.len() >= wordcraft_doc::table::MAX_ROWS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if start < toks.len() {
|
||||
row.push(toks.get(start..).unwrap_or_default());
|
||||
}
|
||||
if !row.is_empty() {
|
||||
rows.push(row);
|
||||
}
|
||||
let mut out = FTable { rows: Vec::new(), widths: Vec::new(), borderless: !has_rules };
|
||||
// Rows still covered by a `\multirow` above, per grid column.
|
||||
let mut covering: Vec<u32> = Vec::new();
|
||||
for r in rows {
|
||||
let mut cells = Vec::new();
|
||||
let mut g = 0usize;
|
||||
for cell_toks in r {
|
||||
let mut cell = Cell::default();
|
||||
let mut content = trim_cell(cell_toks);
|
||||
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
|
||||
// rescans its content, so only MAX_NEST of them are unwrapped (a cell nests one or
|
||||
// two); further ones stay in the content.
|
||||
let mut sub = Parser::new(content, depth + 1);
|
||||
let mut wrappers = 0usize;
|
||||
loop {
|
||||
sub.skip_spaces();
|
||||
match sub.t.get(sub.pos) {
|
||||
Some(Tok::Cmd(c)) if c == "multicolumn" && wrappers < MAX_NEST => {
|
||||
wrappers += 1;
|
||||
sub.pos += 1;
|
||||
cell.colspan = sub.arg_raw().trim().parse::<u32>().unwrap_or(1).clamp(1, 63);
|
||||
let _ = sub.arg_raw();
|
||||
content = sub.arg_tokens();
|
||||
sub = Parser::new(content, depth + 1);
|
||||
}
|
||||
Some(Tok::Cmd(c)) if (c == "multirow" || c == "multirow*") && wrappers < MAX_NEST => {
|
||||
wrappers += 1;
|
||||
sub.pos += 1;
|
||||
let _ = sub.opt_arg();
|
||||
cell.rowspan = sub.arg_raw().trim().parse::<i64>().map(|n| n.unsigned_abs().clamp(1, 1000) as u32).unwrap_or(1);
|
||||
let _ = sub.opt_arg();
|
||||
let _ = sub.arg_raw();
|
||||
let _ = sub.opt_arg();
|
||||
content = sub.arg_tokens();
|
||||
sub = Parser::new(content, depth + 1);
|
||||
}
|
||||
Some(Tok::Cmd(c)) if c == "cellcolor" => {
|
||||
sub.pos += 1;
|
||||
let model = sub.opt_arg();
|
||||
let spec = sub.arg_raw();
|
||||
cell.shading = parse_color(model.as_deref(), &spec);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if covering.get(g).copied().unwrap_or(0) > 0 {
|
||||
cell.covered = true;
|
||||
}
|
||||
if !cell.covered && depth < MAX_NEST {
|
||||
sub.run(None);
|
||||
let (blocks, _) = sub.finish();
|
||||
cell.blocks = blocks;
|
||||
}
|
||||
let span = cell.colspan as usize;
|
||||
if covering.len() < g + span {
|
||||
covering.resize(g + span, 0);
|
||||
}
|
||||
for k in g..g + span {
|
||||
if let Some(c) = covering.get_mut(k)
|
||||
&& cell.rowspan > 1
|
||||
&& !cell.covered
|
||||
{
|
||||
*c = cell.rowspan;
|
||||
}
|
||||
}
|
||||
g += span;
|
||||
cells.push(cell);
|
||||
}
|
||||
for c in covering.iter_mut() {
|
||||
*c = c.saturating_sub(1);
|
||||
}
|
||||
// `\hline` alone after the last `\\` is not a row.
|
||||
let empty = cells.iter().all(|c| !c.covered && c.blocks.is_empty());
|
||||
if empty && cells.len() <= 1 {
|
||||
continue;
|
||||
}
|
||||
out.rows.push(cells);
|
||||
}
|
||||
let cols = out.cols();
|
||||
if !widths.is_empty() && widths.len() == cols && spec_cols == cols {
|
||||
out.widths = widths;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A cell's tokens without the rules (`\hline`…) and spaces around them.
|
||||
fn trim_cell(toks: &[Tok]) -> &[Tok] {
|
||||
let skip = |t: &Tok| match t {
|
||||
Tok::Cmd(c) => {
|
||||
matches!(c.as_str(), "hline" | "toprule" | "midrule" | "bottomrule" | "endhead" | "endfirsthead" | "endfoot" | "endlastfoot" | "noalign")
|
||||
}
|
||||
Tok::Text(s) => s.trim().is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
let mut a = 0;
|
||||
while let Some(t) = toks.get(a) {
|
||||
if skip(t) {
|
||||
a += 1;
|
||||
} else if matches!(t, Tok::Cmd(c) if c == "cline" || c == "cmidrule" || c == "addlinespace") {
|
||||
// `\cline{2-3}`, `\cmidrule(lr){1-2}`: the command and its argument.
|
||||
a += 1;
|
||||
while let Some(Tok::Text(s)) = toks.get(a) {
|
||||
if s.trim().is_empty() || s.trim_start().starts_with('(') || s.trim_start().starts_with('[') {
|
||||
a += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if toks.get(a) == Some(&Tok::Open) {
|
||||
while let Some(t) = toks.get(a) {
|
||||
a += 1;
|
||||
if *t == Tok::Close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut b = toks.len();
|
||||
while b > a && toks.get(b - 1).is_some_and(skip) {
|
||||
b -= 1;
|
||||
}
|
||||
toks.get(a..b).unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use super::export::{LINE_WIDTH, TABCOLSEP};
|
||||
use super::limits::MAX_NEST;
|
||||
use super::math::{balanced, closers};
|
||||
use super::*;
|
||||
|
||||
/// Font size declarations (points, for the default 10 pt article).
|
||||
pub(super) fn size_of(name: &str) -> Option<f32> {
|
||||
Some(match name {
|
||||
"tiny" => 5.0,
|
||||
"scriptsize" => 7.0,
|
||||
"footnotesize" => 8.0,
|
||||
"small" => 9.0,
|
||||
"normalsize" => 0.0,
|
||||
"large" => 12.0,
|
||||
"Large" => 14.4,
|
||||
"LARGE" => 17.28,
|
||||
"huge" => 20.74,
|
||||
"Huge" => 24.88,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A named colour (`xcolor`'s base names).
|
||||
fn named_color(name: &str) -> Option<Rgb> {
|
||||
Some(match name.trim() {
|
||||
"black" => Rgb(0, 0, 0),
|
||||
"white" => Rgb(255, 255, 255),
|
||||
"red" => Rgb(255, 0, 0),
|
||||
"green" => Rgb(0, 255, 0),
|
||||
"blue" => Rgb(0, 0, 255),
|
||||
"cyan" => Rgb(0, 255, 255),
|
||||
"magenta" => Rgb(255, 0, 255),
|
||||
"yellow" => Rgb(255, 255, 0),
|
||||
"gray" => Rgb(128, 128, 128),
|
||||
"darkgray" => Rgb(64, 64, 64),
|
||||
"lightgray" => Rgb(191, 191, 191),
|
||||
"brown" => Rgb(191, 128, 64),
|
||||
"lime" => Rgb(191, 255, 0),
|
||||
"olive" => Rgb(128, 128, 0),
|
||||
"orange" => Rgb(255, 128, 0),
|
||||
"pink" => Rgb(255, 191, 191),
|
||||
"purple" => Rgb(191, 0, 64),
|
||||
"teal" => Rgb(0, 128, 128),
|
||||
"violet" => Rgb(128, 0, 128),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `\textcolor[model]{spec}`: HTML, RGB (0–255) and rgb (0–1) models, or a name.
|
||||
pub(super) fn parse_color(model: Option<&str>, spec: &str) -> Option<Rgb> {
|
||||
let spec = spec.trim();
|
||||
match model.map(str::trim) {
|
||||
Some("HTML") => crate::model::parse_color(&format!("#{spec}")),
|
||||
Some("RGB") => {
|
||||
let v: Vec<u8> = spec.split(',').filter_map(|x| x.trim().parse::<f32>().ok()).map(|x| x.clamp(0.0, 255.0) as u8).collect();
|
||||
match v.as_slice() {
|
||||
[r, g, b] => Some(Rgb(*r, *g, *b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Some("rgb") => {
|
||||
let v: Vec<u8> = spec
|
||||
.split(',')
|
||||
.filter_map(|x| x.trim().parse::<f32>().ok())
|
||||
.filter(|x| x.is_finite())
|
||||
.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
.collect();
|
||||
match v.as_slice() {
|
||||
[r, g, b] => Some(Rgb(*r, *g, *b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Some("gray") => spec.parse::<f32>().ok().filter(|x| x.is_finite()).map(|x| {
|
||||
let g = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Rgb(g, g, g)
|
||||
}),
|
||||
_ => {
|
||||
// `red!50` (a tint) and `red!50!blue` (a mix) keep the first colour.
|
||||
named_color(spec.split('!').next().unwrap_or(spec))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A TeX length in points (`3cm`, `0.4\linewidth`), if it is one.
|
||||
pub(super) fn length_pt(s: &str) -> Option<f32> {
|
||||
let s = s.trim().strip_prefix('+').unwrap_or(s.trim());
|
||||
let split = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
|
||||
let (n, unit) = s.split_at(split);
|
||||
let n: f32 = if n.is_empty() { 1.0 } else { n.parse().ok()? };
|
||||
let unit = unit.trim();
|
||||
// Macro names are case-sensitive (`\TEXTWIDTH` is not `\textwidth`); unit keywords are not.
|
||||
let unit = if unit.starts_with('\\') { unit.to_owned() } else { unit.to_ascii_lowercase() };
|
||||
let k = match unit.as_str() {
|
||||
"pt" => 1.0,
|
||||
"bp" => 72.27 / 72.0,
|
||||
"in" => 72.27,
|
||||
"cm" => 28.45,
|
||||
"mm" => 2.845,
|
||||
"em" => 10.0,
|
||||
"ex" => 4.3,
|
||||
"pc" => 12.0,
|
||||
"\\linewidth" | "\\textwidth" | "\\columnwidth" | "\\hsize" => LINE_WIDTH,
|
||||
_ => return None,
|
||||
};
|
||||
let v = n * k;
|
||||
(v.is_finite() && v > 0.0 && v < 10_000.0).then_some(v)
|
||||
}
|
||||
|
||||
/// Column widths of a `tabular` column spec, when every column has one (`p{3cm}`).
|
||||
pub(super) fn spec_widths(spec: &str) -> (usize, Vec<f32>) {
|
||||
let (cols, widths) = spec_widths_at(spec, 0);
|
||||
(cols.min(wordcraft_doc::table::MAX_COLS), if widths.len() == cols { widths } else { Vec::new() })
|
||||
}
|
||||
|
||||
/// [`spec_widths`] at nesting `depth` of `*{n}{…}` repeats. The column count saturates and the
|
||||
/// widths stop growing past [`wordcraft_doc::table::MAX_COLS`], so nested repeats can't overflow
|
||||
/// or exhaust memory; the returned widths are empty unless every column has one.
|
||||
fn spec_widths_at(spec: &str, depth: usize) -> (usize, Vec<f32>) {
|
||||
let max_cols = wordcraft_doc::table::MAX_COLS;
|
||||
let chars: Vec<char> = spec.chars().collect();
|
||||
let braces = closers(&chars, '{', '}');
|
||||
let mut cols = 0usize;
|
||||
let mut widths = Vec::new();
|
||||
let mut all = true;
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
match c {
|
||||
'l' | 'c' | 'r' | 'X' | 'S' | 'L' | 'C' | 'R' | 'J' => {
|
||||
cols = cols.saturating_add(1);
|
||||
all = false;
|
||||
}
|
||||
'p' | 'm' | 'b' => {
|
||||
cols = cols.saturating_add(1);
|
||||
match balanced(&chars, &braces, i + 1) {
|
||||
Some((w, end)) => {
|
||||
match length_pt(&w) {
|
||||
Some(v) if widths.len() < max_cols => widths.push(v + 2.0 * TABCOLSEP),
|
||||
Some(_) => {}
|
||||
None => all = false,
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
None => all = false,
|
||||
}
|
||||
}
|
||||
'*' => {
|
||||
// `*{3}{l}`: repeat. Past the nesting limit the repeat is skipped, unmeasured.
|
||||
if let Some((n, end)) = balanced(&chars, &braces, i + 1)
|
||||
&& let Some((inner, end2)) = balanced(&chars, &braces, end)
|
||||
{
|
||||
if depth < MAX_NEST {
|
||||
let n: usize = n.trim().parse().unwrap_or(1).min(64);
|
||||
let (c2, w2) = spec_widths_at(&inner, depth + 1);
|
||||
cols = cols.saturating_add(c2.saturating_mul(n));
|
||||
if w2.len() == c2 {
|
||||
for _ in 0..n {
|
||||
if widths.len() >= max_cols {
|
||||
break;
|
||||
}
|
||||
widths.extend(w2.iter().take(max_cols - widths.len()));
|
||||
}
|
||||
} else {
|
||||
all = false;
|
||||
}
|
||||
} else {
|
||||
all = false;
|
||||
}
|
||||
i = end2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
'@' | '!' | '>' | '<' => {
|
||||
// `@{…}`, `>{…}`: skip the argument.
|
||||
if let Some((_, end)) = balanced(&chars, &braces, i + 1) {
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
(cols, if all && widths.len() == cols { widths } else { Vec::new() })
|
||||
}
|
||||
Reference in New Issue
Block a user