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    // A line that is one word of a DOI suffix's characters, digits among
526    // them, is the rest of the link, however the line broke: PDFs wrap a
527    // suffix anywhere (`…/HJH.0b013e` then `3282f35c67`). Not a number,
528    // nor an identifier of its own.
529    let suffix = |c: char| c.is_ascii_alphanumeric() || "._;()/:-<>".contains(c);
530    if next.trim() == token
531        && !text.ends_with('.')
532        && token.chars().all(suffix)
533        && token.bytes().any(|b| b.is_ascii_digit())
534        && !plain_number(token.trim_end_matches(|c: char| !c.is_alphanumeric()))
535        && crate::id::scan(token).is_empty()
536    {
537        return true;
538    }
539    let lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
540    // A DOI of its own, unless the line broke right before it
541    // (`https://doi.org/` then `10.…`).
542    let doi = token.starts_with("10.") && token.contains('/');
543    if text.ends_with(['/', '-', '_']) {
544        true
545    } else if text.ends_with('.') {
546        lower_or_digit && !doi
547    } else {
548        // Mid-word: only before more of a link, never a word, a
549        // hyphenated word or a number.
550        let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
551        lower_or_digit
552            && !doi
553            && inner.parse::<f64>().is_err()
554            && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
555    }
556}
557
558/// A number that reads as itself ("3.14", "2019", "797"), not a run of a
559/// DOI's digits ("0000659", "00489").
560fn plain_number(token: &str) -> bool {
561    let digits = token.bytes().all(|b| b.is_ascii_digit());
562    (token.contains('.') && token.parse::<f64>().is_ok())
563        || (digits && token.len() < 5 && !token.starts_with('0'))
564}
565
566/// `token` is a link of its own, not the rest of one.
567fn starts_link(token: &str) -> bool {
568    let lower = token.get(..8).unwrap_or(token).to_ascii_lowercase();
569    ["http://", "https://", "www."]
570        .iter()
571        .any(|p| lower.starts_with(p))
572        || token
573            .split_once(':')
574            .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
575}
576
577/// `token` numbers a list item (`18.`).
578fn is_number(token: &str) -> bool {
579    token
580        .strip_suffix('.')
581        .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
582}
583
584/// `text` ends inside a DOI or URL.
585fn in_link(text: &str) -> bool {
586    let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
587    let starts = |p: &str| {
588        word.get(..p.len())
589            .is_some_and(|h| h.eq_ignore_ascii_case(p))
590    };
591    starts("http") || starts("doi:") || word.starts_with("10.")
592}
593
594fn is_heading(text: &str) -> bool {
595    let t = text.trim_end_matches(':').trim().to_lowercase();
596    matches!(
597        t.as_str(),
598        "references"
599            | "reference list"
600            | "bibliography"
601            | "works cited"
602            | "literature cited"
603            | "sources"
604            | "citations"
605    )
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn texts(list: &str) -> Vec<String> {
613        split_references(list).into_iter().map(|r| r.text).collect()
614    }
615
616    #[test]
617    fn numbered_lists_split_at_each_number_in_sequence() {
618        let list = "References\n\
619            1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
620            2015;521:436-44.\n\
621            2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
622        assert_eq!(
623            texts(list),
624            [
625                "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
626                "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
627            ]
628        );
629    }
630
631    #[test]
632    fn bracketed_and_parenthesised_numbers() {
633        let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
634        assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
635        let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
636        assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
637    }
638
639    #[test]
640    fn numbers_out_of_sequence_continue_the_reference() {
641        let list = "1. Smith J. A study of things. Journal\n\
642            2015. 12(3):1-9.\n\
643            2. Jones K. Another. 2021.\n";
644        assert_eq!(
645            texts(list),
646            [
647                "Smith J. A study of things. Journal 2015. 12(3):1-9.",
648                "Jones K. Another. 2021.",
649            ]
650        );
651    }
652
653    #[test]
654    fn bullets_mark_references() {
655        let list = "• Smith J. One. 2020.\n• Jones K. Two,\n  continued. 2021.\n";
656        assert_eq!(
657            texts(list),
658            ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
659        );
660    }
661
662    #[test]
663    fn blank_lines_separate_wrapped_references() {
664        let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
665            Advances in Neural Information Processing Systems. 2017.\n\
666            \n\
667            LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
668            Nature. 2015;521:436-44.\n";
669        assert_eq!(
670            texts(list),
671            [
672                "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
673                "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
674            ]
675        );
676    }
677
678    #[test]
679    fn one_per_line() {
680        let list = "Smith J. One. 2020.\n\
681            World Health Organization. Two. 2021.\n\
682            Jones K. Three. https://example.org/three\n";
683        assert_eq!(texts(list).len(), 3);
684    }
685
686    #[test]
687    fn wrapped_lines_without_blank_lines_split_at_authors() {
688        let list = "Smith, J. (2020). A study of\n\
689            things. Journal of Studies, 12, 1-9.\n\
690            Jones, K. (2021). Another study. Nature,\n\
691            521, 436.\n";
692        assert_eq!(
693            texts(list),
694            [
695                "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
696                "Jones, K. (2021). Another study. Nature, 521, 436.",
697            ]
698        );
699    }
700
701    #[test]
702    fn hyphenation_and_broken_links_are_mended() {
703        let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
704            2. Jones K. https://example.org/a-\nb/c\n";
705        assert_eq!(
706            texts(list),
707            [
708                "Smith J. Neurological findings. doi:10.1038/nature14539",
709                "Jones K. https://example.org/a-b/c",
710            ]
711        );
712    }
713
714    #[test]
715    fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
716        let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
717            2020.01.001\n\
718            [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
719            020-2649-2\n\
720            [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
721            ll.2021.02.003\n";
722        assert_eq!(
723            texts(list),
724            [
725                "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
726                "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
727                "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
728            ]
729        );
730        // Unnumbered, the continuation isn't a reference of its own.
731        let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
732            Jones K. Folds. Nature. 2020.\n";
733        assert_eq!(texts(list).len(), 2);
734        // A link that ends the line, then a new sentence, stays apart.
735        assert_eq!(
736            texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
737            "Smith J. doi:10.1038/x. Nature. 2020."
738        );
739    }
740
741    #[test]
742    fn a_single_numbered_reference_loses_its_number() {
743        assert_eq!(
744            texts("1. LeCun Y. Deep learning. Nature. 2015."),
745            ["LeCun Y. Deep learning. Nature. 2015."]
746        );
747        // A number further down is part of the reference.
748        assert_eq!(
749            texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
750            ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
751        );
752    }
753
754    #[test]
755    fn references_numbered_inline_split_at_each_number() {
756        // Dogfood: a PDF's list copied by Preview onto one line.
757        let list = "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x. \
758            [21] Finucane T, Smith B. Title two. Lancet. 2012;379:10-12. \
759            [22] Ortel TL. Title three. Blood Adv. 2020;4:4693. \
760            [23] Konstantinides SV. Title four. Eur Heart J. 2020;41:543.";
761        assert_eq!(
762            texts(list),
763            [
764                "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x.",
765                "Finucane T, Smith B. Title two. Lancet. 2012;379:10-12.",
766                "Ortel TL. Title three. Blood Adv. 2020;4:4693.",
767                "Konstantinides SV. Title four. Eur Heart J. 2020;41:543.",
768            ]
769        );
770        // Undated, as styles write it ("n.d."), or one piece of four
771        // without a date.
772        let undated = "Sanchez A. One. Blood. 2011;9:1-8. \
773            [21] Finucane T. Two. Lancet. 2012;379:10. \
774            [22] Defense Health Board. Three. Falls Church, VA, n.d. \
775            [23] Konstantinides SV. Four. Eur Heart J. 2020;41:543.";
776        assert_eq!(texts(undated).len(), 4);
777        assert_eq!(texts(&undated.replace("n.d.", "undated.")).len(), 4);
778        // Two, when the first starts the line.
779        assert_eq!(
780            texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
781            2
782        );
783        // In-text citations: out of sequence, not after a full stop, or
784        // pieces without a year.
785        for prose in [
786            "Shown before [3] and since. [4] The trial. [5] Then 2019.",
787            "Smith J. Title. 2019. [3] Jones. 2020. [7] Brown. 2021. [9] Lee. 2022.",
788            "As shown. [1] The first. [2] The second. [3] The third 2019.",
789        ] {
790            assert_eq!(texts(prose).len(), 1, "{prose}");
791        }
792    }
793
794    #[test]
795    fn unnumbered_vancouver_lists_split_however_lines_break() {
796        // #40: copied from a journal PDF; Preview's copies break lines
797        // with `\r` or Unicode separators.
798        let list = "Benjamin EJ, Muntner P, Alonso A, et al. Heart disease\n\
799            and stroke statistics-2019 update. Circulation\n\
800            2019;139:e56-528. https://doi.org/10.1161/CIR.0000000000000659\n\
801            Schmieder RE, Kjeldsen SE, Julius S, et al. Reduced incidence\n\
802            of new-onset atrial fibrillation: the VALUE trial. J\n\
803            Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e3282f35c67\n\
804            Chokesuwattanaskul R, Thongprayoon C, Bathini T, Sharma K, Watthanasuntorn K, Lertjitbanjong\n\
805            P, et al. Incident atrial fibrillation in patients undergoing bariatric sur-\n\
806            gery: a systematic review and meta-analysis. Intern Med J 2020;50:810-7. https://doi.org/10.1111/imj.14436";
807        for sep in ["\n", "\r\n", "\r", "\u{2028}", "\u{2029}"] {
808            let found = texts(&list.replace('\n', sep));
809            assert_eq!(found.len(), 3, "{sep:?}: {found:?}");
810            assert!(
811                found[2].contains("bariatric surgery: a systematic"),
812                "{found:?}"
813            );
814        }
815    }
816
817    #[test]
818    fn a_doi_wrapped_onto_a_line_of_its_own_ends_its_reference() {
819        // #40, as copied: each DOI's end alone on the next line.
820        let list = "Benjamin EJ, Muntner P, et al. Heart disease\n\
821            and stroke statistics-2019 update. Circulation\n\
822            2019;139:e56-528. https://doi.org/10.1161/CIR.000000000\n\
823            0000659\n\
824            Schmieder RE, Kjeldsen SE, et al. Reduced incidence\n\
825            of new-onset atrial fibrillation: the VALUE trial. J\n\
826            Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e\n\
827            3282f35c67";
828        let found = texts(list);
829        assert_eq!(found.len(), 2, "{found:?}");
830        assert!(
831            found[0].ends_with("https://doi.org/10.1161/CIR.0000000000000659"),
832            "{found:?}"
833        );
834        assert!(
835            found[1].ends_with("https://doi.org/10.1097/HJH.0b013e3282f35c67"),
836            "{found:?}"
837        );
838        let dois: Vec<String> = crate::id::scan(list)
839            .iter()
840            .map(|m| m.id.to_string())
841            .collect();
842        assert_eq!(
843            dois,
844            [
845                "doi:10.1161/cir.0000000000000659",
846                "doi:10.1097/hjh.0b013e3282f35c67"
847            ]
848        );
849    }
850
851    #[test]
852    fn indents_never_start_a_reference_mid_word_or_in_lower_case() {
853        // Hanging indents, with lines a PDF's layout put at the margin
854        // although they continue the reference.
855        let lines = [
856            IndentedLine::new(
857                "Chokesuwattanaskul R, et al. Incident atrial fibrillation in bariatric sur-",
858                0.0,
859            ),
860            IndentedLine::new(
861                "gery: a systematic review. Intern Med J 2020;50:810-7.",
862                0.0,
863            ),
864            IndentedLine::new("Sardana M, et al. Obstructive sleep apnea (the SAVE", 0.0),
865            IndentedLine::new("trial). Circ Arrhythm Electrophysiol 2017;10:e005463.", 0.0),
866            IndentedLine::new("Smith J, et al. Another reference about", 0.0),
867            IndentedLine::new("the same thing. Lancet 2019;393:10-12.", 12.0),
868        ];
869        let found: Vec<String> = split_lines(&lines).into_iter().map(|r| r.text).collect();
870        assert_eq!(found.len(), 3, "{found:?}");
871        assert!(
872            found[0].contains("bariatric surgery: a systematic"),
873            "{found:?}"
874        );
875        assert!(found[1].contains("(the SAVE trial)."), "{found:?}");
876    }
877
878    #[test]
879    fn markers_alone_on_their_line_number_the_next() {
880        assert_eq!(
881            texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
882            ["Smith J. One. 2020.", "Jones K. Two. 2021."]
883        );
884    }
885
886    #[test]
887    fn pdf_text_is_cleaned_and_spans_point_into_it() {
888        let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
889            2. Jones K. Two. 2021.";
890        let refs = split_references(list);
891        assert_eq!(
892            refs[0].text,
893            "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
894        );
895        assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
896        assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
897    }
898
899    #[test]
900    fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
901        let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
902        assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
903        let alt = &refs[0].alternatives[0];
904        assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
905        assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
906        assert!(refs[1].alternatives.is_empty());
907    }
908
909    #[test]
910    fn hanging_indents_mark_starts_that_text_cannot() {
911        // Each line ends a sentence and the next starts with a capital,
912        // so the text alone reads three references.
913        let lines = [
914            IndentedLine::new("References", 72.4),
915            IndentedLine::new("World Health Organization. Global report.", 72.0),
916            IndentedLine::new("Geneva: WHO; 2020.", 90.2),
917            IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
918        ];
919        let refs = split_lines(&lines);
920        assert_eq!(
921            refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
922            [
923                "World Health Organization. Global report. Geneva: WHO; 2020.",
924                "Smith J. A study. Nature. 2021.",
925            ]
926        );
927        assert_eq!(refs[0].lines, 1..3);
928        assert_eq!(refs[1].lines, 3..4);
929    }
930
931    #[test]
932    fn first_line_indents_and_flat_lists() {
933        let lines = [
934            IndentedLine::new("Smith J. A study of", 90.0),
935            IndentedLine::new("Things. Nature. 2021.", 72.0),
936            IndentedLine::new("Jones K. Another. 2022.", 90.0),
937        ];
938        assert_eq!(split_lines(&lines).len(), 2);
939        // One indent throughout: the text rules decide.
940        let flat = [
941            IndentedLine::new("1. Smith J. One.", 72.0),
942            IndentedLine::new("2020.", 72.0),
943            IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
944        ];
945        let refs = split_lines(&flat);
946        assert_eq!(refs[0].text, "Smith J. One. 2020.");
947        assert_eq!(refs[1].lines, 2..3);
948    }
949
950    #[test]
951    fn spans_cover_the_reference_without_its_marker() {
952        let list = "1. Smith J. One.\r\n   2020.\r\n2. Jones K. Two. 2021.";
953        let refs = split_references(list);
954        assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n   2020.");
955        assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
956    }
957
958    #[test]
959    fn a_single_reference_is_one() {
960        assert_eq!(
961            texts("Smith J. A study.\nJournal 2020;1:2."),
962            ["Smith J. A study. Journal 2020;1:2."]
963        );
964        assert!(texts("  \n\n").is_empty());
965    }
966}