Skip to main content

document_svg/document/
ris.rs

1//! Bounded RIS bibliography interchange preview.
2//!
3//! RIS records are rendered as inert citation rows. DOI/URL fields and other
4//! identifiers are never fetched, resolved, or interpreted as commands.
5
6use std::path::Path;
7
8use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
9use crate::error::{Error, Result};
10use crate::table::{TableAlign, TableData, convert_table_pages};
11
12const MAX_RIS_BYTES: u64 = 128 * 1024 * 1024;
13const MAX_RIS_LINES: usize = 2_000_000;
14const MAX_RIS_LINE_BYTES: usize = 1 << 20;
15const MAX_RIS_RECORDS: usize = 100_000;
16const MAX_RIS_FIELD_BYTES: usize = 64 * 1024;
17const MAX_RIS_RENDERED_BYTES: usize = 64 * 1024 * 1024;
18const MAX_RIS_AUTHORS: usize = 512;
19const MAX_RIS_KEYWORDS: usize = 512;
20const MAX_RIS_PREVIEW: usize = 512;
21
22pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
23    let Ok(text) = std::str::from_utf8(prefix) else {
24        return false;
25    };
26    text.lines()
27        .map(str::trim)
28        .find(|line| !line.is_empty())
29        .is_some_and(|line| is_tag_line(line, "TY"))
30}
31
32pub(crate) fn convert(
33    path: &Path,
34    options: &ConvertOptions,
35    sink: &mut dyn PageConsumer,
36) -> Result<Vec<String>> {
37    let bytes = read_limited_file(
38        path,
39        options.max_input_bytes.min(MAX_RIS_BYTES),
40        "RIS input",
41    )?;
42    let text = String::from_utf8(bytes)
43        .map_err(|error| Error::InvalidInput(format!("RIS input must be UTF-8/ASCII: {error}")))?;
44    let (mut table, warnings) = parse_ris(&text)?;
45    let mut page_sink = RisPageSink {
46        inner: sink,
47        warnings: &warnings,
48    };
49    convert_table_pages(&mut table, "ris", options, &mut page_sink)?;
50    Ok(warnings)
51}
52
53struct RisPageSink<'a> {
54    inner: &'a mut dyn PageConsumer,
55    warnings: &'a [String],
56}
57
58impl PageConsumer for RisPageSink<'_> {
59    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
60        page.source_format = "ris".into();
61        page.title = "RIS bibliography".into();
62        page.description =
63            "RIS citation fields are displayed inertly without DOI or URL lookup".into();
64        for warning in self.warnings {
65            page.warn(warning.clone());
66        }
67        self.inner.consume(page)
68    }
69}
70
71#[derive(Default)]
72struct Record {
73    kind: String,
74    title: String,
75    authors: Vec<String>,
76    year: String,
77    journal: String,
78    doi: String,
79    url: String,
80    abstract_text: String,
81    keywords: Vec<String>,
82    pages: String,
83    unknown_fields: usize,
84    last_tag: Option<String>,
85}
86
87fn parse_ris(text: &str) -> Result<(TableData, Vec<String>)> {
88    if text.len() as u64 > MAX_RIS_BYTES {
89        return Err(Error::LimitExceeded(format!(
90            "RIS input exceeds {MAX_RIS_BYTES} bytes"
91        )));
92    }
93    let lines = text.lines().collect::<Vec<_>>();
94    if lines.len() > MAX_RIS_LINES {
95        return Err(Error::LimitExceeded(format!(
96            "RIS input exceeds {MAX_RIS_LINES} lines"
97        )));
98    }
99    let mut rows = Vec::new();
100    let mut warnings = Vec::new();
101    let mut current = None::<Record>;
102    let mut record_number = 0usize;
103    let mut rendered_bytes = 0usize;
104    for (line_number, original) in lines.iter().enumerate() {
105        if original.len() > MAX_RIS_LINE_BYTES {
106            return Err(Error::LimitExceeded(format!(
107                "RIS line {} exceeds {MAX_RIS_LINE_BYTES} bytes",
108                line_number + 1
109            )));
110        }
111        let line = original.trim_end_matches('\r');
112        if line.trim().is_empty() {
113            continue;
114        }
115        if is_tag_line(line, "TY") {
116            if let Some(previous) = current.take() {
117                warnings.push(format!(
118                    "RIS record before line {} was missing ER; it was finalized at the next TY",
119                    line_number + 1
120                ));
121                finalize_record(
122                    previous,
123                    &mut rows,
124                    &mut warnings,
125                    &mut record_number,
126                    &mut rendered_bytes,
127                )?;
128            }
129            current = Some(Record {
130                kind: tag_value(line).to_owned(),
131                ..Record::default()
132            });
133            if current
134                .as_ref()
135                .is_some_and(|record| record.kind.is_empty())
136            {
137                return Err(Error::InvalidInput(format!(
138                    "RIS line {} TY has an empty reference type",
139                    line_number + 1
140                )));
141            }
142            current.as_mut().unwrap().last_tag = Some("TY".into());
143            continue;
144        }
145        let Some(record) = current.as_mut() else {
146            return Err(Error::InvalidInput(format!(
147                "RIS line {} appeared before TY",
148                line_number + 1
149            )));
150        };
151        if is_tag_line(line, "ER") {
152            let record = current.take().expect("current record exists");
153            finalize_record(
154                record,
155                &mut rows,
156                &mut warnings,
157                &mut record_number,
158                &mut rendered_bytes,
159            )?;
160            continue;
161        }
162        if line.as_bytes().starts_with(b"      ") {
163            let continuation = line.get(6..).unwrap_or_default().trim();
164            if continuation.is_empty() {
165                continue;
166            }
167            append_continuation(record, continuation)?;
168            continue;
169        }
170        if !valid_tag_line(line) {
171            return Err(Error::InvalidInput(format!(
172                "RIS line {} is not a canonical XX  - value field",
173                line_number + 1
174            )));
175        }
176        let tag = line.get(..2).unwrap_or_default().to_ascii_uppercase();
177        let value = tag_value(line).trim();
178        validate_field(value, "RIS field")?;
179        apply_field(record, &tag, value)?;
180        record.last_tag = Some(tag);
181    }
182    if let Some(record) = current.take() {
183        warnings.push("RIS final record was missing ER and was finalized at EOF".into());
184        finalize_record(
185            record,
186            &mut rows,
187            &mut warnings,
188            &mut record_number,
189            &mut rendered_bytes,
190        )?;
191    }
192    if rows.is_empty() {
193        return Err(Error::InvalidInput("RIS input contains no records".into()));
194    }
195    if warnings
196        .iter()
197        .any(|warning| warning.contains("unknown RIS"))
198    { /* retained for dedup */ }
199    let headers = [
200        "Record", "Type", "Title", "Authors", "Year", "Journal", "Pages", "DOI", "URL", "Keywords",
201        "Abstract",
202    ]
203    .into_iter()
204    .map(str::to_owned)
205    .collect();
206    let alignments = vec![
207        TableAlign::Right,
208        TableAlign::Center,
209        TableAlign::Left,
210        TableAlign::Left,
211        TableAlign::Right,
212        TableAlign::Left,
213        TableAlign::Left,
214        TableAlign::Left,
215        TableAlign::Left,
216        TableAlign::Left,
217        TableAlign::Left,
218    ];
219    Ok((
220        TableData {
221            headers,
222            rows,
223            alignments,
224            raw_source: String::new(),
225        },
226        dedup_warnings(warnings),
227    ))
228}
229
230fn apply_field(record: &mut Record, tag: &str, value: &str) -> Result<()> {
231    match tag {
232        "TY" => record.kind = value.to_owned(),
233        "TI" | "T1" => append_value(&mut record.title, value, "RIS title")?,
234        "AU" | "A1" => {
235            if record.authors.len() >= MAX_RIS_AUTHORS {
236                return Err(Error::LimitExceeded(format!(
237                    "RIS exceeds {MAX_RIS_AUTHORS} authors per record"
238                )));
239            }
240            record.authors.push(value.to_owned());
241        }
242        "PY" | "Y1" => append_value(&mut record.year, value, "RIS year")?,
243        "JO" | "JF" | "T2" | "J2" => append_value(&mut record.journal, value, "RIS journal")?,
244        "DO" => append_value(&mut record.doi, value, "RIS DOI")?,
245        "UR" => append_value(&mut record.url, value, "RIS URL")?,
246        "AB" | "N2" => append_value(&mut record.abstract_text, value, "RIS abstract")?,
247        "KW" => {
248            if record.keywords.len() >= MAX_RIS_KEYWORDS {
249                return Err(Error::LimitExceeded(format!(
250                    "RIS exceeds {MAX_RIS_KEYWORDS} keywords per record"
251                )));
252            }
253            record.keywords.push(value.to_owned());
254        }
255        "SP" => append_value(&mut record.pages, value, "RIS start page")?,
256        "EP" => {
257            if !record.pages.is_empty() {
258                record.pages.push('-');
259            }
260            record.pages.push_str(value);
261            validate_field(&record.pages, "RIS pages")?;
262        }
263        "ER" => {}
264        _ => record.unknown_fields = record.unknown_fields.saturating_add(1),
265    }
266    Ok(())
267}
268
269fn append_continuation(record: &mut Record, value: &str) -> Result<()> {
270    match record.last_tag.as_deref() {
271        Some("TI") | Some("T1") => append_value(&mut record.title, value, "RIS title"),
272        Some("PY") | Some("Y1") => append_value(&mut record.year, value, "RIS year"),
273        Some("JO") | Some("JF") | Some("T2") | Some("J2") => {
274            append_value(&mut record.journal, value, "RIS journal")
275        }
276        Some("DO") => append_value(&mut record.doi, value, "RIS DOI"),
277        Some("UR") => append_value(&mut record.url, value, "RIS URL"),
278        Some("AB") | Some("N2") => append_value(&mut record.abstract_text, value, "RIS abstract"),
279        _ => Ok(()),
280    }
281}
282
283fn finalize_record(
284    record: Record,
285    rows: &mut Vec<Vec<String>>,
286    warnings: &mut Vec<String>,
287    record_number: &mut usize,
288    rendered_bytes: &mut usize,
289) -> Result<()> {
290    *record_number = (*record_number)
291        .checked_add(1)
292        .ok_or_else(|| Error::LimitExceeded("RIS record count overflowed".into()))?;
293    if *record_number > MAX_RIS_RECORDS {
294        return Err(Error::LimitExceeded(format!(
295            "RIS exceeds {MAX_RIS_RECORDS} records"
296        )));
297    }
298    if record.unknown_fields > 0 {
299        warnings.push(format!(
300            "unknown RIS fields in record {} were kept inert",
301            *record_number
302        ));
303    }
304    let authors = record.authors.join("; ");
305    let keywords = record.keywords.join("; ");
306    let abstract_preview = preview_text(&record.abstract_text);
307    let values = vec![
308        record_number.to_string(),
309        record.kind,
310        record.title,
311        authors,
312        record.year,
313        record.journal,
314        record.pages,
315        record.doi,
316        record.url,
317        keywords,
318        abstract_preview,
319    ];
320    let bytes = values.iter().map(String::len).sum::<usize>();
321    *rendered_bytes = (*rendered_bytes)
322        .checked_add(bytes)
323        .ok_or_else(|| Error::LimitExceeded("RIS rendered text byte count overflowed".into()))?;
324    if *rendered_bytes > MAX_RIS_RENDERED_BYTES {
325        return Err(Error::LimitExceeded(format!(
326            "RIS rendered text exceeds {MAX_RIS_RENDERED_BYTES} bytes"
327        )));
328    }
329    rows.push(values);
330    Ok(())
331}
332
333fn valid_tag_line(line: &str) -> bool {
334    line.len() >= 6
335        && line.as_bytes().get(0..2).is_some_and(|tag| {
336            tag.iter()
337                .all(|byte| byte.is_ascii_alphabetic() || byte.is_ascii_digit())
338        })
339        && line.as_bytes().get(2..4) == Some(b"  ")
340        && line.as_bytes().get(4) == Some(&b'-')
341        && line.as_bytes().get(5) == Some(&b' ')
342}
343fn is_tag_line(line: &str, tag: &str) -> bool {
344    line.len() >= 5
345        && line
346            .get(..2)
347            .is_some_and(|value| value.eq_ignore_ascii_case(tag))
348        && line.as_bytes().get(2..4) == Some(b"  ")
349        && line.as_bytes().get(4) == Some(&b'-')
350}
351fn tag_value(line: &str) -> &str {
352    line.get(5..).unwrap_or_default()
353}
354fn append_value(target: &mut String, value: &str, context: &str) -> Result<()> {
355    if !target.is_empty() {
356        target.push(' ');
357    }
358    target.push_str(value);
359    validate_field(target, context)
360}
361fn preview_text(value: &str) -> String {
362    if value.len() <= MAX_RIS_PREVIEW {
363        value.to_owned()
364    } else {
365        let mut end = MAX_RIS_PREVIEW;
366        while !value.is_char_boundary(end) {
367            end -= 1;
368        }
369        format!("{}…", &value[..end])
370    }
371}
372fn validate_field(value: &str, context: &str) -> Result<()> {
373    if value.len() > MAX_RIS_FIELD_BYTES {
374        return Err(Error::LimitExceeded(format!(
375            "{context} exceeds {MAX_RIS_FIELD_BYTES} bytes"
376        )));
377    }
378    if value.chars().any(|character| character.is_control()) {
379        return Err(Error::InvalidInput(format!(
380            "{context} contains a control character"
381        )));
382    }
383    Ok(())
384}
385fn dedup_warnings(warnings: Vec<String>) -> Vec<String> {
386    let mut seen = std::collections::HashSet::new();
387    warnings
388        .into_iter()
389        .filter(|warning| seen.insert(warning.clone()))
390        .collect()
391}