Skip to main content

document_svg/document/
csl_json.rs

1//! Bounded Citation Style Language (CSL-JSON) bibliography previews.
2//!
3//! CSL-JSON stores bibliographic metadata for citation processors. This adapter
4//! renders safe citation fields and author/date summaries without applying a
5//! CSL style, executing markup, resolving DOI/URL links, or fetching resources.
6
7use 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}