latexml/bib_session.rs
1//! The recursive BibTeX session behind post-processing's bibliography.
2//!
3//! Port of `LaTeXML::Post::MakeBibliography::convertBibliography`
4//! (`MakeBibliography.pm` L171-242): a raw `.bib` is not parsed by a bespoke
5//! reader, it is **converted** — by the same engine, through `BibTeX.pool`, with
6//! the article's own class and packages preloaded so the fields mean what the
7//! article meant.
8//!
9//! It lives here rather than in `latexml_post` because a conversion needs
10//! `convert_document`, which needs this crate's model loader; `latexml_post`
11//! depending on `latexml_oxide` would be a cycle. `latexml_post` declares the
12//! hook, this module fills it in — see [`install`].
13
14use latexml_core::{
15 Core, CoreOptions,
16 binding::content::{InputDefinitionOptions, input_definitions},
17 common::{DigestionMode, error::*},
18 s,
19};
20// The post-phase diagnostic macros: plain reporters, with none of the
21// too-many-errors escalation the engine-side `Error!` carries. This module runs
22// inside post-processing, and an inner-session failure must not itself become a
23// document Fatal — the failure is already reported as an error, and Perl's
24// convertBibliography likewise just returns empty-handed (L240-242).
25use latexml_post::{
26 Error, Info,
27 document::{PostDocument, PostDocumentOptions},
28 make_bibliography::{BibConversionRequest, RawBibSource, set_bib_converter},
29};
30
31use crate::core_interface::{DigestionAPI, DigestionOptions};
32
33/// The commands a real `.bst`-generated `.bbl` provides before its entries.
34///
35/// BibTeX copies a field into the `.bbl`, whose preamble opens with a block of
36/// `\providecommand`s so `\url{…}` in a `note` renders even in a document that
37/// loads neither `hyperref` nor `url.sty`. We convert the `.bib` directly, one
38/// step earlier, so without this block we raise `undefined:\url` where real
39/// LaTeX renders text.
40///
41/// It is also what makes the percent-encoded-URL family work at all, and that is
42/// the deeper reason to keep it: `\url` is declared with a **Semiverbatim**
43/// parameter, so once it is defined, `\url{…/B130936%20Law%20of%20War.pdf}` reads
44/// its argument with `%` at catcode 12. Measured against same-host Perl on the
45/// same input: with `url.sty` present both engines emit 0 errors and identical
46/// XML; without it both mis-read the `%`. So this block is not a Rust patch
47/// papering over the engine — it is the condition under which the engine's own
48/// parameter types can act.
49///
50/// `\providecommand` is the right primitive precisely because it defers: a
51/// document that DOES load `hyperref` keeps hyperref's `\url`. Bodies mirror the
52/// conventional `.bbl` definitions (plain/natbib/revtex all ship these shapes).
53const BBL_STANDARD_FALLBACKS: &str = concat!(
54 r"\providecommand{\url}[1]{\texttt{#1}}",
55 r"\providecommand{\urlprefix}{URL }",
56 r"\providecommand{\doi}[1]{doi:#1}",
57 r"\providecommand{\bibinfo}[2]{#2}",
58 r"\providecommand{\eprint}[2][]{\url{#2}}",
59 r"\providecommand{\selectlanguage}[1]{\relax}",
60 r"\providecommand{\newblock}{}",
61);
62
63/// Install this crate's recursive-session implementation into `latexml_post`.
64///
65/// Call once per thread before running the post-processing pipeline.
66pub fn install() { set_bib_converter(convert); }
67
68/// Assemble the sources into one conversion payload.
69///
70/// Perl `MakeBibliography.pm` L147-163: a lone file is converted as-is (so
71/// locators name it), and several are concatenated into one `literal:` payload —
72/// each followed by `%\n` — so that a single session sees them all and `@string`
73/// macros defined in one file are in scope for the next.
74///
75/// The file reading deliberately does NOT use `read_to_string`: a strict UTF-8
76/// read hard-errors on the first stray Cp1252 byte and would silently lose the
77/// whole bibliography (witness 2605.00490). `decode_input_bytes` is the shared
78/// decoder — UTF-8, else a lossless Latin-1 passthrough — that both `.bib` read
79/// sites already use.
80fn payload(sources: &[RawBibSource]) -> Option<String> {
81 if let [RawBibSource::Path(path)] = sources {
82 return Some(path.clone());
83 }
84 let mut combined = String::from("literal:");
85 for source in sources {
86 match source {
87 RawBibSource::Literal(data) => combined.push_str(data),
88 RawBibSource::Path(path) => match std::fs::read(path) {
89 Ok(bytes) => combined.push_str(&latexml_core::mouth::decode_input_bytes(&bytes)),
90 // Perl L162: `Info("open", ...)` — one unreadable file must not sink
91 // the bibliographies that ARE readable.
92 Err(e) => Info!(
93 "open",
94 path,
95 "Couldn't open bibliography file {}: {}",
96 path,
97 e
98 ),
99 },
100 }
101 combined.push_str("%\n");
102 }
103 if combined == "literal:" {
104 None
105 } else {
106 Some(combined)
107 }
108}
109
110/// Make this thread's engine ready to digest BibTeX, **keeping the document's
111/// own definitions**.
112///
113/// Perl spins a brand-new converter and rebuilds an approximation of the
114/// article's environment from `[options]class.cls` + `[options]pkg.sty` preloads
115/// recovered from the `<?latexml?>` PIs — and says so in a comment right above
116/// `convertBibliography` (`MakeBibliography.pm` L174-177):
117///
118/// > In general, it should use the same STATE information as the main document,
119/// > so IF that state is still around, we should use it! That's for future
120/// > enhancement!!!
121///
122/// It is still around: post-processing runs in the same thread, right after
123/// digestion. Using it is the enhancement Perl asks for, and it is strictly more
124/// faithful to the *document*: preloads recover the class and packages but not
125/// the author's own preamble macros, which is precisely what a `howpublished` or
126/// `note` field tends to use (Perl's L184 comment — "custom macros often used in
127/// e.g. howpublished field" — is the stated reason for preloading at all).
128///
129/// It also avoids two concrete hazards. A second `initialize_singletons` on one
130/// thread re-runs the pools' `Let!`s (WISDOM #67 — that is a *non-unwinding*
131/// abort, not a catchable failure), and it resets the `REPORT` the outer
132/// document is still counting into, so Perl's `MergeStatus` would have to be
133/// re-implemented by hand. Sharing the state makes both moot.
134///
135/// So all this needs is `BibTeX.pool` on top. The preloads are still consulted —
136/// but only for the fallback path below, where there is no live session to
137/// share (a `--post`-only run over an existing `.xml`, where nothing was ever
138/// digested in this process).
139fn ensure_session(request: &BibConversionRequest) -> Result<()> {
140 if latexml_core::state::has_value("LATEXML_VERSION") {
141 // Live session: add the pool the document never needed. Idempotent —
142 // `input_definitions` early-returns on its own `_loaded` flag.
143 input_definitions("BibTeX", InputDefinitionOptions {
144 extension: Some("pool".into()),
145 ..InputDefinitionOptions::default()
146 })
147 } else {
148 // No conversion happened in this process (XML input). This IS the thread's
149 // first initialization, so it is the safe case for `initialize_singletons`,
150 // and Perl's preload reconstruction is exactly right here.
151 let mut preloads = vec![s!("TeX.pool"), s!("BibTeX.pool")];
152 preloads.extend(request.preloads.iter().cloned());
153 let mut core = Core::new(CoreOptions {
154 preload: Some(request.preloads.clone()),
155 search_paths: Some(request.search_paths.clone()),
156 ..CoreOptions::default()
157 });
158 core.initialize_singletons(preloads)
159 }
160}
161
162/// Ensure `\url` exists, and exists with its REAL definition.
163///
164/// [`BBL_STANDARD_FALLBACKS`] alone would define it as
165/// `\providecommand{\url}[1]{\texttt{#1}}` — an ordinary `{}` argument, which
166/// renders but gives no catcode protection. Its argument is then read with `%`
167/// at catcode 14, so a percent-encoded URL (`…/B130936%20Law%20of%20War.pdf` —
168/// entirely routine in a `howpublished`) is truncated at the first `%`, taking
169/// the closing brace with it.
170///
171/// Loading LaTeXML's own `url.sty` binding instead gives `\url` the
172/// **Semiverbatim** parameter it is supposed to have (`url_sty.rs`), and the
173/// truncation disappears. That is what a document which actually uses `\url`
174/// would have had: a `.bst` emitting `\url{…}` into the `.bbl` assumes the
175/// document loads `url` or `hyperref`, and nearly all do.
176///
177/// SURPASS-PERL, and deliberately: same-host Perl truncates this input (measured
178/// — `latexmlc` renders `https://example.org/B130936` and spills the raw entry
179/// into the note), because it defines nothing here at all. So does pdflatex
180/// without a url package. This continues the beyond-Perl content-recovery line
181/// already taken by the `.bbl` fallback block itself; it does not silence any
182/// diagnostic Perl raises for a *correct* document, since a document that loads
183/// `url`/`hyperref` keeps its own definition — `input_definitions` early-returns
184/// on the `_loaded` flag, and `\providecommand` defers.
185fn provide_url_command() -> Result<()> {
186 if latexml_core::state::lookup_meaning(&latexml_core::T_CS!("\\url")).is_some() {
187 return Ok(());
188 }
189 input_definitions("url", InputDefinitionOptions {
190 extension: Some("sty".into()),
191 handleoptions: true,
192 ..InputDefinitionOptions::default()
193 })
194}
195
196/// Run the recursive session and hand back its document.
197fn convert(request: &BibConversionRequest) -> Option<PostDocument> {
198 let source = payload(&request.sources)?;
199 let stage = if source.starts_with("literal:") {
200 s!("Recursive MakeBibliography Anonymous Bib String")
201 } else {
202 s!("Recursive MakeBibliography {source}")
203 };
204 note_begin(&stage);
205
206 // The document's OWN state, still live, is the session — see the module docs
207 // for why that is both better and cheaper than a fresh one. `Core` is a thin
208 // handle over the thread-local engine (`Core::new` would install a *new*
209 // State and throw the document's away), so it is constructed directly.
210 let mut core = Core {
211 preload: request.preloads.clone(),
212 };
213
214 let result = (|| -> Result<PostDocument> {
215 ensure_session(request)?;
216 // Give the fields what a `.bbl` would have given them, once the session is
217 // ready and before any entry is digested.
218 provide_url_command()?;
219 latexml_core::stomach::digest(latexml_core::mouth::tokenize(BBL_STANDARD_FALLBACKS))?;
220
221 // Digest only what the document cites — a `.bib` is a library, and
222 // `bibtex(1)` reads the `.aux`'s `\citation` records rather than the whole
223 // file. Set around `digest_file` only: the filter lives in the same
224 // thread-local State the outer document uses, so leaving it installed
225 // would leak into any later `.bib` in this process.
226 latexml_engine::pre_bibtex::set_wanted_keys(request.wanted_keys.clone());
227 // Hide the OUTER document's bibliography count for the duration of the
228 // recursive digestion — the one value reusing the live State would
229 // otherwise leak (see the module docs for why the State is shared).
230 // `{bibtex@bibliography}` runs `begin_bibliography_clean`, which does
231 // `n_bibliographies += 1` and names the session's ids
232 // `<docid>bib<radix_alpha(n-1)>` (latex_constructs.rs:2392-2398). Perl
233 // builds a FRESH State per `.bib` (MakeBibliography.pm:181-215), so its
234 // inner session always sees 0 and mints `bib.bibN`; sharing ours made the
235 // FIRST `.bib` of a one-bibliography document mint `biba.bibN` — the
236 // second slot of the multi-bibliography sequence. Post then strips `^bib`
237 // and re-prepends the OUTER bibid (MakeBibliography.pm:407-415), which is
238 // exactly why the inner ids must be `bib`-rooted regardless of which
239 // bibliography they end up in.
240 let outer_n_bibliographies = latexml_core::state::lookup_int("n_bibliographies");
241 latexml_core::state::assign_value(
242 "n_bibliographies",
243 0,
244 Some(latexml_core::state::Scope::Global),
245 );
246 let digested = core.digest_file(source.clone(), DigestionOptions {
247 mode: Some(DigestionMode::BibTeX),
248 noinitialize: Some(true),
249 ..DigestionOptions::default()
250 });
251 latexml_core::state::assign_value(
252 "n_bibliographies",
253 outer_n_bibliographies,
254 Some(latexml_core::state::Scope::Global),
255 );
256 latexml_engine::pre_bibtex::clear_wanted_keys();
257 let digested = digested?;
258 let document = core.convert_document(digested)?;
259 Ok(PostDocument::new(document.document, PostDocumentOptions {
260 source_directory: Some(".".to_string()),
261 ..PostDocumentOptions::default()
262 }))
263 })();
264
265 // Perl `MergeStatus` (L237) ADDS the inner session's tally to the outer
266 // document's (Common/Error.pm L669-686) — a `.bib` that raises errors makes
267 // the document an error document, which is what the corpus signal depends on.
268 // Sharing the live state gives that for free: the counters never left.
269 note_end(&stage);
270
271 match result {
272 Ok(bibdoc) => Some(bibdoc),
273 Err(e) => {
274 Error!(
275 "bibliography",
276 "convert",
277 "Recursive bibliography conversion failed: {}",
278 e
279 );
280 None
281 },
282 }
283}