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.
- {rustypaper-0.2.3 → rustypaper-0.2.4}/Cargo.lock +2 -2
- {rustypaper-0.2.3 → rustypaper-0.2.4}/Cargo.toml +2 -2
- {rustypaper-0.2.3 → rustypaper-0.2.4}/PKG-INFO +1 -1
- {rustypaper-0.2.3 → rustypaper-0.2.4}/pyproject.toml +1 -1
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/backend/rustium.rs +3 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/doc.rs +148 -5
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/ir.rs +7 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/columns.rs +1 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/furniture.rs +80 -2
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/math/build.rs +1 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/math/mod.rs +1 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/pipeline.rs +7 -1
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/refs.rs +88 -1
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/text/lines.rs +141 -11
- rustypaper-0.2.4/rustypaper/tests/pengap.rs +111 -0
- rustypaper-0.2.4/rustypaper/tests/urlwrap.rs +327 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/README.md +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/Cargo.toml +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/README.md +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/__init__.py +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/__main__.py +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/cli.py +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/examples/ingest_share.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/backend/mod.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/cli.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/compress.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/emit/markdown.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/emit/mod.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/emit/text.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/emit/typst.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/error.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/figure.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/mod.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/order.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/layout/stats.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/lib.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/main.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/math/latex.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/math/symbols.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/table.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/text/mod.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/text/vocab.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/src/util.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/tests/actualtext.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/tests/cli.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper/tests/robustness.rs +0 -0
- {rustypaper-0.2.3 → rustypaper-0.2.4}/rustypaper-py/Cargo.toml +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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,
|
|
@@ -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 {
|
|
@@ -443,7 +443,13 @@ fn extract_bibliography(document: &mut doc::Document) {
|
|
|
443
443
|
} else {
|
|
444
444
|
&block.text
|
|
445
445
|
};
|
|
446
|
-
|
|
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");
|