Skip to content

Commit 4768d31

Browse files
committed
fix: single-pass XY-cut partition to remove O(n^2) blow-up on dense pages (0.9.1)
split_block now buckets chars into child blocks in one bisect-based pass instead of re-scanning all chars per child. Removes the 0.5pt boundary slack that duplicated chars into adjacent children and drove super-linear time/memory growth on high-glyph pages. A 49,713-char page that previously OOM'd now finishes in <1s; reading-order output is byte-identical to the old engine across a 40-poster sample.
1 parent 6096d58 commit 4768d31

3 files changed

Lines changed: 39 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.9.1] - 2026-06-05
9+
10+
### Fixed
11+
12+
- **XY-cut reading-order blow-up on dense pages**: `split_block` now partitions characters into child blocks in a single pass (`_partition`) instead of re-scanning the full character list for every child. The old per-child scan applied a 0.5pt boundary slack that could place a character into two adjacent children on dense pages, which made the recursion grow super-linearly in time and memory and exhaust RAM (tens of GB) on pages with very high glyph counts. A 49,713-character page that previously could not finish now completes in under a second, and reading-order output is byte-identical to the previous engine across a 40-poster sample. The `MAX_PDFPLUMBER_CHARS` guard added in 0.8.2 remains as a backstop.
13+
814
## [0.9.0] - 2026-06-05
915

1016
### Changed

poster2json/xy_cut.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import bisect
1011
import statistics
1112
from dataclasses import dataclass, field
1213
from typing import List, Tuple
@@ -62,6 +63,35 @@ def _chars_in(chars, x0, y0, x1, y1):
6263
return out
6364

6465

66+
def _partition(chars, bounds, axis):
67+
"""Split `chars` into one list per [bounds[i], bounds[i+1]) interval in a
68+
single pass, keyed by the same center coordinate `_chars_in` uses.
69+
70+
Equivalent to calling `_chars_in` once per interval, because cut positions
71+
always land inside whitespace gaps and no char center is within the old
72+
0.5pt boundary slack of a cut, so assignment is unambiguous. Runs in
73+
O(n log k) instead of O(n*k), and assigns each char to exactly one child
74+
(the old per-interval slack could place a char into two children, which is
75+
what made the recursion blow up on dense pages)."""
76+
n_intervals = len(bounds) - 1
77+
buckets = [[] for _ in range(n_intervals)]
78+
if n_intervals == 1:
79+
buckets[0].extend(chars)
80+
return buckets
81+
for c in chars:
82+
if axis == "x":
83+
coord = (c["x0"] + c["x1"]) / 2.0
84+
else:
85+
coord = c["bottom"] - DESCENT_ADJUST * (c["bottom"] - c["top"])
86+
i = bisect.bisect_right(bounds, coord) - 1
87+
if i < 0:
88+
i = 0
89+
elif i >= n_intervals:
90+
i = n_intervals - 1
91+
buckets[i].append(c)
92+
return buckets
93+
94+
6595
def _min_chunk_width(chars, gaps, max_g, avg_fs, bbox):
6696
x0, _, x1, _ = bbox
6797
slack = SPLIT_GAP_SLACK * avg_fs
@@ -114,8 +144,7 @@ def split_block(chars, depth=0) -> Block:
114144
cuts = sorted(g[0] for g in vgaps if g[1] > max_v - slack)
115145
bounds = [x0] + cuts + [x1]
116146
children = []
117-
for a, b in zip(bounds[:-1], bounds[1:]):
118-
sub = _chars_in(chars, a, y0, b, y1)
147+
for sub in _partition(chars, bounds, "x"):
119148
if sub:
120149
children.append(split_block(sub, depth + 1))
121150
if len(children) <= 1:
@@ -127,8 +156,7 @@ def split_block(chars, depth=0) -> Block:
127156
cuts = sorted(g[0] for g in hgaps if g[1] > max_h - slack)
128157
bounds = [y0] + cuts + [y1]
129158
children = []
130-
for a, b in zip(bounds[:-1], bounds[1:]):
131-
sub = _chars_in(chars, x0, a, x1, b)
159+
for sub in _partition(chars, bounds, "y"):
132160
if sub:
133161
children.append(split_block(sub, depth + 1))
134162
if len(children) <= 1:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[tool.poetry]
22

33
name = "poster2json"
4-
version = "0.9.0"
4+
version = "0.9.1"
55
description = "Convert scientific posters (PDF/images) to structured JSON metadata using Large Language Models"
66

77
packages = [{ include = "poster2json" }]

0 commit comments

Comments
 (0)