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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1870 -0
- data/LICENSE +19 -0
- data/README.md +599 -0
- data/lib/rpdfium/annotation/annotation.rb +114 -0
- data/lib/rpdfium/document.rb +226 -0
- data/lib/rpdfium/errors.rb +55 -0
- data/lib/rpdfium/form/form.rb +121 -0
- data/lib/rpdfium/image/embedded.rb +145 -0
- data/lib/rpdfium/io/png.rb +65 -0
- data/lib/rpdfium/page.rb +1623 -0
- data/lib/rpdfium/raw.rb +982 -0
- data/lib/rpdfium/search/search.rb +101 -0
- data/lib/rpdfium/structure/attachment.rb +40 -0
- data/lib/rpdfium/structure/element.rb +330 -0
- data/lib/rpdfium/structure/outline.rb +48 -0
- data/lib/rpdfium/structure/tree.rb +202 -0
- data/lib/rpdfium/table/cells.rb +137 -0
- data/lib/rpdfium/table/debugger.rb +122 -0
- data/lib/rpdfium/table/edges.rb +225 -0
- data/lib/rpdfium/table/extractor.rb +246 -0
- data/lib/rpdfium/table/table.rb +184 -0
- data/lib/rpdfium/util/cluster.rb +143 -0
- data/lib/rpdfium/util/column_inference.rb +139 -0
- data/lib/rpdfium/util/label_matcher.rb +214 -0
- data/lib/rpdfium/util/text_extraction.rb +49 -0
- data/lib/rpdfium/util/word_extractor.rb +151 -0
- data/lib/rpdfium/util/word_merger.rb +102 -0
- data/lib/rpdfium/version.rb +5 -0
- data/lib/rpdfium.rb +92 -0
- metadata +134 -0
|
@@ -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
|