rpdfium 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ module Table
5
+ # Trova tabelle su una pagina, fedele al `pdfplumber.TableFinder`.
6
+ #
7
+ # Pipeline:
8
+ # 1. raccogli edges candidati per ogni asse, secondo strategia
9
+ # (`:lines` / `:lines_strict` / `:text` / `:explicit`)
10
+ # 2. merge_edges (snap collineari + join contigui)
11
+ # 3. filter per lunghezza minima
12
+ # 4. edges_to_intersections con tolerance
13
+ # 5. intersections_to_cells (smallest cell per ogni punto)
14
+ # 6. cells_to_tables (grouping per corner condivisi)
15
+ #
16
+ # API pubblica:
17
+ # ext = Rpdfium::Table::Extractor.new(page, **opts)
18
+ # ext.tables # => [Table, ...] (oggetti Rpdfium::Table::Table)
19
+ # ext.extract # => [[[String]]] (Array di tabelle, ogni tabella
20
+ # è Array di righe, ogni riga
21
+ # è Array di stringhe)
22
+ # ext.find # alias di .tables (compat back con 0.2.x)
23
+ # ext.edges # edges raffinati
24
+ # ext.intersections # Hash {[x,y] => {v:[],h:[]}}
25
+ # ext.cells # Array<bbox>
26
+ class Extractor
27
+ DEFAULTS = {
28
+ vertical_strategy: :lines,
29
+ horizontal_strategy: :lines,
30
+ explicit_vertical_lines: [],
31
+ explicit_horizontal_lines: [],
32
+
33
+ # Tolleranze. I `_x_` / `_y_` ereditano dal valore non-suffisso.
34
+ snap_tolerance: 3.0,
35
+ snap_x_tolerance: nil,
36
+ snap_y_tolerance: nil,
37
+ join_tolerance: 3.0,
38
+ join_x_tolerance: nil,
39
+ join_y_tolerance: nil,
40
+
41
+ edge_min_length: 3.0,
42
+ edge_min_length_prefilter: 1.0,
43
+
44
+ min_words_vertical: Edges::DEFAULT_MIN_WORDS_VERTICAL,
45
+ min_words_horizontal: Edges::DEFAULT_MIN_WORDS_HORIZONTAL,
46
+
47
+ intersection_tolerance: 3.0,
48
+ intersection_x_tolerance: nil,
49
+ intersection_y_tolerance: nil,
50
+
51
+ # Settings testo (passati a TextExtraction quando si chiama .extract).
52
+ # I default 3.0 sono quelli di pdfplumber.
53
+ text_x_tolerance: Util::WordExtractor::DEFAULT_X_TOLERANCE,
54
+ text_y_tolerance: Util::WordExtractor::DEFAULT_Y_TOLERANCE,
55
+ text_keep_blank_chars: false,
56
+
57
+ # Auto-fallback: se :lines non produce edges, riprova con :text.
58
+ # Manteniamo il flag (era già in 0.2.x) ma SOLO come fallback,
59
+ # mai come "fix" su layout patologici — coerente con pdfplumber che
60
+ # non lo ha (chi usa pdfplumber sa che deve scegliere la strategia).
61
+ auto_fallback: true
62
+ }.freeze
63
+
64
+ VALID_STRATEGIES = %i[lines lines_strict text explicit].freeze
65
+
66
+ attr_reader :page, :settings
67
+
68
+ def initialize(page, **opts)
69
+ @page = page
70
+ @settings = resolve_settings(DEFAULTS.merge(opts))
71
+ validate_strategies!
72
+ end
73
+
74
+ # Pipeline completa, costruisce gli edges raffinati.
75
+ def edges
76
+ @edges ||= build_edges(@settings[:vertical_strategy],
77
+ @settings[:horizontal_strategy]).then do |built|
78
+ if built.empty? && @settings[:auto_fallback] &&
79
+ (@settings[:vertical_strategy] != :text ||
80
+ @settings[:horizontal_strategy] != :text)
81
+ # Fallback: l'auto-fallback è LASCO, riprova tutto a :text.
82
+ build_edges(:text, :text)
83
+ else
84
+ built
85
+ end
86
+ end
87
+ end
88
+
89
+ def intersections
90
+ @intersections ||= Edges.edges_to_intersections(
91
+ edges,
92
+ x_tolerance: @settings[:intersection_x_tolerance],
93
+ y_tolerance: @settings[:intersection_y_tolerance]
94
+ )
95
+ end
96
+
97
+ def cells
98
+ @cells ||= Cells.intersections_to_cells(intersections)
99
+ end
100
+
101
+ def tables
102
+ @tables ||= Cells.cells_to_tables(cells).map { |group| Table.new(@page, group) }
103
+ end
104
+ alias find tables
105
+
106
+ # Estrai i dati di tutte le tabelle: Array<Array<Array<String>>>.
107
+ def extract(**text_opts)
108
+ merged = {
109
+ x_tolerance: @settings[:text_x_tolerance],
110
+ y_tolerance: @settings[:text_y_tolerance],
111
+ keep_blank_chars: @settings[:text_keep_blank_chars]
112
+ }.merge(text_opts)
113
+
114
+ tables.map { |t| t.extract(**merged) }
115
+ end
116
+
117
+ private
118
+
119
+ def resolve_settings(s)
120
+ # Cascata x/y dai non-suffissi
121
+ s[:snap_x_tolerance] ||= s[:snap_tolerance]
122
+ s[:snap_y_tolerance] ||= s[:snap_tolerance]
123
+ s[:join_x_tolerance] ||= s[:join_tolerance]
124
+ s[:join_y_tolerance] ||= s[:join_tolerance]
125
+ s[:intersection_x_tolerance] ||= s[:intersection_tolerance]
126
+ s[:intersection_y_tolerance] ||= s[:intersection_tolerance]
127
+ s
128
+ end
129
+
130
+ def validate_strategies!
131
+ %i[vertical_strategy horizontal_strategy].each do |k|
132
+ unless VALID_STRATEGIES.include?(@settings[k])
133
+ raise ArgumentError, "#{k} must be one of #{VALID_STRATEGIES}"
134
+ end
135
+ if @settings[k] == :explicit
136
+ list = @settings[:"explicit_#{k.to_s.split('_').first}_lines"]
137
+ if list.nil? || list.size < 2
138
+ raise ArgumentError, "Strategy :explicit on #{k} requires " \
139
+ "at least 2 explicit_*_lines"
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ def build_edges(v_strat, h_strat)
146
+ words = nil
147
+ words = page_words if v_strat == :text || h_strat == :text
148
+
149
+ v_base = edges_for_strategy(:v, v_strat, words)
150
+ h_base = edges_for_strategy(:h, h_strat, words)
151
+
152
+ v_explicit = explicit_v_edges
153
+ h_explicit = explicit_h_edges
154
+
155
+ all = v_base + v_explicit + h_base + h_explicit
156
+ merged = Edges.merge_edges(
157
+ all,
158
+ snap_x_tolerance: @settings[:snap_x_tolerance],
159
+ snap_y_tolerance: @settings[:snap_y_tolerance],
160
+ join_x_tolerance: @settings[:join_x_tolerance],
161
+ join_y_tolerance: @settings[:join_y_tolerance]
162
+ )
163
+ Edges.filter_edges(merged, min_length: @settings[:edge_min_length])
164
+ end
165
+
166
+ def page_words
167
+ # Genera words usando il nostro WordExtractor (consistente con
168
+ # quello usato in Table#extract, così i thresholds combaciano).
169
+ # `lean: true`: vedi commento in Table#extract.
170
+ chars = @page.chars(lean: true)
171
+ Util::WordExtractor.new(
172
+ x_tolerance: @settings[:text_x_tolerance],
173
+ y_tolerance: @settings[:text_y_tolerance],
174
+ keep_blank_chars: @settings[:text_keep_blank_chars]
175
+ ).extract_words(chars)
176
+ end
177
+
178
+ def edges_for_strategy(axis, strat, words)
179
+ case strat
180
+ when :lines, :lines_strict
181
+ axis == :v ? page_vertical_edges(strict: strat == :lines_strict)
182
+ : page_horizontal_edges(strict: strat == :lines_strict)
183
+ when :text
184
+ axis == :v ? Edges.words_to_edges_v(words || [], word_threshold: @settings[:min_words_vertical])
185
+ : Edges.words_to_edges_h(words || [], word_threshold: @settings[:min_words_horizontal])
186
+ when :explicit then []
187
+ end
188
+ end
189
+
190
+ # Converte i `vertical_lines` di Page (formato {x, top, bottom}) al
191
+ # formato pdfplumber-style atteso dalle Edges.
192
+ # Nota: in 0.3.0 NON includiamo i lati di rettangoli quando :strict
193
+ # (ma al momento Page non li espone separatamente, è una semplificazione
194
+ # che documenteremo).
195
+ def page_vertical_edges(strict: false) # rubocop:disable Lint/UnusedMethodArgument
196
+ prefilter = @settings[:edge_min_length_prefilter]
197
+ @page.vertical_lines.filter_map do |s|
198
+ length = s[:bottom] - s[:top]
199
+ next if length < prefilter
200
+
201
+ { x0: s[:x], x1: s[:x], top: s[:top], bottom: s[:bottom],
202
+ orientation: "v" }
203
+ end
204
+ end
205
+
206
+ def page_horizontal_edges(strict: false) # rubocop:disable Lint/UnusedMethodArgument
207
+ prefilter = @settings[:edge_min_length_prefilter]
208
+ @page.horizontal_lines.filter_map do |s|
209
+ length = s[:x1] - s[:x0]
210
+ next if length < prefilter
211
+
212
+ { x0: s[:x0], x1: s[:x1], top: s[:y], bottom: s[:y],
213
+ orientation: "h" }
214
+ end
215
+ end
216
+
217
+ def explicit_v_edges
218
+ page_h = @page.height
219
+ @settings[:explicit_vertical_lines].map do |item|
220
+ x, top, bottom = case item
221
+ when Numeric then [item.to_f, 0.0, page_h]
222
+ when Hash
223
+ [item[:x] || item.fetch("x"),
224
+ item[:top] || item["top"] || 0.0,
225
+ item[:bottom] || item["bottom"] || page_h]
226
+ end
227
+ { x0: x, x1: x, top: top, bottom: bottom, orientation: "v" }
228
+ end
229
+ end
230
+
231
+ def explicit_h_edges
232
+ page_w = @page.width
233
+ @settings[:explicit_horizontal_lines].map do |item|
234
+ y, x0, x1 = case item
235
+ when Numeric then [item.to_f, 0.0, page_w]
236
+ when Hash
237
+ [item[:y] || item.fetch("y"),
238
+ item[:x0] || item["x0"] || 0.0,
239
+ item[:x1] || item["x1"] || page_w]
240
+ end
241
+ { x0: x0, x1: x1, top: y, bottom: y, orientation: "h" }
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ module Table
5
+ # Rappresenta una tabella trovata su una pagina. Espone celle, righe,
6
+ # colonne, bbox, e il metodo `extract` che ritorna i dati testuali.
7
+ #
8
+ # Ogni cella è una bbox `[x0, top, x1, bottom]` (top-down).
9
+ # Una "row" è il gruppo di celle che condividono la stessa `top`.
10
+ # Una "column" è il gruppo che condivide la stessa `x0`.
11
+ class Table
12
+ attr_reader :page, :cells
13
+
14
+ def initialize(page, cells)
15
+ @page = page
16
+ @cells = cells
17
+ end
18
+
19
+ def bbox
20
+ @cells.each_with_object(
21
+ [Float::INFINITY, Float::INFINITY, -Float::INFINITY, -Float::INFINITY]
22
+ ) do |c, acc|
23
+ acc[0] = c[0] if c[0] < acc[0]
24
+ acc[1] = c[1] if c[1] < acc[1]
25
+ acc[2] = c[2] if c[2] > acc[2]
26
+ acc[3] = c[3] if c[3] > acc[3]
27
+ end
28
+ end
29
+
30
+ # Restituisce le righe come Array<Array<bbox|nil>>. Le celle "mancanti"
31
+ # in una riga (es. perché la tabella ha una topologia irregolare) sono
32
+ # rappresentate come nil — coerente con pdfplumber.
33
+ def rows
34
+ rows_or_columns(:row)
35
+ end
36
+
37
+ def columns
38
+ rows_or_columns(:col)
39
+ end
40
+
41
+ # Estrai dati: Array<Array<String>>. Per ogni riga, per ogni cella,
42
+ # filtra i char della pagina il cui MIDPOINT è nella bbox della cella,
43
+ # poi ricostruisce il testo via Util::TextExtraction (che a sua volta
44
+ # passa da WordExtractor).
45
+ #
46
+ # Questo è il path di pdfplumber.Table.extract — per ogni riga prima
47
+ # filtra i char della riga (ottimizzazione: quasi tutti i char delle
48
+ # altre righe vengono scartati subito), poi per ogni cella filtra
49
+ # ancora dentro la sub-bbox.
50
+ #
51
+ # Ottimizzazione rispetto al path naïve: i char vengono ordinati per
52
+ # midpoint verticale una sola volta; per ogni riga si usa bsearch per
53
+ # trovare in O(log n) i char candidati invece di scansionare tutto
54
+ # l'array O(n) per ogni riga.
55
+ #
56
+ # NOTA su strategia :text: `words_to_edges_h` emette per design DUE
57
+ # edges per riga (top e bottom della bbox del cluster). Significa che
58
+ # una tabella detectata da text-strategy avrà righe "vere" intervallate
59
+ # da righe "vuote" tra il bottom-edge della riga N e il top-edge della
60
+ # riga N+1. Questo è identico al comportamento di pdfplumber. Il
61
+ # caller può filtrare via `result.reject { |row| row.all?(&:empty?) }`
62
+ # se vuole eliminarle.
63
+ # `cell_padding`: estende il bbox di ogni cella verso sinistra e verso
64
+ # l'alto di N punti. Default 0 (= comportamento pdfplumber identico).
65
+ # Utile per PDF dove i char sporgono leggermente dal bordo della cella
66
+ # (es. la "I" maiuscola della cella "Intermediario" in CR Banca d'Italia
67
+ # ha x0=24.0 ma il bordo della cella è a x=25.6 — viene scartata dal
68
+ # filtro midpoint, output "ntermediario:"). Con `cell_padding: 2.0` la
69
+ # cella diventa [23.6, ..., 100, ...] e la "I" viene catturata.
70
+ #
71
+ # Padding solo sui bordi "interno-sinistro" e "interno-alto" per
72
+ # evitare di duplicare char condivisi tra celle adiacenti (un char tra
73
+ # cella A e cella B finirebbe in entrambe se entrambe paddassero su
74
+ # tutti i lati).
75
+ def extract(x_tolerance: Util::WordExtractor::DEFAULT_X_TOLERANCE,
76
+ y_tolerance: Util::WordExtractor::DEFAULT_Y_TOLERANCE,
77
+ keep_blank_chars: false,
78
+ cell_padding: 0.0)
79
+ # `lean: true`: salta 5 chiamate FFI per char (font name, weight,
80
+ # angle, hyphen flag, unicode error) che non servono al pipeline
81
+ # di estrazione tabelle. Su tabelle con migliaia di char riduce
82
+ # il tempo di compute_chars del ~30%.
83
+ chars = @page.chars(lean: true)
84
+
85
+ # Ordina per midpoint verticale una volta sola; costruisce un array
86
+ # parallelo di vmid per bsearch. Costo: O(n log n) una tantum.
87
+ sorted_chars = chars.sort_by { |c| (c[:top] + c[:bottom]) / 2.0 }
88
+ vmids = sorted_chars.map { |c| (c[:top] + c[:bottom]) / 2.0 }
89
+
90
+ # Istanzia WordExtractor UNA volta sola e riusalo per tutte le celle
91
+ # (può esserci una tabella con decine di celle, evitiamo allocazioni).
92
+ word_extractor = Util::WordExtractor.new(
93
+ x_tolerance: x_tolerance,
94
+ y_tolerance: y_tolerance,
95
+ keep_blank_chars: keep_blank_chars
96
+ )
97
+
98
+ all_rows = rows
99
+ all_rows.map do |row|
100
+ row_bbox = row_bounding_box(row)
101
+ lo = vmids.bsearch_index { |v| v >= row_bbox[1] - cell_padding } || sorted_chars.size
102
+ hi = vmids.bsearch_index { |v| v >= row_bbox[3] } || sorted_chars.size
103
+ row_chars = sorted_chars[lo...hi]
104
+
105
+ row.map do |cell|
106
+ next nil if cell.nil?
107
+
108
+ padded = cell_padding.zero? ? cell : pad_cell_bbox(cell, cell_padding)
109
+ cell_chars = row_chars.select { |c| char_in_bbox?(c, padded) }
110
+ if cell_chars.empty?
111
+ ""
112
+ else
113
+ extract_text_with(cell_chars, word_extractor, y_tolerance)
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ # Versione "inlined" di Util::TextExtraction.extract_text che riusa
122
+ # un WordExtractor preesistente invece di crearlo ogni volta.
123
+ def extract_text_with(chars, word_extractor, y_tolerance)
124
+ words = word_extractor.extract_words(chars)
125
+ return "" if words.empty?
126
+
127
+ line_clusters = Util::Cluster.cluster_objects(words, :top, tolerance: y_tolerance)
128
+ line_clusters.map do |line_words|
129
+ line_words.sort_by { |w| w[:x0] }.map { |w| w[:text] }.join(" ")
130
+ end.join("\n")
131
+ end
132
+
133
+ def pad_cell_bbox(bbox, padding)
134
+ x0, top, x1, bottom = bbox
135
+ # Estendi solo i bordi "interno-sinistro" e "interno-alto" per evitare
136
+ # di catturare char della cella adiacente destra/sotto.
137
+ [x0 - padding, top - padding, x1, bottom]
138
+ end
139
+
140
+ # Test "char midpoint dentro bbox" — esattamente come pdfplumber.
141
+ # Il midpoint del char (non gli estremi della bbox) è il criterio:
142
+ # un char a cavallo del bordo viene assegnato alla cella in cui ha
143
+ # più "peso visivo".
144
+ def char_in_bbox?(char, bbox)
145
+ x0, top, x1, bottom = bbox
146
+ h_mid = (char[:x0] + char[:x1]) / 2.0
147
+ v_mid = (char[:top] + char[:bottom]) / 2.0
148
+ h_mid >= x0 && h_mid < x1 && v_mid >= top && v_mid < bottom
149
+ end
150
+
151
+ def row_bounding_box(row)
152
+ row.compact.each_with_object(
153
+ [Float::INFINITY, Float::INFINITY, -Float::INFINITY, -Float::INFINITY]
154
+ ) do |c, acc|
155
+ acc[0] = c[0] if c[0] < acc[0]
156
+ acc[1] = c[1] if c[1] < acc[1]
157
+ acc[2] = c[2] if c[2] > acc[2]
158
+ acc[3] = c[3] if c[3] > acc[3]
159
+ end
160
+ end
161
+
162
+ # Ricostruisce righe o colonne. axis 0 = x (per row clustering antiaxis=top),
163
+ # axis 1 = top (per column clustering antiaxis=x0). Usa il key invariante
164
+ # come "anchor" e il key variabile come ordering interno.
165
+ def rows_or_columns(kind)
166
+ # Per row: sortBy = top, antiaxis = x0
167
+ # Per col: sortBy = x0, antiaxis = top
168
+ sort_idx, group_idx = kind == :row ? [1, 0] : [0, 1]
169
+
170
+ # Tutti gli x0 (per row) o top (per col) distinti, sortati
171
+ all_keys = @cells.map { |c| c[group_idx] }.uniq.sort
172
+
173
+ # Group by sort_idx
174
+ sorted_cells = @cells.sort_by { |c| [c[sort_idx], c[group_idx]] }
175
+ grouped = sorted_cells.chunk_while { |a, b| a[sort_idx] == b[sort_idx] }.to_a
176
+
177
+ grouped.map do |group_cells|
178
+ by_anchor = group_cells.to_h { |c| [c[group_idx], c] }
179
+ all_keys.map { |k| by_anchor[k] }
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ module Util
5
+ # Primitive di clustering 1D usate da tutto il pipeline tabellare.
6
+ # Mappa diretta su `pdfplumber.utils.clustering` (cluster_list,
7
+ # cluster_objects, make_cluster_dict).
8
+ #
9
+ # PROPRIETÀ CHIAVE: questi cluster sono "1D agglomerative single-linkage":
10
+ # due valori finiscono nello stesso cluster se sono entro `tolerance` da
11
+ # un valore qualsiasi del cluster. NON solo dal centro/media. Ne consegue
12
+ # che catene di valori ravvicinati possono estendere il cluster ben oltre
13
+ # `tolerance` (questo è esattamente il comportamento di pdfplumber, e su
14
+ # cui si appoggiano le sue euristiche edge/intersection).
15
+ module Cluster
16
+ module_function
17
+
18
+ # Raggruppa valori scalari in cluster. I valori dentro lo stesso cluster
19
+ # sono entro `tolerance` da almeno un altro valore del cluster.
20
+ #
21
+ # Esempio:
22
+ # cluster_list([1.0, 1.5, 2.0, 5.0], tolerance: 1.0)
23
+ # #=> [[1.0, 1.5, 2.0], [5.0]]
24
+ #
25
+ # NOTA: Catene "stepping stone": [1, 2, 3, 4] con tol=1 fanno UN cluster
26
+ # solo, anche se 1 e 4 distano 3. Questo è il comportamento di
27
+ # pdfplumber, è documentato nei suoi issue come potenzialmente
28
+ # sorprendente ma intenzionale. Lo manteniamo identico.
29
+ def cluster_list(values, tolerance: 0)
30
+ return [] if values.empty?
31
+
32
+ sorted = values.sort
33
+ clusters = [[sorted.first]]
34
+ sorted[1..].each do |v|
35
+ if (v - clusters.last.last).abs <= tolerance
36
+ clusters.last << v
37
+ else
38
+ clusters << [v]
39
+ end
40
+ end
41
+ clusters
42
+ end
43
+
44
+ # Raggruppa oggetti (Hash) in cluster basandosi su una funzione di
45
+ # estrazione `key_fn` (oppure simbolo Hash key) e tolleranza.
46
+ #
47
+ # Esempio:
48
+ # cluster_objects(words, ->(w) { w[:top] }, tolerance: 1)
49
+ # cluster_objects(words, :top, tolerance: 1) # syntactic sugar
50
+ def cluster_objects(objects, key_fn, tolerance: 0, presorted: false)
51
+ return [] if objects.empty?
52
+
53
+ # Fast path per il caso Symbol più comune (:top, :x0, :bottom):
54
+ # accesso diretto Hash[symbol] è ~2× più veloce della lambda call.
55
+ if key_fn.is_a?(Symbol)
56
+ # Se il chiamante garantisce che l'input è già sortato per key_fn
57
+ # (es. perché viene da un sort lessicografico [key_fn, ...]) si
58
+ # può saltare il sort interno. Risparmio significativo quando
59
+ # cluster_objects è chiamato in loop su molte righe piccole.
60
+ sorted = presorted ? objects : objects.sort_by { |o| o[key_fn] }
61
+ first = sorted.first
62
+ last_key = first[key_fn]
63
+ clusters = [[first]]
64
+ tol = tolerance.to_f
65
+ i = 1
66
+ n = sorted.size
67
+ while i < n
68
+ obj = sorted[i]
69
+ curr_key = obj[key_fn]
70
+ if (curr_key - last_key).abs <= tol
71
+ clusters.last << obj
72
+ else
73
+ clusters << [obj]
74
+ end
75
+ last_key = curr_key
76
+ i += 1
77
+ end
78
+ return clusters
79
+ end
80
+
81
+ # Path generico con accessor callable
82
+ accessor = key_fn
83
+ sorted = presorted ? objects : objects.sort_by { |o| accessor.call(o) }
84
+ last_key = accessor.call(sorted.first)
85
+ clusters = [[sorted.first]]
86
+
87
+ sorted[1..].each do |obj|
88
+ curr_key = accessor.call(obj)
89
+ if (curr_key - last_key).abs <= tolerance
90
+ clusters.last << obj
91
+ else
92
+ clusters << [obj]
93
+ end
94
+ last_key = curr_key
95
+ end
96
+ clusters
97
+ end
98
+
99
+ # bbox = [x0, top, x1, bottom] (top-down). Ritorna la bbox che racchiude
100
+ # tutti gli oggetti passati. Usa min/max di x0/top/x1/bottom.
101
+ def objects_to_bbox(objects)
102
+ objects.each_with_object(
103
+ [Float::INFINITY, Float::INFINITY, -Float::INFINITY, -Float::INFINITY]
104
+ ) do |o, acc|
105
+ acc[0] = o[:x0] if o[:x0] < acc[0]
106
+ acc[1] = o[:top] if o[:top] < acc[1]
107
+ acc[2] = o[:x1] if o[:x1] > acc[2]
108
+ acc[3] = o[:bottom] if o[:bottom] > acc[3]
109
+ end
110
+ end
111
+
112
+ # Variante che ritorna un Hash invece di tuple — comoda nel contesto
113
+ # edge dove ci serve mescolare bbox+orientation.
114
+ def objects_to_rect(objects)
115
+ x0, top, x1, bottom = objects_to_bbox(objects)
116
+ { x0: x0, top: top, x1: x1, bottom: bottom,
117
+ width: x1 - x0, height: bottom - top }
118
+ end
119
+
120
+ # bbox sovrapposti. None overlap => nil. Match pdfplumber's
121
+ # get_bbox_overlap: ritorna la bbox di intersezione, oppure nil.
122
+ def bbox_overlap(a, b)
123
+ ax0, atop, ax1, abot = a
124
+ bx0, btop, bx1, bbot = b
125
+ x0 = [ax0, bx0].max
126
+ x1 = [ax1, bx1].min
127
+ return nil if x0 >= x1
128
+
129
+ top = [atop, btop].max
130
+ bot = [abot, bbot].min
131
+ return nil if top >= bot
132
+
133
+ [x0, top, x1, bot]
134
+ end
135
+
136
+ # True se due bbox si sovrappongono (anche solo a un punto è no, deve
137
+ # esserci area positiva).
138
+ def bbox_overlaps?(a, b)
139
+ !bbox_overlap(a, b).nil?
140
+ end
141
+ end
142
+ end
143
+ end