use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
use std::path::Path;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::NodeKind;
pub struct BundleReport {
pub tex_name: String,
pub bib_entries: usize,
pub figures: Vec<String>,
pub missing_figures: Vec<String>,
pub zipped: bool,
}
pub fn collect_bib_entries(
layout: &ProjectLayout,
h: &Hierarchy,
) -> Vec<crate::sources::BibEntry> {
let Some(sources_book) = h.iter().find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_SOURCES)
}) else {
return Vec::new();
};
let mut entries = Vec::new();
for id in h.collect_subtree(sources_book.id) {
let Some(n) = h.get(id) else { continue };
if n.kind != NodeKind::Paragraph {
continue;
}
let Some(rel) = &n.file else { continue };
let Ok(raw) = std::fs::read_to_string(layout.root.join(rel)) else {
continue;
};
let body = strip_leading_heading(&raw);
if let Some(e) = crate::sources::BibEntry::from_hjson(&body) {
if e.is_valid() {
entries.push(e);
}
}
}
entries
}
fn strip_leading_heading(raw: &str) -> String {
let t = raw.trim_start();
if t.starts_with("= ") {
t.splitn(2, '\n').nth(1).unwrap_or("").to_string()
} else {
raw.to_string()
}
}
fn latex_bst(csl_style: &str) -> &'static str {
match csl_style.trim().to_lowercase().as_str() {
"ieee" => "IEEEtran",
"apa" => "apalike",
"chicago-author-date" | "chicago" => "plainnat",
"mla" => "plain",
"plain" | "" => "plain",
_ => "plain",
}
}
fn fix_citations_and_style(tex: &str, bib_keys: &[String], csl_style: &str) -> String {
let mut out = tex.to_string();
for key in bib_keys {
out = out.replace(&format!("\\ref{{{key}}}"), &format!("\\cite{{{key}}}"));
}
let marker = "\\bibliographystyle{";
if let Some(start) = out.find(marker) {
let after = start + marker.len();
if let Some(rel) = out[after..].find('}') {
let end = after + rel;
let bst = latex_bst(csl_style);
out = format!("{}{}{}", &out[..after], bst, &out[end..]);
}
}
out
}
fn extract_graphics(tex: &str) -> Vec<String> {
let needle = "\\includegraphics";
let mut out = Vec::new();
let mut idx = 0usize;
while let Some(pos) = tex[idx..].find(needle) {
let mut p = idx + pos + needle.len();
let skip_ws = |b: &[u8], mut q: usize| {
while q < b.len() && b[q].is_ascii_whitespace() {
q += 1;
}
q
};
let bytes = tex.as_bytes();
p = skip_ws(bytes, p);
if p < bytes.len() && bytes[p] == b'[' {
if let Some(rel) = tex[p..].find(']') {
p = p + rel + 1;
}
}
p = skip_ws(bytes, p);
if p < bytes.len() && bytes[p] == b'{' {
if let Some(rel) = tex[p + 1..].find('}') {
let path = &tex[p + 1..p + 1 + rel];
if !path.trim().is_empty() {
out.push(path.trim().to_string());
}
idx = p + 1 + rel + 1;
continue;
}
}
idx = idx + pos + needle.len();
}
out
}
fn unique_basename(path: &str, used: &mut BTreeSet<String>) -> String {
let base = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "figure".to_string());
if used.insert(base.clone()) {
return base;
}
for i in 1.. {
let cand = format!("{i}-{base}");
if used.insert(cand.clone()) {
return cand;
}
}
unreachable!()
}
fn slugify(s: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !out.is_empty() && !prev_dash {
out.push('-');
prev_dash = true;
}
}
let out = out.trim_end_matches('-').to_string();
if out.is_empty() { "paper".to_string() } else { out }
}
pub fn build_bundle(
layout: &ProjectLayout,
h: &Hierarchy,
cfg: &Config,
combined_typst: &str,
title: &str,
out: &Path,
) -> Result<BundleReport> {
let entries = collect_bib_entries(layout, h);
let bib_keys: Vec<String> = entries.iter().map(|e| e.key.clone()).collect();
let (bib_text, bib_count) = crate::sources::compile_bibtex(&entries);
let mut typ = combined_typst.to_string();
if bib_count > 0 && !typ.contains("#bibliography(") {
typ.push_str(&format!(
"\n#bibliography(\"sources.bib\", style: \"{}\")\n",
cfg.sources.bibliography_style
));
}
let mut tex = crate::export::tex::typst_to_tex(&typ, &cfg.tex_export);
tex = fix_citations_and_style(&tex, &bib_keys, &cfg.sources.bibliography_style);
let mut assigned: BTreeMap<String, String> = BTreeMap::new();
let mut used_names: BTreeSet<String> = BTreeSet::new();
let mut figure_files: Vec<(String, Vec<u8>)> = Vec::new();
let mut missing_figures: Vec<String> = Vec::new();
for path in extract_graphics(&tex) {
if assigned.contains_key(&path) {
continue;
}
match std::fs::read(layout.root.join(&path)) {
Ok(bytes) => {
let dest = unique_basename(&path, &mut used_names);
figure_files.push((dest.clone(), bytes));
assigned.insert(path, dest);
}
Err(_) => {
missing_figures.push(path.clone());
assigned.insert(path.clone(), path); }
}
}
for (src, dest) in &assigned {
if src != dest {
tex = tex.replace(&format!("{{{src}}}"), &format!("{{{dest}}}"));
}
}
let tex_name = format!("{}.tex", slugify(title));
let figures: Vec<String> = figure_files.iter().map(|(n, _)| n.clone()).collect();
let manifest = build_manifest(
title,
&tex_name,
&cfg.tex_export.document_class,
&cfg.sources.bibliography_style,
latex_bst(&cfg.sources.bibliography_style),
bib_count,
&figures,
&missing_figures,
);
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
files.push((tex_name.clone(), tex.into_bytes()));
if bib_count > 0 {
files.push(("sources.bib".to_string(), bib_text.into_bytes()));
}
files.extend(figure_files);
files.push(("MANIFEST.txt".to_string(), manifest.into_bytes()));
let zipped = out
.extension()
.map(|e| e.eq_ignore_ascii_case("zip"))
.unwrap_or(false);
if zipped {
write_zip(out, &files)?;
} else {
write_dir(out, &files)?;
}
Ok(BundleReport {
tex_name,
bib_entries: bib_count,
figures,
missing_figures,
zipped,
})
}
#[allow(clippy::too_many_arguments)]
fn build_manifest(
title: &str,
tex_name: &str,
document_class: &str,
csl_style: &str,
bst: &str,
bib_count: usize,
figures: &[String],
missing: &[String],
) -> String {
let class = if document_class.trim().is_empty() {
"article (tylax default)"
} else {
document_class.trim()
};
let mut s = String::new();
s.push_str(&format!("arXiv / preprint bundle — {title}\n"));
s.push_str("Generated by `inkhaven export tex --bundle`.\n\n");
s.push_str("Contents:\n");
s.push_str(&format!(" {tex_name} — the LaTeX source\n"));
if bib_count > 0 {
s.push_str(&format!(
" sources.bib — {bib_count} bibliography {}\n",
if bib_count == 1 { "entry" } else { "entries" }
));
}
if figures.is_empty() {
s.push_str(" (no figures referenced)\n");
} else {
s.push_str(&format!(" {} figure(s):\n", figures.len()));
for f in figures {
s.push_str(&format!(" {f}\n"));
}
}
s.push_str("\nBefore uploading, verify:\n");
s.push_str(&format!(
" · \\documentclass is right for your venue (currently: {class}); bundle any\n journal .cls/.sty not in arXiv's TeXLive.\n"
));
if bib_count > 0 {
s.push_str(&format!(
" · \\bibliographystyle is `{bst}` (mapped from the Typst `{csl_style}` style);\n arXiv runs BibTeX over sources.bib. Swap the .bst if your venue needs another.\n"
));
s.push_str(
" · citations: `@key` was rewritten to \\cite{key}; cross-references stay \\ref{…}.\n",
);
}
if missing.is_empty() {
s.push_str(" · all referenced figures were found and copied.\n");
} else {
s.push_str(&format!(
" · {} figure(s) referenced but NOT found on disk — add them by hand:\n",
missing.len()
));
for m in missing {
s.push_str(&format!(" {m}\n"));
}
}
s
}
fn write_dir(out: &Path, files: &[(String, Vec<u8>)]) -> Result<()> {
std::fs::create_dir_all(out).map_err(Error::Io)?;
for (name, bytes) in files {
std::fs::write(out.join(name), bytes).map_err(Error::Io)?;
}
Ok(())
}
fn write_zip(out: &Path, files: &[(String, Vec<u8>)]) -> Result<()> {
if let Some(parent) = out.parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
let file = std::fs::File::create(out).map_err(Error::Io)?;
let mut zw = zip::ZipWriter::new(file);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (name, bytes) in files {
zw.start_file(name, opts)
.map_err(|e| Error::Store(format!("zip {name}: {e}")))?;
zw.write_all(bytes).map_err(Error::Io)?;
}
zw.finish()
.map_err(|e| Error::Store(format!("zip finish: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrites_bib_ref_to_cite_but_leaves_labels() {
let tex = "See \\ref{smith2020} and figure \\ref{fig:flux}.\n";
let out = fix_citations_and_style(tex, &["smith2020".to_string()], "ieee");
assert!(out.contains("\\cite{smith2020}"), "{out}");
assert!(out.contains("\\ref{fig:flux}"), "{out}"); }
#[test]
fn fixes_bibliographystyle_quotes_and_maps_bst() {
let tex = "\\bibliographystyle{\"ieee\"}\n\\bibliography{sources}\n";
let out = fix_citations_and_style(tex, &[], "ieee");
assert!(out.contains("\\bibliographystyle{IEEEtran}"), "{out}");
assert!(!out.contains("\"ieee\""), "{out}");
}
#[test]
fn unknown_style_falls_back_to_plain() {
assert_eq!(latex_bst("some-obscure-style"), "plain");
assert_eq!(latex_bst("apa"), "apalike");
}
#[test]
fn extract_graphics_handles_options_and_multiple() {
let tex = "\\includegraphics{a.png}\n\\includegraphics[width=0.5\\textwidth]{sub/b.pdf}\n";
assert_eq!(extract_graphics(tex), vec!["a.png", "sub/b.pdf"]);
}
#[test]
fn unique_basename_disambiguates_collisions() {
let mut used = BTreeSet::new();
assert_eq!(unique_basename("a/flux.png", &mut used), "flux.png");
assert_eq!(unique_basename("b/flux.png", &mut used), "1-flux.png");
}
#[test]
fn slugify_is_filesystem_safe() {
assert_eq!(slugify("On Off-Target Effects!"), "on-off-target-effects");
assert_eq!(slugify(""), "paper");
}
}