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