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,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
|