1use serde_json::Value;
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::table::{TableAlign, TableData};
14
15const MAX_CSL_BYTES: u64 = 64 * 1024 * 1024;
16const MAX_CSL_DEPTH: usize = 100;
17const MAX_CSL_VALUES: usize = 300_000;
18const MAX_CSL_ITEMS: usize = 100_000;
19const MAX_CSL_AUTHORS: usize = 512;
20const MAX_CSL_STRING_BYTES: usize = 2 * 1024 * 1024;
21const MAX_CSL_DISPLAY_BYTES: usize = 512;
22
23pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
24 let text = String::from_utf8_lossy(prefix);
25 let trimmed = text.trim_start_matches('\u{feff}').trim_start();
26 (trimmed.starts_with('[') || trimmed.starts_with('{'))
27 && text.contains("\"type\"")
28 && (text.contains("\"title\"") || text.contains("\"author\""))
29 && (text.contains("\"issued\"")
30 || text.contains("\"container-title\"")
31 || text.contains("\"DOI\""))
32}
33
34struct CslPageSink<'a> {
35 inner: &'a mut dyn PageConsumer,
36 warnings: &'a [String],
37}
38
39impl PageConsumer for CslPageSink<'_> {
40 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
41 page.source_format = "csl-json".into();
42 if page.title.is_empty() {
43 page.title = "CSL-JSON bibliography".into();
44 }
45 page.description =
46 "CSL-JSON citation metadata is rendered inertly; styles, links and external resources are never resolved".into();
47 for warning in self.warnings {
48 page.warn(warning.clone());
49 }
50 self.inner.consume(page)
51 }
52}
53
54pub(crate) fn convert(
55 path: &Path,
56 options: &ConvertOptions,
57 sink: &mut dyn PageConsumer,
58) -> Result<Vec<String>> {
59 let bytes = read_limited_file(
60 path,
61 options.max_input_bytes.min(MAX_CSL_BYTES),
62 "CSL-JSON input",
63 )?;
64 let text = String::from_utf8(bytes)
65 .map_err(|error| Error::InvalidInput(format!("CSL-JSON must be UTF-8 JSON: {error}")))?;
66 let (table, metadata, warnings) = parse(&text)?;
67 let blocks = vec![
68 HtmlBlock::Heading {
69 level: 1,
70 text: "CSL-JSON bibliography".into(),
71 },
72 HtmlBlock::Paragraph { text: metadata },
73 HtmlBlock::Table(table),
74 ];
75 let mut page_sink = CslPageSink {
76 inner: sink,
77 warnings: &warnings,
78 };
79 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
80 Ok(warnings)
81}
82
83fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
84 if text.len() as u64 > MAX_CSL_BYTES {
85 return Err(Error::LimitExceeded(format!(
86 "CSL-JSON exceeds {MAX_CSL_BYTES} bytes"
87 )));
88 }
89 preflight_depth(text)?;
90 let value: Value = serde_json::from_str(text)
91 .map_err(|error| Error::InvalidInput(format!("invalid CSL-JSON: {error}")))?;
92 let mut values = 0usize;
93 count_values(&value, 0, &mut values)?;
94 let items = if let Some(items) = value.as_array() {
95 items.as_slice()
96 } else if let Some(items) = value.get("items").and_then(Value::as_array) {
97 items.as_slice()
98 } else {
99 return Err(Error::InvalidInput(
100 "CSL-JSON root must be an item array or an object containing items".into(),
101 ));
102 };
103 if items.len() > MAX_CSL_ITEMS {
104 return Err(Error::LimitExceeded(format!(
105 "CSL-JSON items exceed {MAX_CSL_ITEMS}"
106 )));
107 }
108 let mut rows = Vec::new();
109 let mut warnings = Vec::new();
110 let mut with_links = 0usize;
111 for (index, item) in items.iter().enumerate() {
112 let Some(object) = item.as_object() else {
113 warnings.push(format!(
114 "CSL-JSON item {} was not an object and was omitted",
115 index + 1
116 ));
117 continue;
118 };
119 let item_type = object
120 .get("type")
121 .and_then(Value::as_str)
122 .unwrap_or("unknown");
123 let title = object
124 .get("title")
125 .and_then(Value::as_str)
126 .unwrap_or("(untitled)");
127 let id = object
128 .get("id")
129 .or_else(|| object.get("citation-key"))
130 .and_then(Value::as_str)
131 .unwrap_or("—");
132 let authors = format_names(object.get("author"), object.get("editor"), &mut warnings)?;
133 let issued = date_text(object.get("issued"));
134 if object.get("DOI").and_then(Value::as_str).is_some()
135 || object.get("URL").and_then(Value::as_str).is_some()
136 {
137 with_links = with_links.saturating_add(1);
138 }
139 rows.push(vec![
140 truncate(id),
141 truncate(item_type),
142 truncate(title),
143 truncate(&authors),
144 truncate(&issued),
145 ]);
146 }
147 if rows.is_empty() {
148 return Err(Error::InvalidInput(
149 "CSL-JSON contains no usable citation items".into(),
150 ));
151 }
152 let metadata = format!(
153 "Items: {}\nWith authors/editors: {}\nWith DOI/URL: {}",
154 rows.len(),
155 rows.iter().filter(|row| row[3] != "—").count(),
156 with_links
157 );
158 warnings.push("CSL styles/locales, citation formatting, DOI/URL links, abstract markup and external resources remain inert; no style processor or network request runs".into());
159 Ok((
160 TableData {
161 headers: vec![
162 "ID".into(),
163 "Type".into(),
164 "Title".into(),
165 "Author".into(),
166 "Yr".into(),
167 ],
168 rows,
169 alignments: vec![TableAlign::Left; 5],
170 raw_source: String::new(),
171 },
172 metadata,
173 warnings,
174 ))
175}
176
177fn format_names(
178 author: Option<&Value>,
179 editor: Option<&Value>,
180 warnings: &mut Vec<String>,
181) -> Result<String> {
182 let names = author.or(editor).and_then(Value::as_array);
183 let Some(names) = names else {
184 return Ok("—".into());
185 };
186 if names.len() > MAX_CSL_AUTHORS {
187 return Err(Error::LimitExceeded(format!(
188 "CSL-JSON authors/editors exceed {MAX_CSL_AUTHORS}"
189 )));
190 }
191 let mut rendered = Vec::new();
192 for name in names {
193 let Some(object) = name.as_object() else {
194 warnings.push("CSL-JSON non-object name entry was omitted".into());
195 continue;
196 };
197 let value = object
198 .get("literal")
199 .or_else(|| object.get("family"))
200 .or_else(|| object.get("given"))
201 .and_then(Value::as_str)
202 .unwrap_or("(unnamed)");
203 rendered.push(truncate(value));
204 }
205 if rendered.is_empty() {
206 Ok("—".into())
207 } else {
208 Ok(rendered.join(", "))
209 }
210}
211
212fn date_text(value: Option<&Value>) -> String {
213 let parts = value
214 .and_then(Value::as_object)
215 .and_then(|object| object.get("date-parts"))
216 .and_then(Value::as_array)
217 .and_then(|parts| parts.first())
218 .and_then(Value::as_array);
219 let Some(parts) = parts else {
220 return "—".into();
221 };
222 let values = parts
223 .iter()
224 .filter_map(Value::as_i64)
225 .map(|value| value.to_string())
226 .collect::<Vec<_>>();
227 values.first().cloned().unwrap_or_else(|| "—".into())
228}
229
230fn truncate(value: &str) -> String {
231 if value.len() <= MAX_CSL_DISPLAY_BYTES {
232 return value.to_owned();
233 }
234 let mut end = MAX_CSL_DISPLAY_BYTES;
235 while !value.is_char_boundary(end) {
236 end -= 1;
237 }
238 format!("{}…", &value[..end])
239}
240
241fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
242 if depth > MAX_CSL_DEPTH {
243 return Err(Error::LimitExceeded(format!(
244 "CSL-JSON nesting exceeds {MAX_CSL_DEPTH} levels"
245 )));
246 }
247 *count = count.saturating_add(1);
248 if *count > MAX_CSL_VALUES {
249 return Err(Error::LimitExceeded(format!(
250 "CSL-JSON contains more than {MAX_CSL_VALUES} values"
251 )));
252 }
253 match value {
254 Value::Array(values) => {
255 for item in values {
256 count_values(item, depth + 1, count)?;
257 }
258 }
259 Value::Object(map) => {
260 for item in map.values() {
261 count_values(item, depth + 1, count)?;
262 }
263 }
264 Value::String(value) if value.len() > MAX_CSL_STRING_BYTES => {
265 return Err(Error::LimitExceeded(format!(
266 "CSL-JSON string exceeds {MAX_CSL_STRING_BYTES} bytes"
267 )));
268 }
269 _ => {}
270 }
271 Ok(())
272}
273
274fn preflight_depth(text: &str) -> Result<()> {
275 let mut depth = 0usize;
276 let mut quoted = false;
277 let mut escaped = false;
278 for byte in text.bytes() {
279 if quoted {
280 if escaped {
281 escaped = false;
282 } else if byte == b'\\' {
283 escaped = true;
284 } else if byte == b'"' {
285 quoted = false;
286 }
287 continue;
288 }
289 match byte {
290 b'"' => quoted = true,
291 b'{' | b'[' => {
292 depth += 1;
293 if depth > MAX_CSL_DEPTH {
294 return Err(Error::LimitExceeded(format!(
295 "CSL-JSON nesting exceeds {MAX_CSL_DEPTH} levels"
296 )));
297 }
298 }
299 b'}' | b']' => depth = depth.saturating_sub(1),
300 _ => {}
301 }
302 }
303 Ok(())
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn recognizes_csl_json() {
312 assert!(looks_like_prefix(br#"[{"id":"x","type":"article-journal","title":"T","author":[],"issued":{"date-parts":[[2024]]}}]"#));
313 assert!(!looks_like_prefix(b"[{\"type\":\"article\",\"value\":1}]"));
314 }
315
316 #[test]
317 fn summarizes_citations_without_fetching() {
318 let (table, metadata, warnings) = parse(
319 r#"[{"id":"paper-1","type":"article-journal","title":"A title","author":[{"family":"Doe","given":"Jane"}],"issued":{"date-parts":[[2024,5]]},"DOI":"10.1234/example","URL":"https://private.example/paper","abstract":"very-secret"}]"#,
320 )
321 .unwrap();
322 assert!(metadata.contains("Items: 1"));
323 assert_eq!(table.rows[0][3], "Doe");
324 assert_eq!(table.rows[0][4], "2024");
325 assert!(
326 !table
327 .rows
328 .iter()
329 .flatten()
330 .any(|value| value.contains("secret") || value.contains("private"))
331 );
332 assert!(warnings.iter().any(|warning| warning.contains("inert")));
333 }
334}