Skip to content

Commit 1f28c61

Browse files
committed
fix: algorithm handling
1 parent 68bc962 commit 1f28c61

5 files changed

Lines changed: 484 additions & 67 deletions

File tree

crates/papers-extract/EXTRACTION.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,90 @@ Then:
9797
| `output.rs` | `dedup_heading_echo()` | Both (runs in reflow) |
9898
| `output.rs` | `dedup_consecutive_text()` | Both (runs in reflow) |
9999

100+
## Text-Only Detection Logic
101+
102+
### Font-based heading detection
103+
The **font is the definitive signal** for headings. `partition_heading_chars()`
104+
extracts chars matching the heading font family (e.g., LinBiolinum vs body
105+
font LinLibertine). Heading chars are validated against `extract_headings()`
106+
results. Known headings take **priority over formula zones** — a heading at
107+
a Y-position overlapping a formula zone is kept because font overrides
108+
geometric heuristics.
109+
110+
Body blocks should NEVER be classified as headings when font-based detection
111+
is active (`has_font_headings` flag). Multi-line body blocks that happen to
112+
start with heading text are not headings — the font is the tell.
113+
114+
### Formula detection
115+
Two detection paths at the line level:
116+
1. **Content-based** (`is_likely_formula_text()`) — operators, math symbols,
117+
prose word rejection, `$...$` marker density
118+
2. **Font-based** — ≥3 math italic Unicode chars (U+1D400-1D7FF) with no
119+
prose words. These chars ARE the font signal.
120+
121+
Formula detection is **suppressed in algorithm zones** (see below).
122+
123+
**Key issues encountered:**
124+
- `$`/`{`/`}`/`_`/`^` markers from `extract_region_text()` inflate char
125+
counts and dilute math_ratio. Use `content_total` (excluding these
126+
formatting chars) for ratio calculations.
127+
- "fi"/"fifi" ligature artifacts from PDF absolute value bars `|...|` look
128+
like prose words. Excluded via `is_ligature_artifact()`.
129+
- Pseudocode keywords ("if", "for") should only reject formulas when at the
130+
START of a line (pseudocode), not mid-line (piecewise formula conditions
131+
like "value, if condition").
132+
- No upper char limit on `is_likely_formula_text()` — piecewise formulas
133+
and multi-line formulas can be very long.
134+
135+
### Algorithm detection
136+
Algorithm zones are detected from **line numbers** ("1:", "15:", "37:") — an
137+
unambiguous structural signal that display formulas never have. When ≥2
138+
numbered lines exist in a column, their Y-range defines an algorithm zone.
139+
140+
Inside algorithm zones:
141+
- **Formula detection suppressed** — pseudocode with math variables is not
142+
a display formula
143+
- **All heuristic breaks suppressed** (y_break, x_break, font_break) —
144+
algorithm pseudocode has subscript fragments, varying indentation, and
145+
font size changes that would fragment the block
146+
147+
Algorithm caption splitting: when a block starts with "Algorithm N *title*"
148+
and contains numbered lines, the caption is split into a separate
149+
`FigureTitle` region and the body becomes an `Algorithm` region.
150+
151+
In the reflow stage, `Algorithm` nodes are **never demoted to Text**.
152+
They either stay as `Algorithm` (pseudocode) or get promoted to `CodeBlock`
153+
(actual programming code). `Algorithm` renders as plain text (not fenced
154+
code blocks) because algorithms can contain `$...$` LaTeX math.
155+
156+
### Subscript / superscript handling
157+
**Fundamental issue**: PDF text layer chars in math expressions span
158+
multiple Y positions (subscripts, superscripts, fraction numerators and
159+
denominators). `group_into_lines()` uses Y-proximity to group chars into
160+
lines.
161+
162+
**Current approach**: bbox-based line grouping. A char belongs to the current
163+
line if its Y-center falls within the line's Y bounding box (expanded by
164+
`avg_height * 0.3` padding). This handles normal subscripts (3-4pt offset)
165+
but NOT fraction numerators/denominators (6-8pt offset) because they would
166+
merge actual separate prose lines.
167+
168+
**Remaining limitation**: fraction parts (`1/Δt²`) still fragment into
169+
separate lines in the text-only path. The ML layout path handles this via
170+
OCR (GLM-OCR produces per-line LaTeX). Fixing this in the text-only path
171+
requires X-proximity-aware grouping — chars at different Y but overlapping
172+
X are part of the same expression. This is a known TODO.
173+
174+
**Fragment break suppression**: tiny lines (≤3 chars) never cause heuristic
175+
breaks (y_break, x_break, font_break) because they're subscript/superscript
176+
fragments attached to adjacent content, not separate blocks.
177+
178+
### Overlapping formula deduplication
179+
`dedup_overlapping_formulas()` **merges** (not picks-one) overlapping formula
180+
regions. Requires both vertical AND horizontal overlap to prevent merging
181+
formulas across columns. This handles cases where loose_bounds inflate
182+
formula bboxes into overlapping territory.
183+
100184
## Known Limitations
101185

102186
### Text-only path
@@ -105,6 +189,7 @@ Then:
105189
- **Missing body content**: Some PDFs (computer_systems, programming_massively_parallel) have body pages that extract to empty.
106190
- **Font mapping failures**: lambda book — TeX fonts lack Unicode mappings.
107191
- **Margin notes**: Interleaved with body text in some books (EDO, fluids).
192+
- **Fraction fragmentation**: Math fractions (`1/Δt²`) produce separate lines for numerator, bar, and denominator in `group_into_lines()`. No Y-threshold alone can merge them without also merging real separate prose lines. Needs X-proximity-aware grouping.
108193

109194
### Layout path
110195
- Requires GPU for layout detection, formula OCR, and table OCR.

crates/papers-extract/src/output.rs

Lines changed: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3424,35 +3424,41 @@ fn detect_code_blocks(flat_nodes: &mut Vec<ReflowNode>) {
34243424
}
34253425
}
34263426
ReflowNode::Algorithm { content } => {
3427-
// Algorithm regions can be: real pseudocode, programming code, or
3428-
// misclassified prose. Check structural signals to decide.
34293427
let is_pseudocode = looks_like_pseudocode(content);
3430-
if is_pseudocode || looks_like_code(content) || code_score(content) >= 2 {
3431-
// Check if it's actually prose with math — full sentences without
3432-
// algorithmic structure shouldn't be code blocks
3433-
let has_math = content.matches('$').count() >= 2
3434-
|| content.contains("[[FORMULA")
3435-
|| count_italic_spans(content) >= 2;
3436-
if has_math && !is_pseudocode && !looks_like_code(content) {
3437-
// Math-heavy prose misclassified as Algorithm
3438-
*node = ReflowNode::Text {
3439-
content: content.replace('\n', " "),
3440-
footnotes: Vec::new(),
3441-
};
3442-
} else {
3443-
let trimmed = trim_trailing_prose(content);
3444-
let lang = if is_pseudocode {
3445-
None
3446-
} else {
3447-
guess_language(&trimmed)
3448-
};
3449-
*node = ReflowNode::CodeBlock {
3450-
content: trimmed,
3451-
language: lang,
3452-
};
3453-
}
3428+
let is_code = looks_like_code(content);
3429+
let has_line_nums = crate::text_cleanup::has_algorithm_line_number(content);
3430+
let has_math = content.matches('$').count() >= 2
3431+
|| content.contains("[[FORMULA")
3432+
|| count_italic_spans(content) >= 2;
3433+
let trimmed = trim_trailing_prose(content);
3434+
let lang = guess_language(&trimmed);
3435+
3436+
if lang.is_some() {
3437+
// Recognized programming language → CodeBlock
3438+
*node = ReflowNode::CodeBlock {
3439+
content: trimmed,
3440+
language: lang,
3441+
};
3442+
} else if is_pseudocode || has_line_nums {
3443+
// Pseudocode or has line numbers → keep as Algorithm
3444+
*node = ReflowNode::Algorithm {
3445+
content: trimmed,
3446+
};
3447+
} else if has_math && !is_code {
3448+
// Math-heavy content without code signals — ML model
3449+
// misclassified prose as Algorithm. Demote to Text.
3450+
*node = ReflowNode::Text {
3451+
content: content.replace('\n', " "),
3452+
footnotes: Vec::new(),
3453+
};
3454+
} else if is_code || code_score(content) >= 2 {
3455+
// Code without recognized language → CodeBlock
3456+
*node = ReflowNode::CodeBlock {
3457+
content: trimmed,
3458+
language: None,
3459+
};
34543460
} else {
3455-
// Not code — demote to Text so it renders as prose
3461+
// No signals at all → demote to Text
34563462
*node = ReflowNode::Text {
34573463
content: content.replace('\n', " "),
34583464
footnotes: Vec::new(),
@@ -3463,7 +3469,7 @@ fn detect_code_blocks(flat_nodes: &mut Vec<ReflowNode>) {
34633469
}
34643470
}
34653471

3466-
// Step 2: Merge consecutive CodeBlock nodes
3472+
// Step 2: Merge consecutive CodeBlock nodes and consecutive Algorithm nodes
34673473
let mut i = 0;
34683474
while i + 1 < flat_nodes.len() {
34693475
let is_code_pair = matches!(&flat_nodes[i], ReflowNode::CodeBlock { .. })
@@ -3473,13 +3479,23 @@ fn detect_code_blocks(flat_nodes: &mut Vec<ReflowNode>) {
34733479
if let ReflowNode::CodeBlock { content: c1, language: l1 } = &mut flat_nodes[i] {
34743480
c1.push('\n');
34753481
c1.push_str(&c2);
3476-
// Keep the more specific language
34773482
if l1.is_none() && l2.is_some() {
34783483
*l1 = l2;
34793484
}
34803485
}
34813486
}
3482-
continue; // re-check same position for further merging
3487+
continue;
3488+
}
3489+
let is_algo_pair = matches!(&flat_nodes[i], ReflowNode::Algorithm { .. })
3490+
&& matches!(&flat_nodes[i + 1], ReflowNode::Algorithm { .. });
3491+
if is_algo_pair {
3492+
if let ReflowNode::Algorithm { content: c2 } = flat_nodes.remove(i + 1) {
3493+
if let ReflowNode::Algorithm { content: c1 } = &mut flat_nodes[i] {
3494+
c1.push('\n');
3495+
c1.push_str(&c2);
3496+
}
3497+
}
3498+
continue;
34833499
}
34843500
i += 1;
34853501
}
@@ -3917,8 +3933,20 @@ fn render_children(children: &[ReflowNode], parts: &mut Vec<String>) {
39173933
}
39183934
}
39193935
ReflowNode::Algorithm { content } => {
3920-
// Wrap algorithm/code content in fenced code block
3921-
parts.push(format!("```\n{content}\n```"));
3936+
// Render algorithm with preserved newlines and indentation.
3937+
// NOT fenced code blocks (``` kills $...$ LaTeX math).
3938+
// Trailing " " on each line = markdown <br> line break.
3939+
let formatted: Vec<String> = content
3940+
.lines()
3941+
.map(|line| {
3942+
if line.trim().is_empty() {
3943+
String::new()
3944+
} else {
3945+
format!("{line} ")
3946+
}
3947+
})
3948+
.collect();
3949+
parts.push(formatted.join("\n"));
39223950
}
39233951
ReflowNode::FigureGroup { path, caption } => {
39243952
if path.is_empty() {

0 commit comments

Comments
 (0)