@@ -223,17 +223,122 @@ def detect_layout(img: Image.Image) -> list[dict]:
223223 bbox = [round (c ) for c in box .tolist ()] # [x1, y1, x2, y2] in pixels
224224 regions .append ({"label" : label , "bbox" : bbox , "score" : round (score .item (), 3 )})
225225
226- # Sort by reading order: top-to-bottom, then left-to-right
227- regions . sort ( key = lambda r : ( r [ "bbox" ][ 1 ], r [ "bbox" ][ 0 ]) )
226+ # Sort by column-aware reading order
227+ regions = _sort_by_columns ( regions , img . size [ 0 ] )
228228
229229 logger .info (f"[layout] Detected { len (regions )} regions" )
230230 return regions
231231
232232
233+ def _detect_columns (regions : list [dict ], img_width : int ) -> list [list [dict ]]:
234+ """Detect multi-column layout by clustering region x-centers.
235+ Returns list of columns (each a list of regions), left-to-right.
236+ Full-width regions (spanning >60% of image width) are NOT assigned to columns;
237+ they are returned as a special first "column" to be placed before columnar content.
238+ """
239+ if not regions :
240+ return []
241+
242+ FULL_WIDTH_RATIO = 0.6 # regions wider than this fraction are "full-width"
243+ # Labels that are always treated as full-width (headers, titles)
244+ FULL_WIDTH_LABELS = {"doc_title" , "paragraph_title" , "title" , "section_title" }
245+ full_width = []
246+ narrow = []
247+
248+ for r in regions :
249+ x1 , _ , x2 , _ = r ["bbox" ]
250+ width = x2 - x1
251+ if width > img_width * FULL_WIDTH_RATIO or r ["label" ] in FULL_WIDTH_LABELS :
252+ full_width .append (r )
253+ else :
254+ narrow .append (r )
255+
256+ if not narrow :
257+ return [full_width ] if full_width else []
258+
259+ # Cluster narrow regions into columns by x-center
260+ centers = [(r ["bbox" ][0 ] + r ["bbox" ][2 ]) / 2 for r in narrow ]
261+ columns = _cluster_columns (narrow , centers , img_width )
262+
263+ # Sort columns left-to-right by average x-center
264+ columns .sort (key = lambda col : sum ((r ["bbox" ][0 ] + r ["bbox" ][2 ]) / 2 for r in col ) / len (col ))
265+
266+ # Sort regions within each column top-to-bottom
267+ for col in columns :
268+ col .sort (key = lambda r : r ["bbox" ][1 ])
269+
270+ # Tag each region with its column index for merge grouping
271+ if full_width :
272+ full_width .sort (key = lambda r : r ["bbox" ][1 ])
273+ for r in full_width :
274+ r ["_column" ] = 0
275+
276+ for ci , col in enumerate (columns ):
277+ for r in col :
278+ r ["_column" ] = ci + (1 if full_width else 0 )
279+
280+ result = []
281+ if full_width :
282+ result .append (full_width )
283+ result .extend (columns )
284+ return result
285+
286+
287+ def _cluster_columns (regions : list [dict ], centers : list [float ], img_width : int ) -> list [list [dict ]]:
288+ """Simple 1D clustering of regions into columns by x-center.
289+ Uses a gap threshold: if the gap between sorted x-centers exceeds
290+ a fraction of the image width, start a new column.
291+ """
292+ GAP_RATIO = 0.15 # minimum gap between columns as fraction of image width
293+ gap_threshold = img_width * GAP_RATIO
294+
295+ indexed = sorted (enumerate (regions ), key = lambda t : centers [t [0 ]])
296+ columns : list [list [dict ]] = []
297+ current_col : list [dict ] = [indexed [0 ][1 ]]
298+ prev_center = centers [indexed [0 ][0 ]]
299+
300+ for idx , region in indexed [1 :]:
301+ c = centers [idx ]
302+ if c - prev_center > gap_threshold :
303+ columns .append (current_col )
304+ current_col = [region ]
305+ else :
306+ current_col .append (region )
307+ prev_center = c
308+
309+ columns .append (current_col )
310+ return columns
311+
312+
313+ def _sort_by_columns (regions : list [dict ], img_width : int ) -> list [dict ]:
314+ """Sort regions by column-aware reading order.
315+ For single-column docs: top-to-bottom.
316+ For multi-column docs: full-width first, then left col top-to-bottom, right col top-to-bottom.
317+ """
318+ columns = _detect_columns (regions , img_width )
319+
320+ if len (columns ) <= 1 :
321+ # Single column or all full-width: simple top-to-bottom
322+ regions .sort (key = lambda r : (r ["bbox" ][1 ], r ["bbox" ][0 ]))
323+ return regions
324+
325+ # Multi-column: interleave full-width regions by y-position among columns
326+ # Full-width regions (columns[0] if wider) come before each column section
327+ # that starts below them
328+ sorted_regions = []
329+ for col in columns :
330+ sorted_regions .extend (col )
331+
332+ logger .info (f"[layout] Detected { len (columns )} columns "
333+ f"({ ', ' .join (str (len (c )) + ' regions' for c in columns )} )" )
334+ return sorted_regions
335+
336+
233337def _merge_adjacent_regions (raw_regions : list [dict ]) -> list [list [dict ]]:
234338 """Group adjacent non-solo regions into merge groups.
235339 Returns list of groups, where each group is a list of raw regions.
236340 Solo regions (table, figure) always form their own group.
341+ Column boundaries (tagged by _detect_columns) split groups.
237342 """
238343 groups : list [list [dict ]] = []
239344 current : list [dict ] = []
@@ -245,6 +350,10 @@ def _merge_adjacent_regions(raw_regions: list[dict]) -> list[list[dict]]:
245350 current = []
246351 groups .append ([region ])
247352 else :
353+ # Split at column boundaries (different _column tag)
354+ if current and current [- 1 ].get ("_column" ) != region .get ("_column" ):
355+ groups .append (current )
356+ current = []
248357 current .append (region )
249358
250359 if current :
0 commit comments