use regex::Regex;
use std::collections::HashSet;
use std::sync::OnceLock;
use crate::config::{FloatNeeds, Pos, Span};
use crate::esc::inline;
fn sep_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"^:?-{2,}:?$").unwrap())
}
fn field_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"(?m)^\s*([A-Za-z]+)\s*=").unwrap())
}
fn key_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"@\w+\{([^,\s]+)\s*,").unwrap())
}
pub fn split_row(line: &str) -> Vec<String> {
line.trim().trim_matches('|').split('|').map(|c| c.trim().to_string()).collect()
}
pub fn is_sep(line: &str) -> bool {
let cells = split_row(line);
!cells.is_empty() && cells.iter().all(|c| sep_re().is_match(c))
}
pub fn table_block(
rows: &[String],
cap: &str,
keys: Option<&HashSet<String>>,
spec: Option<&str>,
span: Span,
pos: Pos,
caption_top: bool,
width: Option<&str>,
) -> Result<(String, FloatNeeds), String> {
let mut needs = FloatNeeds::none();
let wide = matches!(span, Span::Wide);
if wide && matches!(pos, Pos::Here | Pos::Force) {
return Err("table*: pos here/force is illegal on full-width floats (use span=column)".to_string());
}
if wide && matches!(pos, Pos::Bottom | Pos::Both) {
needs.dblfloat = true;
}
if matches!(pos, Pos::Force) {
needs.float_h = true;
}
if matches!(pos, Pos::Barrier) {
needs.barrier = true;
}
let env = if wide { "table*" } else { "table" };
let span_w = if wide { "\\textwidth" } else { "\\columnwidth" };
let w = match width {
None => span_w.to_string(),
Some(f) if f.parse::<f64>().map(|v| v > 0.0 && v <= 2.0).unwrap_or(false) => {
format!("{f}{span_w}")
}
Some(abs) => abs.to_string(),
};
let n = rows.iter().map(|r| split_row(r).len()).max().unwrap_or(0);
let caption = format!("\\caption{{{cap}}}");
let mut lines = vec![
(if matches!(pos, Pos::Barrier) { "\\FloatBarrier\n" } else { "" }).to_string()
+ &format!("\\begin{{{env}}}{}", pos.latex_spec()),
];
if caption_top {
lines.push(caption.clone());
}
lines.extend([
"\\small".to_string(),
format!("\\begin{{tabularx}}{{{w}}}{{{}}}", spec.unwrap_or(&"X".repeat(n))),
"\\hline".to_string(),
]);
let mut first = true;
for r in rows {
if is_sep(r) {
continue;
}
let mut cells = split_row(r);
cells.resize(n, String::new());
let mut rendered = Vec::new();
for c in &cells {
let mut cell = inline(c, keys)?;
if first {
cell = format!("\\textbf{{{cell}}}");
}
rendered.push(cell);
}
lines.push(rendered.join(" & ") + " \\\\");
if first {
lines.push("\\hline".to_string());
first = false;
}
}
lines.push("\\hline".to_string());
lines.push("\\end{tabularx}".to_string());
if !caption_top {
lines.push(caption);
}
lines.push(format!("\\end{{{env}}}"));
Ok((lines.join("\n"), needs))
}
pub fn bib_keys_of(bib_src: &str) -> HashSet<String> {
key_re().captures_iter(bib_src).map(|c| c[1].to_string()).collect()
}
fn escape_field_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_backslash = false;
for c in s.chars() {
if (c == '_' || c == '#' || c == '%' || c == '&') && !prev_backslash {
out.push('\\');
}
prev_backslash = c == '\\' && !prev_backslash;
out.push(c);
}
out
}
pub fn bib_safe(bib_src: &str) -> String {
let author_re = Regex::new(r"^(\s*author\s*=\s*\{)(.*)(\},\s*)$").unwrap();
let mut out = Vec::new();
for raw in bib_src.lines() {
let mut line = raw.to_string();
if let Some(cap) = author_re.captures(raw) {
if !cap[2].starts_with('{') {
line = format!("{}{{{}}}{}", &cap[1], &cap[2], &cap[3]);
}
}
if let Some(f) = field_re().captures(raw) {
match f[1].to_lowercase().as_str() {
"author" | "url" | "doi" => {}
_ => line = escape_field_text(&line),
}
}
out.push(line);
}
let mut s = out.join("\n");
s.push('\n');
return s;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sep_detection() {
assert!(is_sep("| --- | :---: | --: |"));
assert!(!is_sep("| a | b |"));
}
#[test]
fn block_shape() {
let rows = ["| A | B |".to_string(), "|--|--|".to_string(), "| `x_y` | 3 |".to_string()];
let (got, needs) = table_block(&rows, "Cap.", None, None, Span::Column, Pos::Top, true, None).unwrap();
assert!(got.contains("\\begin{table}[t]"));
assert!(got.contains("\\begin{tabularx}{\\columnwidth}{XX}"));
assert!(got.contains("\\textbf{A} & \\textbf{B} \\\\"));
assert!(got.contains("\\texttt{x\\_y} & 3 \\\\"));
assert_eq!(needs, FloatNeeds::none());
}
#[test]
fn wide_barrier_table() {
let rows = ["| A |".to_string()];
let (got, needs) =
table_block(&rows, "C.", None, None, Span::Wide, Pos::Barrier, true, Some("0.8")).unwrap();
assert!(got.contains("\\FloatBarrier\n\\begin{table*}[t]"));
assert!(got.contains("\\begin{tabularx}{0.8\\textwidth}{X}"));
assert!(needs.barrier);
}
#[test]
fn wide_force_table_is_an_error() {
let rows = ["| A |".to_string()];
assert!(table_block(&rows, "C.", None, None, Span::Wide, Pos::Force, true, None).is_err());
}
#[test]
fn appendix_spec() {
let rows = ["| A | B | C |".to_string(), "| 1 | 2 | 3 |".to_string()];
let (got, _) = table_block(&rows, "C.", None, Some("Xcl"), Span::Column, Pos::Top, true, None).unwrap();
assert!(got.contains("{Xcl}"));
}
#[test]
fn bib_roundtrip() {
let src = "@article{k,\n author = {A. B and C. D},\n title = {x_y and R&D},\n url = {http://e/x_y},\n}\n";
let safe = bib_safe(src);
assert!(safe.contains("author = {{A. B and C. D}},"));
assert!(safe.contains("title = {x\\_y and R\\&D},"));
assert!(safe.contains("url = {http://e/x_y},"));
let keys = bib_keys_of(src);
assert!(keys.contains("k"));
}
}