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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ VERSION = "0.4.1"
5
+ end