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                // A reference so far without a year or identifier (an author
375                // line ending "et al.") still needs its title and the rest.
376                Some(group)
377                    if !(ends_sentence(&self.join(group))
378                        && dated(&self.join(group))
379                        && starts_with_author(text)) =>
380                {
381                    group.push(line)
382                }
383                _ => groups.push(vec![line]),
384            }
385        }
386        groups
387    }
388
389    /// A group's lines as one: joined with a space, or with none where a
390    /// word, DOI or URL was broken across lines.
391    fn join(&self, group: &[Range<usize>]) -> String {
392        let mut out = String::new();
393        for line in group {
394            let next = self.line(line);
395            let first = next.chars().next().unwrap_or(' ');
396            if out.is_empty() {
397                // Nothing to join to.
398            } else if out.ends_with('-') && first.is_lowercase() && hyphenated_word(&out) {
399                out.pop();
400            } else if !link_continues(&out, next) {
401                out.push(' ');
402            }
403            out.push_str(next);
404        }
405        out
406    }
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410enum Marker {
411    Number(u32),
412    Bullet,
413}
414
415/// The marker a line starts with, and its length with the space after.
416fn marker(line: &str) -> Option<(Marker, usize)> {
417    let spaced = |rest: &str, used: usize, needs_space: bool| {
418        let gap = rest.len() - rest.trim_start().len();
419        (gap > 0 || !needs_space).then_some(used + gap)
420    };
421    if let Some(rest) = line.strip_prefix(['•', '–', '-', '*', '·']) {
422        return Some((Marker::Bullet, spaced(rest, line.len() - rest.len(), true)?));
423    }
424    let (open, close): (usize, &[char]) = match line.as_bytes().first()? {
425        b'[' => (1, &[']']),
426        b'(' => (1, &[')']),
427        _ => (0, &['.', ')']),
428    };
429    let inner = &line[open..];
430    let digits = inner.len() - inner.trim_start_matches(|c: char| c.is_ascii_digit()).len();
431    if !(1..=4).contains(&digits) {
432        return None;
433    }
434    let n: u32 = inner[..digits].parse().ok()?;
435    let rest = inner[digits..].strip_prefix(close)?;
436    // `[1]Smith` is unambiguous; `1.` needs a space, so `1.5` isn't one.
437    Some((
438        Marker::Number(n),
439        spaced(rest, line.len() - rest.len(), open == 0)?,
440    ))
441}
442
443/// Ends as a reference does: a full stop, page numbers, a closing bracket,
444/// or an identifier.
445fn ends_reference(line: &str) -> bool {
446    ends_sentence(line) || line.ends_with(|c: char| c.is_ascii_digit() || c == ')' || c == ']')
447}
448
449/// Ends with a full stop (or `?`, `!`), or with a whole DOI or URL, as
450/// references often do without one.
451fn ends_sentence(line: &str) -> bool {
452    line.ends_with(['.', '?', '!']) || in_link(line) && !line.ends_with(['/', '-'])
453}
454
455/// Has a year (1600 to 2099) or an identifier, as a whole reference does.
456fn dated(line: &str) -> bool {
457    let year = line
458        .split(|c: char| !c.is_ascii_digit())
459        .any(|n| n.len() == 4 && ("1600".."2100").contains(&n));
460    // What styles write for a work without a year, or a web page's date.
461    let lower = line.to_lowercase();
462    let undated = [
463        "n.d.",
464        "n. d.",
465        "no date",
466        "accessed",
467        "[cited",
468        "available from",
469    ]
470    .iter()
471    .any(|m| lower.contains(m));
472    year || undated || line.contains("doi") || line.contains("10.") || line.contains("http")
473}
474
475/// Starts as a wrapped line's continuation does: in lower case, or with
476/// punctuation.
477fn continues(line: &str) -> bool {
478    line.starts_with(|c: char| c.is_lowercase() || ",;:)&".contains(c))
479}
480
481/// Starts with a surname and initials or a comma, as author lists do:
482/// "Smith J,", "Smith JA.", "Smith, J.", "SMITH, J.", "O'Neil, K.".
483fn starts_with_author(line: &str) -> bool {
484    let surname_len = line
485        .char_indices()
486        .take_while(|(_, c)| c.is_alphabetic() || "'’-".contains(*c))
487        .last()
488        .map_or(0, |(i, c)| i + c.len_utf8());
489    let surname = &line[..surname_len];
490    if surname.chars().count() < 2 || !surname.starts_with(char::is_uppercase) {
491        return false;
492    }
493    let rest = &line[surname_len..];
494    // "Smith, J." or "Smith, John": a capital after the comma, never a
495    // word in lower case ("National, regional, and global trends").
496    if let Some(after) = rest.strip_prefix(',') {
497        return after.trim_start().starts_with(char::is_uppercase);
498    }
499    // Initials: one to three capitals, maybe dotted, then a break.
500    let Some(rest) = rest.strip_prefix(' ') else {
501        return false;
502    };
503    let initials: String = rest
504        .chars()
505        .take_while(|c| c.is_uppercase() || *c == '.')
506        .collect();
507    let capitals = initials.chars().filter(|c| c.is_uppercase()).count();
508    let after = rest[initials.len()..].chars().next();
509    (1..=3).contains(&capitals) && after.is_none_or(|c| ", .;".contains(c))
510}
511
512/// A word broken with a hyphen at the end of `text`: letters before it.
513fn hyphenated_word(text: &str) -> bool {
514    let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
515    !in_link(text) && word.trim_end_matches('-').chars().all(char::is_alphabetic)
516}
517
518/// `next` continues a DOI or URL that `text` ends with, broken where PDFs
519/// break them: after `/`, `-` or `_` (`10.1038/s41586-` + `020-2649-2`),
520/// after a dot before a digit or lower case (`j.cell.` + `2020.01.001`),
521/// or mid-word before more of the link (`j.ce` + `ll.2020.01.001`, not
522/// `2015` or `2015.`).
523/// Hyphens are kept as printed: DOIs have real ones, and typesetters
524/// rarely add their own inside a link.
525pub(crate) fn link_continues(text: &str, next: &str) -> bool {
526    let token = next.split_whitespace().next().unwrap_or_default();
527    let Some(first) = token.chars().next() else {
528        return false;
529    };
530    if !in_link(text) || !first.is_alphanumeric() || starts_link(token) || is_number(token) {
531        return false;
532    }
533    // A line that is one word of a DOI suffix's characters, digits among
534    // them, is the rest of the link, however the line broke: PDFs wrap a
535    // suffix anywhere (`…/HJH.0b013e` then `3282f35c67`). Not a number,
536    // nor an identifier of its own.
537    let suffix = |c: char| c.is_ascii_alphanumeric() || "._;()/:-<>".contains(c);
538    if next.trim() == token
539        && !text.ends_with('.')
540        && token.chars().all(suffix)
541        && token.bytes().any(|b| b.is_ascii_digit())
542        && !plain_number(token.trim_end_matches(|c: char| !c.is_alphanumeric()))
543        && crate::id::scan(token).is_empty()
544    {
545        return true;
546    }
547    let lower_or_digit = first.is_lowercase() || first.is_ascii_digit();
548    // A DOI of its own, unless the line broke right before it
549    // (`https://doi.org/` then `10.…`).
550    let doi = token.starts_with("10.") && token.contains('/');
551    if text.ends_with(['/', '-', '_']) {
552        true
553    } else if text.ends_with('.') {
554        lower_or_digit && !doi
555    } else {
556        // Mid-word: only before more of a link, never a word, a
557        // hyphenated word or a number.
558        let inner = token.trim_end_matches(|c: char| !c.is_alphanumeric());
559        lower_or_digit
560            && !doi
561            && inner.parse::<f64>().is_err()
562            && (inner.contains(['.', '/', '_']) || (first.is_ascii_digit() && inner.contains('-')))
563    }
564}
565
566/// A number that reads as itself ("3.14", "2019", "797"), not a run of a
567/// DOI's digits ("0000659", "00489").
568fn plain_number(token: &str) -> bool {
569    let digits = token.bytes().all(|b| b.is_ascii_digit());
570    (token.contains('.') && token.parse::<f64>().is_ok())
571        || (digits && token.len() < 5 && !token.starts_with('0'))
572}
573
574/// `token` is a link of its own, not the rest of one.
575fn starts_link(token: &str) -> bool {
576    let lower = token.get(..8).unwrap_or(token).to_ascii_lowercase();
577    ["http://", "https://", "www."]
578        .iter()
579        .any(|p| lower.starts_with(p))
580        || token
581            .split_once(':')
582            .is_some_and(|(head, _)| crate::id::IdKind::from_prefix(head).is_some())
583}
584
585/// `token` numbers a list item (`18.`).
586fn is_number(token: &str) -> bool {
587    token
588        .strip_suffix('.')
589        .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
590}
591
592/// `text` ends inside a DOI or URL.
593fn in_link(text: &str) -> bool {
594    let word = text.rsplit(char::is_whitespace).next().unwrap_or_default();
595    let starts = |p: &str| {
596        word.get(..p.len())
597            .is_some_and(|h| h.eq_ignore_ascii_case(p))
598    };
599    starts("http") || starts("doi:") || word.starts_with("10.")
600}
601
602fn is_heading(text: &str) -> bool {
603    let t = text.trim_end_matches(':').trim().to_lowercase();
604    matches!(
605        t.as_str(),
606        "references"
607            | "reference list"
608            | "bibliography"
609            | "works cited"
610            | "literature cited"
611            | "sources"
612            | "citations"
613    )
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    fn texts(list: &str) -> Vec<String> {
621        split_references(list).into_iter().map(|r| r.text).collect()
622    }
623
624    #[test]
625    fn numbered_lists_split_at_each_number_in_sequence() {
626        let list = "References\n\
627            1. LeCun Y, Bengio Y, Hinton G. Deep learning. Nature.\n\
628            2015;521:436-44.\n\
629            2. Vaswani A, et al. Attention is all you need. NeurIPS 2017.\n";
630        assert_eq!(
631            texts(list),
632            [
633                "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
634                "Vaswani A, et al. Attention is all you need. NeurIPS 2017.",
635            ]
636        );
637    }
638
639    #[test]
640    fn bracketed_and_parenthesised_numbers() {
641        let list = "[1] Smith J. One. 2020.\n[2]Jones K. Two. 2021.\n";
642        assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
643        let list = "(1) Smith J. One. 2020.\n(2) Jones K. Two. 2021.\n";
644        assert_eq!(texts(list), ["Smith J. One. 2020.", "Jones K. Two. 2021."]);
645    }
646
647    #[test]
648    fn numbers_out_of_sequence_continue_the_reference() {
649        let list = "1. Smith J. A study of things. Journal\n\
650            2015. 12(3):1-9.\n\
651            2. Jones K. Another. 2021.\n";
652        assert_eq!(
653            texts(list),
654            [
655                "Smith J. A study of things. Journal 2015. 12(3):1-9.",
656                "Jones K. Another. 2021.",
657            ]
658        );
659    }
660
661    #[test]
662    fn bullets_mark_references() {
663        let list = "• Smith J. One. 2020.\n• Jones K. Two,\n  continued. 2021.\n";
664        assert_eq!(
665            texts(list),
666            ["Smith J. One. 2020.", "Jones K. Two, continued. 2021."]
667        );
668    }
669
670    #[test]
671    fn blank_lines_separate_wrapped_references() {
672        let list = "Vaswani A, Shazeer N. Attention is all you need.\n\
673            Advances in Neural Information Processing Systems. 2017.\n\
674            \n\
675            LeCun Y, Bengio Y, Hinton G. Deep learning.\n\
676            Nature. 2015;521:436-44.\n";
677        assert_eq!(
678            texts(list),
679            [
680                "Vaswani A, Shazeer N. Attention is all you need. Advances in Neural Information Processing Systems. 2017.",
681                "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-44.",
682            ]
683        );
684    }
685
686    #[test]
687    fn one_per_line() {
688        let list = "Smith J. One. 2020.\n\
689            World Health Organization. Two. 2021.\n\
690            Jones K. Three. https://example.org/three\n";
691        assert_eq!(texts(list).len(), 3);
692    }
693
694    #[test]
695    fn wrapped_lines_without_blank_lines_split_at_authors() {
696        let list = "Smith, J. (2020). A study of\n\
697            things. Journal of Studies, 12, 1-9.\n\
698            Jones, K. (2021). Another study. Nature,\n\
699            521, 436.\n";
700        assert_eq!(
701            texts(list),
702            [
703                "Smith, J. (2020). A study of things. Journal of Studies, 12, 1-9.",
704                "Jones, K. (2021). Another study. Nature, 521, 436.",
705            ]
706        );
707    }
708
709    #[test]
710    fn hyphenation_and_broken_links_are_mended() {
711        let list = "1. Smith J. Neuro-\nlogical find-\nings. doi:10.1038/\nnature14539\n\
712            2. Jones K. https://example.org/a-\nb/c\n";
713        assert_eq!(
714            texts(list),
715            [
716                "Smith J. Neurological findings. doi:10.1038/nature14539",
717                "Jones K. https://example.org/a-b/c",
718            ]
719        );
720    }
721
722    #[test]
723    fn links_broken_by_pdf_extraction_are_rejoined_as_printed() {
724        let list = "[1] Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.\n\
725            2020.01.001\n\
726            [2] Jones K. Folds. Nature. 2020. doi:10.1038/s41586-\n\
727            020-2649-2\n\
728            [3] Brown L. Stems. https://doi.org/10.1016/j.ce\n\
729            ll.2021.02.003\n";
730        assert_eq!(
731            texts(list),
732            [
733                "Smith J. Cells. Cell. 2020. https://doi.org/10.1016/j.cell.2020.01.001",
734                "Jones K. Folds. Nature. 2020. doi:10.1038/s41586-020-2649-2",
735                "Brown L. Stems. https://doi.org/10.1016/j.cell.2021.02.003",
736            ]
737        );
738        // Unnumbered, the continuation isn't a reference of its own.
739        let list = "Smith J. Cells. Cell. 2020. doi:10.1016/j.cell.\n2020.01.001\n\
740            Jones K. Folds. Nature. 2020.\n";
741        assert_eq!(texts(list).len(), 2);
742        // A link that ends the line, then a new sentence, stays apart.
743        assert_eq!(
744            texts("1. Smith J. doi:10.1038/x.\nNature. 2020.\n2. Jones K. 2021.")[0],
745            "Smith J. doi:10.1038/x. Nature. 2020."
746        );
747    }
748
749    #[test]
750    fn a_single_numbered_reference_loses_its_number() {
751        assert_eq!(
752            texts("1. LeCun Y. Deep learning. Nature. 2015."),
753            ["LeCun Y. Deep learning. Nature. 2015."]
754        );
755        // A number further down is part of the reference.
756        assert_eq!(
757            texts("Smith J. A book.\n2. ed. Berlin: Springer; 2010."),
758            ["Smith J. A book. 2. ed. Berlin: Springer; 2010."]
759        );
760    }
761
762    #[test]
763    fn references_numbered_inline_split_at_each_number() {
764        // Dogfood: a PDF's list copied by Preview onto one line.
765        let list = "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x. \
766            [21] Finucane T, Smith B. Title two. Lancet. 2012;379:10-12. \
767            [22] Ortel TL. Title three. Blood Adv. 2020;4:4693. \
768            [23] Konstantinides SV. Title four. Eur Heart J. 2020;41:543.";
769        assert_eq!(
770            texts(list),
771            [
772                "Sanchez A, et al. Title one. J Thromb Haemost. 2011;9:1-8. doi:10.1111/j.1538-7836.2010.01159.x.",
773                "Finucane T, Smith B. Title two. Lancet. 2012;379:10-12.",
774                "Ortel TL. Title three. Blood Adv. 2020;4:4693.",
775                "Konstantinides SV. Title four. Eur Heart J. 2020;41:543.",
776            ]
777        );
778        // Undated, as styles write it ("n.d."), or one piece of four
779        // without a date.
780        let undated = "Sanchez A. One. Blood. 2011;9:1-8. \
781            [21] Finucane T. Two. Lancet. 2012;379:10. \
782            [22] Defense Health Board. Three. Falls Church, VA, n.d. \
783            [23] Konstantinides SV. Four. Eur Heart J. 2020;41:543.";
784        assert_eq!(texts(undated).len(), 4);
785        assert_eq!(texts(&undated.replace("n.d.", "undated.")).len(), 4);
786        // Two, when the first starts the line.
787        assert_eq!(
788            texts("[1] Smith J. One. 2019. [2] Jones K. Two. 2020.").len(),
789            2
790        );
791        // In-text citations: out of sequence, not after a full stop, or
792        // pieces without a year.
793        for prose in [
794            "Shown before [3] and since. [4] The trial. [5] Then 2019.",
795            "Smith J. Title. 2019. [3] Jones. 2020. [7] Brown. 2021. [9] Lee. 2022.",
796            "As shown. [1] The first. [2] The second. [3] The third 2019.",
797        ] {
798            assert_eq!(texts(prose).len(), 1, "{prose}");
799        }
800    }
801
802    #[test]
803    fn unnumbered_vancouver_lists_split_however_lines_break() {
804        // #40: copied from a journal PDF; Preview's copies break lines
805        // with `\r` or Unicode separators.
806        let list = "Benjamin EJ, Muntner P, Alonso A, et al. Heart disease\n\
807            and stroke statistics-2019 update. Circulation\n\
808            2019;139:e56-528. https://doi.org/10.1161/CIR.0000000000000659\n\
809            Schmieder RE, Kjeldsen SE, Julius S, et al. Reduced incidence\n\
810            of new-onset atrial fibrillation: the VALUE trial. J\n\
811            Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e3282f35c67\n\
812            Chokesuwattanaskul R, Thongprayoon C, Bathini T, Sharma K, Watthanasuntorn K, Lertjitbanjong\n\
813            P, et al. Incident atrial fibrillation in patients undergoing bariatric sur-\n\
814            gery: a systematic review and meta-analysis. Intern Med J 2020;50:810-7. https://doi.org/10.1111/imj.14436";
815        for sep in ["\n", "\r\n", "\r", "\u{2028}", "\u{2029}"] {
816            let found = texts(&list.replace('\n', sep));
817            assert_eq!(found.len(), 3, "{sep:?}: {found:?}");
818            assert!(
819                found[2].contains("bariatric surgery: a systematic"),
820                "{found:?}"
821            );
822        }
823    }
824
825    #[test]
826    fn a_doi_wrapped_onto_a_line_of_its_own_ends_its_reference() {
827        // #40, as copied: each DOI's end alone on the next line.
828        let list = "Benjamin EJ, Muntner P, et al. Heart disease\n\
829            and stroke statistics-2019 update. Circulation\n\
830            2019;139:e56-528. https://doi.org/10.1161/CIR.000000000\n\
831            0000659\n\
832            Schmieder RE, Kjeldsen SE, et al. Reduced incidence\n\
833            of new-onset atrial fibrillation: the VALUE trial. J\n\
834            Hypertens 2008;26:403-11. https://doi.org/10.1097/HJH.0b013e\n\
835            3282f35c67";
836        let found = texts(list);
837        assert_eq!(found.len(), 2, "{found:?}");
838        assert!(
839            found[0].ends_with("https://doi.org/10.1161/CIR.0000000000000659"),
840            "{found:?}"
841        );
842        assert!(
843            found[1].ends_with("https://doi.org/10.1097/HJH.0b013e3282f35c67"),
844            "{found:?}"
845        );
846        let dois: Vec<String> = crate::id::scan(list)
847            .iter()
848            .map(|m| m.id.to_string())
849            .collect();
850        assert_eq!(
851            dois,
852            [
853                "doi:10.1161/cir.0000000000000659",
854                "doi:10.1097/hjh.0b013e3282f35c67"
855            ]
856        );
857    }
858
859    #[test]
860    fn indents_never_start_a_reference_mid_word_or_in_lower_case() {
861        // Hanging indents, with lines a PDF's layout put at the margin
862        // although they continue the reference.
863        let lines = [
864            IndentedLine::new(
865                "Chokesuwattanaskul R, et al. Incident atrial fibrillation in bariatric sur-",
866                0.0,
867            ),
868            IndentedLine::new(
869                "gery: a systematic review. Intern Med J 2020;50:810-7.",
870                0.0,
871            ),
872            IndentedLine::new("Sardana M, et al. Obstructive sleep apnea (the SAVE", 0.0),
873            IndentedLine::new("trial). Circ Arrhythm Electrophysiol 2017;10:e005463.", 0.0),
874            IndentedLine::new("Smith J, et al. Another reference about", 0.0),
875            IndentedLine::new("the same thing. Lancet 2019;393:10-12.", 12.0),
876        ];
877        let found: Vec<String> = split_lines(&lines).into_iter().map(|r| r.text).collect();
878        assert_eq!(found.len(), 3, "{found:?}");
879        assert!(
880            found[0].contains("bariatric surgery: a systematic"),
881            "{found:?}"
882        );
883        assert!(found[1].contains("(the SAVE trial)."), "{found:?}");
884    }
885
886    #[test]
887    fn an_author_line_is_not_a_reference_of_its_own() {
888        // #44: the authors on a line of their own, ending "et al.", then a
889        // title starting "National, regional, …".
890        let reference = "Finucane MM, Stevens GA, Cowan MJ, Danaei G, Lin JK, Paciorek CJ, et al.\n\
891            National, regional, and global trends in body-mass index since 1980: system-\n\
892            atic analysis of health examination surveys and epidemiological studies with\n\
893            960 country-years and 9 1 million participants. Lancet 2011;377:557\u{2013}67.\n\
894            http://dx.doi.org/10.1016/S0140-6736(10)62037-5.";
895        for text in [reference.to_owned(), reference.replacen('F', "", 1)] {
896            let found = texts(&text);
897            assert_eq!(found.len(), 1, "{found:?}");
898            assert!(found[0].contains("et al. National, regional"), "{found:?}");
899        }
900        // A list of such references still splits.
901        let two =
902            format!("{reference}\nSmith J, Jones K, et al.\nA second title. BMJ 2019;365:l123.");
903        assert_eq!(texts(&two).len(), 2, "{:?}", texts(&two));
904    }
905
906    #[test]
907    fn markers_alone_on_their_line_number_the_next() {
908        assert_eq!(
909            texts("[1]\nSmith J. One. 2020.\n[2]\nJones K. Two. 2021.\n[3] "),
910            ["Smith J. One. 2020.", "Jones K. Two. 2021."]
911        );
912    }
913
914    #[test]
915    fn pdf_text_is_cleaned_and_spans_point_into_it() {
916        let list = "1. Smith J. Neuro\u{AD}\nlogical findings. doi:10.1038/s41586\u{2010}020-2649-2\n\
917            2. Jones K. Two. 2021.";
918        let refs = split_references(list);
919        assert_eq!(
920            refs[0].text,
921            "Smith J. Neurological findings. doi:10.1038/s41586-020-2649-2"
922        );
923        assert!(list[refs[0].span.clone()].starts_with("Smith J. Neuro\u{AD}"));
924        assert!(list[refs[0].span.clone()].ends_with("\u{2010}020-2649-2"));
925    }
926
927    #[test]
928    fn dois_broken_at_a_hyphen_carry_the_reading_without_it() {
929        let refs = split_references("1. Smith J. doi:10.1038/nat-\nure14539\n2. Jones K. 2021.");
930        assert_eq!(refs[0].text, "Smith J. doi:10.1038/nat-ure14539");
931        let alt = &refs[0].alternatives[0];
932        assert_eq!(alt.claimed.to_string(), "doi:10.1038/nat-ure14539");
933        assert_eq!(alt.alternative.to_string(), "doi:10.1038/nature14539");
934        assert!(refs[1].alternatives.is_empty());
935    }
936
937    #[test]
938    fn hanging_indents_mark_starts_that_text_cannot() {
939        // Each line ends a sentence and the next starts with a capital,
940        // so the text alone reads three references.
941        let lines = [
942            IndentedLine::new("References", 72.4),
943            IndentedLine::new("World Health Organization. Global report.", 72.0),
944            IndentedLine::new("Geneva: WHO; 2020.", 90.2),
945            IndentedLine::new("Smith J. A study. Nature. 2021.", 71.8),
946        ];
947        let refs = split_lines(&lines);
948        assert_eq!(
949            refs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
950            [
951                "World Health Organization. Global report. Geneva: WHO; 2020.",
952                "Smith J. A study. Nature. 2021.",
953            ]
954        );
955        assert_eq!(refs[0].lines, 1..3);
956        assert_eq!(refs[1].lines, 3..4);
957    }
958
959    #[test]
960    fn first_line_indents_and_flat_lists() {
961        let lines = [
962            IndentedLine::new("Smith J. A study of", 90.0),
963            IndentedLine::new("Things. Nature. 2021.", 72.0),
964            IndentedLine::new("Jones K. Another. 2022.", 90.0),
965        ];
966        assert_eq!(split_lines(&lines).len(), 2);
967        // One indent throughout: the text rules decide.
968        let flat = [
969            IndentedLine::new("1. Smith J. One.", 72.0),
970            IndentedLine::new("2020.", 72.0),
971            IndentedLine::new("2. Jones K. Two. 2021.", 72.0),
972        ];
973        let refs = split_lines(&flat);
974        assert_eq!(refs[0].text, "Smith J. One. 2020.");
975        assert_eq!(refs[1].lines, 2..3);
976    }
977
978    #[test]
979    fn spans_cover_the_reference_without_its_marker() {
980        let list = "1. Smith J. One.\r\n   2020.\r\n2. Jones K. Two. 2021.";
981        let refs = split_references(list);
982        assert_eq!(&list[refs[0].span.clone()], "Smith J. One.\r\n   2020.");
983        assert_eq!(&list[refs[1].span.clone()], "Jones K. Two. 2021.");
984    }
985
986    #[test]
987    fn a_single_reference_is_one() {
988        assert_eq!(
989            texts("Smith J. A study.\nJournal 2020;1:2."),
990            ["Smith J. A study. Journal 2020;1:2."]
991        );
992        assert!(texts("  \n\n").is_empty());
993    }
994}