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,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ module Structure
5
+ # StructTree di una pagina PDF tagged.
6
+ #
7
+ # Per PDF tagged (PDF/UA, esport accessibility-friendly da
8
+ # Word/LibreOffice/InDesign), espone la struttura logica del documento:
9
+ # Document → P, H1, Table, TR, TH, TD, Figure, ecc.
10
+ #
11
+ # Per PDF NON tagged, `Page#struct_tree` ritorna nil. Per PDF "tagged
12
+ # ma vuoti" (es. CR Banca d'Italia, StructTreeRoot presente ma con
13
+ # element placeholder senza type/MCID), `Tree#empty?` ritorna true.
14
+ #
15
+ # Lifecycle: il Tree mantiene un handle PDFium che è "owning" — chiamare
16
+ # `FPDF_StructTree_Close` lo dealloca. PDFium dealloca automaticamente
17
+ # lo struct tree alla chiusura del documento, quindi in pratica:
18
+ #
19
+ # - se non chiudi mai il tree esplicitamente, PDFium lo libera con
20
+ # `FPDF_CloseDocument` (zero perdita persistente, ma il tree resta
21
+ # in memoria fino alla chiusura del doc — può essere ~MB)
22
+ # - per controllo deterministico (rilascia subito), usa il blocco:
23
+ #
24
+ # page.struct_tree do |tree|
25
+ # tree.walk { |el| ... }
26
+ # end
27
+ # all'uscita dal blocco il tree viene chiuso, anche su eccezione.
28
+ #
29
+ # Per scelta progettuale NON usiamo `ObjectSpace.define_finalizer`: se
30
+ # il GC chiamasse `FPDF_StructTree_Close` dopo che il documento è già
31
+ # stato chiuso, si avrebbe un use-after-free → segfault. La chiusura
32
+ # via Document è sempre sicura; la chiusura via Tree.close (esplicita
33
+ # o tramite blocco) richiede che il documento sia ancora vivo.
34
+ class Tree
35
+ attr_reader :handle, :page
36
+
37
+ # Ritorna nil se la pagina non è tagged. Altrimenti un Tree.
38
+ def self.for_page(page)
39
+ h = Raw.FPDF_StructTree_GetForPage(page.handle)
40
+ return nil if h.null?
41
+
42
+ new(page, h)
43
+ end
44
+
45
+ def initialize(page, handle)
46
+ @page = page
47
+ @handle = handle
48
+ @closed = false
49
+ @mcid_text_cache = nil
50
+
51
+ # NOTA: niente finalizer. FPDF_StructTree_Close è "owning": chiama
52
+ # ~CPDF_StructTree() che libera l'oggetto. Se il documento PDF
53
+ # viene chiuso prima del tree, il finalizer GC chiamerebbe Close
54
+ # su memoria già liberata → segfault. Lifetime sicuro:
55
+ # - close esplicito via `tree.close` o via blocco
56
+ # `page.struct_tree { |tree| ... }`
57
+ # - se nessuno chiude esplicitamente, PDFium libera il tree
58
+ # insieme al documento al `FPDF_CloseDocument` (no leak
59
+ # persistent, solo riserva memoria fino a chiusura doc)
60
+ end
61
+
62
+ def closed?
63
+ @closed
64
+ end
65
+
66
+ # Chiusura esplicita (idempotente). Dopo close, non chiamare metodi
67
+ # su questo Tree né sugli Element che ha generato.
68
+ def close
69
+ return if @closed
70
+
71
+ Raw.FPDF_StructTree_Close(@handle)
72
+ @closed = true
73
+ @mcid_text_cache = nil
74
+ end
75
+
76
+ # Numero di element root (figli diretti del StructTreeRoot per
77
+ # questa pagina). Tipicamente 1 (`<Document>`), ma può essere
78
+ # arbitrariamente alto su PDF strani (es. cu.pdf: 717 placeholder).
79
+ def root_count
80
+ n = Raw.FPDF_StructTree_CountChildren(@handle)
81
+ [n, 0].max
82
+ end
83
+
84
+ # Element root (figli diretti del StructTreeRoot). Tipicamente 1
85
+ # (`<Document>`).
86
+ def roots
87
+ (0...root_count).filter_map do |i|
88
+ h = Raw.FPDF_StructTree_GetChildAtIndex(@handle, i)
89
+ h.null? ? nil : Element.new(self, h)
90
+ end
91
+ end
92
+
93
+ # True se il tree è strutturalmente vuoto (nessun element con type
94
+ # leggibile dai root). Caso comune per PDF "fintamente tagged" come
95
+ # CR Banca d'Italia: il StructTreeRoot esiste ma gli element sono
96
+ # placeholder vuoti.
97
+ def empty?
98
+ return true if root_count.zero?
99
+
100
+ roots.none? { |r| r.type || r.children.any? }
101
+ end
102
+
103
+ # Walk depth-first di TUTTI gli element del tree. Equivalente a
104
+ # `roots.flat_map(&:walk)`. Senza block ritorna Enumerator.
105
+ def walk(&block)
106
+ return enum_for(:walk) unless block
107
+
108
+ roots.each { |r| r.walk(&block) }
109
+ end
110
+
111
+ # Trova tutti gli element del tipo specificato (es. "Table", "P",
112
+ # "Figure"). Confronto case-sensitive (i tipi PDF sono "Table",
113
+ # "P", "H1", ecc.).
114
+ def find_all(type:)
115
+ walk.select { |el| el.type == type }
116
+ end
117
+
118
+ # Restituisce tutti gli element di tipo "Table". Conveniente per
119
+ # estrazione tabelle semantica.
120
+ def tables
121
+ find_all(type: "Table")
122
+ end
123
+
124
+ # Page objects raggruppati per Marked Content ID, per consentire a
125
+ # Element#text di risolvere il testo dei suoi MCID. La mappa è
126
+ # costruita una sola volta per Tree e cached.
127
+ #
128
+ # Pubblico ma destinato a uso interno; non parte dell'API stabile.
129
+ def mcid_text_map
130
+ @mcid_text_cache ||= build_mcid_text_map
131
+ end
132
+
133
+ def to_s
134
+ "#<Rpdfium::Structure::Tree roots=#{root_count}#{empty? ? ' empty' : ''}>"
135
+ end
136
+ alias inspect to_s
137
+
138
+ private
139
+
140
+ # Itera tutti i page objects (incl. Form XObject) e raggruppa il loro
141
+ # testo per MCID. Il pattern probe-then-fetch su FPDFTextObj_GetText
142
+ # è già rodato (vedi Page#read_text_obj_text_fast).
143
+ def build_mcid_text_map
144
+ map = Hash.new { |h, k| h[k] = +"" }
145
+ tp = @page.text_page
146
+ page_handle = @page.handle
147
+ buf = FFI::MemoryPointer.new(:uint8, 1024)
148
+
149
+ walk_objects = lambda do |handle, is_form|
150
+ n = is_form ? Raw.FPDFFormObj_CountObjects(handle) : Raw.FPDFPage_CountObjects(handle)
151
+ n.times do |i|
152
+ obj = is_form ? Raw.FPDFFormObj_GetObject(handle, i) : Raw.FPDFPage_GetObject(handle, i)
153
+ next if obj.null?
154
+
155
+ obj_type = Raw.FPDFPageObj_GetType(obj)
156
+ if obj_type == Raw::PAGEOBJ_TEXT
157
+ mcid = Raw.FPDFPageObj_GetMarkedContentID(obj)
158
+ if mcid >= 0
159
+ text = read_text_obj_text(obj, tp, buf)
160
+ map[mcid] << text if text
161
+ end
162
+ elsif obj_type == Raw::PAGEOBJ_FORM
163
+ walk_objects.call(obj, true)
164
+ end
165
+ end
166
+ end
167
+
168
+ walk_objects.call(page_handle, false)
169
+ map
170
+ end
171
+
172
+ def read_text_obj_text(obj, tp, buf)
173
+ # Probe con buffer 1024 byte (sufficiente per il 99% dei marked
174
+ # content runs, che tipicamente sono parole singole o frasi brevi).
175
+ needed = Raw.FPDFTextObj_GetText(obj, tp.handle, buf, 1024)
176
+ return nil if needed < 2
177
+
178
+ if needed > 1024
179
+ big = FFI::MemoryPointer.new(:uint8, needed)
180
+ needed = Raw.FPDFTextObj_GetText(obj, tp.handle, big, needed)
181
+ return nil if needed < 2
182
+
183
+ payload = needed - 2
184
+ return nil if payload <= 0
185
+
186
+ return big.read_bytes(payload)
187
+ .force_encoding("UTF-16LE")
188
+ .encode("UTF-8", invalid: :replace, undef: :replace)
189
+ .delete("\u0000")
190
+ end
191
+
192
+ payload = needed - 2
193
+ return nil if payload <= 0
194
+
195
+ buf.read_bytes(payload)
196
+ .force_encoding("UTF-16LE")
197
+ .encode("UTF-8", invalid: :replace, undef: :replace)
198
+ .delete("\u0000")
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ module Table
5
+ # Costruisce celle da intersezioni e tabelle da celle.
6
+ # Algoritmi 1:1 con pdfplumber.intersections_to_cells e
7
+ # pdfplumber.cells_to_tables.
8
+ module Cells
9
+ module_function
10
+
11
+ # Ricerca della "smallest cell" per ogni intersezione: dato un punto
12
+ # `pt = (x, y)`, cerca il rettangolo minimo i cui 4 corner sono
13
+ # intersezioni e i cui 4 lati hanno edge che le connettono.
14
+ #
15
+ # Il vincolo "edge connect" è cruciale: due intersezioni con stessa
16
+ # x non bastano — devono CONDIVIDERE almeno un edge verticale (cioè
17
+ # appartenere a uno stesso segmento continuo). Idem orizzontale.
18
+ # Questo evita falsi positivi tipo "due colonne lontane allineate
19
+ # accidentalmente".
20
+ #
21
+ # `intersections` è il Hash prodotto da Edges.edges_to_intersections,
22
+ # con chiavi `[x, y]` e valori `{ v: [edges...], h: [edges...] }`.
23
+ def intersections_to_cells(intersections)
24
+ return [] if intersections.empty?
25
+
26
+ # Indici di adiacenza: per ogni edge (oggetto Hash, identità di
27
+ # ruby), quali intersection points contiene? Pdfplumber lo fa
28
+ # confrontando bbox degli edge — noi abbiamo accesso diretto agli
29
+ # oggetti edge dentro `intersections[pt]`, basta usare l'identity.
30
+ # Per "stesso edge" usiamo `equal?` (identità d'oggetto).
31
+ edge_ids = intersections.transform_values do |val|
32
+ { v: val[:v].map(&:object_id).to_set,
33
+ h: val[:h].map(&:object_id).to_set }
34
+ end
35
+
36
+ edge_connects = lambda do |p1, p2|
37
+ if p1[0] == p2[0]
38
+ return !(edge_ids[p1][:v] & edge_ids[p2][:v]).empty?
39
+ end
40
+ if p1[1] == p2[1]
41
+ return !(edge_ids[p1][:h] & edge_ids[p2][:h]).empty?
42
+ end
43
+ false
44
+ end
45
+
46
+ points = intersections.keys.sort
47
+ npoints = points.size
48
+
49
+ # Indici spaziali: precomputa punti per colonna (stessa x) e per riga
50
+ # (stessa y), già ordinati perché `points` è sorted.
51
+ # Permette lookup O(log n) via bsearch invece di O(n) via select.
52
+ by_x = Hash.new { |h, k| h[k] = [] }
53
+ by_y = Hash.new { |h, k| h[k] = [] }
54
+ points.each { |p| by_x[p[0]] << p; by_y[p[1]] << p }
55
+
56
+ cells = []
57
+ points.each_with_index do |pt, i|
58
+ next if i == npoints - 1
59
+
60
+ # Punti direttamente sotto `pt` (stessa x, y maggiore)
61
+ col = by_x[pt[0]]
62
+ below_start = col.bsearch_index { |q| q[1] > pt[1] } || col.size
63
+ below = col[below_start..]
64
+
65
+ # Punti direttamente a destra di `pt` (stessa y, x maggiore)
66
+ row_pts = by_y[pt[1]]
67
+ right_start = row_pts.bsearch_index { |q| q[0] > pt[0] } || row_pts.size
68
+ right = row_pts[right_start..]
69
+
70
+ # Cerca il PRIMO (== più piccolo per via dell'ordinamento) bottom-right
71
+ # i cui 4 corner sono presenti e gli edge connettono.
72
+ found = nil
73
+ below.each do |b|
74
+ next unless edge_connects.call(pt, b)
75
+
76
+ right.each do |r|
77
+ next unless edge_connects.call(pt, r)
78
+
79
+ br = [r[0], b[1]]
80
+ next unless intersections.key?(br)
81
+ next unless edge_connects.call(br, r)
82
+ next unless edge_connects.call(br, b)
83
+
84
+ found = [pt[0], pt[1], br[0], br[1]]
85
+ break
86
+ end
87
+ break if found
88
+ end
89
+ cells << found if found
90
+ end
91
+ cells
92
+ end
93
+
94
+ # Raggruppa celle in tabelle in base ai corner condivisi.
95
+ #
96
+ # Algoritmo: Union-Find (disjoint set) sui corner — O(n α(n)) invece
97
+ # del greedy fixed-point O(n²) di pdfplumber. Il risultato è identico:
98
+ # due celle finiscono nello stesso gruppo se condividono almeno un corner.
99
+ #
100
+ # Filtro finale: scarta tabelle con UNA SOLA cella (rumore).
101
+ def cells_to_tables(cells)
102
+ return [] if cells.empty?
103
+
104
+ n = cells.size
105
+ parent = Array.new(n) { |i| i }
106
+
107
+ find = lambda do |i|
108
+ i = parent[i] = parent[parent[i]] while parent[i] != i
109
+ i
110
+ end
111
+ union = ->(a, b) { parent[find.call(a)] = find.call(b) }
112
+
113
+ # Per ogni corner, raccoglie gli indici delle celle che lo condividono
114
+ # e le unisce nel medesimo componente.
115
+ corner_to_cells = Hash.new { |h, k| h[k] = [] }
116
+ cells.each_with_index do |cell, idx|
117
+ x0, top, x1, bottom = cell
118
+ [[x0, top], [x0, bottom], [x1, top], [x1, bottom]].each do |corner|
119
+ corner_to_cells[corner] << idx
120
+ end
121
+ end
122
+ corner_to_cells.each_value do |idxs|
123
+ idxs.each_cons(2) { |a, b| union.call(a, b) }
124
+ end
125
+
126
+ # Raggruppa per root del Union-Find
127
+ groups = Hash.new { |h, k| h[k] = [] }
128
+ cells.each_with_index { |cell, i| groups[find.call(i)] << cell }
129
+
130
+ # Sort top-to-bottom, left-to-right; filtra single-cell.
131
+ groups.values
132
+ .sort_by { |t| t.map { |c| [c[1], c[0]] }.min }
133
+ .reject { |t| t.size <= 1 }
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ module Table
5
+ # Genera una visualizzazione di debug: la pagina renderizzata in PNG con
6
+ # sovrapposti gli edges rilevati e i bbox delle celle. Equivalente di
7
+ # pdfplumber.Page.to_image().debug_tablefinder().
8
+ #
9
+ # Implementato puro Ruby: rasterizza la pagina via render(), poi disegna
10
+ # sopra il bitmap manipolando i bytes RGBA, infine salva in PNG.
11
+ module Debugger
12
+ module_function
13
+
14
+ RED = [255, 0, 0, 200].freeze
15
+ GREEN = [0, 200, 0, 200].freeze
16
+ BLUE = [80, 80, 255, 120].freeze
17
+
18
+ def visualize(page, output_path, scale: 2.0, **table_opts)
19
+ extractor = Extractor.new(page, **table_opts)
20
+ edges = extractor.edges
21
+ intersections = extractor.intersections
22
+ tables = extractor.tables
23
+
24
+ w, h, bytes, _stride = page.render(scale: scale, output: :rgba)
25
+ canvas = Canvas.new(w, h, bytes)
26
+
27
+ # Disegna edges. Nuovo formato: ogni edge ha orientation + x0/x1/top/bottom.
28
+ # Un edge orizzontale ha top == bottom; un verticale ha x0 == x1.
29
+ edges.each do |e|
30
+ canvas.line((e[:x0] * scale).to_i, (e[:top] * scale).to_i,
31
+ (e[:x1] * scale).to_i, (e[:bottom] * scale).to_i, RED)
32
+ end
33
+
34
+ # Disegna intersezioni (cerchi 4px). Sono Hash con chiave [x, y].
35
+ intersections.each_key do |(x, y)|
36
+ canvas.dot((x * scale).to_i, (y * scale).to_i, GREEN, 4)
37
+ end
38
+
39
+ # Riempie tabelle con blu trasparente. Table#bbox è tuple [x0, top, x1, bottom].
40
+ tables.each do |t|
41
+ x0, top, x1, bottom = t.bbox
42
+ canvas.rect_fill((x0 * scale).to_i, (top * scale).to_i,
43
+ (x1 * scale).to_i, (bottom * scale).to_i, BLUE)
44
+ end
45
+
46
+ Rpdfium::IO::PNG.write(output_path, w, h, canvas.bytes, stride: w * 4)
47
+ output_path
48
+ end
49
+ end
50
+
51
+ # Mini canvas RGBA per disegnare sopra il rendering. Niente di sofisticato:
52
+ # linee Bresenham, dots, rect fill con alpha blending semplice.
53
+ class Canvas
54
+ attr_reader :bytes, :width, :height
55
+
56
+ def initialize(width, height, rgba_bytes)
57
+ @width = width
58
+ @height = height
59
+ # Lavoriamo su una stringa mutabile (binstring)
60
+ @bytes = rgba_bytes.dup.force_encoding(Encoding::ASCII_8BIT)
61
+ end
62
+
63
+ def set_pixel(x, y, color)
64
+ return if x < 0 || x >= @width || y < 0 || y >= @height
65
+
66
+ idx = (y * @width + x) * 4
67
+ r, g, b, a = color
68
+ if a >= 255
69
+ @bytes.setbyte(idx, r)
70
+ @bytes.setbyte(idx + 1, g)
71
+ @bytes.setbyte(idx + 2, b)
72
+ @bytes.setbyte(idx + 3, 255)
73
+ else
74
+ # Alpha blending semplice (over operator)
75
+ src_a = a / 255.0
76
+ inv = 1 - src_a
77
+ @bytes.setbyte(idx, (r * src_a + @bytes.getbyte(idx) * inv).to_i)
78
+ @bytes.setbyte(idx + 1, (g * src_a + @bytes.getbyte(idx + 1) * inv).to_i)
79
+ @bytes.setbyte(idx + 2, (b * src_a + @bytes.getbyte(idx + 2) * inv).to_i)
80
+ end
81
+ end
82
+
83
+ # Bresenham
84
+ def line(x0, y0, x1, y1, color)
85
+ dx = (x1 - x0).abs
86
+ dy = -(y1 - y0).abs
87
+ sx = x0 < x1 ? 1 : -1
88
+ sy = y0 < y1 ? 1 : -1
89
+ err = dx + dy
90
+ x = x0; y = y0
91
+ loop do
92
+ set_pixel(x, y, color)
93
+ break if x == x1 && y == y1
94
+
95
+ e2 = 2 * err
96
+ if e2 >= dy
97
+ err += dy; x += sx
98
+ end
99
+ if e2 <= dx
100
+ err += dx; y += sy
101
+ end
102
+ end
103
+ end
104
+
105
+ def dot(cx, cy, color, radius)
106
+ (-radius..radius).each do |dy|
107
+ (-radius..radius).each do |dx|
108
+ set_pixel(cx + dx, cy + dy, color) if dx * dx + dy * dy <= radius * radius
109
+ end
110
+ end
111
+ end
112
+
113
+ def rect_fill(x0, y0, x1, y1, color)
114
+ (y0..y1).each do |y|
115
+ (x0..x1).each do |x|
116
+ set_pixel(x, y, color)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ module Table
5
+ # Operazioni su edges (segmenti orizzontali/verticali) usate dal
6
+ # TableFinder. Mappa diretta su `pdfplumber/table.py`.
7
+ #
8
+ # Convenzioni interne (allineate a pdfplumber):
9
+ # - Ogni edge è un Hash con :orientation ("v" | "h"),
10
+ # :x0, :x1, :top, :bottom (in coordinate top-down).
11
+ # - Edge orizzontale: top == bottom, x0 < x1.
12
+ # - Edge verticale: x0 == x1, top < bottom.
13
+ #
14
+ # Le edges possono provenire da:
15
+ # - linee vettoriali del PDF (path segments)
16
+ # - rettangoli (decomposti in 4 lati)
17
+ # - line "implicite" dedotte dall'allineamento di words (strategia :text)
18
+ # - line specificate dall'utente (strategia :explicit)
19
+ module Edges
20
+ module_function
21
+
22
+ # Snap: cluster di edges quasi-collineari → coordinata media comune.
23
+ # Per orizzontali snappa la `top` (== `bottom`); per verticali la `x0`.
24
+ def snap_edges(edges, x_tolerance: 3.0, y_tolerance: 3.0)
25
+ v_edges, h_edges = edges.partition { |e| e[:orientation] == "v" }
26
+
27
+ snapped_v = Util::Cluster.cluster_objects(v_edges, :x0, tolerance: x_tolerance)
28
+ .flat_map { |g| move_to_avg(g, "v") }
29
+ snapped_h = Util::Cluster.cluster_objects(h_edges, :top, tolerance: y_tolerance)
30
+ .flat_map { |g| move_to_avg(g, "h") }
31
+ snapped_v + snapped_h
32
+ end
33
+
34
+ def move_to_avg(cluster, orientation)
35
+ case orientation
36
+ when "h"
37
+ mean = cluster.sum { |e| e[:top] } / cluster.size.to_f
38
+ cluster.map { |e| e.merge(top: mean, bottom: mean) }
39
+ when "v"
40
+ mean = cluster.sum { |e| e[:x0] } / cluster.size.to_f
41
+ cluster.map { |e| e.merge(x0: mean, x1: mean) }
42
+ end
43
+ end
44
+
45
+ # Join: dato un gruppo di edges sulla stessa retta infinita (stessa top
46
+ # per orizzontali, stessa x0 per verticali), fonde quelli i cui estremi
47
+ # sono entro `tolerance`.
48
+ #
49
+ # Match esatto del comportamento di pdfplumber.join_edge_group: scorre
50
+ # sorted per minprop, estende il "current" se overlap/contiguità entro
51
+ # tolerance, altrimenti apre nuovo current.
52
+ def join_edge_group(edges, orientation, tolerance: 3.0)
53
+ return [] if edges.empty?
54
+
55
+ min_prop, max_prop =
56
+ orientation == "h" ? [:x0, :x1] : [:top, :bottom]
57
+
58
+ sorted = edges.sort_by { |e| e[min_prop] }
59
+ joined = [sorted.first.dup]
60
+ sorted[1..].each do |e|
61
+ last = joined.last
62
+ if e[min_prop] <= last[max_prop] + tolerance
63
+ last[max_prop] = e[max_prop] if e[max_prop] > last[max_prop]
64
+ else
65
+ joined << e.dup
66
+ end
67
+ end
68
+ joined
69
+ end
70
+
71
+ # Pipeline completa: snap + join. Fedele a pdfplumber.merge_edges.
72
+ def merge_edges(edges,
73
+ snap_x_tolerance: 3.0, snap_y_tolerance: 3.0,
74
+ join_x_tolerance: 3.0, join_y_tolerance: 3.0)
75
+ if snap_x_tolerance.positive? || snap_y_tolerance.positive?
76
+ edges = snap_edges(edges,
77
+ x_tolerance: snap_x_tolerance,
78
+ y_tolerance: snap_y_tolerance)
79
+ end
80
+
81
+ # Raggruppa per (orientation, "valore della retta")
82
+ # h → top, v → x0
83
+ groups = edges.group_by do |e|
84
+ e[:orientation] == "h" ? ["h", e[:top]] : ["v", e[:x0]]
85
+ end
86
+ groups.flat_map do |(orient, _key), group|
87
+ tol = orient == "h" ? join_x_tolerance : join_y_tolerance
88
+ join_edge_group(group, orient, tolerance: tol)
89
+ end
90
+ end
91
+
92
+ # Filtra edges troppo corti.
93
+ def filter_edges(edges, orientation: nil, min_length: 1.0)
94
+ edges.reject do |e|
95
+ next true if orientation && e[:orientation] != orientation
96
+
97
+ length = if e[:orientation] == "h"
98
+ e[:x1] - e[:x0]
99
+ else
100
+ e[:bottom] - e[:top]
101
+ end
102
+ length < min_length
103
+ end
104
+ end
105
+
106
+ # ------------------------------------------------------------------
107
+ # words → edges (strategia :text)
108
+ # ------------------------------------------------------------------
109
+
110
+ DEFAULT_MIN_WORDS_VERTICAL = 3
111
+ DEFAULT_MIN_WORDS_HORIZONTAL = 1
112
+
113
+ # Per ogni cluster di word allineate "in alto" (stessa top, entro tol=1)
114
+ # con almeno `word_threshold` membri, emette DUE edges orizzontali (top
115
+ # e bottom della bbox di quel cluster). Avere il bottom oltre al top è
116
+ # critico: garantisce che l'ultima riga di ogni tabella abbia un edge
117
+ # orizzontale di chiusura.
118
+ def words_to_edges_h(words, word_threshold: DEFAULT_MIN_WORDS_HORIZONTAL)
119
+ by_top = Util::Cluster.cluster_objects(words, :top, tolerance: 1.0)
120
+ large = by_top.select { |g| g.size >= word_threshold }
121
+ rects = large.map { |g| Util::Cluster.objects_to_rect(g) }
122
+ return [] if rects.empty?
123
+
124
+ min_x0 = rects.map { |r| r[:x0] }.min
125
+ max_x1 = rects.map { |r| r[:x1] }.max
126
+
127
+ rects.flat_map do |r|
128
+ [
129
+ { x0: min_x0, x1: max_x1, top: r[:top], bottom: r[:top], orientation: "h" },
130
+ { x0: min_x0, x1: max_x1, top: r[:bottom], bottom: r[:bottom], orientation: "h" }
131
+ ]
132
+ end
133
+ end
134
+
135
+ # Tre cluster di word per x: x0, x1, centerpoint. Cluster con almeno
136
+ # `word_threshold` membri sono candidati colonna. Le bbox di ciascun
137
+ # cluster vengono "condensate": se una bbox si sovrappone a un'altra
138
+ # già selezionata (più popolata), viene scartata.
139
+ #
140
+ # Per ogni bbox condensata emetto un edge verticale al suo x0 (left
141
+ # della colonna). In aggiunta, emetto un edge "right" finale al max
142
+ # x1 di tutte le bbox: chiude visivamente la tabella sulla destra.
143
+ def words_to_edges_v(words, word_threshold: DEFAULT_MIN_WORDS_VERTICAL)
144
+ by_x0 = Util::Cluster.cluster_objects(words, :x0, tolerance: 1.0)
145
+ by_x1 = Util::Cluster.cluster_objects(words, :x1, tolerance: 1.0)
146
+ center_fn = ->(w) { (w[:x0] + w[:x1]) / 2.0 }
147
+ by_center = Util::Cluster.cluster_objects(words, center_fn, tolerance: 1.0)
148
+
149
+ clusters = by_x0 + by_x1 + by_center
150
+ # Più popolati prima
151
+ sorted = clusters.sort_by { |c| -c.size }
152
+ large = sorted.select { |c| c.size >= word_threshold }
153
+ bboxes = large.map { |c| Util::Cluster.objects_to_bbox(c) }
154
+
155
+ condensed_bboxes = bboxes.each_with_object([]) do |b, acc|
156
+ acc << b unless acc.any? { |c| Util::Cluster.bbox_overlaps?(b, c) }
157
+ end
158
+ return [] if condensed_bboxes.empty?
159
+
160
+ # Sort left-to-right per emettere edges in ordine geometrico.
161
+ condensed_rects = condensed_bboxes.map do |b|
162
+ { x0: b[0], top: b[1], x1: b[2], bottom: b[3] }
163
+ end.sort_by { |r| r[:x0] }
164
+
165
+ max_x1, min_top, max_bottom = condensed_rects.each_with_object(
166
+ [-Float::INFINITY, Float::INFINITY, -Float::INFINITY]
167
+ ) do |r, acc|
168
+ acc[0] = r[:x1] if r[:x1] > acc[0]
169
+ acc[1] = r[:top] if r[:top] < acc[1]
170
+ acc[2] = r[:bottom] if r[:bottom] > acc[2]
171
+ end
172
+
173
+ # Edge "left" di ogni colonna + un edge finale "right".
174
+ left_edges = condensed_rects.map do |r|
175
+ { x0: r[:x0], x1: r[:x0], top: min_top, bottom: max_bottom, orientation: "v" }
176
+ end
177
+ right_edge = { x0: max_x1, x1: max_x1, top: min_top, bottom: max_bottom, orientation: "v" }
178
+ left_edges + [right_edge]
179
+ end
180
+
181
+ # ------------------------------------------------------------------
182
+ # intersezioni edges
183
+ # ------------------------------------------------------------------
184
+
185
+ # Per ogni coppia (h, v) che si interseca entro tolerance, registra
186
+ # un'intersezione `(v.x0, h.top)` con i puntatori agli edge sorgenti.
187
+ # Il valore in `intersections[(x, y)] = { v: [...], h: [...] }` permette
188
+ # poi al cell-builder di verificare "edge connect".
189
+ #
190
+ # Ottimizzazione rispetto al loop naïve O(|v|×|h|): sorted_h è ordinato
191
+ # per top; per ogni edge verticale si usa bsearch per trovare il primo h
192
+ # candidato e si esce appena h[:top] supera v[:bottom] + y_tolerance,
193
+ # riducendo le iterazioni al solo sottoinsieme verticalmente rilevante.
194
+ def edges_to_intersections(edges, x_tolerance: 1.0, y_tolerance: 1.0)
195
+ v_edges, h_edges = edges.partition { |e| e[:orientation] == "v" }
196
+ intersections = {}
197
+ sorted_v = v_edges.sort_by { |v| [v[:x0], v[:top]] }
198
+ sorted_h = h_edges.sort_by { |h| [h[:top], h[:x0]] }
199
+ h_tops = sorted_h.map { |h| h[:top] }
200
+
201
+ sorted_v.each do |v|
202
+ v_top_min = v[:top] - y_tolerance
203
+ v_top_max = v[:bottom] + y_tolerance
204
+
205
+ # Salta tutti gli h il cui top è ancora sotto la finestra verticale.
206
+ start_idx = h_tops.bsearch_index { |t| t >= v_top_min } || sorted_h.size
207
+
208
+ sorted_h[start_idx..].each do |h|
209
+ # Gli h rimanenti sono oltre la finestra: esci subito.
210
+ break if h[:top] > v_top_max
211
+
212
+ next unless v[:x0] >= h[:x0] - x_tolerance
213
+ next unless v[:x0] <= h[:x1] + x_tolerance
214
+
215
+ key = [v[:x0], h[:top]]
216
+ entry = intersections[key] ||= { v: [], h: [] }
217
+ entry[:v] << v
218
+ entry[:h] << h
219
+ end
220
+ end
221
+ intersections
222
+ end
223
+ end
224
+ end
225
+ end