rustypaper 0.2.3__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {rustypaper-0.2.3 → rustypaper-0.2.4}/Cargo.lock +2 -2
  2. {rustypaper-0.2.3 → rustypaper-0.2.4}/Cargo.toml +2 -2
  3. {rustypaper-0.2.3 → rustypaper-0.2.4}/PKG-INFO +1 -1
  4. {rustypaper-0.2.3 → rustypaper-0.2.4}/pyproject.toml +1 -1
  5. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/backend/rustium.rs +3 -0
  6. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/doc.rs +148 -5
  7. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/ir.rs +7 -0
  8. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/columns.rs +1 -0
  9. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/furniture.rs +80 -2
  10. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/math/build.rs +1 -0
  11. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/math/mod.rs +1 -0
  12. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/pipeline.rs +7 -1
  13. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/refs.rs +88 -1
  14. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/text/lines.rs +141 -11
  15. rustypaper-0.2.4/rustypaper/tests/pengap.rs +111 -0
  16. rustypaper-0.2.4/rustypaper/tests/urlwrap.rs +327 -0
  17. {rustypaper-0.2.3 → rustypaper-0.2.4}/README.md +0 -0
  18. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/Cargo.toml +0 -0
  19. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/README.md +0 -0
  20. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/__init__.py +0 -0
  21. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/__main__.py +0 -0
  22. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/cli.py +0 -0
  23. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/examples/ingest_share.rs +0 -0
  24. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/backend/mod.rs +0 -0
  25. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/cli.rs +0 -0
  26. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/compress.rs +0 -0
  27. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/emit/markdown.rs +0 -0
  28. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/emit/mod.rs +0 -0
  29. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/emit/text.rs +0 -0
  30. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/emit/typst.rs +0 -0
  31. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/error.rs +0 -0
  32. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/figure.rs +0 -0
  33. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/mod.rs +0 -0
  34. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/order.rs +0 -0
  35. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/stats.rs +0 -0
  36. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/lib.rs +0 -0
  37. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/main.rs +0 -0
  38. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/math/latex.rs +0 -0
  39. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/math/symbols.rs +0 -0
  40. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/table.rs +0 -0
  41. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/text/mod.rs +0 -0
  42. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/text/vocab.rs +0 -0
  43. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/util.rs +0 -0
  44. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/tests/actualtext.rs +0 -0
  45. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/tests/cli.rs +0 -0
  46. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/tests/robustness.rs +0 -0
  47. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper-py/Cargo.toml +0 -0
  48. {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper-py/src/lib.rs +0 -0
@@ -567,7 +567,7 @@ dependencies = [
567
567
 
568
568
  [[package]]
569
569
  name = "rustypaper"
570
- version = "0.2.3"
570
+ version = "0.2.4"
571
571
  dependencies = [
572
572
  "anyhow",
573
573
  "clap",
@@ -580,7 +580,7 @@ dependencies = [
580
580
 
581
581
  [[package]]
582
582
  name = "rustypaper-py"
583
- version = "0.2.3"
583
+ version = "0.2.4"
584
584
  dependencies = [
585
585
  "pyo3",
586
586
  "rustypaper",
@@ -3,14 +3,14 @@ resolver = "2"
3
3
  members = ["rustypaper", "rustypaper-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.3"
6
+ version = "0.2.4"
7
7
  edition = "2021"
8
8
  rust-version = "1.94"
9
9
  license = "MIT OR Apache-2.0"
10
10
  repository = "https://github.com/pgarrett-scripps/rustypaper"
11
11
 
12
12
  [workspace.dependencies]
13
- rustypaper = { path = "rustypaper", version = "0.2.3" }
13
+ rustypaper = { path = "rustypaper", version = "0.2.4" }
14
14
  # The pure-Rust PDF backend, from the registry. Deliberately not a path dependency: a path
15
15
  # only resolves where the sibling checkout happens to exist, which is this machine and not a
16
16
  # CI runner, and `cargo fmt --all` walks into it whether it is there or not. To develop the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rustypaper
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Topic :: Text Processing :: Markup
6
6
  Classifier: Topic :: Scientific/Engineering
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rustypaper"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Structure-aware conversion of born-digital scientific PDFs to Markdown, Typst and JSON."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -278,6 +278,9 @@ fn collect_glyphs(page: &Page, space: &Space, fonts: &mut FontTable, raw: &mut P
278
278
  text,
279
279
  bbox,
280
280
  origin: space.point(g.origin),
281
+ // The page transform is rigid — shift, flip, `/Rotate` — so the advance length
282
+ // carries over from user space unchanged.
283
+ advance: g.advance,
281
284
  font,
282
285
  size: resolve_size(g.font_size, &bbox, angle),
283
286
  angle,
@@ -560,11 +560,16 @@ fn join_lines(group: &[&Line], vocab: &Vocabulary) -> (String, Vec<Span>) {
560
560
  }
561
561
 
562
562
  let justified = line_reaches(group[i - 1], right_edge, group[i - 1].size);
563
- match justified
564
- .then(|| split_boundary(&out, &text))
565
- .flatten()
566
- .and_then(|(head, tail)| rejoin_across_break(vocab, head, tail))
567
- {
563
+ match split_boundary(&out, &text).and_then(|(head, tail)| {
564
+ // A URL wrap is tried first and without the geometric guard: a line broken inside
565
+ // a URL often cannot be stretched to the margin, and a trailing hyphen there is
566
+ // part of the identifier, not a hyphenation to undo.
567
+ url_wrap(head, tail).or_else(|| {
568
+ justified
569
+ .then(|| rejoin_across_break(vocab, head, tail))
570
+ .flatten()
571
+ })
572
+ }) {
568
573
  Some(merged) => {
569
574
  let head = head_len(&out);
570
575
  let head_chars = out[out.len() - head..].chars().count();
@@ -659,11 +664,82 @@ fn rejoin_across_break(vocab: &Vocabulary, head: &str, tail: &str) -> Option<Str
659
664
  }
660
665
  }
661
666
 
667
+ /// Joins a URL or DOI wrapped across a line break back into one token.
668
+ ///
669
+ /// A typesetter breaks a long URL *after* a separator — `/`, `-`, `.`, `_` — and adds no
670
+ /// hyphen, so the separator ending the line is itself the evidence of continuation. The word
671
+ /// vocabulary knows nothing here, and the usual soft-hyphen removal would eat a `-` that is
672
+ /// part of the identifier, which is why this runs before [`rejoin_across_break`].
673
+ ///
674
+ /// Both sides are held to it. The head must already be inside a URL (a scheme, `www.`,
675
+ /// `doi.org/`, or a bare DOI prefix) and end at a separator; the tail must be made of URL
676
+ /// characters and carry a digit or a `/`, so a sentence that genuinely ends in a URL cannot
677
+ /// swallow the capitalised word that starts the next one.
678
+ fn url_wrap(head: &str, tail: &str) -> Option<String> {
679
+ let in_url = head.contains("://")
680
+ || head.starts_with("www.")
681
+ || head.starts_with("doi.org/")
682
+ || bare_doi_prefix(head);
683
+ if !in_url || !head.ends_with(['/', '-', '.', '_']) {
684
+ return None;
685
+ }
686
+ // A numbered bibliography's next entry starts with its own label — `15.`, `(15)`, `15)`,
687
+ // `[15]` or a bare `15` — and an entry often ends with a complete DOI and a final period,
688
+ // or a URL ending in `/`. That boundary looks exactly like a wrap (separator-ended head,
689
+ // digit-bearing tail) and must never be glued: one swallowed label collapses every entry
690
+ // after it. The one head that keeps its claim is a hyphen-ended one — an entry does not
691
+ // end mid-identifier at a hyphen, so `…08328-` + `5` is still the identifier's own tail.
692
+ if !head.ends_with('-') {
693
+ if label_shaped(tail) {
694
+ return None;
695
+ }
696
+ // A pure-digit tail after a final period is a year starting the next line, not a
697
+ // DOI fragment: every wrapped identifier observed continues with a slash, dot,
698
+ // hyphen or letter, never with bare digits alone.
699
+ if head.ends_with('.') && !tail.is_empty() && tail.bytes().all(|b| b.is_ascii_digit()) {
700
+ return None;
701
+ }
702
+ }
703
+ let urlish = tail
704
+ .chars()
705
+ .all(|c| c.is_ascii_alphanumeric() || "-._~/%#?=&:+();<>".contains(c));
706
+ let continues = tail.contains('/') || tail.chars().any(|c| c.is_ascii_digit());
707
+ (urlish && continues).then(|| format!("{head}{tail}"))
708
+ }
709
+
710
+ /// A reference label as bibliographies print them: one to three digits, bare, wrapped in
711
+ /// `()` or `[]`, or followed by `.`, `)` or `]`.
712
+ fn label_shaped(tail: &str) -> bool {
713
+ let digits = tail
714
+ .trim_start_matches(['(', '['])
715
+ .trim_end_matches(['.', ')', ']']);
716
+ !digits.is_empty() && digits.len() <= 3 && digits.bytes().all(|b| b.is_ascii_digit())
717
+ }
718
+
719
+ /// Whether `head` starts like a bare DOI: `10.` then at least four digits, with the
720
+ /// registrant's `/` already seen.
721
+ fn bare_doi_prefix(head: &str) -> bool {
722
+ head.strip_prefix("10.").is_some_and(|rest| {
723
+ rest.bytes().take_while(u8::is_ascii_digit).count() >= 4 && rest.contains('/')
724
+ })
725
+ }
726
+
662
727
  /// Whether a line runs to the block's right edge, within one character's slack.
663
728
  fn line_reaches(line: &Line, right_edge: f32, size: f32) -> bool {
664
729
  right_edge - line.bbox.x1 <= size
665
730
  }
666
731
 
732
+ /// Whether `left`'s last word and `right`'s first word are one URL wrapped across the break
733
+ /// between two *blocks* — the test [`join_lines`] applies between two lines of one block,
734
+ /// offered to the seam where a reference entry continues across a page or column break. The
735
+ /// guards are [`url_wrap`]'s own: the caller decides only that the two texts are already a
736
+ /// continuation, never that unrelated blocks should join.
737
+ pub(crate) fn url_wrap_at_seam(left: &str, right: &str) -> bool {
738
+ split_boundary(left, right)
739
+ .and_then(|(head, tail)| url_wrap(head, tail))
740
+ .is_some()
741
+ }
742
+
667
743
  /// The last word of `left` and the first word of `right`, if both exist.
668
744
  fn split_boundary<'a>(left: &'a str, right: &'a str) -> Option<(&'a str, &'a str)> {
669
745
  let head = left.rsplit(' ').next()?;
@@ -1235,6 +1311,73 @@ mod tests {
1235
1311
  assert_eq!(rejoin_across_break(&vocab, "learn-", "(ing)"), None);
1236
1312
  }
1237
1313
 
1314
+ #[test]
1315
+ fn a_url_broken_at_a_separator_rejoins_without_a_space() {
1316
+ // The shapes the pilot papers print: broken after the scheme, after a path slash,
1317
+ // after a dot inside the DOI, and at a hyphen that is part of the identifier.
1318
+ assert_eq!(
1319
+ url_wrap("https://", "doi.org/10.1038/s41467-019-08328-5"),
1320
+ Some("https://doi.org/10.1038/s41467-019-08328-5".into())
1321
+ );
1322
+ assert_eq!(
1323
+ url_wrap("https://doi.org/10.1186/", "s12974-021-02283-z."),
1324
+ Some("https://doi.org/10.1186/s12974-021-02283-z.".into())
1325
+ );
1326
+ assert_eq!(
1327
+ url_wrap("https://doi.org/10.", "1016/s0196-9781(02)00015-3"),
1328
+ Some("https://doi.org/10.1016/s0196-9781(02)00015-3".into())
1329
+ );
1330
+ assert_eq!(
1331
+ url_wrap("https://doi.org/10.1128/jb.181.21.6650-", "6655.1999"),
1332
+ Some("https://doi.org/10.1128/jb.181.21.6650-6655.1999".into())
1333
+ );
1334
+ // A bare DOI with no scheme still counts as being inside a URL.
1335
+ assert_eq!(
1336
+ url_wrap("10.1016/j.", "expneurol.2015.05.005"),
1337
+ Some("10.1016/j.expneurol.2015.05.005".into())
1338
+ );
1339
+ }
1340
+
1341
+ #[test]
1342
+ fn a_sentence_ending_in_a_url_keeps_the_next_word_separate() {
1343
+ // The tail is an ordinary word: no digit, no slash. The URL ended with the sentence.
1344
+ assert_eq!(url_wrap("https://example.com/", "The"), None);
1345
+ assert_eq!(url_wrap("https://example.com/survey.", "Respondents"), None);
1346
+ // The head is not inside a URL at all.
1347
+ assert_eq!(url_wrap("ratio.", "2020"), None);
1348
+ assert_eq!(url_wrap("learn-", "ing"), None);
1349
+ // The head ends mid-word, not at a separator a typesetter breaks after.
1350
+ assert_eq!(url_wrap("https://doi.org/10.1038/srep", "14206"), None);
1351
+ // An entry ending in a DOI and a period, with the next entry's label on the next
1352
+ // line: the label is a new entry, not the DOI's continuation, in every style a
1353
+ // bibliography prints one.
1354
+ let entry_end = "https://doi.org/10.1101/2020.04.02.022277.";
1355
+ for label in ["15.", "15", "15)", "(15)", "[15]", "15]", "7", "7."] {
1356
+ assert_eq!(url_wrap(entry_end, label), None, "glued label {label:?}");
1357
+ }
1358
+ // The same labels after an entry whose URL ends in a slash.
1359
+ for label in ["15.", "15", "15)", "(15)"] {
1360
+ assert_eq!(
1361
+ url_wrap("https://doi.org/10.1016/j.cell.2020.01.001/", label),
1362
+ None,
1363
+ "glued label {label:?} after a slash"
1364
+ );
1365
+ }
1366
+ // A year starting the next line after a sentence-final URL is prose, not a DOI tail.
1367
+ assert_eq!(url_wrap("https://example.com/survey.", "2010"), None);
1368
+ assert_eq!(url_wrap("https://example.com/.", "3"), None);
1369
+ }
1370
+
1371
+ #[test]
1372
+ fn a_hyphen_ended_head_keeps_its_claim_on_a_digit_tail() {
1373
+ // An entry does not end mid-identifier at a hyphen: `…08328-` + `5` is the
1374
+ // identifier's own tail even though `5` alone is label-shaped.
1375
+ assert_eq!(
1376
+ url_wrap("https://doi.org/10.1038/s41467-019-08328-", "5"),
1377
+ Some("https://doi.org/10.1038/s41467-019-08328-5".into())
1378
+ );
1379
+ }
1380
+
1238
1381
  #[test]
1239
1382
  fn consecutive_body_lines_form_one_paragraph() {
1240
1383
  let page = vec![
@@ -239,6 +239,11 @@ pub enum GlyphText {
239
239
  /// One rendered character.
240
240
  #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
241
241
  pub struct Glyph {
242
+ /// Pen advance from this glyph's origin to the next, in page units along the baseline —
243
+ /// the displacement the PDF interpreter applied after showing it, character and word
244
+ /// spacing included. `0.0` means the backend does not know it, and the ink box has to
245
+ /// stand in for the pen.
246
+ pub advance: f32,
242
247
  pub text: GlyphText,
243
248
  /// Tight glyph bounds — the inked extent, used for baseline and script detection.
244
249
  pub bbox: Rect,
@@ -431,6 +436,7 @@ mod tests {
431
436
  text,
432
437
  bbox: Rect::from_corners(0.0, 0.0, 1.0, 1.0),
433
438
  origin: Point::new(0.0, 0.0),
439
+ advance: 0.0,
434
440
  font: FontId(0),
435
441
  size: 10.0,
436
442
  angle: 0.0,
@@ -465,6 +471,7 @@ mod tests {
465
471
  text: GlyphText::Char('a'),
466
472
  bbox: Rect::from_corners(0.0, 0.0, 1.0, 1.0),
467
473
  origin: Point::new(0.0, 0.0),
474
+ advance: 0.0,
468
475
  font: FontId(0),
469
476
  size: 10.0,
470
477
  angle: 0.0,
@@ -665,6 +665,7 @@ mod tests {
665
665
  text: GlyphText::Char('x'),
666
666
  bbox: Rect::from_corners(x, baseline - 7.0, x + 5.0, baseline),
667
667
  origin: Point::new(x, baseline),
668
+ advance: 0.0,
668
669
  font: FontId(0),
669
670
  size: 10.0,
670
671
  angle: 0.0,
@@ -17,7 +17,7 @@
17
17
  //! that carries the whole document. See [`alternates`] for what stops that from being a licence
18
18
  //! to delete anything that repeats twice.
19
19
 
20
- use std::collections::HashMap;
20
+ use std::collections::{HashMap, HashSet};
21
21
 
22
22
  use crate::text::lines::Line;
23
23
 
@@ -92,6 +92,11 @@ pub fn strip(pages: &mut [Vec<Line>], page_heights: &[f32]) -> usize {
92
92
  .into_iter()
93
93
  .filter(|&at| alternates(at, total, &by_class))
94
94
  .collect();
95
+ let established: HashSet<&str> = seen
96
+ .iter()
97
+ .filter(|(_, &n)| n >= quorum)
98
+ .map(|(key, _)| key.as_str())
99
+ .collect();
95
100
 
96
101
  let mut removed = 0;
97
102
  for (index, (lines, &height)) in pages.iter_mut().zip(page_heights).enumerate() {
@@ -107,7 +112,7 @@ pub fn strip(pages: &mut [Vec<Line>], page_heights: &[f32]) -> usize {
107
112
  .get(&(at, parity))
108
113
  .and_then(|counts| counts.get(&key))
109
114
  .is_some_and(|&n| n >= class_quorum(class_size(total, parity)));
110
- let furniture = repeats || carries_its_class;
115
+ let furniture = repeats || carries_its_class || composed_of(&key, &established);
111
116
  removed += usize::from(furniture);
112
117
  !furniture
113
118
  });
@@ -116,6 +121,31 @@ pub fn strip(pages: &mut [Vec<Line>], page_heights: &[f32]) -> usize {
116
121
  removed
117
122
  }
118
123
 
124
+ /// Whether a signature is two or more quorum-clearing signatures run together.
125
+ ///
126
+ /// A running head set as two spans a fraction of a point apart builds as two lines on most pages
127
+ /// and as one merged line where the baselines coincide. The merged line's own signature then
128
+ /// appears only a handful of times — below any quorum — while each of its halves clears the
129
+ /// whole-document quorum comfortably. Signatures concatenate exactly, because whitespace is
130
+ /// dropped and case is folded, so the merged line can be recognised as the composition it is.
131
+ ///
132
+ /// At least two parts are required: a line matching a single furniture signature is already
133
+ /// covered by the quorum test, and demanding a genuine composition keeps this from being a
134
+ /// second, laxer spelling of it.
135
+ fn composed_of(sig: &str, established: &HashSet<&str>) -> bool {
136
+ fn splits(rest: &str, established: &HashSet<&str>, parts: usize) -> bool {
137
+ if rest.is_empty() {
138
+ return parts >= 2;
139
+ }
140
+ established.iter().any(|head| {
141
+ !head.is_empty()
142
+ && rest.starts_with(head)
143
+ && splits(&rest[head.len()..], established, parts + 1)
144
+ })
145
+ }
146
+ splits(sig, established, 0)
147
+ }
148
+
119
149
  /// Whether the head at this margin alternates between recto and verso pages.
120
150
  ///
121
151
  /// This is the guard on the parity count, and it is what keeps a section heading that happens to
@@ -460,6 +490,54 @@ mod tests {
460
490
  );
461
491
  }
462
492
 
493
+ /// A head set as two spans half a point apart in baseline: line building keeps them apart on
494
+ /// most pages and merges them where the baselines coincide. The merged line's signature is
495
+ /// seen nowhere else, but it is exactly the concatenation of two signatures that each clear
496
+ /// the quorum, and it is as much furniture as its halves.
497
+ #[test]
498
+ fn a_merged_pair_of_furniture_lines_is_furniture() {
499
+ let mut pages: Vec<Vec<Line>> = (0..8)
500
+ .map(|i| {
501
+ let mut page = vec![line(&format!("body of page {}", i + 1), 300.0)];
502
+ if i == 5 {
503
+ page.push(line(
504
+ "Hamoud et al. Journal (2024) 22:287 Page 6 of 17",
505
+ 40.0,
506
+ ));
507
+ } else {
508
+ page.push(line("Hamoud et al. Journal (2024) 22:287", 40.0));
509
+ page.push(line(&format!("Page {} of 17", i + 1), 40.5));
510
+ }
511
+ page
512
+ })
513
+ .collect();
514
+ let heights = vec![HEIGHT; 8];
515
+ strip(&mut pages, &heights);
516
+ assert!(
517
+ pages.iter().all(|p| p.len() == 1),
518
+ "the merged head is two furniture signatures run together, in {:?}",
519
+ pages
520
+ .iter()
521
+ .flatten()
522
+ .map(Line::text)
523
+ .collect::<Vec<String>>()
524
+ );
525
+ }
526
+
527
+ /// The composition test must not become a laxer spelling of the quorum: a margin line whose
528
+ /// signature matches a single furniture signature with something else appended is not a
529
+ /// concatenation of two established heads, and it stays.
530
+ #[test]
531
+ fn a_furniture_prefix_alone_does_not_compose() {
532
+ let (mut pages, heights) = document(8);
533
+ pages[0].push(line("Preprint. Under review. Comments welcome", 55.0));
534
+ strip(&mut pages, &heights);
535
+ assert!(
536
+ pages[0].iter().any(|l| l.text().ends_with("welcome")),
537
+ "one established head plus a one-off tail is not a composition"
538
+ );
539
+ }
540
+
463
541
  #[test]
464
542
  fn class_sizes_partition_the_document() {
465
543
  for total in 0..12 {
@@ -456,6 +456,7 @@ mod tests {
456
456
  text: GlyphText::Char(c),
457
457
  bbox: Rect::from_corners(x, baseline - size * 0.7, x + w, baseline),
458
458
  origin: Point::new(x, baseline),
459
+ advance: 0.0,
459
460
  font: FontId(0),
460
461
  size,
461
462
  angle: 0.0,
@@ -638,6 +638,7 @@ mod tests {
638
638
  text: GlyphText::Char(c),
639
639
  bbox: Rect::from_corners(x, baseline - size * 0.7, x + w, baseline),
640
640
  origin: Point::new(x, baseline),
641
+ advance: 0.0,
641
642
  font: FontId(font),
642
643
  size,
643
644
  angle: 0.0,
@@ -443,7 +443,13 @@ fn extract_bibliography(document: &mut doc::Document) {
443
443
  } else {
444
444
  &block.text
445
445
  };
446
- if !joined.is_empty() {
446
+ // A reference entry wraps its URL at the page or column break that split these
447
+ // blocks exactly as it wraps at a line break inside one — radiofrequency's
448
+ // `…10.3390/` ends one page and `ijerph192316150` opens the next. These blocks are
449
+ // already one bibliography being rejoined, so the seam gets the same test a line
450
+ // break gets, and the space is withheld only where its guards say the identifier
451
+ // continues.
452
+ if !joined.is_empty() && !doc::url_wrap_at_seam(&joined, body) {
447
453
  joined.push(' ');
448
454
  }
449
455
  origins.push((joined.len(), block));
@@ -654,10 +654,24 @@ fn leading_label(text: &str) -> Option<(String, &str)> {
654
654
  /// publication year sits at the end in most styles. Two runs of four digits are ruled out first,
655
655
  /// because in the styles that put the year early the *last* number in the entry is one of them
656
656
  /// and the year is not — see [`in_a_page_range`] and [`arxiv_span`].
657
+ ///
658
+ /// Two refinements of "the last one", both learned from entries where the last plausible year is
659
+ /// not the year at all. Digits inside the DOI are stepped over the way the arXiv identifier is:
660
+ /// `10.1016/j.sjbs.2014.07.006` embeds the year an identifier was minted, not the year printed on
661
+ /// the entry. And a year the style *marks* — `(2022)` in author–year styles, `2022;` opening a
662
+ /// Vancouver volume — beats an unmarked one, because a volume or article number can sit in the
663
+ /// year band (`2021;1865(12):130014` is volume 1865) while a marked year is the style saying
664
+ /// which number is the date.
665
+ ///
666
+ /// Among marked years the *first* wins, unlike among plain ones. The hazard runs the other way:
667
+ /// material swept into the entry's tail — a running header, the opening of the next entry at a
668
+ /// column seam — arrives marked, while the entry's own marked year opens the citation.
657
669
  fn find_year(text: &str) -> Option<u16> {
658
670
  let chars: Vec<char> = text.chars().collect();
659
671
  let identifier = arxiv_span(&chars);
672
+ let doi = doi_char_span(text);
660
673
  let mut found = None;
674
+ let mut marked = None;
661
675
  for i in 0..chars.len().saturating_sub(3) {
662
676
  if !chars[i].is_ascii_digit() {
663
677
  continue;
@@ -675,16 +689,39 @@ fn find_year(text: &str) -> Option<u16> {
675
689
  if identifier.as_ref().is_some_and(|span| span.contains(&i)) {
676
690
  continue;
677
691
  }
692
+ if doi.as_ref().is_some_and(|span| span.contains(&i)) {
693
+ continue;
694
+ }
678
695
  if in_a_page_range(&chars, i) {
679
696
  continue;
680
697
  }
681
698
  if let Ok(year) = window.parse::<u16>() {
682
699
  if (1800..=2100).contains(&year) {
700
+ // `(2022)` and `(2022a)` are marked; so is the `2022;` that opens a Vancouver
701
+ // volume. `22(4)` is not: the parenthesis there encloses the issue, not the year,
702
+ // and an issue number never opens with one.
703
+ let in_parens = i > 0
704
+ && chars[i - 1] == '('
705
+ && match chars.get(i + 4) {
706
+ Some(')') => true,
707
+ Some(c) if c.is_ascii_lowercase() => chars.get(i + 5) == Some(&')'),
708
+ _ => false,
709
+ };
710
+ if in_parens || chars.get(i + 4) == Some(&';') {
711
+ marked.get_or_insert(year);
712
+ }
683
713
  found = Some(year);
684
714
  }
685
715
  }
686
716
  }
687
- found
717
+ marked.or(found)
718
+ }
719
+
720
+ /// Where the DOI sits, as a span of character indices, so the year search can step over it.
721
+ fn doi_char_span(text: &str) -> Option<std::ops::Range<usize>> {
722
+ let doi = find_doi(text)?;
723
+ let start = text[..text.find(&doi)?].chars().count();
724
+ Some(start..start + doi.chars().count())
688
725
  }
689
726
 
690
727
  /// Whether the four digits at `at` are one end of a page range.
@@ -1316,6 +1353,56 @@ mod tests {
1316
1353
  );
1317
1354
  }
1318
1355
 
1356
+ /// A DOI embeds the year an identifier was minted, which can differ from the printed year.
1357
+ #[test]
1358
+ fn a_year_inside_a_doi_is_not_the_year() {
1359
+ assert_eq!(
1360
+ parse("Alkhalaf S. A title. Saudi J Biol Sci. 2015;22(4):465-9. 10.1016/j.sjbs.2014.07.006")
1361
+ .year,
1362
+ Some(2015)
1363
+ );
1364
+ }
1365
+
1366
+ /// Vancouver style reads `Journal. YYYY;volume(issue):pages`, and both the volume and an
1367
+ /// article number can land in the band a year occupies.
1368
+ #[test]
1369
+ fn a_volume_or_article_number_does_not_beat_a_marked_year() {
1370
+ assert_eq!(
1371
+ parse("Berger B. A title. Biochim Biophys Acta Gen Subj. 2021;1865(12):130014.").year,
1372
+ Some(2021)
1373
+ );
1374
+ // An article number is the worse trap: no dash, no parenthesis, just four digits last.
1375
+ assert_eq!(
1376
+ parse("Hu H, et al. A title. Int J Mol Sci. 2021;22(4):1831.").year,
1377
+ Some(2021)
1378
+ );
1379
+ }
1380
+
1381
+ /// Author–year styles mark the year with parentheses, possibly with a disambiguating letter.
1382
+ #[test]
1383
+ fn a_parenthesised_year_beats_a_later_plain_one() {
1384
+ assert_eq!(
1385
+ parse("Kucukguven A, et al. (2022) A title. Ann Anat 240:151882, vol 2023.").year,
1386
+ Some(2022)
1387
+ );
1388
+ assert_eq!(
1389
+ parse("Smith J (2019a) A title. Journal 12, 2021.").year,
1390
+ Some(2019)
1391
+ );
1392
+ }
1393
+
1394
+ /// Among marked years the first wins: what an entry sweeps up at its tail — a running header,
1395
+ /// the next entry's opening at a column seam — arrives marked too, and the entry's own marked
1396
+ /// year comes first.
1397
+ #[test]
1398
+ fn the_first_marked_year_beats_a_later_one() {
1399
+ assert_eq!(
1400
+ parse("Thul PJ, et al. A map. Science. 2017;356(6340):eaal3321. Hamoud et al. Journal (2024) 22:501")
1401
+ .year,
1402
+ Some(2017)
1403
+ );
1404
+ }
1405
+
1319
1406
  #[test]
1320
1407
  fn finds_dois() {
1321
1408
  let parsed = parse("[1] A. B. A title. Journal, 2016. doi:10.1145/3292500.3330701");