Skip to main content

aspero_core/
references.rs

1//! Splitting a pasted reference list into one citation each, offline.
2//!
3//! Lists arrive numbered (`1.`, `[1]`, `(1)`), bulleted, separated by blank
4//! lines, or one per line, with lines wrapped by PDFs and word processors.
5//! The heuristics, in order:
6//!
7//! 1. **Numbered or bulleted**: two or more lines start with a marker
8//!    (numbers in sequence, so `2015. Title` inside a reference doesn't
9//!    count). Each marker starts a reference; other lines continue it.
10//! 2. **Paragraphs**: blank lines separate references; within one, a line
11//!    starts another reference only if it looks like an author list after
12//!    a line that ended a sentence.
13//! 3. **Lines**: one reference per line when every line has a year or an
14//!    identifier, ends as a reference ends, and doesn't start in lower
15//!    case; otherwise wrapped, split as within a paragraph.
16//!
17//! Wrapped lines are joined with a space; a word hyphenated across lines
18//! is mended, and DOIs and URLs broken after `/` or `-` are rejoined.
19//! Headings such as "References" are dropped.
20
21use std::ops::Range;
22
23use crate::Alternative;
24use crate::fold::Folded;
25
26/// One reference found in a list.
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct Reference {
30    /// On one line, without its number or bullet.
31    pub text: String,
32    /// Byte range in the list: from after the marker to the end of its
33    /// last line.
34    pub span: Range<usize>,
35    /// DOIs broken across lines at a hyphen, read without it: to try if
36    /// the DOI as printed doesn't exist.
37    pub alternatives: Vec<Alternative>,
38}
39
40/// The references in `text`, in order. Text from PDFs is cleaned first
41/// (ligatures, invisible characters, look-alike dashes and quotes); spans
42/// point into `text` as given.
43pub fn split_references(text: &str) -> Vec<Reference> {
44    let folded = Folded::new(text);
45    let list = List::new(&folded.text).split_inline();
46    let groups = list.groups();
47    references(text, &folded, &list, groups)
48}
49
50/// A line of a reference list as laid out, with its distance from the
51/// left edge (any unit, such as PDF points).
52#[derive(Debug, Clone, PartialEq)]
53#[non_exhaustive]
54pub struct IndentedLine {
55    pub text: String,
56    pub indent: f64,
57}
58
59impl IndentedLine {
60    pub fn new(text: impl Into<String>, indent: f64) -> Self {
61        IndentedLine {
62            text: text.into(),
63            indent,
64        }
65    }
66}
67
68/// One reference found in laid-out lines.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct LineReference {
72    /// On one line, without its number or bullet.
73    pub text: String,
74    /// Indices of the lines it spans.
75    pub lines: Range<usize>,
76    pub alternatives: Vec<Alternative>,
77}
78
79/// The references in laid-out lines (as from a PDF), using the layout
80/// plain text loses: with a hanging indent, references start at the
81/// margin; with a first-line indent, indented. Numbered lists, and lines
82/// all at one indent, are split by the text rules of [`split_references`].
83pub fn split_lines(lines: &[IndentedLine]) -> Vec<LineReference> {
84    let text = lines
85        .iter()
86        // One line each, whatever breaks they hold (cleaning makes all of
87        // these line breaks).
88        .map(|l| {
89            l.text
90                .replace(['\r', '\n', '\u{85}', '\u{2028}', '\u{2029}'], " ")
91        })
92        .collect::<Vec<_>>()
93        .join("\n");
94    let folded = Folded::new(&text);
95    let list = List::new(&folded.text);
96    let groups = match starts_by_indent(lines) {
97        Some(starts) if list.numbered().is_none() => {
98            let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
99            for (i, line) in list.lines.iter().enumerate() {
100                let Some(line) = line else { continue };
101                // Whatever the indent, a line in lower case or closing a
102                // bracket, or after a word broken at a hyphen, continues
103                // the reference ("sur-" then "gery: a systematic…").
104                let text = &folded.text[line.clone()];
105                match groups.last_mut() {
106                    Some(group)
107                        if !starts[i]
108                            || continues(text)
109                            || group
110                                .last()
111                                .is_some_and(|l| folded.text[l.clone()].ends_with('-')) =>
112                    {
113                        group.push(line.clone())
114                    }
115                    _ => groups.push(vec![line.clone()]),
116                }
117            }
118            groups
119        }
120        _ => list.groups(),
121    };
122    let line_starts: Vec<usize> = std::iter::once(0)
123        .chain(text.match_indices('\n').map(|(i, _)| i + 1))
124        .collect();
125    let line_of = |byte: usize| line_starts.partition_point(|&s| s <= byte) - 1;
126    references(&text, &folded, &list, groups)
127        .into_iter()
128        .map(|r| LineReference {
129            lines: line_of(r.span.start)..line_of(r.span.end.saturating_sub(1)) + 1,
130            text: r.text,
131            alternatives: r.alternatives,
132        })
133        .collect()
134}
135
136/// Which lines start a reference, by indent: `None` if the lines don't
137/// differ enough to tell. Indents within half the gap to the next level
138/// count as the same, so jitter in a PDF's positions doesn't matter.
139fn starts_by_indent(lines: &[IndentedLine]) -> Option<Vec<bool>> {
140    let content: Vec<&IndentedLine> = lines.iter().filter(|l| !l.text.trim().is_empty()).collect();
141    let margin = content
142        .iter()
143        .map(|l| l.indent)
144        .fold(f64::INFINITY, f64::min);
145    let next = content
146        .iter()
147        .map(|l| l.indent)
148        .filter(|&i| i > margin + 1.0)
149        .fold(f64::INFINITY, f64::min);
150    if !next.is_finite() {
151        return None;
152    }
153    let at_margin = |l: &IndentedLine| l.indent - margin < (next - margin) / 2.0;
154    let first = content.iter().find(|l| !is_heading(l.text.trim()))?;
155    let starting = at_margin(first);
156    Some(
157        lines
158            .iter()
159            .map(|l| !l.text.trim().is_empty() && at_margin(l) == starting)
160            .collect(),
161    )
162}
163
164/// Groups of lines (ranges in `folded`) as references in `text`.
165fn references(
166    text: &str,
167    folded: &Folded,
168    list: &List,
169    groups: Vec<Vec<Range<usize>>>,
170) -> Vec<Reference> {
171    groups
172        .into_iter()
173        .map(|g| {
174            let span = folded.original(&(g[0].start..g[g.len() - 1].end));
175            let written = &text[span.clone()];
176            let alternatives = crate::id::scan(written)
177                .iter()
178                .filter_map(|m| Some(Alternative::new(m.id.clone(), m.alternative(written)?)))
179                .collect();
180            Reference {
181                text: list.join(&g),
182                span,
183                alternatives,
184            }
185        })
186        .filter(|r| !is_heading(&r.text))
187        .collect()
188}
189
190struct List<'a> {
191    text: &'a str,
192    /// Every line trimmed, as a byte range; `None` for blank ones.
193    lines: Vec<Option<Range<usize>>>,
194}
195
196impl<'a> List<'a> {
197    fn new(text: &'a str) -> Self {
198        let mut lines = Vec::new();
199        let mut at = 0;
200        for raw in text.split_inclusive('\n') {
201            let trimmed = raw.trim();
202            lines.push((!trimmed.is_empty()).then(|| {
203                let start = at + (raw.len() - raw.trim_start().len());
204                start..start + trimmed.len()
205            }));
206            at += raw.len();
207        }
208        List { text, lines }
209    }
210
211    /// Lines holding references numbered inline, as a PDF's list copied
212    /// onto one line ("… 01159.x. [21] Finucane … [22] …"), split before
213    /// each number. Only `[n]` markers in sequence count: three or more,
214    /// or two with one starting the line, each starting the line or after
215    /// a full stop and before a capital, and every piece dated. Citations
216    /// in prose ("as shown. [3] The") rarely meet all of these.
217    fn split_inline(mut self) -> Self {
218        let lines = std::mem::take(&mut self.lines);
219        for line in lines {
220            match line.as_ref().and_then(|l| self.inline_starts(l)) {
221                Some(starts) => {
222                    let range = line.expect("has starts");
223                    let bounds: Vec<usize> = std::iter::once(range.start)
224                        .chain(starts)
225                        .chain(std::iter::once(range.end))
226                        .collect();
227                    for w in bounds.windows(2) {
228                        let piece = &self.text[w[0]..w[1]];
229                        let start = w[0] + (piece.len() - piece.trim_start().len());
230                        let end = w[0] + piece.trim_end().len();
231                        if start < end {
232                            self.lines.push(Some(start..end));
233                        }
234                    }
235                }
236                None => self.lines.push(line),
237            }
238        }
239        self
240    }
241
242    /// Where a line's inline-numbered references start, after its own
243    /// start: see [`List::split_inline`].
244    fn inline_starts(&self, line: &Range<usize>) -> Option<Vec<usize>> {
245        let text = self.line(line);
246        let mut markers: Vec<(usize, u32)> = Vec::new();
247        for (at, _) in text.match_indices('[') {
248            let Some((Marker::Number(n), used)) = marker(&text[at..]) else {
249                continue;
250            };
251            let follows_stop = text[..at].trim_end();
252            let next = text[at + used..].chars().next();
253            if (follows_stop.is_empty() || follows_stop.ends_with('.'))
254                && next.is_some_and(char::is_uppercase)
255            {
256                markers.push((at, n));
257            }
258        }
259        // The longest run numbered in sequence.
260        let mut runs: Vec<Vec<(usize, u32)>> = Vec::new();
261        for m in markers {
262            match runs
263                .iter_mut()
264                .find(|r| r.last().is_some_and(|l| l.1 + 1 == m.1))
265            {
266                Some(run) => run.push(m),
267                None => runs.push(vec![m]),
268            }
269        }
270        let run = runs.into_iter().max_by_key(Vec::len)?;
271        let at_start = run.first().is_some_and(|m| m.0 == 0);
272        if run.len() < 3 && !(run.len() == 2 && at_start) {
273            return None;
274        }
275        let starts: Vec<usize> = run
276            .iter()
277            .filter(|m| m.0 > 0)
278            .map(|m| line.start + m.0)
279            .collect();
280        let bounds: Vec<usize> = std::iter::once(line.start)
281            .chain(starts.iter().copied())
282            .chain(std::iter::once(line.end))
283            .collect();
284        // Every piece dated, or all but one of four or more: a reference
285        // the style or copy left without a date doesn't undo the list.
286        let pieces = bounds.len() - 1;
287        let undated = bounds
288            .windows(2)
289            .filter(|w| !dated(&self.text[w[0]..w[1]]))
290            .count();
291        (undated == 0 || (undated == 1 && pieces >= 4)).then_some(starts)
292    }
293
294    /// Groups by the text rules: numbers or bullets, else paragraphs, else
295    /// lines.
296    fn groups(&self) -> Vec<Vec<Range<usize>>> {
297        self.numbered().unwrap_or_else(|| {
298            let paragraphs: Vec<Vec<Range<usize>>> = self
299                .lines
300                .split(Option::is_none)
301                .filter(|p| !p.is_empty())
302                .map(|p| p.iter().flatten().cloned().collect())
303                .collect();
304            let alone = paragraphs.len() == 1;
305            paragraphs
306                .into_iter()
307                .flat_map(|p| self.unnumbered(p, alone))
308                .collect()
309        })
310    }
311
312    fn line(&self, r: &Range<usize>) -> &'a str {
313        &self.text[r.clone()]
314    }
315
316    /// Groups by number or bullet, if two or more lines start with one.
317    /// Lines before the first marker are dropped (a heading, usually).
318    fn numbered(&self) -> Option<Vec<Vec<Range<usize>>>> {
319        let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
320        let mut last: Option<Marker> = None;
321        for line in self.lines.iter().flatten() {
322            let starts = marker(self.line(line)).filter(|(m, _)| match (last, m) {
323                (None, _) => true,
324                (Some(Marker::Number(n)), Marker::Number(m)) => *m == n + 1,
325                (Some(Marker::Bullet), Marker::Bullet) => true,
326                _ => false,
327            });
328            match (starts, groups.last_mut()) {
329                (Some((m, used)), _) => {
330                    last = Some(m);
331                    // A marker alone on its line: the text follows.
332                    let first = line.start + used..line.end;
333                    groups.push(Vec::new());
334                    if !first.is_empty() {
335                        groups.last_mut().expect("just pushed").push(first);
336                    }
337                }
338                (None, Some(group)) => group.push(line.clone()),
339                // Before the first number: a heading, or the end of a
340                // reference the copy began in the middle of.
341                (None, None) if dated(self.line(line)) => groups.push(vec![line.clone()]),
342                (None, None) => {}
343            }
344        }
345        // One marker counts only on the first line ("1. Smith J. …"): later,
346        // it may be a wrapped line ("2. ed. Berlin").
347        let first_marked = self
348            .lines
349            .iter()
350            .flatten()
351            .next()
352            .is_some_and(|l| marker(self.line(l)).is_some());
353        groups.retain(|g| !g.is_empty());
354        (groups.len() >= 2 || groups.len() == 1 && first_marked).then_some(groups)
355    }
356
357    /// Groups lines without markers: each its own reference if `alone`
358    /// (the whole list) and they read as one per line; otherwise split
359    /// where an author list follows a finished sentence.
360    fn unnumbered(&self, lines: Vec<Range<usize>>, alone: bool) -> Vec<Vec<Range<usize>>> {
361        let one_per_line = alone
362            && lines.iter().all(|l| dated(self.line(l)))
363            && lines.windows(2).all(|w| {
364                let (line, next) = (self.line(&w[0]), self.line(&w[1]));
365                ends_reference(line) && !continues(next) && !link_continues(line, next)
366            });
367        if one_per_line {
368            return lines.into_iter().map(|l| vec![l]).collect();
369        }
370        let mut groups: Vec<Vec<Range<usize>>> = Vec::new();
371        for line in lines {
372            let text = self.line(&line);
373            match groups.last_mut() {
374                Some(group) if !(ends_sentence(&self.join(group)) && starts_with_author(text)) => {
375                    group.push(line)
376                }
377                _ => groups.push(vec![line]),
378            }
379        }
380        groups
381    }
382
383    /// A group's lines as one: joined with a space, or with none where a
384    /// word, DOI or URL was broken across lines.
385    fn join(&self, group: &[Range<usize>]) -> String {
386        let mut out = String::new();
387        for line in group {
388            let next = self.line(line);
389            let first = next.chars().next().unwrap_or(' ');
390            if out.is_empty() {
391                // Nothing to join to.
392            } else if out.ends_with('-') && first.is_lowercase() && hyphenated_word(&out) {
393                out.pop();
394            } else if !link_continues(&out, next) {
395                out.push(' ');
396            }
397            out.push_str(next);
398        }
399        out
400    }
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404enum Marker {
405    Number(u32),
406    Bullet,
407}
408
409/// The marker a line starts with, and its length with the space after.
410fn marker(line: &str) -> Option<(Marker, usize)> {
411    let spaced = |rest: &str, used: usize, needs_space: bool| {
412        let gap = rest.len() - rest.trim_start().len();
413        (gap > 0 || !needs_space).then_some(used + gap)
414    };
415    if let Some(rest) = line.strip_prefix(['•', '–', '-', '*', '·']) {
416        return Some((Marker::Bullet, spaced(rest, line.len() - rest.len(), true)?));
417    }
418    let (open, close): (usize, &[char]) = match line.as_bytes().first()? {
419        b'[' => (1, &[']']),
420        b'(' => (1, &[')']),
421        _ => (0, &['.', ')']),
422    };
423    let inner = &line[open..];
424    let digits = inner.len() - inner.trim_start_matches(|c: char| c.is_ascii_digit()).len();
425    if !(1..=4).contains(&digits) {
426        return None;
427    }
428    let n: u32 = inner[..digits].parse().ok()?;
429    let rest = inner[digits..].strip_prefix(close)?;
430    // `[1]Smith` is unambiguous; `1.` needs a space, so `1.5` isn't one.
431    Some((
432        Marker::Number(n),
433        spaced(rest, line.len() - rest.len(), open == 0)?,
434    ))
435}
436
437/// Ends as a reference does: a full stop, page numbers, a closing bracket,
438/// or an identifier.
439fn ends_reference(line: &str) -> bool {
440    ends_sentence(line) || line.ends_with(|c: char| c.is_ascii_digit() || c == ')' || c == ']')
441}
442
443/// Ends with a full stop (or `?`, `!`), or with a whole DOI or URL, as
444/// references often do without one.
445fn ends_sentence(line: &str) -> bool {
446    line.ends_with(['.', '?', '!']) || in_link(line) && !line.ends_with(['/', '-'])
447}
448
449/// Has a year (1600 to 2099) or an identifier, as a whole reference does.
450fn dated(line: &str) -> bool {
451    let year = line
452        .split(|c: char| !c.is_ascii_digit())
453        .any(|n| n.len() == 4 && ("1600".."2100").contains(&n));
454    // What styles write for a work without a year, or a web page's date.
455    let lower = line.to_lowercase();
456    let undated = [
457        "n.d.",
458        "n. d.",
459        "no date",
460        "accessed",
461        "[cited",
462        "available from",
463    ]
464    .iter()
465    .any(|m| lower.contains(m));
466    year || undated || line.contains("doi") || line.contains("10.") || line.contains("http")
467}
468
469/// Starts as a wrapped line's continuation does: in lower case, or with
470/// punctuation.
471fn continues(line: &str) -> bool {
472    line.starts_with(|c: char| c.is_lowercase() || ",;:)&".contains(c))
473}
474
475/// Starts with a surname and initials or a comma, as author lists do:
476/// "Smith J,", "Smith JA.", "Smith, J.", "SMITH, J.", "O'Neil, K.".
477fn starts_with_author(line: &str) -> bool {
478    let surname_len = line
479        .char_indices()
480        .take_while(|(_, c)| c.is_alphabetic() || "'’-".contains(*c))
481        .last()
482        .map_or(0, |(i, c)| i + c.len_utf8());
483    let surname = &line[..surname_len];
484    if surname.chars().count() < 2 || !surname.starts_with(char::is_uppercase) {
485        return false;
486    }
487    let rest = &line[surname_len..];
488    if rest.starts_with(',') {
489        return true;
490    }
491    // Initials: one to three capitals, maybe dotted, then a break.
492    let Some(rest) = rest.strip_prefix(' ') else {
493        return false;
494    };
495    let initials: String = rest
496        .chars()
497        .take_while(|c| c.is_uppercase() || *c == '.')
498        .collect();
499    let capitals = initials.chars().filter(|c| c.is_uppercase()).count();
500    let after = rest[initials.len()..].chars().next();
501    (1..=3).contains(&capitals) && after.is_none_or(|c| ", .;".contains(c))
502}
503
504/// A word broken with a hyphen at the end of `text`: letters before it.
505fn hyphenated_word(text: &str) -> bool {
506    let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
507    !in_link(text) && word.trim_end_matches('-').chars().all(char::is_alphabetic)
508}
509
510/// `next` continues a DOI or URL that `text` ends with, broken where PDFs
511/// break them: after `/`, `-` or `_` (`10.1038/s41586-` + `020-2649-2`),
512/// after a dot before a digit or lower case (`j.cell.` + `2020.01.001`),
513/// or mid-word before more of the link (`j.ce` + `ll.2020.01.001`, not
514/// `2015` or `2015.`).
515/// Hyphens are kept as printed: DOIs have real ones, and typesetters
516/// rarely add their own inside a link.
517pub(crate) fn link_continues(text: &str, next: &str) -> bool {
518    let token = next.split_whitespace().next().unwrap_or_default();
519    let Some(first) = token.chars().next() else {
520        return false;
521    };
522    if !in_link(text) || !first.is_alphanumeric() || starts_link(token) || is_number(token) {
523        return false;
524    }
525    let lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
526    // A DOI of its own, unless the line broke right before it
527    // (`https://doi.org/` then `10.…`).
528    let doi = token.starts_with("10.") && token.contains('/');
529    if text.ends_with(['/', '-', '_']) {
530        true
531    } else if text.ends_with('.') {
532        lower_or_digit && !doi
533    } else {
534        // Mid-word: only before more of a link, never a word, a
535        // hyphenated word or a number.
536        let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
537        lower_or_digit
538            && !doi
539            && inner.parse::<f64>().is_err()
540            && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
541    }
542}
543
544/// `token` is a link of its own, not the rest of one.
545fn starts_link(token: &str) -> bool {
546    let lower = token.get(..8).unwrap_or(token).to_ascii_lowercase();
547    ["http://", "https://", "www."]
548        .iter()
549        .any(|p| lower.starts_with(p))
550        || token
551            .split_once(':')
552            .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
553}
554
555/// `token` numbers a list item (`18.`).
556fn is_number(token: &str) -> bool {
557    token
558        .strip_suffix('.')
559        .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
560}
561
562/// `text` ends inside a DOI or URL.
563fn in_link(text: &str) -> bool {
564    let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
565    let starts = |p: &str| {
566        word.get(..p.len())
567            .is_some_and(|h| h.eq_ignore_ascii_case(p))
568    };
569    starts("http") || starts("doi:") || word.starts_with("10.")
570}
571
572fn is_heading(text: &str) -> bool {
573    let t = text.trim_end_matches(':').trim().to_lowercase();
574    matches!(
575        t.as_str(),
576        "references"
577            | "reference list"
578            | "bibliography"
579            | "works cited"
580            | "literature cited"
581            | "sources"
582            | "citations"
583    )
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    fn texts(list: &str) -> Vec<String> {
591        split_references(list).into_iter().map(|r| r.text).collect()
592    }
593
594    #[test]
595    fn numbered_lists_split_at_each_number_in_sequence() {
596        let list = "References\n\
597            1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
598            2015;521:436-44.\n\
599            2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
600        assert_eq!(
601            texts(list),
602            [
603                "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
604                "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
605            ]
606        );
607    }
608
609    #[test]
610    fn bracketed_and_parenthesised_numbers() {
611        let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
612        assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
613        let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
614        assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
615    }
616
617    #[test]
618    fn numbers_out_of_sequence_continue_the_reference() {
619        let list = "1. Smith J. A study of things. Journal\n\
620            2015. 12(3):1-9.\n\
621            2. Jones K. Another. 2021.\n";
622        assert_eq!(
623            texts(list),
624            [
625                "Smith J. A study of things. Journal 2015. 12(3):1-9.",
626                "Jones K. Another. 2021.",
627            ]
628        );
629    }
630
631    #[test]
632    fn bullets_mark_references() {
633        let list = "• Smith J. One. 2020.\n• Jones K. Two,\n  continued. 2021.\n";
634        assert_eq!(
635            texts(list),
636            ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
637        );
638    }
639
640    #[test]
641    fn blank_lines_separate_wrapped_references() {
642        let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
643            Advances in Neural Information Processing Systems. 2017.\n\
644            \n\
645            LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
646            Nature. 2015;521:436-44.\n";
647        assert_eq!(
648            texts(list),
649            [
650                "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
651                "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
652            ]
653        );
654    }
655
656    #[test]
657    fn one_per_line() {
658        let list = "Smith J. One. 2020.\n\
659            World Health Organization. Two. 2021.\n\
660            Jones K. Three. https://example.org/three\n";
661        assert_eq!(texts(list).len(), 3);
662    }
663
664    #[test]
665    fn wrapped_lines_without_blank_lines_split_at_authors() {
666        let list = "Smith, J. (2020). A study of\n\
667            things. Journal of Studies, 12, 1-9.\n\
668            Jones, K. (2021). Another study. Nature,\n\
669            521, 436.\n";
670        assert_eq!(
671            texts(list),
672            [
673                "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
674                "Jones, K. (2021). Another study. Nature, 521, 436.",
675            ]
676        );
677    }
678
679    #[test]
680    fn hyphenation_and_broken_links_are_mended() {
681        let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
682            2. Jones K. https://example.org/a-\nb/c\n";
683        assert_eq!(
684            texts(list),
685            [
686                "Smith J. Neurological findings. doi:10.1038/nature14539",
687                "Jones K. https://example.org/a-b/c",
688            ]
689        );
690    }
691
692    #[test]
693    fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
694        let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
695            2020.01.001\n\
696            [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
697            020-2649-2\n\
698            [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
699            ll.2021.02.003\n";
700        assert_eq!(
701            texts(list),
702            [
703                "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
704                "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
705                "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
706            ]
707        );
708        // Unnumbered, the continuation isn't a reference of its own.
709        let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
710            Jones K. Folds. Nature. 2020.\n";
711        assert_eq!(texts(list).len(), 2);
712        // A link that ends the line, then a new sentence, stays apart.
713        assert_eq!(
714            texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
715            "Smith J. doi:10.1038/x. Nature. 2020."
716        );
717    }
718
719    #[test]
720    fn a_single_numbered_reference_loses_its_number() {
721        assert_eq!(
722            texts("1. LeCun Y. Deep learning. Nature. 2015."),
723            ["LeCun Y. Deep learning. Nature. 2015."]
724        );
725        // A number further down is part of the reference.
726        assert_eq!(
727            texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
728            ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
729        );
730    }
731
732    #[test]
733    fn references_numbered_inline_split_at_each_number() {
734        // Dogfood: a PDF's list copied by Preview onto one line.
735        let list = "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x. \
736            [21] Finucane T, Smith B. Title two. Lancet. 2012;379:10-12. \
737            [22] Ortel TL. Title three. Blood Adv. 2020;4:4693. \
738            [23] Konstantinides SV. Title four. Eur Heart J. 2020;41:543.";
739        assert_eq!(
740            texts(list),
741            [
742                "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x.",
743                "Finucane T, Smith B. Title two. Lancet. 2012;379:10-12.",
744                "Ortel TL. Title three. Blood Adv. 2020;4:4693.",
745                "Konstantinides SV. Title four. Eur Heart J. 2020;41:543.",
746            ]
747        );
748        // Undated, as styles write it ("n.d."), or one piece of four
749        // without a date.
750        let undated = "Sanchez A. One. Blood. 2011;9:1-8. \
751            [21] Finucane T. Two. Lancet. 2012;379:10. \
752            [22] Defense Health Board. Three. Falls Church, VA, n.d. \
753            [23] Konstantinides SV. Four. Eur Heart J. 2020;41:543.";
754        assert_eq!(texts(undated).len(), 4);
755        assert_eq!(texts(&undated.replace("n.d.", "undated.")).len(), 4);
756        // Two, when the first starts the line.
757        assert_eq!(
758            texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
759            2
760        );
761        // In-text citations: out of sequence, not after a full stop, or
762        // pieces without a year.
763        for prose in [
764            "Shown before [3] and since. [4] The trial. [5] Then 2019.",
765            "Smith J. Title. 2019. [3] Jones. 2020. [7] Brown. 2021. [9] Lee. 2022.",
766            "As shown. [1] The first. [2] The second. [3] The third 2019.",
767        ] {
768            assert_eq!(texts(prose).len(), 1, "{prose}");
769        }
770    }
771
772    #[test]
773    fn unnumbered_vancouver_lists_split_however_lines_break() {
774        // #40: copied from a journal PDF; Preview's copies break lines
775        // with `\r` or Unicode separators.
776        let list = "Benjamin EJ, Muntner P, Alonso A, et al. Heart disease\n\
777            and stroke statistics-2019 update. Circulation\n\
778            2019;139:e56-528. https://doi.org/10.1161/CIR.0000000000000659\n\
779            Schmieder RE, Kjeldsen SE, Julius S, et al. Reduced incidence\n\
780            of new-onset atrial fibrillation: the VALUE trial. J\n\
781            Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e3282f35c67\n\
782            Chokesuwattanaskul R, Thongprayoon C, Bathini T, Sharma K, Watthanasuntorn K, Lertjitbanjong\n\
783            P, et al. Incident atrial fibrillation in patients undergoing bariatric sur-\n\
784            gery: a systematic review and meta-analysis. Intern Med J 2020;50:810-7. https://doi.org/10.1111/imj.14436";
785        for sep in ["\n", "\r\n", "\r", "\u{2028}", "\u{2029}"] {
786            let found = texts(&list.replace('\n', sep));
787            assert_eq!(found.len(), 3, "{sep:?}: {found:?}");
788            assert!(
789                found[2].contains("bariatric surgery: a systematic"),
790                "{found:?}"
791            );
792        }
793    }
794
795    #[test]
796    fn indents_never_start_a_reference_mid_word_or_in_lower_case() {
797        // Hanging indents, with lines a PDF's layout put at the margin
798        // although they continue the reference.
799        let lines = [
800            IndentedLine::new(
801                "Chokesuwattanaskul R, et al. Incident atrial fibrillation in bariatric sur-",
802                0.0,
803            ),
804            IndentedLine::new(
805                "gery: a systematic review. Intern Med J 2020;50:810-7.",
806                0.0,
807            ),
808            IndentedLine::new("Sardana M, et al. Obstructive sleep apnea (the SAVE", 0.0),
809            IndentedLine::new("trial). Circ Arrhythm Electrophysiol 2017;10:e005463.", 0.0),
810            IndentedLine::new("Smith J, et al. Another reference about", 0.0),
811            IndentedLine::new("the same thing. Lancet 2019;393:10-12.", 12.0),
812        ];
813        let found: Vec<String> = split_lines(&lines).into_iter().map(|r| r.text).collect();
814        assert_eq!(found.len(), 3, "{found:?}");
815        assert!(
816            found[0].contains("bariatric surgery: a systematic"),
817            "{found:?}"
818        );
819        assert!(found[1].contains("(the SAVE trial)."), "{found:?}");
820    }
821
822    #[test]
823    fn markers_alone_on_their_line_number_the_next() {
824        assert_eq!(
825            texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
826            ["Smith J. One. 2020.", "Jones K. Two. 2021."]
827        );
828    }
829
830    #[test]
831    fn pdf_text_is_cleaned_and_spans_point_into_it() {
832        let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
833            2. Jones K. Two. 2021.";
834        let refs = split_references(list);
835        assert_eq!(
836            refs[0].text,
837            "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
838        );
839        assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
840        assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
841    }
842
843    #[test]
844    fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
845        let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
846        assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
847        let alt = &refs[0].alternatives[0];
848        assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
849        assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
850        assert!(refs[1].alternatives.is_empty());
851    }
852
853    #[test]
854    fn hanging_indents_mark_starts_that_text_cannot() {
855        // Each line ends a sentence and the next starts with a capital,
856        // so the text alone reads three references.
857        let lines = [
858            IndentedLine::new("References", 72.4),
859            IndentedLine::new("World Health Organization. Global report.", 72.0),
860            IndentedLine::new("Geneva: WHO; 2020.", 90.2),
861            IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
862        ];
863        let refs = split_lines(&lines);
864        assert_eq!(
865            refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
866            [
867                "World Health Organization. Global report. Geneva: WHO; 2020.",
868                "Smith J. A study. Nature. 2021.",
869            ]
870        );
871        assert_eq!(refs[0].lines, 1..3);
872        assert_eq!(refs[1].lines, 3..4);
873    }
874
875    #[test]
876    fn first_line_indents_and_flat_lists() {
877        let lines = [
878            IndentedLine::new("Smith J. A study of", 90.0),
879            IndentedLine::new("Things. Nature. 2021.", 72.0),
880            IndentedLine::new("Jones K. Another. 2022.", 90.0),
881        ];
882        assert_eq!(split_lines(&lines).len(), 2);
883        // One indent throughout: the text rules decide.
884        let flat = [
885            IndentedLine::new("1. Smith J. One.", 72.0),
886            IndentedLine::new("2020.", 72.0),
887            IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
888        ];
889        let refs = split_lines(&flat);
890        assert_eq!(refs[0].text, "Smith J. One. 2020.");
891        assert_eq!(refs[1].lines, 2..3);
892    }
893
894    #[test]
895    fn spans_cover_the_reference_without_its_marker() {
896        let list = "1. Smith J. One.\r\n   2020.\r\n2. Jones K. Two. 2021.";
897        let refs = split_references(list);
898        assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n   2020.");
899        assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
900    }
901
902    #[test]
903    fn a_single_reference_is_one() {
904        assert_eq!(
905            texts("Smith J. A study.\nJournal 2020;1:2."),
906            ["Smith J. A study. Journal 2020;1:2."]
907        );
908        assert!(texts("  \n\n").is_empty());
909    }
910}