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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rpdfium
|
|
4
|
+
module Util
|
|
5
|
+
# Inferenza di colonne dati su PDF non-tabellari.
|
|
6
|
+
#
|
|
7
|
+
# Identifica gruppi di word che appartengono alla stessa "colonna"
|
|
8
|
+
# verticale di un layout (es. una colonna di importi in un modulo
|
|
9
|
+
# prestampato) anche quando non ci sono linee disegnate.
|
|
10
|
+
#
|
|
11
|
+
# L'algoritmo opera in tre passaggi:
|
|
12
|
+
#
|
|
13
|
+
# 1. **Cluster per coordinata X** — raggruppa le word con la stessa x0
|
|
14
|
+
# (left-aligned) o x1 (right-aligned, tipico dei numeri) entro la
|
|
15
|
+
# tolleranza configurabile.
|
|
16
|
+
#
|
|
17
|
+
# 2. **Spezza per gap verticali** — se due word consecutive in un
|
|
18
|
+
# gruppo hanno un gap verticale "anomalo" (> 3× la mediana, o
|
|
19
|
+
# > 40pt), le separa in colonne distinte. Risolve casi tipo "codice
|
|
20
|
+
# fiscale in alto + tabella sotto" che condividono la stessa X.
|
|
21
|
+
#
|
|
22
|
+
# 3. **Filtra per densità** — una colonna "vera" ha valori regolarmente
|
|
23
|
+
# equispaziati (coefficiente di variazione dei gap < soglia). Esclude
|
|
24
|
+
# falsi positivi come valori isolati che si trovano per caso allineati.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# inference = Rpdfium::Util::ColumnInference.new(
|
|
28
|
+
# x_tolerance: 3.0,
|
|
29
|
+
# min_size: 3,
|
|
30
|
+
# cv_threshold: 0.15
|
|
31
|
+
# )
|
|
32
|
+
# columns = inference.infer(words)
|
|
33
|
+
# # => [
|
|
34
|
+
# # [word1, word2, ..., word12], # 12 importi nella colonna 1
|
|
35
|
+
# # [word1, word2, ..., word12] # 12 codici nella colonna 2
|
|
36
|
+
# # ]
|
|
37
|
+
class ColumnInference
|
|
38
|
+
DEFAULT_X_TOLERANCE = 3.0
|
|
39
|
+
DEFAULT_MIN_SIZE = 3
|
|
40
|
+
DEFAULT_CV_THRESHOLD = 0.15
|
|
41
|
+
DEFAULT_GAP_MULTIPLIER = 3.0
|
|
42
|
+
DEFAULT_GAP_ABSOLUTE = 40.0
|
|
43
|
+
|
|
44
|
+
def initialize(x_tolerance: DEFAULT_X_TOLERANCE,
|
|
45
|
+
min_size: DEFAULT_MIN_SIZE,
|
|
46
|
+
cv_threshold: DEFAULT_CV_THRESHOLD,
|
|
47
|
+
gap_multiplier: DEFAULT_GAP_MULTIPLIER,
|
|
48
|
+
gap_absolute: DEFAULT_GAP_ABSOLUTE)
|
|
49
|
+
@x_tolerance = x_tolerance
|
|
50
|
+
@min_size = min_size
|
|
51
|
+
@cv_threshold = cv_threshold
|
|
52
|
+
@gap_multiplier = gap_multiplier
|
|
53
|
+
@gap_absolute = gap_absolute
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Inferisce le colonne dai word forniti. Usa sia x0 (left-align) che
|
|
57
|
+
# x1 (right-align) come criteri di allineamento, ritorna l'unione
|
|
58
|
+
# delle colonne identificate.
|
|
59
|
+
#
|
|
60
|
+
# @param words [Array<Hash>] word con :x0, :x1, :top
|
|
61
|
+
# @return [Array<Array<Hash>>] array di colonne, ognuna è un array
|
|
62
|
+
# di word ordinati per :top crescente
|
|
63
|
+
def infer(words)
|
|
64
|
+
return [] if words.empty?
|
|
65
|
+
|
|
66
|
+
by_x0 = cluster_by(words, :x0)
|
|
67
|
+
by_x1 = cluster_by(words, :x1)
|
|
68
|
+
|
|
69
|
+
# Unione: una word può apparire in più colonne. È compito del
|
|
70
|
+
# chiamante decidere come gestire (es. preferire la prima
|
|
71
|
+
# colonna, o quella più grande). Qui ritorniamo tutte.
|
|
72
|
+
(by_x0 + by_x1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Cluster di word per una specifica coordinata.
|
|
76
|
+
# @param coord [Symbol] :x0 o :x1
|
|
77
|
+
def cluster_by(words, coord)
|
|
78
|
+
sorted = words.sort_by { |v| v[coord] }
|
|
79
|
+
x_groups = []
|
|
80
|
+
current = []
|
|
81
|
+
sorted.each do |v|
|
|
82
|
+
if current.empty? || (v[coord] - current.last[coord]).abs <= @x_tolerance
|
|
83
|
+
current << v
|
|
84
|
+
else
|
|
85
|
+
x_groups << current
|
|
86
|
+
current = [v]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
x_groups << current
|
|
90
|
+
|
|
91
|
+
columns = []
|
|
92
|
+
x_groups.each do |group|
|
|
93
|
+
sorted_y = group.sort_by { |v| v[:top] }
|
|
94
|
+
gaps = sorted_y.each_cons(2).map { |a, b| b[:top] - a[:top] }
|
|
95
|
+
|
|
96
|
+
if gaps.empty?
|
|
97
|
+
columns << sorted_y if dense_enough?(sorted_y)
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
median_gap = gaps.sort[gaps.size / 2]
|
|
102
|
+
threshold = [median_gap * @gap_multiplier, @gap_absolute].max
|
|
103
|
+
|
|
104
|
+
sub = [sorted_y.first]
|
|
105
|
+
sorted_y.each_cons(2) do |a, b|
|
|
106
|
+
gap = b[:top] - a[:top]
|
|
107
|
+
if gap > threshold
|
|
108
|
+
columns << sub if dense_enough?(sub)
|
|
109
|
+
sub = [b]
|
|
110
|
+
else
|
|
111
|
+
sub << b
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
columns << sub if dense_enough?(sub)
|
|
115
|
+
end
|
|
116
|
+
columns
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Una colonna è "abbastanza densa" se ha almeno min_size valori e
|
|
120
|
+
# il coefficiente di variazione (std_dev/mean) dei gap verticali è
|
|
121
|
+
# sotto la soglia. CV bassa = spacing regolare = colonna ripetitiva
|
|
122
|
+
# vera (vs. valori sparsi accidentalmente allineati).
|
|
123
|
+
def dense_enough?(col_values)
|
|
124
|
+
return false if col_values.size < @min_size
|
|
125
|
+
|
|
126
|
+
sorted_y = col_values.sort_by { |v| v[:top] }
|
|
127
|
+
gaps = sorted_y.each_cons(2).map { |a, b| b[:top] - a[:top] }
|
|
128
|
+
return true if gaps.size < 2
|
|
129
|
+
|
|
130
|
+
mean = gaps.sum / gaps.size.to_f
|
|
131
|
+
variance = gaps.map { |g| (g - mean)**2 }.sum / gaps.size
|
|
132
|
+
std_dev = Math.sqrt(variance)
|
|
133
|
+
cv = mean.zero? ? Float::INFINITY : std_dev / mean
|
|
134
|
+
|
|
135
|
+
cv < @cv_threshold
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rpdfium
|
|
4
|
+
module Util
|
|
5
|
+
# Associa label semantiche a valori inseriti su PDF di moduli compilati
|
|
6
|
+
# (F24, comunicazioni IVA, modelli 770) dove template e dati coesistono
|
|
7
|
+
# come testo grafico in font diversi.
|
|
8
|
+
#
|
|
9
|
+
# Strategia base:
|
|
10
|
+
#
|
|
11
|
+
# 1. **Cluster** le parole del template in "label coerenti": word
|
|
12
|
+
# geometricamente vicine formano un'unica label.
|
|
13
|
+
#
|
|
14
|
+
# 2. **Per ogni valore** cerca:
|
|
15
|
+
# - `:col` — label SOPRA in stessa colonna
|
|
16
|
+
# - `:row` — label A SINISTRA in stessa riga
|
|
17
|
+
#
|
|
18
|
+
# 3. (Opzionale) **Riassegnazione per colonne**: usa `ColumnInference`
|
|
19
|
+
# per identificare colonne ripetitive (es. ST2..ST13 del 770 Quadro
|
|
20
|
+
# ST) e propaga l'header canonico a tutti i valori della colonna,
|
|
21
|
+
# superando il limite `col_max_dy`.
|
|
22
|
+
#
|
|
23
|
+
# @example uso base
|
|
24
|
+
# matcher = Rpdfium::Util::LabelMatcher.new
|
|
25
|
+
# matcher.match(value_words, anchor_words)
|
|
26
|
+
#
|
|
27
|
+
# @example con tabelle ripetitive (header in cima alla colonna)
|
|
28
|
+
# matcher = Rpdfium::Util::LabelMatcher.new(
|
|
29
|
+
# column_inference: Rpdfium::Util::ColumnInference.new
|
|
30
|
+
# )
|
|
31
|
+
# matcher.match(value_words, anchor_words)
|
|
32
|
+
class LabelMatcher
|
|
33
|
+
DEFAULT_COL_MAX_DY = 80.0
|
|
34
|
+
DEFAULT_ROW_MAX_DX = 200.0
|
|
35
|
+
DEFAULT_COL_X_TOLERANCE = 10.0
|
|
36
|
+
DEFAULT_ROW_Y_TOLERANCE = 2.0
|
|
37
|
+
DEFAULT_CLUSTER_SAME_ROW_DY = 4.0
|
|
38
|
+
DEFAULT_CLUSTER_SAME_ROW_DX = 12.0
|
|
39
|
+
DEFAULT_CLUSTER_ADJ_ROW_DY = 4.0
|
|
40
|
+
DEFAULT_IGNORE_LABEL_PATTERN = /\A\d{1,3}\z|\A[IVX]{1,5}\z/.freeze
|
|
41
|
+
WIDE_VALUE_THRESHOLD = 60.0
|
|
42
|
+
|
|
43
|
+
def initialize(col_max_dy: DEFAULT_COL_MAX_DY,
|
|
44
|
+
row_max_dx: DEFAULT_ROW_MAX_DX,
|
|
45
|
+
col_x_tolerance: DEFAULT_COL_X_TOLERANCE,
|
|
46
|
+
row_y_tolerance: DEFAULT_ROW_Y_TOLERANCE,
|
|
47
|
+
cluster_same_row_dy: DEFAULT_CLUSTER_SAME_ROW_DY,
|
|
48
|
+
cluster_same_row_dx: DEFAULT_CLUSTER_SAME_ROW_DX,
|
|
49
|
+
cluster_adj_row_dy: DEFAULT_CLUSTER_ADJ_ROW_DY,
|
|
50
|
+
ignore_label_pattern: DEFAULT_IGNORE_LABEL_PATTERN,
|
|
51
|
+
column_inference: nil)
|
|
52
|
+
@col_max_dy = col_max_dy
|
|
53
|
+
@row_max_dx = row_max_dx
|
|
54
|
+
@col_x_tolerance = col_x_tolerance
|
|
55
|
+
@row_y_tolerance = row_y_tolerance
|
|
56
|
+
@cluster_same_row_dy = cluster_same_row_dy
|
|
57
|
+
@cluster_same_row_dx = cluster_same_row_dx
|
|
58
|
+
@cluster_adj_row_dy = cluster_adj_row_dy
|
|
59
|
+
@ignore_label_pattern = ignore_label_pattern
|
|
60
|
+
@column_inference = column_inference
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Calcola le associazioni label → valore.
|
|
64
|
+
#
|
|
65
|
+
# @param values [Array<Hash>] word del layer "dati"
|
|
66
|
+
# @param anchors [Array<Hash>] word del layer "template"
|
|
67
|
+
# @return [Array<Hash>] uno per valore: { value:, labels: { col:, row: }, geometry: }
|
|
68
|
+
def match(values, anchors)
|
|
69
|
+
labels = cluster_anchors(anchors)
|
|
70
|
+
|
|
71
|
+
prelim = values.map do |v|
|
|
72
|
+
col = find_col_label(v, labels)
|
|
73
|
+
row = find_row_label(v, labels)
|
|
74
|
+
{ value: v, col: col, row: row }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Riassegnazione opzionale per colonne ripetitive
|
|
78
|
+
prelim = reassign_by_columns(prelim, labels, values) if @column_inference
|
|
79
|
+
|
|
80
|
+
prelim.map do |entry|
|
|
81
|
+
v = entry[:value]
|
|
82
|
+
{
|
|
83
|
+
value: v[:text],
|
|
84
|
+
labels: {
|
|
85
|
+
col: entry[:col]&.dig(:text),
|
|
86
|
+
row: entry[:row]&.dig(:text)
|
|
87
|
+
},
|
|
88
|
+
geometry: {
|
|
89
|
+
x0: v[:x0], x1: v[:x1], top: v[:top], bottom: v[:bottom]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Ricostruisce le label dal cluster delle word del template.
|
|
96
|
+
# Esposto pubblicamente per ispezione/debug.
|
|
97
|
+
def cluster_anchors(anchor_words)
|
|
98
|
+
remaining = anchor_words.dup
|
|
99
|
+
groups = []
|
|
100
|
+
until remaining.empty?
|
|
101
|
+
seed = remaining.shift
|
|
102
|
+
group = [seed]
|
|
103
|
+
grew = true
|
|
104
|
+
while grew
|
|
105
|
+
grew = false
|
|
106
|
+
remaining.dup.each do |w|
|
|
107
|
+
close = group.any? do |g|
|
|
108
|
+
dx_horiz = [w[:x0] - g[:x1], g[:x0] - w[:x1]].max
|
|
109
|
+
same_row = (w[:top] - g[:top]).abs < @cluster_same_row_dy &&
|
|
110
|
+
dx_horiz < @cluster_same_row_dx
|
|
111
|
+
dy_above = (g[:top] - w[:bottom]).abs
|
|
112
|
+
dy_below = (w[:top] - g[:bottom]).abs
|
|
113
|
+
vertical_adjacent = [dy_above, dy_below].min < @cluster_adj_row_dy
|
|
114
|
+
x_overlap = !(w[:x1] < g[:x0] - 3 || w[:x0] > g[:x1] + 3)
|
|
115
|
+
adj_row = vertical_adjacent && x_overlap
|
|
116
|
+
same_row || adj_row
|
|
117
|
+
end
|
|
118
|
+
if close
|
|
119
|
+
group << w
|
|
120
|
+
remaining.delete(w)
|
|
121
|
+
grew = true
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
groups << group
|
|
126
|
+
end
|
|
127
|
+
labels = groups.map { |g| group_to_label(g) }
|
|
128
|
+
if @ignore_label_pattern
|
|
129
|
+
labels = labels.reject { |l| l[:text].match?(@ignore_label_pattern) }
|
|
130
|
+
end
|
|
131
|
+
labels
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def group_to_label(group)
|
|
137
|
+
sorted = group.sort_by { |w| [w[:top].round(0), w[:x0]] }
|
|
138
|
+
{
|
|
139
|
+
text: sorted.map { |w| w[:text] }.join(" "),
|
|
140
|
+
x0: group.map { |w| w[:x0] }.min,
|
|
141
|
+
x1: group.map { |w| w[:x1] }.max,
|
|
142
|
+
top: group.map { |w| w[:top] }.min,
|
|
143
|
+
bottom: group.map { |w| w[:bottom] }.max
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def find_col_label(value, labels)
|
|
148
|
+
# Per word "wide" (più larghe della maggior parte delle label,
|
|
149
|
+
# tipicamente perché frutto di merge di una stringa che attraversa
|
|
150
|
+
# più colonne template) usa il left edge: la label corretta è
|
|
151
|
+
# quella sotto cui INIZIA il valore.
|
|
152
|
+
value_width = value[:x1] - value[:x0]
|
|
153
|
+
anchor_point =
|
|
154
|
+
if value_width > WIDE_VALUE_THRESHOLD
|
|
155
|
+
value[:x0] + 5.0
|
|
156
|
+
else
|
|
157
|
+
(value[:x0] + value[:x1]) / 2.0
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
labels.select do |l|
|
|
161
|
+
l[:x0] - @col_x_tolerance <= anchor_point &&
|
|
162
|
+
l[:x1] + @col_x_tolerance >= anchor_point &&
|
|
163
|
+
l[:bottom] < value[:top] &&
|
|
164
|
+
(value[:top] - l[:bottom]) <= @col_max_dy
|
|
165
|
+
end.min_by { |l| value[:top] - l[:bottom] }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def find_row_label(value, labels)
|
|
169
|
+
vy = (value[:top] + value[:bottom]) / 2.0
|
|
170
|
+
labels.select do |l|
|
|
171
|
+
l[:top] <= vy &&
|
|
172
|
+
l[:bottom] >= vy - @row_y_tolerance &&
|
|
173
|
+
l[:x1] < value[:x0] &&
|
|
174
|
+
(value[:x0] - l[:x1]) <= @row_max_dx
|
|
175
|
+
end.max_by { |l| l[:x1] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Identifica colonne dati e propaga l'header canonico stampato in
|
|
179
|
+
# cima alla colonna a TUTTI i valori della colonna.
|
|
180
|
+
# Usa @column_inference fornito al constructor.
|
|
181
|
+
def reassign_by_columns(prelim, labels, values)
|
|
182
|
+
columns = @column_inference.infer(values)
|
|
183
|
+
return prelim if columns.empty?
|
|
184
|
+
|
|
185
|
+
# Ordina colonne più grandi prima (più evidenza statistica)
|
|
186
|
+
sorted_columns = columns.sort_by { |c| -c.size }
|
|
187
|
+
|
|
188
|
+
column_headers = {}
|
|
189
|
+
sorted_columns.each do |col_values|
|
|
190
|
+
col_top = col_values.map { |v| v[:top] }.min
|
|
191
|
+
anchor_x = col_values.map { |v| (v[:x0] + v[:x1]) / 2.0 }.sum / col_values.size
|
|
192
|
+
|
|
193
|
+
header = labels.select do |l|
|
|
194
|
+
l[:x0] - @col_x_tolerance <= anchor_x &&
|
|
195
|
+
l[:x1] + @col_x_tolerance >= anchor_x &&
|
|
196
|
+
l[:bottom] <= col_top + 1
|
|
197
|
+
end.min_by { |l| col_top - l[:bottom] }
|
|
198
|
+
|
|
199
|
+
next unless header
|
|
200
|
+
|
|
201
|
+
col_values.each do |v|
|
|
202
|
+
column_headers[v.object_id] ||= header
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
prelim.map do |entry|
|
|
207
|
+
v = entry[:value]
|
|
208
|
+
new_col = column_headers[v.object_id]
|
|
209
|
+
new_col ? entry.merge(col: new_col) : entry
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rpdfium
|
|
4
|
+
module Util
|
|
5
|
+
# Estrazione testo "lineare" da una collezione di char, layout=False.
|
|
6
|
+
# Equivalente di pdfplumber.utils.text.chars_to_textmap nella variante
|
|
7
|
+
# senza preservazione del layout grafico.
|
|
8
|
+
#
|
|
9
|
+
# Algoritmo:
|
|
10
|
+
# 1. Estrai words con WordExtractor (gli stessi tolerance).
|
|
11
|
+
# 2. Cluster di words per `top` con y_tolerance → righe logiche.
|
|
12
|
+
# 3. Per ogni riga, ordina per x0 e joina con singolo spazio.
|
|
13
|
+
# 4. Joina le righe con "\n".
|
|
14
|
+
#
|
|
15
|
+
# NOTA su una sottigliezza: pdfplumber permette di usare x_tolerance
|
|
16
|
+
# diverso da y_tolerance sia per word-extraction che per line-clustering.
|
|
17
|
+
# Replichiamo questa flessibilità.
|
|
18
|
+
module TextExtraction
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
DEFAULT_X_TOLERANCE = WordExtractor::DEFAULT_X_TOLERANCE
|
|
22
|
+
DEFAULT_Y_TOLERANCE = WordExtractor::DEFAULT_Y_TOLERANCE
|
|
23
|
+
|
|
24
|
+
def extract_text(chars,
|
|
25
|
+
x_tolerance: DEFAULT_X_TOLERANCE,
|
|
26
|
+
y_tolerance: DEFAULT_Y_TOLERANCE,
|
|
27
|
+
keep_blank_chars: false)
|
|
28
|
+
return "" if chars.empty?
|
|
29
|
+
|
|
30
|
+
words = WordExtractor.new(
|
|
31
|
+
x_tolerance: x_tolerance,
|
|
32
|
+
y_tolerance: y_tolerance,
|
|
33
|
+
keep_blank_chars: keep_blank_chars
|
|
34
|
+
).extract_words(chars)
|
|
35
|
+
return "" if words.empty?
|
|
36
|
+
|
|
37
|
+
# Cluster delle WORDS per top: righe di output finali.
|
|
38
|
+
# Usa y_tolerance "di linea" — pdfplumber qui usa la stessa y_tolerance
|
|
39
|
+
# passata, ed è coerente con come si comporta extract_text.
|
|
40
|
+
line_clusters = Cluster.cluster_objects(words, :top, tolerance: y_tolerance)
|
|
41
|
+
|
|
42
|
+
# Per ogni riga di output joina con spazio singolo.
|
|
43
|
+
line_clusters.map do |line_words|
|
|
44
|
+
line_words.sort_by { |w| w[:x0] }.map { |w| w[:text] }.join(" ")
|
|
45
|
+
end.join("\n")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rpdfium
|
|
4
|
+
module Util
|
|
5
|
+
# Estrae "words" da una lista di char, fedelmente a pdfplumber.WordExtractor.
|
|
6
|
+
#
|
|
7
|
+
# Algoritmo:
|
|
8
|
+
# 1. Ordina i char per (top, x0): righe top-to-bottom, char left-to-right
|
|
9
|
+
# dentro ogni riga.
|
|
10
|
+
# 2. Cluster per top con `y_tolerance` → "righe logiche" di char.
|
|
11
|
+
# 3. Dentro ogni riga, cluster per gap orizzontale: due char sono nella
|
|
12
|
+
# stessa word se `next.x0 - prev.x1 <= x_tolerance`. Anche un char
|
|
13
|
+
# whitespace separa la word (a meno che `keep_blank_chars`).
|
|
14
|
+
# 4. Per ogni cluster di char emette una word: text concatenato, bbox.
|
|
15
|
+
#
|
|
16
|
+
# Differenze da pdfplumber (semplificazioni accettabili per il nostro uso):
|
|
17
|
+
# - Non gestiamo `line_dir`/`char_dir` rotated (testo ruotato non
|
|
18
|
+
# orizzontale ltr): non rilevante per i casi d'uso correnti.
|
|
19
|
+
# - Non gestiamo `use_text_flow` (ordering basato sul content stream):
|
|
20
|
+
# i nostri char arrivano già da PDFium nell'ordine geometrico via
|
|
21
|
+
# `chars` (top, x0).
|
|
22
|
+
# - Non gestiamo `expand_ligatures`: PDFium di solito espande i
|
|
23
|
+
# codepoint correttamente già a livello char.
|
|
24
|
+
#
|
|
25
|
+
# Queste differenze sono documentate; se mai necessarie si aggiungono
|
|
26
|
+
# come feature toggles senza cambiare il path di default.
|
|
27
|
+
class WordExtractor
|
|
28
|
+
DEFAULT_X_TOLERANCE = 3.0
|
|
29
|
+
DEFAULT_Y_TOLERANCE = 3.0
|
|
30
|
+
|
|
31
|
+
attr_reader :x_tolerance, :y_tolerance, :keep_blank_chars
|
|
32
|
+
|
|
33
|
+
def initialize(x_tolerance: DEFAULT_X_TOLERANCE,
|
|
34
|
+
y_tolerance: DEFAULT_Y_TOLERANCE,
|
|
35
|
+
keep_blank_chars: false,
|
|
36
|
+
extra_attrs: nil)
|
|
37
|
+
@x_tolerance = x_tolerance.to_f
|
|
38
|
+
@y_tolerance = y_tolerance.to_f
|
|
39
|
+
@keep_blank_chars = keep_blank_chars
|
|
40
|
+
@extra_attrs = extra_attrs || []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Restituisce un Array di Hash: { text:, x0:, x1:, top:, bottom:, chars: }.
|
|
44
|
+
# Se `extra_attrs` è non vuoto, ogni word splitta anche al cambio di
|
|
45
|
+
# questi attributi (es. fontname/size diversi → word diverse).
|
|
46
|
+
def extract_words(chars)
|
|
47
|
+
return [] if chars.empty?
|
|
48
|
+
|
|
49
|
+
# Fast path: 1 solo char → 1 word triviale (se non whitespace).
|
|
50
|
+
if chars.size == 1
|
|
51
|
+
c = chars.first
|
|
52
|
+
return [] if blank?(c) && !@keep_blank_chars
|
|
53
|
+
|
|
54
|
+
return [build_word([c])]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 1. Ordina per (top, x0). Top-down, left-to-right.
|
|
58
|
+
sorted = chars.sort_by { |c| [c[:top], c[:x0]] }
|
|
59
|
+
|
|
60
|
+
# 2. Cluster in righe per `top`.
|
|
61
|
+
# `presorted: true`: sorted è già ordinato per [top, x0], quindi
|
|
62
|
+
# implicitamente anche per top — cluster_objects salta il proprio
|
|
63
|
+
# sort interno.
|
|
64
|
+
rows = Cluster.cluster_objects(sorted, :top,
|
|
65
|
+
tolerance: @y_tolerance,
|
|
66
|
+
presorted: true)
|
|
67
|
+
|
|
68
|
+
words = []
|
|
69
|
+
rows.each do |row|
|
|
70
|
+
# Re-sort per x0 dentro ogni riga clusterizzata.
|
|
71
|
+
#
|
|
72
|
+
# NOTA: in linea di principio l'input `sorted` è già ordinato per
|
|
73
|
+
# [top, x0], quindi i cluster di top dovrebbero essere già in
|
|
74
|
+
# ordine x0. MA il sort globale `[top, x0]` rispetta strettamente
|
|
75
|
+
# l'ordine per top — se due char della stessa riga visiva hanno
|
|
76
|
+
# top diversi entro tolerance (es. la "i" minuscola spesso ha
|
|
77
|
+
# top più alto di 0.008pt rispetto alle altre lettere a causa di
|
|
78
|
+
# come PDFium calcola la bbox), il sort globale li interfoglia.
|
|
79
|
+
# Il cluster_objects per :top non riordina internamente i char,
|
|
80
|
+
# quindi un char con top leggermente minore finisce DAVANTI a
|
|
81
|
+
# tutte le altre lettere della parola.
|
|
82
|
+
#
|
|
83
|
+
# Esempio reale: "Categoria" dove "i" ha top=414.9789 e le altre
|
|
84
|
+
# 414.9869 → output `iCategora` invece di `Categoria`.
|
|
85
|
+
# Il fix è semplicemente ri-sortare per x0 dentro la riga.
|
|
86
|
+
row_sorted = row.sort_by { |c| c[:x0] }
|
|
87
|
+
|
|
88
|
+
word_chars = []
|
|
89
|
+
row_sorted.each do |c|
|
|
90
|
+
if char_begins_new_word?(word_chars.last, c)
|
|
91
|
+
words << build_word(word_chars) unless word_chars.empty?
|
|
92
|
+
word_chars = []
|
|
93
|
+
end
|
|
94
|
+
# Whitespace: per default lo usiamo come separatore (lo scartiamo).
|
|
95
|
+
# Con keep_blank_chars=true lo includiamo nella word corrente.
|
|
96
|
+
if blank?(c) && !@keep_blank_chars
|
|
97
|
+
words << build_word(word_chars) unless word_chars.empty?
|
|
98
|
+
word_chars = []
|
|
99
|
+
else
|
|
100
|
+
word_chars << c
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
words << build_word(word_chars) unless word_chars.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
words
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def char_begins_new_word?(prev, curr)
|
|
112
|
+
return false if prev.nil?
|
|
113
|
+
|
|
114
|
+
# Gap orizzontale (PDF font hinting può dare overlap leggero, max 0)
|
|
115
|
+
gap = curr[:x0] - prev[:x1]
|
|
116
|
+
return true if gap > @x_tolerance
|
|
117
|
+
|
|
118
|
+
# Cambio di riga (può succedere se y_tolerance è grande ma due
|
|
119
|
+
# char sono comunque su righe diverse)
|
|
120
|
+
return true if (curr[:top] - prev[:top]).abs > @y_tolerance
|
|
121
|
+
|
|
122
|
+
# Cambio di un extra_attr richiesto
|
|
123
|
+
@extra_attrs.any? { |attr| prev[attr] != curr[attr] }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def blank?(c)
|
|
127
|
+
c[:char].nil? || c[:char].match?(/\A\s\z/) || c[:generated]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_word(chars)
|
|
131
|
+
text = +""
|
|
132
|
+
x0 = Float::INFINITY
|
|
133
|
+
x1 = -Float::INFINITY
|
|
134
|
+
top = Float::INFINITY
|
|
135
|
+
bottom = -Float::INFINITY
|
|
136
|
+
|
|
137
|
+
chars.each do |c|
|
|
138
|
+
text << c[:char]
|
|
139
|
+
x0 = c[:x0] if c[:x0] < x0
|
|
140
|
+
x1 = c[:x1] if c[:x1] > x1
|
|
141
|
+
top = c[:top] if c[:top] < top
|
|
142
|
+
bottom = c[:bottom] if c[:bottom] > bottom
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
word = { text: text, x0: x0, x1: x1, top: top, bottom: bottom, chars: chars }
|
|
146
|
+
@extra_attrs.each { |a| word[a] = chars.first[a] }
|
|
147
|
+
word
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rpdfium
|
|
4
|
+
module Util
|
|
5
|
+
# Fonde word adiacenti sulla stessa riga in un'unica word con bbox
|
|
6
|
+
# aggregata e text concatenato.
|
|
7
|
+
#
|
|
8
|
+
# Tre strategie disponibili come metodi separati:
|
|
9
|
+
#
|
|
10
|
+
# - `merge_by_proximity` — fonde tutte le word adiacenti che soddisfano
|
|
11
|
+
# il criterio di vicinanza. Strategia base.
|
|
12
|
+
#
|
|
13
|
+
# - `merge_by_label` — fonde solo word che condividono la stessa "label"
|
|
14
|
+
# (chiave esterna calcolata dal chiamante). Utile per preservare la
|
|
15
|
+
# semantica quando label diverse cadono sulla stessa riga (es. flag
|
|
16
|
+
# in colonne adiacenti).
|
|
17
|
+
#
|
|
18
|
+
# - `merge_unlabeled` — fonde solo word "orfane" (label nil) lasciando
|
|
19
|
+
# intatte quelle con label. Inverso di merge_by_label.
|
|
20
|
+
#
|
|
21
|
+
# Tutte ritornano una nuova lista di word, con quelle fuse rappresentate
|
|
22
|
+
# come hash `{ text:, x0:, x1:, top:, bottom: }`.
|
|
23
|
+
#
|
|
24
|
+
# @example merge per proximity
|
|
25
|
+
# merger = Rpdfium::Util::WordMerger.new(x_gap: 20.0, y_tol: 3.0)
|
|
26
|
+
# merged = merger.merge_by_proximity(words)
|
|
27
|
+
#
|
|
28
|
+
# @example merge per label, con label fornita dal chiamante
|
|
29
|
+
# labels_by_word = words.each_with_object({}) { |w, h| h[w] = compute_label(w) }
|
|
30
|
+
# merged = merger.merge_by_label(words, labels_by_word)
|
|
31
|
+
class WordMerger
|
|
32
|
+
DEFAULT_X_GAP = 20.0
|
|
33
|
+
DEFAULT_Y_TOL = 3.0
|
|
34
|
+
|
|
35
|
+
def initialize(x_gap: DEFAULT_X_GAP, y_tol: DEFAULT_Y_TOL)
|
|
36
|
+
@x_gap = x_gap
|
|
37
|
+
@y_tol = y_tol
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Fonde tutte le word adiacenti (stessa riga + gap orizzontale ≤ x_gap).
|
|
41
|
+
def merge_by_proximity(words)
|
|
42
|
+
merge_groups(words) { |a, b| true }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Fonde solo word con la stessa label.
|
|
46
|
+
# @param labels_by_word [Hash] mapping word → label (qualunque tipo).
|
|
47
|
+
# Word con stessa label vengono fuse, word con label diverse no.
|
|
48
|
+
def merge_by_label(words, labels_by_word)
|
|
49
|
+
merge_groups(words) do |a, b|
|
|
50
|
+
labels_by_word[a] == labels_by_word[b]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Fonde solo word con label nil (orfane).
|
|
55
|
+
def merge_unlabeled(words, labels_by_word)
|
|
56
|
+
merge_groups(words) do |a, b|
|
|
57
|
+
labels_by_word[a].nil? && labels_by_word[b].nil?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Algoritmo generico di merging: scorre i word ordinati per (top, x0)
|
|
64
|
+
# e li raggruppa quando soddisfano sia il criterio geometrico
|
|
65
|
+
# (stessa riga e gap orizzontale stretto) che il predicato `yield`
|
|
66
|
+
# fornito dal chiamante.
|
|
67
|
+
def merge_groups(words)
|
|
68
|
+
return [] if words.empty?
|
|
69
|
+
|
|
70
|
+
sorted = words.sort_by { |w| [w[:top].round(1), w[:x0]] }
|
|
71
|
+
groups = []
|
|
72
|
+
current = [sorted.first]
|
|
73
|
+
sorted.drop(1).each do |w|
|
|
74
|
+
prev = current.last
|
|
75
|
+
on_same_row = (w[:top] - prev[:top]).abs <= @y_tol
|
|
76
|
+
adjacent = w[:x0] - prev[:x1] <= @x_gap && w[:x0] >= prev[:x0]
|
|
77
|
+
if on_same_row && adjacent && yield(prev, w)
|
|
78
|
+
current << w
|
|
79
|
+
else
|
|
80
|
+
groups << current
|
|
81
|
+
current = [w]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
groups << current
|
|
85
|
+
|
|
86
|
+
groups.map { |g| merge_group(g) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def merge_group(group)
|
|
90
|
+
return group.first if group.size == 1
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
text: group.map { |w| w[:text] }.join(" "),
|
|
94
|
+
x0: group.map { |w| w[:x0] }.min,
|
|
95
|
+
x1: group.map { |w| w[:x1] }.max,
|
|
96
|
+
top: group.map { |w| w[:top] }.min,
|
|
97
|
+
bottom: group.map { |w| w[:bottom] }.max
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|