Skip to content

Commit 592dc60

Browse files
committed
Fix line number recognition for sparse and accumulated numbering
markLineNumber() rejected valid line-number columns in two cases the size-scaled coverage threshold from #226 does not cover: 1. Sparse numbering (every 5th/10th line, common in bioRxiv preprints): small, low-coverage clusters were dropped, so under -noLineNumbers the numbers survived. 2. Accumulated numbering on later pages: line numbers that reach large values (e.g. 428..444 on page 21) sit in an irregular layout (a figure or column break splits the column), so an even-spacing test rejects them even though the values are a clean +1 progression. A low-coverage margin cluster is now accepted as line numbers when its values form a regular arithmetic progression (dominant constant increment) at the extreme page margin AND either (a) the smallest value already exceeds the page's line count - meaning the numbers accumulated from earlier pages and cannot be a per-page list such as references or figure labels - or (b) the vertical pitch is roughly even (fresh, small-valued numbering). The margin constraint keeps regular-but-inset labels (figure panel numbering) intact; reference numbers restart near 1 and fail both paths. Validated on the GROBID end-to-end corpora: 229336v1 (every 5th line) 242->3, 003657v1 and 018085v1 (every line, values 287..444) fully stripped, removing only digit tokens; figure panel labels (005124v1) and eLife/PLOS/PMC output unchanged (no regression).
1 parent 95dfeff commit 592dc60

1 file changed

Lines changed: 108 additions & 2 deletions

File tree

src/XmlAltoOutputDev.cc

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5217,6 +5217,100 @@ int find_index_relaxed(vector<double> positions, double val, double margin) {
52175217
return index;
52185218
}
52195219

5220+
// Non-negative integer value of an all-digit word, or -1 if it holds a non-digit.
5221+
static long word_number_value(TextRawWord *word) {
5222+
long val = 0;
5223+
for (int i = 0; i < word->len; i++) {
5224+
Unicode u = ((TextChar *) word->chars->get(i))->c;
5225+
if (u < '0' || u > '9')
5226+
return -1;
5227+
val = val * 10 + (long) (u - '0');
5228+
}
5229+
return val;
5230+
}
5231+
5232+
// A margin cluster of numbers is line numbering even at low page coverage when its values
5233+
// form a regular arithmetic progression (dominant constant increment). Two accept paths:
5234+
// - "big numbers": the smallest value already exceeds this page's line count, so the numbers
5235+
// cannot be a per-page list (references / figure labels start near 1) - they are line
5236+
// numbers accumulated from earlier pages (e.g. 428..444 on a late page). Accept regardless
5237+
// of vertical spacing, which a figure or column break may disrupt.
5238+
// - "even pitch": otherwise (fresh, small-valued numbering such as every-5th-line on page 1)
5239+
// require roughly even vertical spacing.
5240+
// Footnote / bibliographic reference numbers match neither: they restart near 1 and are not
5241+
// evenly spaced down the page.
5242+
static bool is_regular_number_sequence(const vector<int> &cluster,
5243+
const vector<TextRawWord *> &lineNumberWords,
5244+
int totalNumberOfLines) {
5245+
int n = (int) cluster.size();
5246+
if (n < 3)
5247+
return false;
5248+
5249+
// collect (yMin, value), ordered top-to-bottom
5250+
vector<pair<double, long> > seq;
5251+
for (int i = 0; i < n; i++) {
5252+
TextRawWord *w = lineNumberWords[cluster[i]];
5253+
long v = word_number_value(w);
5254+
if (v < 0)
5255+
return false;
5256+
seq.push_back(make_pair(w->yMin, v));
5257+
}
5258+
sort(seq.begin(), seq.end());
5259+
5260+
// values must strictly increase going down the page
5261+
vector<long> dv;
5262+
vector<double> dy;
5263+
for (int i = 1; i < (int) seq.size(); i++) {
5264+
long d = seq[i].second - seq[i - 1].second;
5265+
double gy = seq[i].first - seq[i - 1].first;
5266+
if (d <= 0 || gy <= 0)
5267+
return false;
5268+
dv.push_back(d);
5269+
dy.push_back(gy);
5270+
}
5271+
5272+
// dominant (median) increment; line numbering steps are small (1, 2, 5, 10, 25...)
5273+
vector<long> sortedDv = dv;
5274+
sort(sortedDv.begin(), sortedDv.end());
5275+
long g = sortedDv[sortedDv.size() / 2];
5276+
if (g < 1 || g > 50)
5277+
return false;
5278+
5279+
// a strong majority of steps must share that exact increment; a line number missed
5280+
// by extraction shows up as a multiple of g and is simply not counted here
5281+
int agree = 0;
5282+
for (int i = 0; i < (int) dv.size(); i++)
5283+
if (dv[i] == g)
5284+
agree++;
5285+
if (agree < (int) ((dv.size() * 3 + 4) / 5)) // >= 60%
5286+
return false;
5287+
5288+
// "big numbers" path: smallest value exceeds this page's line count -> accumulated line
5289+
// numbers, accept without requiring even pitch (figure/column breaks disrupt spacing).
5290+
long minVal = seq[0].second;
5291+
if (minVal > (long) totalNumberOfLines)
5292+
return true;
5293+
5294+
// otherwise require roughly even vertical pitch per unit increment
5295+
vector<double> pitch;
5296+
for (int i = 0; i < (int) dv.size(); i++)
5297+
if (dv[i] == g)
5298+
pitch.push_back(dy[i] / (double) dv[i]);
5299+
if ((int) pitch.size() < 2)
5300+
return false;
5301+
double mean = 0.0;
5302+
for (int i = 0; i < (int) pitch.size(); i++)
5303+
mean += pitch[i];
5304+
mean /= pitch.size();
5305+
if (mean <= 0.0)
5306+
return false;
5307+
double var = 0.0;
5308+
for (int i = 0; i < (int) pitch.size(); i++)
5309+
var += (pitch[i] - mean) * (pitch[i] - mean);
5310+
double cv = sqrt(var / pitch.size()) / mean;
5311+
return cv < 0.30;
5312+
}
5313+
52205314
bool TextPage::markLineNumber() {
52215315
// Detect the presence of line number column in the page and mark the corresponding TextWord objects for further appropriate handling
52225316

@@ -5540,8 +5634,20 @@ bool TextPage::markLineNumber() {
55405634
else
55415635
requiredCoverage = 0.5;
55425636

5543-
if (coverage < requiredCoverage)
5544-
continue;
5637+
// Low coverage can still be genuine sparse line numbering (every Nth line). Accept it
5638+
// only when the cluster is a regular arithmetic progression at even vertical pitch AND
5639+
// sits at the extreme page margin. Figure panels / legends with small ascending labels
5640+
// (e.g. rows numbered 1..6) are regular too but are inset from the margin, so they must
5641+
// not be stripped; footnote/reference numbers fail the regular-sequence test.
5642+
if (coverage < requiredCoverage) {
5643+
double span = (double) (rightMostBoundary - leftMostBoundary);
5644+
double marginTol = span * 0.06;
5645+
bool atMargin = (final_vpos - leftMostBoundary <= marginTol) ||
5646+
((double) rightMostBoundary - final_vpos <= marginTol);
5647+
if (!(atMargin &&
5648+
is_regular_number_sequence(clusters[bestClusterIndex[j]], lineNumberWords, totalNumberOfLines)))
5649+
continue;
5650+
}
55455651
validClusterIndices.push_back(bestClusterIndex[j]);
55465652
}
55475653

0 commit comments

Comments
 (0)