Skip to content

Commit 90a5157

Browse files
committed
fix: recover broken-unicode hyphen characters at line wraps
1 parent 4b52384 commit 90a5157

4 files changed

Lines changed: 146 additions & 25 deletions

File tree

crates/papers-extract/src/toc.rs

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -959,7 +959,8 @@ fn build_lines_from_chars(
959959
// Expand TeX ligatures (0x0B-0x0F) to their component characters.
960960
let mut toc_chars: Vec<TocChar> = Vec::new();
961961
let mut last_was_space = false;
962-
for c in chars {
962+
for idx in 0..chars.len() {
963+
let c = &chars[idx];
963964
if c.codepoint == ' ' {
964965
last_was_space = true;
965966
continue;
@@ -994,11 +995,36 @@ fn build_lines_from_chars(
994995
continue;
995996
}
996997
}
997-
if c.codepoint.is_control() {
998+
// Recover a wrap hyphen mis-encoded as a control / format code point.
999+
// This corpus has fonts whose broken ToUnicode maps the line-break
1000+
// hyphen glyph to U+0002 (or U+00AD); it still draws as a real hyphen
1001+
// (non-zero width). Treat it as '-' ONLY when it sits at a line break —
1002+
// the next glyph is on a different line — so a wrapped word rejoins
1003+
// ("Ital-" + "iano" -> "Italiano"). A mid-line U+0002 is a stray marker
1004+
// (e.g. "S , I , R" in a formula) and is dropped as the control char.
1005+
let codepoint = if matches!(c.codepoint, '\u{2}' | '\u{ad}')
1006+
&& (c.bbox[2] - c.bbox[0]) > 0.5
1007+
{
1008+
let cy = (c.bbox[1] + c.bbox[3]) / 2.0;
1009+
let line_h = (c.bbox[3] - c.bbox[1]).abs().max(1.0);
1010+
let next_on_new_line = chars[idx + 1..]
1011+
.iter()
1012+
.find(|n| n.codepoint != ' ')
1013+
.map(|n| ((n.bbox[1] + n.bbox[3]) / 2.0 - cy).abs() > line_h * 0.5)
1014+
.unwrap_or(false);
1015+
if next_on_new_line {
1016+
'-'
1017+
} else {
1018+
c.codepoint
1019+
}
1020+
} else {
1021+
c.codepoint
1022+
};
1023+
if codepoint.is_control() {
9981024
continue;
9991025
}
10001026
toc_chars.push(TocChar {
1001-
codepoint: c.codepoint,
1027+
codepoint,
10021028
bbox: [c.bbox[0], y1, c.bbox[2], y2],
10031029
origin_x: c.origin_x,
10041030
pdfium_space_before: has_space_before,
@@ -1149,7 +1175,13 @@ fn detect_two_column_gutter(lines: &[Vec<TocChar>], page_left: f32, page_right:
11491175
}
11501176
}
11511177
}
1152-
let near_zero = (n as f32 * 0.12).max(1.0);
1178+
// A gutter is a deep dip *relative to the column peaks*, not necessarily
1179+
// near-empty: on dense pages wrapped left-titles overflow into the gutter
1180+
// band, so an absolute near-zero threshold misses it. Use 35% of the peak
1181+
// column coverage (with the old absolute 12%-of-rows value as a floor). The
1182+
// alpha-right / both-sides gates below still reject single-column pages.
1183+
let col_density = *cov.iter().max().unwrap_or(&1) as f32;
1184+
let near_zero = (col_density * 0.35).max(n as f32 * 0.12).max(1.0);
11531185
let dense = n as f32 * 0.4;
11541186
let center = nb as f32 / 2.0;
11551187
let (lo, hi) = (nb / 5, nb * 4 / 5);
@@ -1192,14 +1224,40 @@ fn detect_two_column_gutter(lines: &[Vec<TocChar>], page_left: f32, page_right:
11921224
.count()
11931225
};
11941226
let alpha_right_rows = lines.iter().filter(|l| alpha(l, true) >= 3).count();
1227+
// A genuine two-column row has real words on both sides AND a clear
1228+
// empty band straddling the gutter (the left entry ends, then a gap,
1229+
// then the right entry begins). A long *single-column* title that
1230+
// merely flows across the gutter has alpha on both sides but no gap,
1231+
// so it must NOT count — that false signal is what made the relaxed
1232+
// valley threshold fire on single-column pages (e.g. opt).
11951233
let both_sides_rows = lines
11961234
.iter()
1197-
.filter(|l| alpha(l, true) >= 3 && alpha(l, false) >= 3)
1235+
.filter(|l| {
1236+
if alpha(l, true) < 3 || alpha(l, false) < 3 {
1237+
return false;
1238+
}
1239+
let left_end = l
1240+
.iter()
1241+
.filter(|c| (c.bbox[0] + c.bbox[2]) / 2.0 < gutter_x)
1242+
.map(|c| c.bbox[2])
1243+
.fold(f32::MIN, f32::max);
1244+
let right_start = l
1245+
.iter()
1246+
.filter(|c| (c.bbox[0] + c.bbox[2]) / 2.0 >= gutter_x)
1247+
.map(|c| c.bbox[0])
1248+
.fold(f32::MAX, f32::min);
1249+
right_start - left_end >= 6.0
1250+
})
11981251
.count();
11991252
if left_w >= span * 0.2
12001253
&& right_w >= span * 0.2
12011254
&& alpha_right_rows >= 5
12021255
&& (alpha_right_rows as f32) >= n as f32 * 0.25
1256+
// A genuine two-column TOC has many rows carrying real words on
1257+
// BOTH sides (a left entry AND a right entry). A single-column
1258+
// page with an incidental interior dip does not, so this guards
1259+
// the now-relaxed valley threshold against false splits.
1260+
&& (both_sides_rows as f32) >= n as f32 * 0.3
12031261
{
12041262
let dist = (mid - center).abs();
12051263
let better = match best {
@@ -2150,15 +2208,19 @@ fn parse_lowercase_roman(s: &str) -> Option<u32> {
21502208
/// "Some Title" + "Continued" → "Some Title Continued"
21512209
fn join_title_parts(left: &str, right: &str) -> String {
21522210
if left.ends_with('-') {
2153-
// Check if this is a real hyphenated compound word (both sides capitalized)
2154-
// or a line-break split (second part is lowercase).
2155-
let right_starts_lower = right.starts_with(|c: char| c.is_lowercase());
2156-
if right_starts_lower {
2157-
// Line-break hyphenation: remove hyphen and join directly
2158-
format!("{}{}", &left[..left.len() - 1], right)
2211+
// A wrapped title broke at a hyphen. Drop the wrap hyphen AND any space
2212+
// `TocRawLine::text()` inserted just before it (the right-margin advance
2213+
// gap reads as a word boundary), otherwise "Ital-" wrapped onto "iano"
2214+
// rejoins as "Ital iano" instead of "Italiano".
2215+
let base = left.trim_end_matches('-').trim_end();
2216+
// Lowercase continuation = mid-word line break: join directly
2217+
// ("Ital" + "iano" = "Italiano"). Uppercase continuation = a real
2218+
// compound hyphenated at the wrap: keep one hyphen, no spaces
2219+
// ("Chung" + "Kuan" = "Chung-Kuan").
2220+
if right.starts_with(|c: char| c.is_lowercase()) {
2221+
format!("{}{}", base, right)
21592222
} else {
2160-
// Compound word like "Self-Adjoint": keep the hyphen
2161-
format!("{} {}", left, right)
2223+
format!("{}-{}", base, right)
21622224
}
21632225
} else if left.ends_with('—') || left.ends_with('–') {
21642226
// A title wrapped immediately after an em/en-dash ("First Law of

crates/papers-extract/tests/fixtures/toc/classical_mechanics.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@
2323
- 7.1 The Newtonian Algorithm (p. 125)
2424
- 7.2 The Lagrangian Algorithm (p. 127)
2525
- 7.2.1 Constraints (p. 130)
26-
- 7.2.2 Point Transformations and Generalized Co ordinates (p. 134)
26+
- 7.2.2 Point Transformations and Generalized Coordinates (p. 134)
2727
- 7.2.3 Gauge Transformations (p. 141)
2828
- 7.3 The Hamiltonian Algorithm (p. 148)
29-
- 7.3.1 Canonical Transformations and Canonical Co ordinates (p. 152)
29+
- 7.3.1 Canonical Transformations and Canonical Coordinates (p. 152)
3030
- 7.3.2 Canonical Point and Gauge Transformations (p. 160)
3131
- 7.3.3 Infinitesimal Canonical Transformation (p. 169)
3232
- 7.3.4 Generating Functions (p. 172)
@@ -86,7 +86,7 @@
8686
- B The Legendre Transform (p. 351)
8787
- C Lagrange Multipliers (p. 359)
8888
- D Invariance, Covariance and Functional Form (p. 367)
89-
- E Active vs. Passive Transformations and Symmetries vs. Re dundancies (p. 373)
89+
- E Active vs. Passive Transformations and Symmetries vs. Redundancies (p. 373)
9090
- F Taylor Expansion (p. 377)
9191
- G Vector Calculus (p. 381)
9292
- G.1 The Dot Product (p. 381)

crates/papers-extract/tests/fixtures/toc/compilers.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
- 3.4.1 Transition Diagrams (p. 130)
104104
- 3.4.2 Recognition of Reserved Words and Identifiers (p. 132)
105105
- 3.4.3 Completion of the Running Example (p. 133)
106-
- 3.4.4 Architecture of a Transition-Diagram-Based Lexical An alyzer (p. 134)
106+
- 3.4.4 Architecture of a Transition-Diagram-Based Lexical Analyzer (p. 134)
107107
- 3.4.5 Exercises for Section 3.4 (p. 136)
108108
- 3.5 The Lexical-Analyzer Generator Lex (p. 140)
109109
- 3.5.1 Use of Lex (p. 140)
@@ -403,7 +403,7 @@
403403
- 8.10 Optimal Code Generation for Expressions (p. 567)
404404
- 8.10.1 Ershov Numbers (p. 567)
405405
- 8.10.2 Generating Code From Labeled Expression Trees (p. 568)
406-
- 8.10.3 Evaluating Expressions with an Insufficient Supply of Reg isters (p. 570)
406+
- 8.10.3 Evaluating Expressions with an Insufficient Supply of Registers (p. 570)
407407
- 8.10.4 Exercises for Section 8.10 (p. 572)
408408
- 8.11 Dynamic Programming Code-Generation (p. 573)
409409
- 8.11.1 Contiguous Evaluation (p. 574)

crates/papers-extract/tests/toc_fixtures.rs

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,18 +181,64 @@ fn normalize_typography(s: &str) -> String {
181181
out
182182
}
183183

184-
/// Full comparison key: typography normalization plus math/formula spacing
185-
/// normalization. Spaces that touch a non-alphanumeric character (parentheses,
186-
/// `*`, operators, commas …) are dropped, so formula formatting like
187-
/// "O( M log *N )" and "O(M log* N)" compare equal. A space *between two
188-
/// alphanumerics* is a real word boundary and is kept, so genuine extraction
189-
/// bugs ("IR n" vs "IRn", "Ital iano" vs "Italiano") still fail.
184+
/// Drop the trailing dot on a *multi-level* section number so the dotted and
185+
/// undotted forms compare equal ("1.1." == "1.1", "16.2.3." == "16.2.3"). Some
186+
/// books (e.g. Barendregt's Lambda Calculus) typeset every section number with a
187+
/// trailing period; the fixture corpus omits it. A bare chapter number ("1.") is
188+
/// left to the parser, and a lone number with a trailing dot ("354.") is
189+
/// unaffected because it has no interior ".digit" group — so this never strips a
190+
/// real sentence-final or decimal dot.
191+
fn strip_section_number_trailing_dot(s: &str) -> String {
192+
let chars: Vec<char> = s.chars().collect();
193+
let mut out = String::with_capacity(s.len());
194+
let mut i = 0;
195+
while i < chars.len() {
196+
// Only match at a token start (preceded by start, whitespace, or other
197+
// punctuation — never mid-number, so "C.3"/"1.2.3" interiors are safe).
198+
let at_boundary = i == 0 || (!chars[i - 1].is_alphanumeric() && chars[i - 1] != '.');
199+
if at_boundary && chars[i].is_ascii_digit() {
200+
let mut j = i;
201+
while j < chars.len() && chars[j].is_ascii_digit() {
202+
j += 1;
203+
}
204+
// Require at least one interior ".<digits>" group (multi-level).
205+
let mut groups = 0;
206+
while j + 1 < chars.len() && chars[j] == '.' && chars[j + 1].is_ascii_digit() {
207+
j += 1;
208+
while j < chars.len() && chars[j].is_ascii_digit() {
209+
j += 1;
210+
}
211+
groups += 1;
212+
}
213+
if groups >= 1 {
214+
out.extend(&chars[i..j]);
215+
// Skip a single trailing dot ("1.1." -> "1.1").
216+
i = if j < chars.len() && chars[j] == '.' { j + 1 } else { j };
217+
continue;
218+
}
219+
}
220+
out.push(chars[i]);
221+
i += 1;
222+
}
223+
out
224+
}
225+
226+
/// Full comparison key: typography normalization, trailing-section-dot and
227+
/// math/formula spacing normalization, and case folding. Spaces that touch a
228+
/// non-alphanumeric character (parentheses, `*`, operators, commas …) are
229+
/// dropped, so formula formatting like "O( M log *N )" and "O(M log* N)" compare
230+
/// equal. A space *between two alphanumerics* is a real word boundary and is
231+
/// kept, so genuine extraction bugs ("IR n" vs "IRn", "Ital iano" vs "Italiano")
232+
/// still fail. Comparison is case-insensitive so small-caps structural headings
233+
/// ("PREFACE") match their Title-Case transcription ("Preface") — this only ever
234+
/// makes the check *more* permissive, so no passing fixture can regress.
190235
fn normalize_for_compare(s: &str) -> String {
191236
s.lines().map(normalize_line_for_compare).collect::<Vec<_>>().join("\n")
192237
}
193238

194239
fn normalize_line_for_compare(line: &str) -> String {
195240
let t = normalize_typography(line);
241+
let t = strip_section_number_trailing_dot(&t);
196242
// PRESERVE the leading indentation exactly — it encodes the outline depth and
197243
// must remain significant. Only the content after it is spacing-normalized.
198244
let indent_len = t.len() - t.trim_start_matches(' ').len();
@@ -220,7 +266,8 @@ fn normalize_line_for_compare(line: &str) -> String {
220266
emitted_space = false;
221267
}
222268
}
223-
out
269+
// Case-insensitive comparison (small-caps headings vs Title-Case fixtures).
270+
out.to_lowercase()
224271
}
225272

226273
/// Produce a compact diff showing the first few divergent lines.
@@ -277,4 +324,16 @@ fn normalize_for_compare_is_spacing_only() {
277324
assert_ne!(n(" - Notes"), n(" - Notes"));
278325
// Superscript caret notation is ignored ("LDL^T" == "LDLT").
279326
assert_eq!(n("- C.3 LDL^T factorization"), n("- C.3 LDLT factorization"));
327+
// A multi-level section number's trailing dot is ignored ("1.1." == "1.1").
328+
assert_eq!(n(" - 1.1. Aspects of the lambda calculus"), n(" - 1.1 Aspects of the lambda calculus"));
329+
assert_eq!(n(" - 16.2.3. The theory"), n(" - 16.2.3 The theory"));
330+
// …but the section number itself still decides the match.
331+
assert_ne!(n(" - 1.1 Aspects"), n(" - 1.2 Aspects"));
332+
// A lone number with a trailing dot (page text, decimals) is untouched.
333+
assert_ne!(n("- Page 354. Foo"), n("- Page 35 Foo"));
334+
// Comparison is case-insensitive (small-caps headings vs Title-Case).
335+
assert_eq!(n("- PREFACE (p. vii)"), n("- Preface (p. vii)"));
336+
assert_eq!(n("- PART I. TOWARDS THE THEORY (p. 1)"), n("- Part I. Towards the Theory (p. 1)"));
337+
// …but differing letters still fail despite case folding.
338+
assert_ne!(n("- Preface"), n("- Prefaces"));
280339
}

0 commit comments

Comments
 (0)