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
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,1870 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Tutte le modifiche notevoli a questo progetto.
|
|
4
|
+
Il formato segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [0.4.1] - 2026-05-26
|
|
7
|
+
|
|
8
|
+
### Corretto
|
|
9
|
+
|
|
10
|
+
- **Caricamento su Linux con `rpdfium-binary`**: `rpdfium.rb` ora esegue
|
|
11
|
+
`require "rpdfium/binary"` prima di `raw.rb`. In precedenza `ffi_lib`
|
|
12
|
+
veniva chiamato prima che `Rpdfium::Binary` fosse definito, causando
|
|
13
|
+
un fallback ai nomi di sistema (`pdfium`, `libpdfium.so`) e un
|
|
14
|
+
`LoadError` su ambienti senza PDFium installato globalmente.
|
|
15
|
+
|
|
16
|
+
## [0.4.0] - refactor verso primitive componibili
|
|
17
|
+
|
|
18
|
+
### ⚠️ Breaking changes
|
|
19
|
+
|
|
20
|
+
`Page#label_value_pairs` torna a essere una **primitiva minimale**:
|
|
21
|
+
ritorna `Array<Hash>` con pair grezzi senza opzioni di merging
|
|
22
|
+
applicativo. Le opzioni `merge_adjacent:`, `as_hash:`, `boxed_layout:`
|
|
23
|
+
sono **rimosse** (erano logica di dominio incollata sulla primitiva
|
|
24
|
+
di estrazione).
|
|
25
|
+
|
|
26
|
+
Per chi usava queste opzioni:
|
|
27
|
+
- `merge_adjacent: :smart` → componi a mano con `Util::WordMerger`
|
|
28
|
+
- `as_hash: true` → converti il risultato nel chiamante
|
|
29
|
+
- `boxed_layout: true` → passa direttamente `x_tolerance: 15.0,
|
|
30
|
+
inject_spaces: false` + crea `LabelMatcher.new(row_max_dx: 400.0)`
|
|
31
|
+
|
|
32
|
+
Gli **adapter applicativi specifici** per moduli AE (Modello 770,
|
|
33
|
+
Comunicazione IVA) sono ora forniti come **esempi esterni** in
|
|
34
|
+
`examples/adapters/` (vedi sotto), non come parte della gem.
|
|
35
|
+
|
|
36
|
+
### Aggiunto: `Util::WordMerger`
|
|
37
|
+
|
|
38
|
+
Primitiva di merging configurabile, con tre strategie esplicite:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
merger = Rpdfium::Util::WordMerger.new(x_gap: 20.0, y_tol: 3.0)
|
|
42
|
+
|
|
43
|
+
# Fonde tutte le word adiacenti
|
|
44
|
+
merger.merge_by_proximity(words)
|
|
45
|
+
|
|
46
|
+
# Fonde solo word con stessa label (mapping word → label fornito dal chiamante)
|
|
47
|
+
merger.merge_by_label(words, labels_by_word)
|
|
48
|
+
|
|
49
|
+
# Fonde solo word con label nil (orfane)
|
|
50
|
+
merger.merge_unlabeled(words, labels_by_word)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Aggiunto: `Util::ColumnInference`
|
|
54
|
+
|
|
55
|
+
Primitiva di inferenza di colonne dati su PDF non-tabellari (form
|
|
56
|
+
prestampati, layout con valori allineati per posizione ma senza
|
|
57
|
+
linee). Algoritmo in 3 passi:
|
|
58
|
+
|
|
59
|
+
1. Cluster per coordinata X (x0 left-align O x1 right-align)
|
|
60
|
+
2. Spezza per gap verticali anomali
|
|
61
|
+
3. Filtra per densità (coefficiente di variazione dei gap)
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
inference = Rpdfium::Util::ColumnInference.new(
|
|
65
|
+
x_tolerance: 3.0, # tolleranza cluster X
|
|
66
|
+
min_size: 3, # almeno 3 valori per colonna
|
|
67
|
+
cv_threshold: 0.15 # gap regolari
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
columns = inference.infer(words)
|
|
71
|
+
# => [[word1, word2, ...], [word1, word2, ...]]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### `Util::LabelMatcher` ora compone con `ColumnInference`
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# Senza riassegnazione (comportamento 0.3.15)
|
|
78
|
+
matcher = Rpdfium::Util::LabelMatcher.new
|
|
79
|
+
|
|
80
|
+
# Con riassegnazione per colonne ripetitive (ex repeat_headers)
|
|
81
|
+
matcher = Rpdfium::Util::LabelMatcher.new(
|
|
82
|
+
column_inference: Rpdfium::Util::ColumnInference.new
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Il flag `repeat_headers:` non esiste più — si passa direttamente un
|
|
87
|
+
oggetto `ColumnInference` configurato (o `nil` per disabilitare).
|
|
88
|
+
|
|
89
|
+
### Adapter applicativi (esempi esterni)
|
|
90
|
+
|
|
91
|
+
Distribuiti in `examples/adapters/`, NON parte della gem. Mostrano
|
|
92
|
+
come comporre le primitive per casi specifici:
|
|
93
|
+
|
|
94
|
+
- **`Modello770Reader`** (per Dichiarazione sostituti d'imposta)
|
|
95
|
+
- **`LiquidazioneIVAReader`** (per Comunicazione Liquidazioni IVA)
|
|
96
|
+
|
|
97
|
+
Ognuno è uno script Ruby standalone con classe ~100 righe. Da
|
|
98
|
+
copiare nel proprio progetto e adattare se serve.
|
|
99
|
+
|
|
100
|
+
### Filosofia
|
|
101
|
+
|
|
102
|
+
La gem rpdfium fornisce primitive generaliste per leggere PDF. La
|
|
103
|
+
**logica applicativa specifica per un modulo** (sapere che il 770 ha
|
|
104
|
+
Quadro ST/SV/SX, che l'IVA ha caselline a cifre singole con virgola
|
|
105
|
+
graficamente dipinta) appartiene al **codice del consumatore**, non
|
|
106
|
+
alla gem.
|
|
107
|
+
|
|
108
|
+
Le primitive `WordMerger`, `ColumnInference`, `LabelMatcher` sono
|
|
109
|
+
**componibili**: ogni caso d'uso compone una pipeline specifica.
|
|
110
|
+
|
|
111
|
+
### Non-regressione
|
|
112
|
+
|
|
113
|
+
✅ Tutti i test core passano. F24, busta_paga, cu.pdf, complex,
|
|
114
|
+
sample invariati. Le primitive nuove sono testate con assert
|
|
115
|
+
dedicati.
|
|
116
|
+
|
|
117
|
+
### Migration guide da 0.3.19
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# Prima (0.3.19):
|
|
121
|
+
page.label_value_pairs(
|
|
122
|
+
data_font: "Courier",
|
|
123
|
+
merge_adjacent: :smart,
|
|
124
|
+
as_hash: true
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Dopo (0.4.0): usa l'adapter Modello770Reader (vedi examples/) o
|
|
128
|
+
# componi a mano:
|
|
129
|
+
matcher = Rpdfium::Util::LabelMatcher.new(
|
|
130
|
+
column_inference: Rpdfium::Util::ColumnInference.new
|
|
131
|
+
)
|
|
132
|
+
pairs = page.label_value_pairs(data_font: "Courier", matcher: matcher)
|
|
133
|
+
# poi merge custom + hash conversion nel tuo codice
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## [0.3.19] - estrazione su moduli a caselline (boxed_layout)
|
|
137
|
+
|
|
138
|
+
### Aggiunto: `label_value_pairs(boxed_layout: true)`
|
|
139
|
+
|
|
140
|
+
Alcuni moduli prestampati italiani (Comunicazione Liquidazioni
|
|
141
|
+
Periodiche IVA, Modello Redditi quadri specifici) hanno un layout a
|
|
142
|
+
**caselline separate per ogni cifra**: la partita IVA `01234567890`
|
|
143
|
+
viene stampata come 11 caselline, l'importo `15.357,78` viene scritto
|
|
144
|
+
come `15.357 7 8` con la parte intera, la virgola dipinta dal template,
|
|
145
|
+
e le 2 cifre decimali in caselle ciascuna a ~10pt di distanza.
|
|
146
|
+
|
|
147
|
+
Il default `label_value_pairs` non riconosceva questi numeri come
|
|
148
|
+
singoli valori: spezzava `15.357,78` in 3 word separate (`15.357`, `7`,
|
|
149
|
+
`8`) perché PDFium inseriva automaticamente uno spazio "generato" tra
|
|
150
|
+
le caselline (gap > 5pt → trattato come separatore di parole).
|
|
151
|
+
|
|
152
|
+
### Soluzione: flag `boxed_layout: true`
|
|
153
|
+
|
|
154
|
+
Configura automaticamente i parametri adatti:
|
|
155
|
+
|
|
156
|
+
- **`inject_spaces: false`** sui char (no spazi PDFium-generated
|
|
157
|
+
che spezzano le caselline)
|
|
158
|
+
- **`x_tolerance: 15.0`** (gap tipico tra caselline ~10-13pt)
|
|
159
|
+
- **`row_max_dx: 400.0`** sul LabelMatcher (le label sui moduli VP
|
|
160
|
+
sono a sinistra e i valori in colonna DEBITI/CREDITI sono a 250+pt
|
|
161
|
+
di distanza)
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
Rpdfium.open("iva.pdf") do |doc|
|
|
165
|
+
doc.page(1).label_value_pairs(
|
|
166
|
+
data_font: "Helvetica",
|
|
167
|
+
merge_adjacent: :smart,
|
|
168
|
+
as_hash: true,
|
|
169
|
+
boxed_layout: true # ← nuova opzione
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Risultato sul modulo IVA — Pagina 2 (Quadro VP)
|
|
175
|
+
|
|
176
|
+
**Prima (0.3.18)**:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
{
|
|
180
|
+
"CODICE FISCALE" => "0 2 0 9", # spezzato per le caselline
|
|
181
|
+
"Operazioni straordinarie" => "5.455 8", # label sbagliata, numero spezzato
|
|
182
|
+
"," => ["2", "1"], # virgola del template come label
|
|
183
|
+
"IVA esigibile" => "3.378 7 2", # spezzato
|
|
184
|
+
"CREDITI" => "1.132 7", # label è il sub-header, non semantica
|
|
185
|
+
...
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Adesso (0.3.19) con `boxed_layout: true`**:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
{
|
|
193
|
+
"CODICE FISCALE" => "01234567890", # ✓
|
|
194
|
+
"Mod. N." => "01", # ✓
|
|
195
|
+
"PERIODO DI RIFERIMENTO" => "04", # mese aprile
|
|
196
|
+
"VP2 Totale operazioni attive (al netto dell'IVA)" => "15.35778", # € 15.357,78
|
|
197
|
+
"VP3 Totale operazioni passive (al netto dell'IVA)" => "5.45582", # € 5.455,82
|
|
198
|
+
"VP4 IVA esigibile" => "3.37872", # € 3.378,72
|
|
199
|
+
"VP5 IVA detratta" => "1.13271", # € 1.132,71
|
|
200
|
+
"VP6 IVA dovuta" => "2.24601", # € 2.246,01
|
|
201
|
+
"VP14 IVA da versare" => "2.24601" # € 2.246,01
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
I valori numerici sono concatenati senza virgola decimale (la virgola
|
|
206
|
+
è grafica nel template, non parte del data layer). Il consumatore può
|
|
207
|
+
formatterla con post-processing: gli ultimi 2 caratteri sono i
|
|
208
|
+
decimali, il resto è la parte intera.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
def parse_eur_amount(s)
|
|
212
|
+
s.match(/\A(.*)(\d{2})\z/) { |m| "#{m[1]},#{m[2]}" }
|
|
213
|
+
end
|
|
214
|
+
parse_eur_amount("15.35778") # => "15.357,78"
|
|
215
|
+
parse_eur_amount("2.24601") # => "2.246,01"
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Quando usarlo
|
|
219
|
+
|
|
220
|
+
Attiva `boxed_layout: true` quando il modulo presenta:
|
|
221
|
+
- Codici fiscali / partite IVA con cifre in caselline visibili
|
|
222
|
+
- Importi con virgola decimale grafica e caselle per ogni cifra
|
|
223
|
+
- Date in formato GG MM AAAA con caselle separate
|
|
224
|
+
- Generalmente: PDF Agenzia delle Entrate con sfondo a celle
|
|
225
|
+
|
|
226
|
+
Lascia il default `false` per:
|
|
227
|
+
- F24 stampato (Courier compatto senza caselle)
|
|
228
|
+
- Modello 770 quadri ST/SV (testo libero compatto)
|
|
229
|
+
- Buste paga / cedolini con tabelle standard
|
|
230
|
+
- Tutti i casi dove i char sono già contigui
|
|
231
|
+
|
|
232
|
+
### Non-regressione
|
|
233
|
+
|
|
234
|
+
✅ 15/15 test passano. Il default `boxed_layout: false` mantiene il
|
|
235
|
+
comportamento 0.3.18 byte-per-byte.
|
|
236
|
+
|
|
237
|
+
### API compatibility
|
|
238
|
+
|
|
239
|
+
Nessuna breaking change. Puoi passare `inject_spaces:`, `x_tolerance:`
|
|
240
|
+
e altri kwargs separatamente per controllo fine, oppure usare la
|
|
241
|
+
combinazione `boxed_layout: true` come scorciatoia.
|
|
242
|
+
|
|
243
|
+
## [0.3.18] - propagazione intestazioni su tabelle ripetitive
|
|
244
|
+
|
|
245
|
+
### Fixato: intestazioni di colonna non propagate alle righe successive
|
|
246
|
+
|
|
247
|
+
Su moduli con **tabelle ripetitive** (Quadro ST/SV del 770, sezioni
|
|
248
|
+
Erario/INPS/Regioni di F24 multi-riga, ecc.) le intestazioni di
|
|
249
|
+
colonna sono stampate **una sola volta** in cima alla tabella e
|
|
250
|
+
sottintese per tutte le righe successive (ST2, ST3, ..., ST13).
|
|
251
|
+
|
|
252
|
+
Nelle versioni precedenti `label_value_pairs` limitava il matching
|
|
253
|
+
label→valore a `col_max_dy=80pt`: bastava per la prima riga (ST2) ma
|
|
254
|
+
le righe successive (oltre 80pt dall'header) finivano sotto label
|
|
255
|
+
sbagliate o spurie (es. `ST5: [04 2021, 455,46]` invece di
|
|
256
|
+
`Periodo di riferimento: 04 2021` + `Importo versato: 455,46`).
|
|
257
|
+
|
|
258
|
+
### Soluzione: pass di riassegnazione per colonne
|
|
259
|
+
|
|
260
|
+
Il `LabelMatcher` ora ha una terza fase **`reassign_by_columns`**:
|
|
261
|
+
|
|
262
|
+
1. **Identifica le colonne dati**: clustera i valori per coordinata
|
|
263
|
+
`x0` (left-aligned, es. codici tributo) **e** `x1` (right-aligned,
|
|
264
|
+
es. importi numerici "1.227,70" e "499,81" hanno x0 diversi ma x1
|
|
265
|
+
uguale). I valori sui moduli prestampati sono spesso allineati a
|
|
266
|
+
destra; servono entrambi gli allineamenti per coprire tutti i casi.
|
|
267
|
+
|
|
268
|
+
2. **Spezza colonne per gap verticali**: se due valori consecutivi
|
|
269
|
+
nello stesso x-cluster hanno un gap verticale > 3× la mediana dei
|
|
270
|
+
gap (o > 40pt), li separa in colonne distinte. Risolve casi tipo
|
|
271
|
+
"codice fiscale in alto pagina + tabella sotto" che condividono
|
|
272
|
+
la stessa x ma sono sezioni distinte.
|
|
273
|
+
|
|
274
|
+
3. **Filtra per densità**: una colonna "vera" di tabella ripetitiva
|
|
275
|
+
ha valori regolarmente equispaziati. Misura il coefficiente di
|
|
276
|
+
variazione dei gap (`CV = std_dev/mean`). Soglia stretta: `CV <
|
|
277
|
+
0.15` (spacing molto regolare). Esclude falsi positivi come i 5
|
|
278
|
+
saldi del F24 right-aligned (SALDO A-B, C-D, E-F, G-H, M-N: stessa
|
|
279
|
+
x1 ma sezioni diverse, CV = 0.26).
|
|
280
|
+
|
|
281
|
+
4. **Trova l'header canonico**: per ogni colonna dati identificata,
|
|
282
|
+
cerca la label di template **subito sopra** il `col_top` (il top
|
|
283
|
+
del primo valore della colonna). Quella label è l'intestazione
|
|
284
|
+
canonica.
|
|
285
|
+
|
|
286
|
+
5. **Propaga**: assegna l'header canonico a TUTTI i valori della
|
|
287
|
+
colonna, anche quelli oltre `col_max_dy` dall'header originale.
|
|
288
|
+
|
|
289
|
+
### Risultato sul 770 Quadro ST
|
|
290
|
+
|
|
291
|
+
Pagina 4 prima (0.3.17):
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
{
|
|
295
|
+
"Periodo di riferimento mese anno" => "01 2021",
|
|
296
|
+
"Ritenute operate" => "394,13", # solo ST2
|
|
297
|
+
"Importo versato" => "394,13",
|
|
298
|
+
"Codice tributo 11" => ["1001", "443,73", "1001", "405,96"], # mescolato
|
|
299
|
+
"ST5" => ["04 2021", "455,46"], # label spuria
|
|
300
|
+
"ST6" => ["05 2021", "407,40"],
|
|
301
|
+
"ST7" => ["06 2021", "1.227,70"],
|
|
302
|
+
# ...
|
|
303
|
+
"ST13" => ["12 2021", "32,46"]
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Adesso (0.3.18):
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
{
|
|
311
|
+
"Periodo di riferimento mese anno" => [
|
|
312
|
+
"01 2021", "02 2021", "03 2021", "04 2021", "05 2021", "06 2021",
|
|
313
|
+
"07 2021", "08 2021", "09 2021", "10 2021", "11 2021", "12 2021"
|
|
314
|
+
],
|
|
315
|
+
"Ritenute operate" => [
|
|
316
|
+
"394,13", "443,73", "405,96", "455,46", "407,40", "1.227,70",
|
|
317
|
+
"367,74", "520,00", "463,37", "451,32", "499,81", "32,46"
|
|
318
|
+
],
|
|
319
|
+
"Importo versato" => [...stessi 12 importi...],
|
|
320
|
+
"Codice tributo 11" => ["1001", "1001", ..., "1001", "1712"], # 12 codici
|
|
321
|
+
"Data di versamento giorno mese anno 14" => [
|
|
322
|
+
"16 02 2021", "16 03 2021", ..., "16 12 2021"
|
|
323
|
+
]
|
|
324
|
+
# NO più label spurie ST5/ST7/ST13
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Parametri configurabili
|
|
329
|
+
|
|
330
|
+
`Rpdfium::Util::LabelMatcher.new` accetta tre nuovi parametri:
|
|
331
|
+
|
|
332
|
+
- `repeat_headers:` (default `true`) — attiva/disattiva la
|
|
333
|
+
riassegnazione per colonne. Passa `false` per ripristinare il
|
|
334
|
+
comportamento 0.3.17.
|
|
335
|
+
- `column_x_tolerance:` (default `3.0`) — tolleranza X per
|
|
336
|
+
considerare due valori "in stessa colonna".
|
|
337
|
+
- `min_column_size:` (default `3`) — numero minimo di valori per
|
|
338
|
+
riconoscere una colonna come ripetitiva.
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
matcher = Rpdfium::Util::LabelMatcher.new(
|
|
342
|
+
repeat_headers: true,
|
|
343
|
+
column_x_tolerance: 2.0, # cluster più stretto
|
|
344
|
+
min_column_size: 5 # solo colonne con 5+ righe
|
|
345
|
+
)
|
|
346
|
+
page.label_value_pairs(data_font: "Courier", matcher: matcher, ...)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Non-regressione
|
|
350
|
+
|
|
351
|
+
✅ 15/15 test passano:
|
|
352
|
+
- busta_paga, cu.pdf rotation 90°, sample, complex
|
|
353
|
+
- F24 499,81 → "importi a debito versati"
|
|
354
|
+
- F24 1.615,90 → "SALDO (M-N) +/–" (saldo finale, non confuso coi
|
|
355
|
+
saldi di sezione SALDO A-B / C-D)
|
|
356
|
+
- F24 532,27 → "importi a debito versati" (TOTALE A)
|
|
357
|
+
- 770 p2 "Cognome o Denominazione" → "Azienda S.R.L."
|
|
358
|
+
- 770 p4 12 codici tributo + 12 importi + 12 date + 12 mesi
|
|
359
|
+
- 770 p4 NO label spurie ST5/ST13
|
|
360
|
+
- 770 p4 CODICE FISCALE → solo il singolo codice (non l'intera colonna)
|
|
361
|
+
|
|
362
|
+
### API compatibility
|
|
363
|
+
|
|
364
|
+
Nessuna breaking change. `repeat_headers: false` ripristina il
|
|
365
|
+
comportamento 0.3.17 per chi preferisce.
|
|
366
|
+
|
|
367
|
+
## [0.3.17] - precisione label-value su moduli a colonne strette
|
|
368
|
+
|
|
369
|
+
### Fixato: valori "wide" attraversano label sbagliate
|
|
370
|
+
|
|
371
|
+
Su moduli prestampati con colonne template strette adiacenti (caso
|
|
372
|
+
classico: 770 pagina 2 con "Cognome o Denominazione" / "Nome" /
|
|
373
|
+
"Dichiarazione integrativa" / "Protocollo dichiarazione inviata"
|
|
374
|
+
disposte sulla stessa riga), un valore che semanticamente appartiene
|
|
375
|
+
al primo campo ma che si estende graficamente oltre il suo box
|
|
376
|
+
(es. "Azienda S.R.L." scritto su tutta la riga) veniva spezzato in
|
|
377
|
+
3 entry sotto label diverse.
|
|
378
|
+
|
|
379
|
+
`Page#label_value_pairs(merge_adjacent: :smart, ...)` ora:
|
|
380
|
+
|
|
381
|
+
1. **Tight-merge passo finale**: dopo i passaggi by_label e
|
|
382
|
+
by_proximity esistenti, un terzo passo unisce le word con gap
|
|
383
|
+
orizzontale ≤ 10pt e stessa riga esatta (top differiscono di <1pt)
|
|
384
|
+
anche se cadono sotto label di colonna diverse. La soglia è
|
|
385
|
+
inferiore al gap inter-colonna tipico (>15pt) ma maggiore del
|
|
386
|
+
kerning intra-word (<5pt), così si riconoscono solo stringhe
|
|
387
|
+
"naturalmente unite".
|
|
388
|
+
|
|
389
|
+
2. **Label per wide values usa left-edge**: il `LabelMatcher` ora,
|
|
390
|
+
per valori più larghi di 60pt (tipico di stringhe merged), cerca
|
|
391
|
+
la label di colonna usando il **left edge** del valore (con piccolo
|
|
392
|
+
offset di 5pt) invece del midpoint. Così una denominazione che
|
|
393
|
+
inizia sotto "Cognome o Denominazione" mantiene quella label anche
|
|
394
|
+
se si estende oltre.
|
|
395
|
+
|
|
396
|
+
Risultato sul 770 pagina 2:
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
# Prima (0.3.16):
|
|
400
|
+
{
|
|
401
|
+
"Cognome o Denominazione" => "Azienda", # spezzata
|
|
402
|
+
"Dichiarazione integrativa" => "CONSULTING", # sbagliata
|
|
403
|
+
"Protocollo dichiarazione inviata" => "S.R.L." # sbagliata
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
# Adesso (0.3.17):
|
|
407
|
+
{
|
|
408
|
+
"Cognome o Denominazione" => "Azienda S.R.L." # ✓
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
I tre passaggi sono configurabili separatamente:
|
|
413
|
+
- `merge_x_gap:` (default 20.0) — gap by_label e by_proximity
|
|
414
|
+
- `merge_tight_x_gap:` (default 10.0) — gap del tight-merge
|
|
415
|
+
|
|
416
|
+
### Fixato: marcatori grafici di colonna catturati come label
|
|
417
|
+
|
|
418
|
+
Su Quadro ST/SV del 770, il template stampa numerini "11", "14", "15",
|
|
419
|
+
"16" come marcatori grafici delle colonne (indici delle posizioni nel
|
|
420
|
+
form). Venivano catturati come label semantiche del LabelMatcher,
|
|
421
|
+
producendo entry inutili come `"16" => ["443,73", "405,96", ...]`.
|
|
422
|
+
|
|
423
|
+
`Util::LabelMatcher` ora ignora di default le label che matchano
|
|
424
|
+
`/\A\d{1,3}\z|\A[IVX]{1,5}\z/` — numeri brevi e numeri romani brevi,
|
|
425
|
+
tipici marcatori di colonna. Configurabile via
|
|
426
|
+
`LabelMatcher.new(ignore_label_pattern: ...)`. Passa `nil` per
|
|
427
|
+
disattivare il filtro, o una propria Regexp per pattern custom.
|
|
428
|
+
|
|
429
|
+
Default:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
matcher = Rpdfium::Util::LabelMatcher.new
|
|
433
|
+
# ignore_label_pattern: /\A\d{1,3}\z|\A[IVX]{1,5}\z/
|
|
434
|
+
|
|
435
|
+
matcher = Rpdfium::Util::LabelMatcher.new(ignore_label_pattern: nil)
|
|
436
|
+
# nessun filtro, comportamento 0.3.16
|
|
437
|
+
|
|
438
|
+
matcher = Rpdfium::Util::LabelMatcher.new(ignore_label_pattern: /\AXX\z/)
|
|
439
|
+
# filtro custom
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### Risultato finale sul 770
|
|
443
|
+
|
|
444
|
+
Confronto pagine principali prima/dopo:
|
|
445
|
+
|
|
446
|
+
| Pagina | Prima (0.3.16) | Adesso (0.3.17) |
|
|
447
|
+
| --- | --- | --- |
|
|
448
|
+
| 2 | "Cognome": "Azienda" + 2 entry sbagliate | "Cognome o Denominazione": "Azienda S.R.L." |
|
|
449
|
+
| 4 (Quadro ST) | "16": [443,73, 405,96, ...] (marcatore) | "Sospensione COVID Importo sospeso": [...] (label vera) |
|
|
450
|
+
| 4 | "14": [16, 16, 17, ...] (marcatore) | "Data di versamento giorno mese anno": [16 02 2021, ...] |
|
|
451
|
+
| 4 | "11": [1001, 443,73, ...] (marcatore) | "Codice tributo": [1001, ...] (label vera) |
|
|
452
|
+
|
|
453
|
+
### Non-regressione
|
|
454
|
+
|
|
455
|
+
✅ 16/16 test passano (busta_paga, F24, cu.pdf rotation 90°, cu.pdf
|
|
456
|
+
small font, sample, complex, e tutti i nuovi assert su 770).
|
|
457
|
+
|
|
458
|
+
### API compatibility
|
|
459
|
+
|
|
460
|
+
Nessuna breaking change. I parametri nuovi (`merge_tight_x_gap`,
|
|
461
|
+
`ignore_label_pattern`) hanno default sensati. Disattivare il filtro
|
|
462
|
+
con `ignore_label_pattern: nil` ripristina il comportamento 0.3.16.
|
|
463
|
+
|
|
464
|
+
## [0.3.16] - estrazione strutturata su moduli multi-pagina
|
|
465
|
+
|
|
466
|
+
### Aggiunto: `label_value_pairs(merge_adjacent:, as_hash:)`
|
|
467
|
+
|
|
468
|
+
Due nuove opzioni a `Page#label_value_pairs` che trasformano l'output da
|
|
469
|
+
"lista di pair grezza" a **mappa strutturata `{label => valore}` pronta
|
|
470
|
+
da consumare**, gestendo correttamente sia campi puntuali (checkbox,
|
|
471
|
+
codici) che testo libero multi-word (denominazioni, indirizzi, header).
|
|
472
|
+
|
|
473
|
+
### `merge_adjacent` — 3 strategie selezionabili
|
|
474
|
+
|
|
475
|
+
- **`false` (default)**: nessuna unione. Una word PDF = una entry.
|
|
476
|
+
Comportamento 0.3.15.
|
|
477
|
+
|
|
478
|
+
- **`true` o `:by_label`**: fonde solo word adiacenti con la **stessa
|
|
479
|
+
label col**. Conserva checkbox sotto label distinte (es. su 770 le
|
|
480
|
+
X dei quadri compilati ST/SV/SX restano separate perché ognuna ha
|
|
481
|
+
la sua label).
|
|
482
|
+
|
|
483
|
+
- **`:by_proximity`**: fonde tutte le word adiacenti indipendentemente
|
|
484
|
+
dalla label. Per header con testo libero (es. "Soggetto: Azienda
|
|
485
|
+
S.R.L. ( 01234567890 )" diventa una entry sola).
|
|
486
|
+
|
|
487
|
+
- **`:smart` (raccomandato per moduli complessi)**: combina i due —
|
|
488
|
+
by_label per word con label, by_proximity per word **orfane** senza
|
|
489
|
+
label. Funziona automaticamente su moduli che mescolano header
|
|
490
|
+
testuali (Soggetto), tabelle con checkbox (ST/SV/SX) e campi singoli
|
|
491
|
+
(codice fiscale, codice attività).
|
|
492
|
+
|
|
493
|
+
### `as_hash: true` — output strutturato
|
|
494
|
+
|
|
495
|
+
Trasforma `Array<Hash>` in `Hash` chiavi-valore:
|
|
496
|
+
|
|
497
|
+
```ruby
|
|
498
|
+
Rpdfium.open("770.pdf") do |doc|
|
|
499
|
+
doc.page(1).label_value_pairs(
|
|
500
|
+
data_font: "Courier",
|
|
501
|
+
merge_adjacent: :smart,
|
|
502
|
+
as_hash: true
|
|
503
|
+
)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# => {
|
|
507
|
+
# "Codice fiscale" => "01234567890",
|
|
508
|
+
# "Codice attività" => "999999",
|
|
509
|
+
# "Indirizzo di posta elettronica/PEC" => "AZIENDA@PEC.IT",
|
|
510
|
+
# "Stato (tab. SA)" => "1",
|
|
511
|
+
# "Situazione (tab. SC)" => "6",
|
|
512
|
+
# "ST" => "X",
|
|
513
|
+
# "SV" => "X",
|
|
514
|
+
# "SX" => "X",
|
|
515
|
+
# "Dipendente" => "X",
|
|
516
|
+
# "Tipologia invio" => "2",
|
|
517
|
+
# ...
|
|
518
|
+
# }
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Quando la label è la stessa per più valori, l'output diventa un Array:
|
|
522
|
+
`"Codice fiscale" => ["01234567890", "01234567890"]`.
|
|
523
|
+
|
|
524
|
+
Le word senza label associabile (es. header in alto pagina senza
|
|
525
|
+
template di riferimento) confluiscono sotto la chiave `"_unlabeled"`
|
|
526
|
+
come Array di stringhe.
|
|
527
|
+
|
|
528
|
+
### Esempio: estrazione completa di un Modello 770
|
|
529
|
+
|
|
530
|
+
```ruby
|
|
531
|
+
Rpdfium.open("770.pdf") do |doc|
|
|
532
|
+
doc.each_with_index do |page, i|
|
|
533
|
+
inv = page.font_inventory
|
|
534
|
+
data_font = inv.find { |g| g[:font]&.match?(/courier/i) }&.dig(:font)
|
|
535
|
+
next unless data_font
|
|
536
|
+
|
|
537
|
+
h = page.label_value_pairs(
|
|
538
|
+
data_font: data_font,
|
|
539
|
+
merge_adjacent: :smart,
|
|
540
|
+
as_hash: true
|
|
541
|
+
)
|
|
542
|
+
puts "=== Pagina #{i + 1} ==="
|
|
543
|
+
h.each { |k, v| puts " #{k}: #{v.inspect}" }
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Output reale su modello 770 (3 prime pagine):
|
|
549
|
+
|
|
550
|
+
```
|
|
551
|
+
=== Pagina 1 ===
|
|
552
|
+
_unlabeled: ["Soggetto: Azienda S.R.L. ( 01234567890 )",
|
|
553
|
+
"Identificativo dichiarazione: 11111111111 - 0000002 del 22/10/2022"]
|
|
554
|
+
|
|
555
|
+
=== Pagina 2 ===
|
|
556
|
+
Codice fiscale: ["01234567890", "01234567890"]
|
|
557
|
+
Codice attività: "999999"
|
|
558
|
+
Indirizzo di posta elettronica/PEC: "AZIENDA@PEC.IT"
|
|
559
|
+
Stato (tab. SA): "1"
|
|
560
|
+
Situazione (tab. SC): "6"
|
|
561
|
+
ST: "X"
|
|
562
|
+
SV: "X"
|
|
563
|
+
SX: "X"
|
|
564
|
+
Dipendente: "X"
|
|
565
|
+
Tipologia invio: "2"
|
|
566
|
+
GESTIONE SEPARATA Dipendente Autonomo: "X"
|
|
567
|
+
|
|
568
|
+
=== Pagina 3 ===
|
|
569
|
+
Codice fiscale: "01234567890"
|
|
570
|
+
Codice fiscale dell'incaricato: "01877150696"
|
|
571
|
+
giorno mese: "01 10"
|
|
572
|
+
anno: "2022"
|
|
573
|
+
_unlabeled: ["2", "Firma Presente"]
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### `merge_x_gap` per tarare il merge
|
|
577
|
+
|
|
578
|
+
Parametro `merge_x_gap:` controlla il gap massimo (in punti) tra word
|
|
579
|
+
adiacenti per essere considerate "unite". Default 20.0. Aumentalo per
|
|
580
|
+
moduli con campi molto spaziati (header centrati su pagina).
|
|
581
|
+
|
|
582
|
+
### Heuristica `best_label_for` (interna)
|
|
583
|
+
|
|
584
|
+
Quando il valore ha sia `col` che `row` label, la scelta automatica
|
|
585
|
+
preferisce:
|
|
586
|
+
- `row` se è una label breve identificatrice ("ST", "Codice fiscale")
|
|
587
|
+
- `col` quando è più descrittiva ("importi a debito versati")
|
|
588
|
+
|
|
589
|
+
Per controllo fine, usa la API base senza `as_hash: true` e leggi
|
|
590
|
+
direttamente `p[:labels][:col]` e `p[:labels][:row]`.
|
|
591
|
+
|
|
592
|
+
### Non-regressione
|
|
593
|
+
|
|
594
|
+
✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex, F24, IVA
|
|
595
|
+
— tutti i test invariati.
|
|
596
|
+
|
|
597
|
+
✅ Il default di `merge_adjacent: false` mantiene il comportamento
|
|
598
|
+
0.3.15 byte-per-byte. La 0.3.16 è purely additiva.
|
|
599
|
+
|
|
600
|
+
### API compatibility
|
|
601
|
+
|
|
602
|
+
Nessuna breaking change.
|
|
603
|
+
|
|
604
|
+
## [0.3.15] - associazione label-valore su moduli compilati
|
|
605
|
+
|
|
606
|
+
### Aggiunto: `Page#label_value_pairs` e `Util::LabelMatcher`
|
|
607
|
+
|
|
608
|
+
Su PDF di "moduli compilati" (F24, comunicazioni IVA, modelli 770,
|
|
609
|
+
dichiarazioni dei redditi) le 3 API introdotte nella 0.3.14
|
|
610
|
+
(`font_inventory`, `chars_where`, `lines`) permettono di **separare**
|
|
611
|
+
il layer template dai dati. La 0.3.15 va oltre: **associa
|
|
612
|
+
semanticamente** ogni valore inserito alla sua etichetta nel template,
|
|
613
|
+
così l'utente non deve sapere a priori la geometria del modulo.
|
|
614
|
+
|
|
615
|
+
### Come funziona
|
|
616
|
+
|
|
617
|
+
L'algoritmo opera in tre step:
|
|
618
|
+
|
|
619
|
+
1. **Cluster del template in label coerenti** — parole del modello
|
|
620
|
+
geometricamente vicine (stessa riga adiacenti, o righe successive
|
|
621
|
+
sovrapposte in x) vengono unite in un'unica label. Esempio:
|
|
622
|
+
"importi", "a", "debito", "versati" → label unica `"importi a
|
|
623
|
+
debito versati"`.
|
|
624
|
+
|
|
625
|
+
2. **Per ogni valore inserito, cerca due tipi di label**:
|
|
626
|
+
- `col` — label SOPRA nella stessa colonna (x sovrapposto col
|
|
627
|
+
valore, bottom < value top, scelta la più vicina verticalmente).
|
|
628
|
+
Ruolo tipico: nome del campo/colonna.
|
|
629
|
+
- `row` — label A SINISTRA nella stessa riga (y sovrapposto, x1 <
|
|
630
|
+
value x0, scelta la più vicina orizzontalmente). Ruolo tipico:
|
|
631
|
+
identificatore di riga ("TOTALE A", "SALDO").
|
|
632
|
+
|
|
633
|
+
3. **Ritorna** una mappatura `{ value:, labels: { col:, row: }, geometry: }`
|
|
634
|
+
per ogni valore.
|
|
635
|
+
|
|
636
|
+
### Esempio: F24
|
|
637
|
+
|
|
638
|
+
```ruby
|
|
639
|
+
Rpdfium.open("f24.pdf") do |doc|
|
|
640
|
+
page = doc.page(0)
|
|
641
|
+
pairs = page.label_value_pairs(
|
|
642
|
+
data_font: "Courier",
|
|
643
|
+
template_font: /^Futura/,
|
|
644
|
+
data_filter: ->(t) { t.match?(/^[\d.,]+$/) }
|
|
645
|
+
)
|
|
646
|
+
pairs.each do |p|
|
|
647
|
+
puts "#{p[:value]} → col: #{p[:labels][:col]}, row: #{p[:labels][:row]}"
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
Output:
|
|
653
|
+
|
|
654
|
+
```
|
|
655
|
+
499,81 → col: "importi a debito versati"
|
|
656
|
+
0,00 → col: "importi a credito compensati"
|
|
657
|
+
1001 → col: "codice tributo"
|
|
658
|
+
2021 → col: "anno di riferimento"
|
|
659
|
+
532,27 → col: "importi a debito versati", row: "A" (TOTALE A)
|
|
660
|
+
236,38 → col: "SALDO (A-B) +/–", row: "B"
|
|
661
|
+
1.253,00 → col: "importi a debito versati" (sezione INPS)
|
|
662
|
+
1.341,00 → col: "SALDO (C-D) +/–", row: "D"
|
|
663
|
+
1.615,90 → col: "SALDO (M-N) +/–", row: "EURO +" (saldo finale)
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### Esempio: Modello 770 Quadro ST
|
|
667
|
+
|
|
668
|
+
```ruby
|
|
669
|
+
Rpdfium.open("770.pdf") do |doc|
|
|
670
|
+
doc.page(3).label_value_pairs(
|
|
671
|
+
data_font: "Courier",
|
|
672
|
+
data_filter: ->(t) { t.match?(/^[\d.,]+$/) }
|
|
673
|
+
)
|
|
674
|
+
end
|
|
675
|
+
# 394,13 → col: "Ritenute operate"
|
|
676
|
+
# 394,13 → col: "Importo versato"
|
|
677
|
+
# 1001 → col: "Codice tributo"
|
|
678
|
+
# 16 → col: "Data di versamento giorno mese anno"
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### `Util::LabelMatcher` come classe autonoma
|
|
682
|
+
|
|
683
|
+
Per casi avanzati (es. matching su un sottoinsieme di pagina, con
|
|
684
|
+
soglie tarate, o riusato su più pagine) la logica è esposta come
|
|
685
|
+
classe separata:
|
|
686
|
+
|
|
687
|
+
```ruby
|
|
688
|
+
matcher = Rpdfium::Util::LabelMatcher.new(
|
|
689
|
+
col_max_dy: 50.0, # max distanza label sopra -> valore
|
|
690
|
+
row_max_dx: 150.0, # max distanza label sinistra -> valore
|
|
691
|
+
col_x_tolerance: 5.0, # overlap x richiesto per label "sopra"
|
|
692
|
+
row_y_tolerance: 1.0, # overlap y richiesto per label "sinistra"
|
|
693
|
+
cluster_same_row_dy: 4.0, # tolleranza cluster word stessa riga
|
|
694
|
+
cluster_same_row_dx: 12.0,
|
|
695
|
+
cluster_adj_row_dy: 4.0 # tolleranza cluster word righe adiacenti
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
data_words = Rpdfium::Util::WordExtractor.new.extract_words(page.chars_where(font: "Courier"))
|
|
699
|
+
anchor_words = Rpdfium::Util::WordExtractor.new.extract_words(page.chars_where(font: /^Futura/))
|
|
700
|
+
|
|
701
|
+
pairs = matcher.match(data_words, anchor_words)
|
|
702
|
+
|
|
703
|
+
# Bonus: ispeziona quali label il matcher costruisce
|
|
704
|
+
labels = matcher.cluster_anchors(anchor_words)
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
### Limitazioni note
|
|
708
|
+
|
|
709
|
+
- **Caselline separate per cifre**: su moduli con campi tipo codice
|
|
710
|
+
fiscale o numeri italiani spezzati su caselle separate
|
|
711
|
+
(`0 2 0 9 8 1 2 0 6 8 2`, `15.357 , 7 8`) il word extractor non
|
|
712
|
+
unisce le cifre. Aumentare `x_tolerance` aiuta, ma è tradeoff: il
|
|
713
|
+
fix definitivo richiede post-elaborazione consumer-side.
|
|
714
|
+
- **Label troppo larghe**: a volte il cluster unisce label adiacenti
|
|
715
|
+
che sarebbero meglio distinte. Le soglie default funzionano sulla
|
|
716
|
+
maggior parte dei moduli italiani Agenzia delle Entrate/INPS; per
|
|
717
|
+
layout diversi tara i parametri di `LabelMatcher`.
|
|
718
|
+
- **Label "abbondanti"**: per valori molto vicini al margine, le label
|
|
719
|
+
trovate sono ovvie ma poco informative. Filtrare i pair per
|
|
720
|
+
`data_filter` selettivo aiuta (esempio: solo numeri con virgola).
|
|
721
|
+
|
|
722
|
+
### Non-regressione
|
|
723
|
+
|
|
724
|
+
✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex — tutti
|
|
725
|
+
i test invariati.
|
|
726
|
+
|
|
727
|
+
### API compatibility
|
|
728
|
+
|
|
729
|
+
Nessuna breaking change. Le API 0.3.14 (`font_inventory`,
|
|
730
|
+
`chars_where`, `lines`) restano invariate. `Util::LabelMatcher` è
|
|
731
|
+
una nuova classe additiva.
|
|
732
|
+
|
|
733
|
+
## [0.3.14] - estrazione form-aware tramite font filtering
|
|
734
|
+
|
|
735
|
+
### Aggiunto: `Page#font_inventory`, `Page#chars_where`, `Page#lines`
|
|
736
|
+
|
|
737
|
+
Tre nuove API per estrarre dati da PDF di "moduli compilati" — F24,
|
|
738
|
+
comunicazioni IVA, modelli 770, dichiarazioni dei redditi e in generale
|
|
739
|
+
qualsiasi PDF di output da gestionali in cui il template grafico del
|
|
740
|
+
modulo e i dati inseriti dall'utente coesistono come testo statico
|
|
741
|
+
(nessun AcroForm, nessun tag PDF/UA).
|
|
742
|
+
|
|
743
|
+
Su questi PDF il pipeline `Table::Extractor` produce molto rumore
|
|
744
|
+
perché vede il modulo intero (etichette del template + dati) come una
|
|
745
|
+
griglia di tabelle. La soluzione semantica è separare i char per
|
|
746
|
+
"ruolo" usando il font/altezza: tipicamente il template usa font
|
|
747
|
+
proporzionali (Futura, Helvetica, Times) mentre i dati inseriti dal
|
|
748
|
+
gestionale usano un singolo font (di solito Courier o Helvetica a una
|
|
749
|
+
size specifica).
|
|
750
|
+
|
|
751
|
+
### `Page#font_inventory`
|
|
752
|
+
|
|
753
|
+
Distribuzione dei char per `(font, altezza, weight)`, ordinata per
|
|
754
|
+
count decrescente. Utile per scoprire empiricamente quale font usano
|
|
755
|
+
i dati su un modulo sconosciuto:
|
|
756
|
+
|
|
757
|
+
```ruby
|
|
758
|
+
page.font_inventory.first(5).each do |g|
|
|
759
|
+
puts "#{g[:font].ljust(20)} h=#{g[:height]} w=#{g[:weight]} | #{g[:count]} char | #{g[:sample][0,40]}"
|
|
760
|
+
end
|
|
761
|
+
# Futura-Light h=8.3 w=225 | 946 char | "cognome, denominazione o ragione sociale"
|
|
762
|
+
# Courier h=10.5 w=0 | 365 char | "01234567890Azienda S.R.L.P"
|
|
763
|
+
# Futura-Bold h=10.4 w=868 | 249 char | "CODICE FISCALEDATI ANAGRAFICIDOMICILIO F"
|
|
764
|
+
# Futura-Light h=8.9 w=225 | 194 char | "PROV.CODICE BANCA/POSTE/AGENTE DELLA RIS"
|
|
765
|
+
# Futura-Bold h=11.7 w=868 | 169 char | "CONTRIBUENTESEZIONE ERARIOSEZIONE INPSSE"
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
`height` è l'altezza visiva del char in punti (più affidabile di
|
|
769
|
+
`fontsize` che PDFium normalizza a 1.0 quando la dimensione reale è
|
|
770
|
+
nella matrice CTM, caso frequente sui moduli scalati).
|
|
771
|
+
|
|
772
|
+
### `Page#chars_where(font:, height:, weight:, bbox:, where:)`
|
|
773
|
+
|
|
774
|
+
Filtro generico sui char. Tutti i parametri sono opzionali e
|
|
775
|
+
combinabili in AND:
|
|
776
|
+
|
|
777
|
+
- `font:` String esatto, Array di String, o Regexp
|
|
778
|
+
- `height:` Float (con tolleranza 0.1pt), Range, o Array
|
|
779
|
+
- `weight:` Integer o Range
|
|
780
|
+
- `bbox:` `[left, top, right, bottom]` in coord top-down
|
|
781
|
+
- `where:` block per filtri arbitrari
|
|
782
|
+
|
|
783
|
+
```ruby
|
|
784
|
+
data_chars = page.chars_where(font: "Courier")
|
|
785
|
+
# oppure
|
|
786
|
+
data_chars = page.chars_where(font: /courier/i, height: 8.0..12.0)
|
|
787
|
+
# oppure con bbox
|
|
788
|
+
sezione_erario = page.chars_where(font: "Courier", bbox: [0, 250, 595, 400])
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
### `Page#lines(font:, ...)`
|
|
792
|
+
|
|
793
|
+
Helper di alto livello che combina `chars_where` + WordExtractor +
|
|
794
|
+
clustering per riga. Ritorna un Array di stringhe, una per riga
|
|
795
|
+
(top-to-bottom, char dentro la riga left-to-right):
|
|
796
|
+
|
|
797
|
+
```ruby
|
|
798
|
+
# F24
|
|
799
|
+
Rpdfium.open("f24.pdf") do |doc|
|
|
800
|
+
doc.page(0).lines(font: "Courier")
|
|
801
|
+
end
|
|
802
|
+
# => [
|
|
803
|
+
# "Soggetto: Azienda S.R.L. ( 01234567890 )",
|
|
804
|
+
# "0 1 2 3 4 5 6 7 8 9 0",
|
|
805
|
+
# "Azienda S.R.L.",
|
|
806
|
+
# "CITTA XX VIA ESEMPIO 1",
|
|
807
|
+
# "1001 11 2021 499,81 0,00",
|
|
808
|
+
# "1712 12 2021 32,46 0,00",
|
|
809
|
+
# "1701 11 2021 0,00 295,89",
|
|
810
|
+
# "532,27 295,89 236,38",
|
|
811
|
+
# "1900 DM10 9999999999 11 2021 1.253,00 0,00",
|
|
812
|
+
# ...
|
|
813
|
+
# "1.615,90"
|
|
814
|
+
# ]
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Funziona ugualmente bene su:
|
|
818
|
+
- **F24**: codici tributo, importi a debito/credito, sezioni separate
|
|
819
|
+
- **Comunicazione IVA**: importi del Quadro VP (operazioni attive/passive,
|
|
820
|
+
IVA esigibile/detratta, dovuta/credito)
|
|
821
|
+
- **Modello 770**: ritenute mese per mese con codici tributo e date di
|
|
822
|
+
versamento
|
|
823
|
+
- **Dichiarazione redditi (SP, PF, SC)**: dati anagrafici e quadri
|
|
824
|
+
|
|
825
|
+
### Tradeoff e limitazioni
|
|
826
|
+
|
|
827
|
+
`Page#lines` ritorna righe **leggibili**, non già strutturate. Su
|
|
828
|
+
moduli con caselle separate per cifra (es. il codice fiscale `0 2 0 9
|
|
829
|
+
8 1 2 0 6 8 2`, o numeri italiani con casella decimali separata
|
|
830
|
+
`15.357,78` → `15.357 7 8`), i gap visivi tra caselline superano
|
|
831
|
+
`x_tolerance` di default e le righe risultano "spaziate". Soluzioni:
|
|
832
|
+
|
|
833
|
+
1. Aumentare `x_tolerance` per quei filtri specifici (es. 8.0)
|
|
834
|
+
2. Post-elaborare le righe per riconoscere il pattern del modulo
|
|
835
|
+
(specifico per ogni modello)
|
|
836
|
+
|
|
837
|
+
La libreria fornisce le primitive composable; l'interpretazione del
|
|
838
|
+
modulo specifico resta al chiamante perché ogni modello ha layout
|
|
839
|
+
diverso.
|
|
840
|
+
|
|
841
|
+
### Non-regressione
|
|
842
|
+
|
|
843
|
+
✅ busta_paga.pdf: `1.993,00`, `COGNOME E NOME`, `NETTO BUSTA`
|
|
844
|
+
✅ cu.pdf p1 rotation 90°: `BANCA NAZIONALE`, `Categoria`
|
|
845
|
+
✅ cu.pdf p199 small font: `Categoria` (no `iCategora`)
|
|
846
|
+
✅ sample.pdf, complex.pdf
|
|
847
|
+
|
|
848
|
+
### API compatibility
|
|
849
|
+
|
|
850
|
+
Nessuna breaking change. Le tre nuove API sono additive. Il pipeline
|
|
851
|
+
`Table::Extractor` esistente continua a funzionare invariato per chi
|
|
852
|
+
ha tabelle "vere" con bordi.
|
|
853
|
+
|
|
854
|
+
## [0.3.13] - `Page#struct_tree`: struttura semantica dei PDF tagged
|
|
855
|
+
|
|
856
|
+
### Aggiunto: lettura del PDF Structure Tree
|
|
857
|
+
|
|
858
|
+
Nuova API `Page#struct_tree` che espone la struttura logica dei PDF
|
|
859
|
+
tagged (PDF/UA, esport accessibility-friendly da Word/LibreOffice/InDesign).
|
|
860
|
+
|
|
861
|
+
Per documenti tagged offre un accesso al contenuto **completamente
|
|
862
|
+
indipendente dalla geometria**: per ogni element del tree è possibile
|
|
863
|
+
sapere il suo tipo strutturale (`P`, `H1`, `Table`, `TR`, `TH`, `TD`,
|
|
864
|
+
`Figure`, ecc.), il testo che lo compone, gli attributi PDF strutturali
|
|
865
|
+
e i collegamenti via Marked Content ID al contenuto della pagina.
|
|
866
|
+
|
|
867
|
+
### Esempio: estrazione tabella zero-geometria
|
|
868
|
+
|
|
869
|
+
```ruby
|
|
870
|
+
page.struct_tree do |tree|
|
|
871
|
+
tree.tables.each do |table|
|
|
872
|
+
rows = table.children.select { |c| c.type == "TR" }
|
|
873
|
+
rows.each do |row|
|
|
874
|
+
cells = row.children.select { |c| c.type == "TH" || c.type == "TD" }
|
|
875
|
+
texts = cells.map(&:text).map(&:strip)
|
|
876
|
+
puts texts.inspect
|
|
877
|
+
end
|
|
878
|
+
end
|
|
879
|
+
end
|
|
880
|
+
# → ["Region", "Revenue", "Growth"] (TH — riga header)
|
|
881
|
+
# → ["Italy", "1.250.000", "+12%"] (TD — riga dati)
|
|
882
|
+
# → ["France", "980.000", "+8%"]
|
|
883
|
+
# → ["Germany", "2.100.000", "+15%"]
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
Vantaggi rispetto al pipeline geometrico `Table::Extractor`:
|
|
887
|
+
|
|
888
|
+
- distingue header (`TH`) da data (`TD`) — info che il pipeline
|
|
889
|
+
geometrico perde
|
|
890
|
+
- funziona su tabelle **senza linee** (text-only) o con linee parziali
|
|
891
|
+
- riconosce row/col span via `<TD>.attributes` (`RowSpan`, `ColSpan`)
|
|
892
|
+
- zero euristica di clustering
|
|
893
|
+
|
|
894
|
+
Limitazione: richiede PDF tagged. La maggior parte dei PDF da gestionali
|
|
895
|
+
italiani (TeamSystem, Zucchetti, banche italiane) NON sono tagged. Per
|
|
896
|
+
quelli il pipeline geometrico esistente resta l'unica strada.
|
|
897
|
+
|
|
898
|
+
### API completa
|
|
899
|
+
|
|
900
|
+
```ruby
|
|
901
|
+
tree = page.struct_tree # → Tree o nil
|
|
902
|
+
tree.empty? # true se il tree è strutturalmente vuoto
|
|
903
|
+
tree.roots # → [Element, ...] root del tree (di solito 1 "Document")
|
|
904
|
+
tree.walk { |el| ... } # itera depth-first
|
|
905
|
+
tree.walk.to_a # Enumerator
|
|
906
|
+
tree.find_all(type: "P") # filter by type
|
|
907
|
+
tree.tables # shortcut per find_all(type: "Table")
|
|
908
|
+
|
|
909
|
+
element.type # "P", "Table", "TR", "TD", ...
|
|
910
|
+
element.children # → [Element, ...]
|
|
911
|
+
element.parent # → Element o nil
|
|
912
|
+
element.text # ricostruisce testo da MCID + ActualText
|
|
913
|
+
element.actual_text # /ActualText attribute (override per legature, math)
|
|
914
|
+
element.alt_text # /Alt (per Figure/Formula)
|
|
915
|
+
element.lang # "it-IT", "en-US", ecc.
|
|
916
|
+
element.marked_content_ids # → [Integer]
|
|
917
|
+
element.attributes # → { name => value } (RowSpan, ColSpan, ...)
|
|
918
|
+
element.walk { |el| ... } # depth-first del sub-tree
|
|
919
|
+
element.leaves # foglie (elements senza children)
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
### Lifecycle
|
|
923
|
+
|
|
924
|
+
Il tree è "owning" — chiamare `FPDF_StructTree_Close` lo dealloca.
|
|
925
|
+
|
|
926
|
+
- **Lifecycle implicito (zero-config)**: non chiudere mai esplicitamente.
|
|
927
|
+
PDFium dealloca il tree alla chiusura del documento. Il tree resta in
|
|
928
|
+
memoria fino a quel momento (può essere ~MB su PDF grossi, ma niente
|
|
929
|
+
perdita persistente).
|
|
930
|
+
- **Lifecycle deterministico**: usa il blocco
|
|
931
|
+
`page.struct_tree do |tree| ... end`. All'uscita dal blocco il tree
|
|
932
|
+
viene chiuso, anche in caso di eccezione.
|
|
933
|
+
|
|
934
|
+
**Scelta progettuale**: non usiamo `ObjectSpace.define_finalizer` per il
|
|
935
|
+
tree. Il documento può essere chiuso prima del tree (es. dentro un
|
|
936
|
+
blocco `Rpdfium.open do |doc| ... end`), e il finalizer GC chiamerebbe
|
|
937
|
+
`FPDF_StructTree_Close` su memoria già liberata → use-after-free →
|
|
938
|
+
segfault. Lasciare la cleanup al `FPDF_CloseDocument` è sempre sicuro;
|
|
939
|
+
la cleanup esplicita tramite `tree.close` o blocco è sicura purché il
|
|
940
|
+
documento sia ancora vivo.
|
|
941
|
+
|
|
942
|
+
### 24 binding C-level mancanti aggiunti
|
|
943
|
+
|
|
944
|
+
Oltre agli 8 binding di base (già presenti):
|
|
945
|
+
|
|
946
|
+
- `FPDF_StructElement_GetParent`, `GetID`, `GetLang`, `GetObjType`
|
|
947
|
+
- `FPDF_StructElement_GetActualText`, `GetAltText`, `GetExpansion`
|
|
948
|
+
- `FPDF_StructElement_GetMarkedContentID`, `GetMarkedContentIdCount`,
|
|
949
|
+
`GetMarkedContentIdAtIndex`, `GetChildMarkedContentID`
|
|
950
|
+
- `FPDF_StructElement_GetAttributeCount`, `GetAttributeAtIndex`,
|
|
951
|
+
`GetStringAttribute`
|
|
952
|
+
- `FPDF_StructElement_Attr_GetCount`, `GetName`, `GetValue`, `GetType`,
|
|
953
|
+
`GetBooleanValue`, `GetNumberValue`, `GetStringValue`, `GetBlobValue`,
|
|
954
|
+
`CountChildren`, `GetChildAtIndex`
|
|
955
|
+
|
|
956
|
+
Tutte esposte via `Rpdfium::Raw.FPDF_*` per chi vuole bypassare i
|
|
957
|
+
wrapper.
|
|
958
|
+
|
|
959
|
+
### Tre stati possibili di `page.struct_tree`
|
|
960
|
+
|
|
961
|
+
| Caso | `page.struct_tree` ritorna |
|
|
962
|
+
| --- | --- |
|
|
963
|
+
| PDF non tagged | `nil` |
|
|
964
|
+
| PDF tagged ma vuoto (es. CR Banca d'Italia, 717 placeholder) | Tree con `empty? == true` |
|
|
965
|
+
| PDF tagged correttamente (Word/LibreOffice export) | Tree navigabile |
|
|
966
|
+
|
|
967
|
+
Verificato sui 4 PDF di test: busta_paga/sample/complex → nil; cu.pdf
|
|
968
|
+
p1 → empty; PDF generato via `soffice --convert-to pdf` → tree completo
|
|
969
|
+
con `<Document>` → `<P>`/`<Table>`/`<TR>`/`<TH>`/`<TD>` annidati.
|
|
970
|
+
|
|
971
|
+
### Non-regressione
|
|
972
|
+
|
|
973
|
+
Tutti i casi di test esistenti continuano a funzionare:
|
|
974
|
+
|
|
975
|
+
- ✅ busta_paga.pdf: `1.993,00`, `COGNOME E NOME`, `NETTO BUSTA`
|
|
976
|
+
- ✅ cu.pdf rotation 90° p1: `BANCA NAZIONALE`, `Categoria`
|
|
977
|
+
- ✅ cu.pdf p199: `Categoria` (no `iCategora`)
|
|
978
|
+
- ✅ sample/complex.pdf
|
|
979
|
+
|
|
980
|
+
## [0.3.12] - ottimizzazioni performance estrazione tabelle
|
|
981
|
+
|
|
982
|
+
(vedi note di rilascio precedenti)
|
|
983
|
+
|
|
984
|
+
## [0.3.11] - opzione `cell_padding` per char fuori bordo cella
|
|
985
|
+
|
|
986
|
+
### Aggiunto: `Table#extract(cell_padding: N)` per recuperare char border-line
|
|
987
|
+
|
|
988
|
+
Su alcuni PDF (CR Banca d'Italia, header tabelle) il primo char di una
|
|
989
|
+
cella è disegnato **leggermente fuori** dal bordo della cella stessa.
|
|
990
|
+
Esempio: la `I` maiuscola di "Intermediario:" ha `x0=24.0` ma la
|
|
991
|
+
cella inizia a `x=25.6` (la `I` sporge di 1.6 punti a sinistra del
|
|
992
|
+
bordo). Il filtro midpoint (identico a pdfplumber) calcola `h_mid =
|
|
993
|
+
25.25` e esclude la `I` perché < 25.6, producendo `"ntermediario:"`.
|
|
994
|
+
|
|
995
|
+
Pdfplumber ha **esattamente lo stesso problema** (verificato sul PDF):
|
|
996
|
+
il midpoint filter è una decisione di design comune. Però noi possiamo
|
|
997
|
+
offrire una via di mezzo.
|
|
998
|
+
|
|
999
|
+
### Nuova API
|
|
1000
|
+
|
|
1001
|
+
```ruby
|
|
1002
|
+
table.extract # default: pdfplumber-compat
|
|
1003
|
+
table.extract(cell_padding: 2.0) # recupera char che sporgono fino
|
|
1004
|
+
# a 2pt fuori dai bordi sinistro/alto
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
`cell_padding` estende il bbox di ogni cella verso **sinistra** e
|
|
1008
|
+
verso l'**alto** di N punti prima di applicare il filtro midpoint.
|
|
1009
|
+
Default 0.0 = comportamento identico a prima (e a pdfplumber).
|
|
1010
|
+
|
|
1011
|
+
Il padding è asimmetrico (solo bordi sinistro/alto, non destro/basso)
|
|
1012
|
+
per evitare di catturare char condivisi con celle adiacenti: se
|
|
1013
|
+
entrambe le celle vicine espandessero su tutti i lati, un char tra
|
|
1014
|
+
loro finirebbe in entrambe. Limitando il padding ai bordi "interno-
|
|
1015
|
+
sinistro" e "interno-alto" un char fuori-bordo-sinistro finisce solo
|
|
1016
|
+
nella cella alla sua destra, dove probabilmente appartiene.
|
|
1017
|
+
|
|
1018
|
+
### Risultato sul PDF problematico
|
|
1019
|
+
|
|
1020
|
+
```
|
|
1021
|
+
ext = Rpdfium::Table::Extractor.new(page, ...)
|
|
1022
|
+
ext.tables.first.extract # → ["ntermediario:", "BANCA NAZIONALE..."]
|
|
1023
|
+
ext.tables.first.extract(cell_padding: 2.0) # → ["Intermediario:", "BANCA NAZIONALE..."]
|
|
1024
|
+
```
|
|
1025
|
+
|
|
1026
|
+
### Test di non-regressione
|
|
1027
|
+
|
|
1028
|
+
Tutti i PDF di test continuano a funzionare correttamente con cell_padding
|
|
1029
|
+
default (0.0):
|
|
1030
|
+
|
|
1031
|
+
- ✅ busta_paga.pdf (numeri, parole con spazi, NETTO BUSTA)
|
|
1032
|
+
- ✅ cu.pdf pag. 1 (rotation 90°): Categoria, RISCHI AUTOLIQUIDANTI, 172.136
|
|
1033
|
+
- ✅ cu.pdf pag. 199 (rotation 0°, font piccolo): Categoria, Tipo Attività
|
|
1034
|
+
- ✅ sample.pdf (Lorem ipsum) + complex.pdf (>200k char)
|
|
1035
|
+
|
|
1036
|
+
Con `cell_padding: 2.0` su cu.pdf pag. 1:
|
|
1037
|
+
- ✅ "Intermediario:" recuperato per intero
|
|
1038
|
+
- ✅ Nessun valore numerico duplicato (172.136 appare 3 volte come atteso)
|
|
1039
|
+
|
|
1040
|
+
## [0.3.10] - bugfix: ordine char nelle celle con `top` quasi-uguale
|
|
1041
|
+
|
|
1042
|
+
### Risolto: parole scrambled tipo `iCategora` invece di `Categoria`
|
|
1043
|
+
|
|
1044
|
+
Su alcuni PDF (esempio: CR Banca d'Italia, pag. 199+ con font piccolo)
|
|
1045
|
+
le celle delle tabelle uscivano con char riordinati in modo errato:
|
|
1046
|
+
|
|
1047
|
+
| Atteso | Output errato |
|
|
1048
|
+
| ------------- | -------------------- |
|
|
1049
|
+
| `Categoria` | `iCategora` |
|
|
1050
|
+
| `Localizzazione` | `iLoca li zzazone i` |
|
|
1051
|
+
| `Tipo Attività` | `iTpo i Attvt i i à` |
|
|
1052
|
+
|
|
1053
|
+
Pattern: il char `i` (e occasionalmente altri con `x-height` sottile)
|
|
1054
|
+
veniva spostato all'inizio della parola o disperso.
|
|
1055
|
+
|
|
1056
|
+
### Causa
|
|
1057
|
+
|
|
1058
|
+
Regressione dell'ottimizzazione 0.3.9 in `WordExtractor#extract_words`.
|
|
1059
|
+
|
|
1060
|
+
L'ottimizzazione partiva dal presupposto che, dopo `chars.sort_by { |c|
|
|
1061
|
+
[c[:top], c[:x0]] }` + `Cluster.cluster_objects(:top)`, ogni cluster
|
|
1062
|
+
"riga" fosse già ordinato internamente per x0 — quindi il `row.sort_by
|
|
1063
|
+
{ |c| c[:x0] }` interno era eliminato come ridondante.
|
|
1064
|
+
|
|
1065
|
+
Il presupposto è **falso** quando due char della stessa riga visiva hanno
|
|
1066
|
+
`top` leggermente diversi (es. la `i` minuscola di `Categoria` ha
|
|
1067
|
+
`top=414.9789`, le altre lettere `top=414.9869`, differenza 0.008pt).
|
|
1068
|
+
PDFium spesso assegna alle bbox di char ascender/descender top
|
|
1069
|
+
leggermente diversi per ragioni di hinting/anti-aliasing. La
|
|
1070
|
+
differenza è invisibile graficamente ma rilevante per il sort.
|
|
1071
|
+
|
|
1072
|
+
Effetto: il sort globale `[top, x0]` mette la `i` (top minore) **prima
|
|
1073
|
+
di tutte le altre lettere** della parola, indipendentemente da x0. Il
|
|
1074
|
+
`cluster_objects` poi raggruppa tutti i char nella stessa riga (entro
|
|
1075
|
+
y_tolerance=3.0), ma non riordina internamente. Quindi all'iterazione
|
|
1076
|
+
della riga, la `i` viene letta per prima e finisce all'inizio.
|
|
1077
|
+
|
|
1078
|
+
### Fix
|
|
1079
|
+
|
|
1080
|
+
Ripristinato il `row_sorted = row.sort_by { |c| c[:x0] }` dentro il
|
|
1081
|
+
loop delle righe. L'ottimizzazione 0.3.9 era valida solo per il caso
|
|
1082
|
+
di top perfettamente identici; non lo è in generale.
|
|
1083
|
+
|
|
1084
|
+
Il costo computazionale aggiunto è marginale: un sort O(n log n) su
|
|
1085
|
+
righe corte (~50 char), dominato dall'overhead dell'FFI roundtrip per
|
|
1086
|
+
char nella fase precedente. Verificato empiricamente: tempo
|
|
1087
|
+
`extract_text` su 20 pagine di complex.pdf invariato (~80ms).
|
|
1088
|
+
|
|
1089
|
+
### Test di non-regressione
|
|
1090
|
+
|
|
1091
|
+
Tutti i PDF di test continuano a funzionare correttamente:
|
|
1092
|
+
|
|
1093
|
+
- ✅ busta_paga.pdf: numeri (`1.993,00`, `2.895,26`), spazi parole (`COGNOME E NOME`, `NETTO BUSTA`)
|
|
1094
|
+
- ✅ sample.pdf: Lorem ipsum (2913 char)
|
|
1095
|
+
- ✅ complex.pdf (85 pag): 224.645 char totali
|
|
1096
|
+
- ✅ cu.pdf pag. 1 (rotation 90°): `BANCA NAZIONALE DEL LAVORO`, `Categoria`, valori numerici
|
|
1097
|
+
- ✅ **cu.pdf pag. 199** (rotation 0°, font piccolo): `Categoria`, `Localizzazione`, `Tipo Attività`, `Accordato Operativo` — tutti integri
|
|
1098
|
+
|
|
1099
|
+
## [0.3.8] - supporto pagine ruotate (90°, 180°, 270°)
|
|
1100
|
+
|
|
1101
|
+
### Risolto: estrazione completamente errata su PDF con `Page#rotation != 0`
|
|
1102
|
+
|
|
1103
|
+
Su PDF con pagine ruotate (esempio: CU Banca d'Italia, certificate
|
|
1104
|
+
ruotate 90° CW per essere visualizzate landscape ma "logicamente"
|
|
1105
|
+
portrait), `Page#chars` ritornava bbox nel sistema **raw** PDFium
|
|
1106
|
+
(pre-rotazione), mentre PDFium stesso esponeva `width`/`height`
|
|
1107
|
+
**post-rotazione**. Il mismatch causava:
|
|
1108
|
+
|
|
1109
|
+
- `top` dei char tutti uguali nella stessa colonna ma `top` diverso
|
|
1110
|
+
tra char della stessa parola (perché il testo era "verticale" nel
|
|
1111
|
+
sistema raw)
|
|
1112
|
+
- L'estrazione tabelle produceva celle illeggibili: testo letto
|
|
1113
|
+
carattere per carattere a rovescio (`.A/.P/.S/O/R/O/V/A/L/L/E/D/E/L/A`
|
|
1114
|
+
invece di `BANCA NAZIONALE DEL LAVORO S.P.A.`)
|
|
1115
|
+
- I segmenti di linea (`line_segments`) erano nel sistema raw, mentre
|
|
1116
|
+
i char erano (parzialmente) nel sistema post-rotation, rendendo
|
|
1117
|
+
impossibile il match cellule/contenuti
|
|
1118
|
+
|
|
1119
|
+
### Fix
|
|
1120
|
+
|
|
1121
|
+
Tre interventi simmetrici per uniformare il sistema di coordinate:
|
|
1122
|
+
|
|
1123
|
+
1. **`compute_chars`**: applica la rotazione della pagina a ogni bbox di
|
|
1124
|
+
char (e all'origin point) prima di restituirli. Le coord sono ora
|
|
1125
|
+
sempre top-down nel sistema della pagina post-rotazione, allineate
|
|
1126
|
+
col rendering visivo. Coerente con pdfplumber.
|
|
1127
|
+
|
|
1128
|
+
2. **`line_segments`**: stesso trattamento agli endpoint dei segmenti.
|
|
1129
|
+
Il `build_segment` ora riceve un `rotation_ctx` invece del solo
|
|
1130
|
+
`page_h`, e trasforma entrambi i punti del segmento.
|
|
1131
|
+
|
|
1132
|
+
3. **Helper `apply_page_rotation_to_char` e `apply_page_rotation_to_point`**:
|
|
1133
|
+
centralizzano la matematica delle 4 rotazioni canoniche (0°, 90° CW,
|
|
1134
|
+
180°, 270° CW). Per rotation = 0 il comportamento è identico al pre-
|
|
1135
|
+
0.3.8 (semplice bottom-up → top-down).
|
|
1136
|
+
|
|
1137
|
+
### Verifica
|
|
1138
|
+
|
|
1139
|
+
Test di non-regressione su 4 PDF:
|
|
1140
|
+
|
|
1141
|
+
| PDF | Rotation | Risultato |
|
|
1142
|
+
| --- | -------: | --------- |
|
|
1143
|
+
| busta_paga.pdf (cedolino TeamSystem) | 0° | Invariato — tutti i valori critici (`1.993,00`, `COGNOME E NOME`, `NETTO BUSTA`) preservati |
|
|
1144
|
+
| sample.pdf | 0° | Invariato (Lorem ipsum, 2913 char) |
|
|
1145
|
+
| complex.pdf (85 pag) | 0° | Invariato (224.645 char totali) |
|
|
1146
|
+
| **cu.pdf (CR Banca d'Italia, 226 pag)** | **90° CW** | **Estrazione ora corretta** |
|
|
1147
|
+
|
|
1148
|
+
Esempio CU pagina 1 dopo il fix:
|
|
1149
|
+
|
|
1150
|
+
```
|
|
1151
|
+
=== Tabella 0 ===
|
|
1152
|
+
['ntermediario:', 'BANCA NAZIONALE DEL LAVORO S.P.A.']
|
|
1153
|
+
|
|
1154
|
+
=== Tabella 1 ===
|
|
1155
|
+
Header: ['Categoria', 'Localizzazione', 'Durata/Residua', 'Divisa',
|
|
1156
|
+
'Import Export', 'Tipo Attività', 'Stato Rapporto',
|
|
1157
|
+
'Tipo/Garanzia', 'Ruolo/Affidato', 'Accordato',
|
|
1158
|
+
'Accordato/Operativo', 'Utilizzato', 'Importo/Garantito']
|
|
1159
|
+
Row 1: ['RISCHI/AUTOLIQUIDANTI', 'TERMOLI', 'FINO A 1/ANNO', ...,
|
|
1160
|
+
'0', '172.136', '172.136', '172.136', '0']
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
Coincide cella-per-cella con pdfplumber sullo stesso PDF.
|
|
1164
|
+
|
|
1165
|
+
### API: nessuna breaking change
|
|
1166
|
+
|
|
1167
|
+
Le API pubbliche `Page#chars`, `Page#words`, `Page#text`,
|
|
1168
|
+
`Page#line_segments`, `Page#extract_tables` mantengono la stessa
|
|
1169
|
+
firma. I valori restituiti **cambiano per i PDF ruotati**: prima
|
|
1170
|
+
erano in coord raw (sbagliate), ora in coord post-rotation (corrette,
|
|
1171
|
+
allineate al rendering visivo). Per PDF con rotation = 0 (la stragrande
|
|
1172
|
+
maggioranza) non c'è alcuna differenza.
|
|
1173
|
+
|
|
1174
|
+
## [0.3.7] - bugfix critico: buffer overrun in `read_text_obj_text_from`
|
|
1175
|
+
|
|
1176
|
+
### Risolto: IndexError "Memory access offset=0 size=N out of bounds"
|
|
1177
|
+
|
|
1178
|
+
`Page#chars` (e di conseguenza `extract_text` / `extract_tables`) crashava
|
|
1179
|
+
con `IndexError` quando un text object PDF conteneva una stringa più
|
|
1180
|
+
lunga di 128 byte (parole come `consectetuer`, `Phasellus`, frasi intere
|
|
1181
|
+
da rivista). Lo stack trace tipico:
|
|
1182
|
+
|
|
1183
|
+
```
|
|
1184
|
+
IndexError: Memory access offset=0 size=158 out of bounds
|
|
1185
|
+
page.rb:429 in FFI::AbstractMemory#read_bytes
|
|
1186
|
+
page.rb:429 in Page#read_text_obj_text_from
|
|
1187
|
+
page.rb:343 in block in Page#compute_chars
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
Sui PDF tipici di gestionali italiani (cedolini TeamSystem) il bug NON
|
|
1191
|
+
si manifestava perché ogni text object lì contiene 1-4 char (sotto-soglia).
|
|
1192
|
+
Si attivava su PDF con text run più lunghi (riviste, articoli, qualsiasi
|
|
1193
|
+
PDF generato da TeX/Word/InDesign con kerning conservato a livello di
|
|
1194
|
+
parola).
|
|
1195
|
+
|
|
1196
|
+
### Causa
|
|
1197
|
+
|
|
1198
|
+
Errore mio nell'introdurre `:text_obj_ends_with_space` nella 0.3.4. La
|
|
1199
|
+
firma C di `FPDFTextObj_GetText` è:
|
|
1200
|
+
|
|
1201
|
+
```c
|
|
1202
|
+
unsigned long FPDFTextObj_GetText(FPDF_PAGEOBJECT, FPDF_TEXTPAGE,
|
|
1203
|
+
FPDF_WCHAR* buffer, unsigned long length);
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
dove **`length` è in BYTE** (non in count di uint16) e il return è il
|
|
1207
|
+
numero di byte **totali necessari** per scrivere il testo, anche se il
|
|
1208
|
+
buffer è troppo piccolo. Stavamo allocando 64 uint16 (= 128 byte),
|
|
1209
|
+
passando `64` come length (interpretato da PDFium come 64 BYTE = 32
|
|
1210
|
+
uint16!), e poi leggendo `(nbytes - 1) * 2` byte dal buffer dove `nbytes`
|
|
1211
|
+
era il return-value, che eccedeva il buffer allocato. Tre bug
|
|
1212
|
+
sovrapposti.
|
|
1213
|
+
|
|
1214
|
+
### Fix
|
|
1215
|
+
|
|
1216
|
+
Pattern probe-then-fetch con clamp difensivo:
|
|
1217
|
+
|
|
1218
|
+
1. Provo con buffer ragionevole (256 byte = 128 char UTF-16, copre ~99%
|
|
1219
|
+
dei text obj reali).
|
|
1220
|
+
2. Se PDFium ne richiede di più (`needed > buf_capacity`), rialloco
|
|
1221
|
+
esatto e rileggo.
|
|
1222
|
+
3. Clamp finale: leggo `min(needed - 2, buf_capacity - 2)` byte, mai
|
|
1223
|
+
oltre quanto effettivamente allocato. Difesa-in-profondità.
|
|
1224
|
+
|
|
1225
|
+
Il costo extra di FFI nei casi tipici è zero (il buffer iniziale basta);
|
|
1226
|
+
solo per text obj > 256 byte serve una seconda chiamata.
|
|
1227
|
+
|
|
1228
|
+
### Bug latenti collaterali fixati
|
|
1229
|
+
|
|
1230
|
+
Stesso pattern di buffer-overrun era presente in 3 altri helper aggiunti
|
|
1231
|
+
nella 0.3.6:
|
|
1232
|
+
|
|
1233
|
+
- `read_mark_name` (buffer 128 uint16)
|
|
1234
|
+
- `read_mark_param_key` (buffer 64 uint16)
|
|
1235
|
+
- `read_mark_param_string` (buffer 256 uint16)
|
|
1236
|
+
|
|
1237
|
+
Mai stati hit in produzione perché i mark name / param sono tipicamente
|
|
1238
|
+
brevi ("Span", "Artifact", "MCID"), ma la patologia esisteva.
|
|
1239
|
+
Risolti tutti con lo stesso clamp.
|
|
1240
|
+
|
|
1241
|
+
Anche `Structure::Attachment#bytes` aveva un pattern analogo: leggeva
|
|
1242
|
+
`buf.read_bytes(out_size.read_ulong)` dopo la seconda chiamata, dove
|
|
1243
|
+
`out_size` poteva eccedere il buffer. Cambiato a `buf.read_bytes(n)`
|
|
1244
|
+
con `n` = dimensione effettivamente allocata.
|
|
1245
|
+
|
|
1246
|
+
### Test
|
|
1247
|
+
|
|
1248
|
+
Smoke test esteso su `sample.pdf`, `complex.pdf` (60 MB / 85 pagine),
|
|
1249
|
+
e `busta_paga.pdf`: tutte le API pubbliche (`chars`, `words`, `text`,
|
|
1250
|
+
`line_segments`, `mediabox`, `cropbox`, `annotations`, `images`,
|
|
1251
|
+
`marked_content_regions`, `marked_content_inventory`, `extract_tables`,
|
|
1252
|
+
`attachments`) verdi su tutti e tre i PDF.
|
|
1253
|
+
|
|
1254
|
+
Tutti i valori critici di non-regressione preservati:
|
|
1255
|
+
`1.993,00`, `2.895,26`, `COGNOME E NOME`, `MATRICOLA INPS`,
|
|
1256
|
+
`NETTO BUSTA`, Lorem ipsum su sample, 224.645 char su complex.
|
|
1257
|
+
|
|
1258
|
+
## [0.3.6] - copertura binding pubbliche PDFium
|
|
1259
|
+
|
|
1260
|
+
### Aggiunto: 52 binding pubbliche PDFium mancanti
|
|
1261
|
+
|
|
1262
|
+
L'inventario sistematico dell'API pubblica PDFium (455 simboli esportati
|
|
1263
|
+
dal binario ufficiale) ha rivelato 319 funzioni non ancora attaccate.
|
|
1264
|
+
Selezionate 52 ad alto valore per una libreria di estrazione PDF generalista,
|
|
1265
|
+
escludendo i setter (mutation), gli event handler form-fill (mouse/keyboard)
|
|
1266
|
+
e API niche (thumbnail, JS actions). Tutte sono getter e tutti i tipi
|
|
1267
|
+
ritornati sono FFI-safe.
|
|
1268
|
+
|
|
1269
|
+
Distribuzione per categoria:
|
|
1270
|
+
|
|
1271
|
+
| Categoria | Binding | Aiuta a... |
|
|
1272
|
+
| ---------------------- | ------: | ---------- |
|
|
1273
|
+
| Page geometry | 5 | Sapere mediabox/cropbox/bleed/trim/art (pdfplumber-compat) |
|
|
1274
|
+
| PageObject state | 5 | Filtrare oggetti nascosti, distinguere linee tratteggiate |
|
|
1275
|
+
| Marked Content | 9 | Raggruppare semanticamente char in PDF tagged (PDF/UA) |
|
|
1276
|
+
| Catalog/Doc metadata | 2 | Language, PageMode |
|
|
1277
|
+
| Links + hit-test | 7 | API posizionale `link_at(x, y)`, mapping link → text range |
|
|
1278
|
+
| Actions/Destinations | 6 | Outline navigation completa |
|
|
1279
|
+
| Font extras | 4 | Font data raw, glyph path vettoriale |
|
|
1280
|
+
| Text page extras | 3 | Char ↔ text index mapping per ricerca |
|
|
1281
|
+
| Annotation extras | 7 | Flags/colors/border/AP/file attachment / quad points |
|
|
1282
|
+
| Attachment metadata | 4 | Subtype, key-value custom metadata |
|
|
1283
|
+
|
|
1284
|
+
### Nuove API pubbliche di alto livello
|
|
1285
|
+
|
|
1286
|
+
- **`Page#mediabox / cropbox / bleedbox / trimbox / artbox`** — accessor
|
|
1287
|
+
pdfplumber-compatibili. Ritornano tuple `[x0, top, x1, bottom]` in
|
|
1288
|
+
coordinate top-down (coerenti con `chars`, `edges`, `cells`). `cropbox`
|
|
1289
|
+
fa fallback automatico su mediabox se assente, come prescrive PDF spec
|
|
1290
|
+
14.11.2. Ritornano `nil` se il box non è definito.
|
|
1291
|
+
|
|
1292
|
+
- **`Page#marked_content_regions`** → Hash `{mcid => [page_objects]}`.
|
|
1293
|
+
Raggruppa gli oggetti per Marked Content ID. Vuoto su PDF non-tagged
|
|
1294
|
+
(gestionali italiani); su PDF tagged è il modo più affidabile di
|
|
1295
|
+
ottenere unità semantiche (paragrafi, span, celle tabella).
|
|
1296
|
+
|
|
1297
|
+
- **`Page#marked_content_inventory`** → Array di marks con `:obj`,
|
|
1298
|
+
`:mark_name`, `:params`. Per inspection di Tagged PDF (nomi tipici:
|
|
1299
|
+
"Span", "P", "TR", "TD", "Artifact", "Figure").
|
|
1300
|
+
|
|
1301
|
+
- **`Page#link_at(x, y)`** — hit-test posizionale: ritorna l'Annotation
|
|
1302
|
+
link che contiene il punto, o `nil`. Per il mapping click sul rendering
|
|
1303
|
+
→ URL.
|
|
1304
|
+
|
|
1305
|
+
- **`Page#line_segments(include_curves: false, include_dashed: false)`**
|
|
1306
|
+
— nuovo flag `include_dashed`. **Default cambiato a `false`**: le
|
|
1307
|
+
linee tratteggiate sono spesso "guide non-printing" che confondono la
|
|
1308
|
+
detection di cellule tabella. Chi le vuole esplicitamente (drawing
|
|
1309
|
+
extraction completo) passa `include_dashed: true`. I segment hanno
|
|
1310
|
+
ora il campo `:dashed` (bool).
|
|
1311
|
+
|
|
1312
|
+
- **PageObject inactive automaticamente skippati** in line_segments:
|
|
1313
|
+
oggetti con Optional Content disabilitato non finiscono più nell'output.
|
|
1314
|
+
Su PDF normali (sempre attivi) il comportamento è invariato.
|
|
1315
|
+
|
|
1316
|
+
### Bug fix collaterali
|
|
1317
|
+
|
|
1318
|
+
- Rimossa duplicazione di `FPDFText_GetMatrix` (era attached due volte;
|
|
1319
|
+
FFI dava warning ma una sola definizione era effettiva). La binding
|
|
1320
|
+
resta solo nella sezione Text page (riga ~351 di `raw.rb`).
|
|
1321
|
+
- Tutti gli helper `read_*` per marked content sono in `begin/rescue
|
|
1322
|
+
Rpdfium::LoadError` per supportare build PDFium più vecchi senza
|
|
1323
|
+
introdurre regressioni.
|
|
1324
|
+
|
|
1325
|
+
### Casi border-line text extraction
|
|
1326
|
+
|
|
1327
|
+
**Non risolti** sui PDF da gestionali italiani (TeamSystem, Zucchetti):
|
|
1328
|
+
parole come `Sede pr i nc` (`Sede principale`), `Imp i ega to`
|
|
1329
|
+
(`Impiegato`), `IMPONIBILE INAILMESE` (`IMPONIBILE INAIL MESE`) restano
|
|
1330
|
+
spezzate o fuse perché il content stream PDF emette quei char con
|
|
1331
|
+
kerning interno (operatori `TJ` con valori intermedi) che PDFium consuma
|
|
1332
|
+
internamente per il rendering ma non espone via API C pubblica.
|
|
1333
|
+
|
|
1334
|
+
Le binding `FPDFDICT_*` che permetterebbero di accedere al content stream
|
|
1335
|
+
raw (e ottenere il kerning, come fa pdfminer.six) **non esistono nel
|
|
1336
|
+
PDFium ufficiale di Google/Chromium**. Esistono solo nel fork commerciale
|
|
1337
|
+
Pdfium.NET di Patagames Software, non utilizzabile sotto licenza
|
|
1338
|
+
open-source. Le 421 simboli `FPDF*` esportati dal binario bblanchon
|
|
1339
|
+
sono stati verificati: nessun `FPDFDICT_*`.
|
|
1340
|
+
|
|
1341
|
+
I marked content (`FPDFPageObj_GetMark` / `CountMarks`) **sono** la via
|
|
1342
|
+
ufficiale per accedere alla struttura semantica, ma richiedono che il
|
|
1343
|
+
PDF sia stato generato come Tagged PDF. I PDF da gestionali italiani
|
|
1344
|
+
non lo sono. Per PDF da Word/InDesign/InEsign-style tagged, le nuove
|
|
1345
|
+
API ora coprono il caso.
|
|
1346
|
+
|
|
1347
|
+
## [0.3.5] - Ottimizzazioni
|
|
1348
|
+
|
|
1349
|
+
### Migliorato: ridotta computazione e semplificati branch condizionali
|
|
1350
|
+
|
|
1351
|
+
## [0.3.4] - advance del glifo, identità text-object, segnale fine-token
|
|
1352
|
+
|
|
1353
|
+
### Aggiunto: bindings e nuove proprietà sui char
|
|
1354
|
+
|
|
1355
|
+
Tre binding PDFium fondamentali che mancavano:
|
|
1356
|
+
|
|
1357
|
+
- **`FPDFFont_GetGlyphWidth(font, glyph_cp, font_size, *float)`** — larghezza
|
|
1358
|
+
nominale del glifo nel font program. Equivale concettualmente alla
|
|
1359
|
+
metric che pdfminer.six legge dal font dictionary del PDF.
|
|
1360
|
+
|
|
1361
|
+
- **`FPDFFont_GetAscent` / `FPDFFont_GetDescent`** — metriche font in
|
|
1362
|
+
unità del font program, utili per baseline e leading detection.
|
|
1363
|
+
|
|
1364
|
+
- **`FPDFText_GetMatrix(textpage, char_index, *FS_MATRIX)`** — matrice di
|
|
1365
|
+
trasformazione (CTM) applicata al char. Componente `:a` è la scala
|
|
1366
|
+
orizzontale font→pagina.
|
|
1367
|
+
|
|
1368
|
+
Queste binding sono ora esposte come API pubbliche di `Rpdfium::Raw` ed
|
|
1369
|
+
utilizzate internamente per arricchire ogni char con tre nuove proprietà:
|
|
1370
|
+
|
|
1371
|
+
| Proprietà | Tipo | Significato |
|
|
1372
|
+
| -------------------------- | -------- | ----------- |
|
|
1373
|
+
| `:advance` | Float? | Larghezza nominale del glifo in coordinate pagina, calcolata come `glyph_width × |CTM.a|`. Più stabile della `bbox_width` per char con kerning post-applied. |
|
|
1374
|
+
| `:text_obj_id` | Integer? | Identificatore stabile (pointer address) del text object contenente questo char. Tutti i char dello stesso text obj condividono lo stesso ID — utile per raggruppare semanticamente char correlati a livello content-stream. |
|
|
1375
|
+
| `:text_obj_ends_with_space` | bool? | True se il content stream PDF ha emesso uno spazio finale dopo questo char (es. fine di un token testuale). Segnale di "fine token" dichiarato dal PDF — non sempre coincidente con fine parola visiva, ma utile come indizio. |
|
|
1376
|
+
|
|
1377
|
+
### Migliorato: rebuild_word_separators usa i nuovi segnali
|
|
1378
|
+
|
|
1379
|
+
`Page#chars` (con `inject_spaces: true`, default) ora ricostruisce i word
|
|
1380
|
+
boundary combinando:
|
|
1381
|
+
|
|
1382
|
+
1. **Veto duro**: se `prev[:text_obj_ends_with_space] == false`, nessuno
|
|
1383
|
+
spazio viene inserito anche con gap geometrico grande. È kerning
|
|
1384
|
+
interno a un token dichiarato dal PDF.
|
|
1385
|
+
|
|
1386
|
+
2. **Threshold dinamica**: per i candidati ammessi (prev fine-token o
|
|
1387
|
+
segnale assente), uso soglia geometrica `gap > 0.3 × max_advance`
|
|
1388
|
+
come default, alzata a `0.7 × max_advance` se il contesto è numerico
|
|
1389
|
+
(cifre o punteggiatura `.`/`,`). Questa euristica preserva i numeri
|
|
1390
|
+
`2.895,26`/`1.993,00` interi mentre recupera la maggior parte degli
|
|
1391
|
+
spazi tra parole.
|
|
1392
|
+
|
|
1393
|
+
### Confronto con pdfplumber sul PDF di test
|
|
1394
|
+
|
|
1395
|
+
Recuperi netti rispetto alla 0.3.3:
|
|
1396
|
+
|
|
1397
|
+
| Cella | rpdfium 0.3.3 | rpdfium 0.3.4 | pdfplumber |
|
|
1398
|
+
| ---------------------- | ------------: | ------------: | ---------: |
|
|
1399
|
+
| COGNOME E NOME | `COGNOME ENOME` | `COGNOME E NOME` ✓ | `COGNOME E NOME` |
|
|
1400
|
+
| MATRICOLA INPS | `MATRICOLAINPS` | `MATRICOLA INPS` ✓ | `MATRICOLA INPS` |
|
|
1401
|
+
| POSIZIONE INAIL | `POSIZIONE INAIL` ✓ | `POSIZIONE INAIL` ✓ | `POSIZIONE INAIL` |
|
|
1402
|
+
| DATA NASCITA | `DATANASCITA` | `DATA NASCITA` ✓ | `DATA NASCITA` |
|
|
1403
|
+
| CODICE FISCALE | `CODICE FISCALE` ✓ | `CODICE FISCALE` ✓ | `CODICE FISCALE` |
|
|
1404
|
+
| COMUNE DI RESIDENZA | `COMUNEDI RESIDENZA` | `COMUNE DI RESIDENZA` ✓ | `COMUNE DI RESIDENZA` |
|
|
1405
|
+
| DATA ASSUNZIONE | `DATAASSUNZIONE` | `DATA ASSUNZIONE` ✓ | `DATA ASSUNZIONE` |
|
|
1406
|
+
| QUALIFICA INPS | `QUALIFICAINPS` | `QUALIFICA INPS` ✓ | `QUALIFICA INPS` |
|
|
1407
|
+
| TIPO RAPPORTO | `TIPORAPPORTO` | `TIPO RAPPORTO` ✓ | `TIPO RAPPORTO` |
|
|
1408
|
+
| RETR. DI FATTO | `RETR.DI FATTO` | `RETR. DI FATTO` ✓ | `RETR. DI FATTO` |
|
|
1409
|
+
| CCNL APPLICATO | `CCNLAPPLICATO` | `CCNL APPLICATO` ✓ | `CCNL APPLICATO` |
|
|
1410
|
+
| ADD. REG. ANNO DOVUTA | `ADD. REG.ANNODOVUTA` | `ADD. REG. ANNO DOVUTA` ✓ | `ADD. REG. ANNO DOVUTA` |
|
|
1411
|
+
| ADD. COM. ANNO DOVUTA | `ADD. COM.ANNODOVUTA` | `ADD. COM. ANNO DOVUTA` ✓ | `ADD. COM. ANNO DOVUTA` |
|
|
1412
|
+
| BONUS IRPEF ANNO | `BONUS IRPEFANNO` | `BONUS IRPEF ANNO` ✓ | `BONUS IRPEF ANNO` |
|
|
1413
|
+
| 2.857,15 (e altri num) | `2.857,15` ✓ | `2.857,15` ✓ | `2.857,15` |
|
|
1414
|
+
|
|
1415
|
+
Casi border-line residui (PDFium non emette il segnale fine-token):
|
|
1416
|
+
`Sede pr i nc`, `Imp i ega to`, `IMPONIBILE INAILMESE`. Pdfminer.six li
|
|
1417
|
+
gestisce perché legge gli operatori `TJ` con kerning dal content stream
|
|
1418
|
+
raw, info che PDFium consuma internamente e non espone via API pubblica.
|
|
1419
|
+
|
|
1420
|
+
### Test
|
|
1421
|
+
|
|
1422
|
+
- 30 unit test + 8 test di integrazione su PDF reale, tutti verdi.
|
|
1423
|
+
- Nuovi test: presenza di `:advance`, `:text_obj_id`,
|
|
1424
|
+
`:text_obj_ends_with_space` su char reali.
|
|
1425
|
+
|
|
1426
|
+
## [0.3.3] - ricostruzione word boundary geometry-based
|
|
1427
|
+
|
|
1428
|
+
### Risolto
|
|
1429
|
+
|
|
1430
|
+
**`Page#chars` ricostruisce gli spazi tra parole basandosi sulla geometria
|
|
1431
|
+
dei char**, invece di affidarsi agli spazi sintetici di PDFium (che sono
|
|
1432
|
+
inaffidabili: PDFium li emette aggressivamente anche tra cifre di numeri).
|
|
1433
|
+
|
|
1434
|
+
#### Perché era un problema
|
|
1435
|
+
|
|
1436
|
+
PDFium ha due comportamenti patologici sui spazi sintetici:
|
|
1437
|
+
|
|
1438
|
+
1. **Bbox degenere**: gli spazi tra parole hanno `top == bottom == baseline`,
|
|
1439
|
+
non in linea con i char circostanti. Il cluster per riga in
|
|
1440
|
+
`extract_text` li scartava, e parole adiacenti come `COGNOME E NOME`
|
|
1441
|
+
si fondevano in `COGNOMEENOME`.
|
|
1442
|
+
|
|
1443
|
+
2. **Falsi positivi sui numeri**: PDFium inserisce uno spazio sintetico
|
|
1444
|
+
tra OGNI cifra e la punteggiatura di un numero. `2.895,26` aveva
|
|
1445
|
+
spazi tra `2/.`, `./8`, `5/,`, `,/2`. Se li accettavamo, l'output
|
|
1446
|
+
diventava `2 . 895 , 26`.
|
|
1447
|
+
|
|
1448
|
+
#### La fix
|
|
1449
|
+
|
|
1450
|
+
Ho buttato via tutti gli spazi sintetici di PDFium e ricostruito i
|
|
1451
|
+
word boundary basandomi solo sulla geometria dei char "veri":
|
|
1452
|
+
`gap > 0.4 × max(prev_width, next_width)` → spazio. La soglia 0.4 con
|
|
1453
|
+
`max_w` (non `avg_w`) è cruciale: i char di punteggiatura come `.` e `,`
|
|
1454
|
+
sono più stretti delle cifre, e usare la media gonfierebbe i ratio dei
|
|
1455
|
+
gap intra-numero. Usando il max delle due larghezze, il numero
|
|
1456
|
+
`2.895,26` ha tutti i gap intra-numero con ratio < 0.35, mentre i veri
|
|
1457
|
+
gap inter-parola hanno ratio > 0.45.
|
|
1458
|
+
|
|
1459
|
+
Soglia 0.4 tarata empiricamente sui dati TeamSystem reali (1400 casi
|
|
1460
|
+
intra + 663 inter), con classificazione corretta al 100% sui casi
|
|
1461
|
+
non-borderline.
|
|
1462
|
+
|
|
1463
|
+
#### Confronto col PDF di test
|
|
1464
|
+
|
|
1465
|
+
| Cella | rpdfium 0.3.2 | rpdfium 0.3.3 | pdfplumber |
|
|
1466
|
+
| ------------------------ | ------------: | ------------: | ----------: |
|
|
1467
|
+
| Imponibile IRPEF Mese | `2.618,84` | `2.618,84` | `2.618,84` |
|
|
1468
|
+
| Netto Busta | `NETTOBUSTA/1.993,00` | `NETTOBUSTA/1.993,00` | `NETTO BUSTA/1.993,00` |
|
|
1469
|
+
| COGNOME E NOME | `COGNOMEENOME` | `COGNOME ENOME` | `COGNOME E NOME` |
|
|
1470
|
+
| MATRICOLA INPS | `MATRICOLAINPS` | `MATRICOLAINPS` | `MATRICOLA INPS` |
|
|
1471
|
+
| POSIZIONE INAIL | `POSIZIONEINAIL` | `POSIZIONE INAIL` | `POSIZIONE INAIL` |
|
|
1472
|
+
| RETR. DI FATTO | `RETR.DIFATTO` | `RETR.DI FATTO` | `RETR. DI FATTO` |
|
|
1473
|
+
| GIORNO DI RIPOSO | `GIORNODIRIPOSO` | `GIORNO DI RIPOSO` | `GIORNO DI RIPOSO` |
|
|
1474
|
+
| ONERI DED. | `ONERIDED.` | `ONERIDED.` | `ONERI DED.` |
|
|
1475
|
+
|
|
1476
|
+
La 0.3.3 recupera la maggior parte degli spazi inter-parola (vedi
|
|
1477
|
+
`POSIZIONE INAIL`, `GIORNO DI RIPOSO`, `RETR.DI FATTO`). Restano persi
|
|
1478
|
+
alcuni casi border-line dove il gap visivo è genuinamente piccolo
|
|
1479
|
+
(`COGNOME E NOME` che ha `E` molto vicina a `NOME`). Questi sono al
|
|
1480
|
+
limite delle possibilità di un algoritmo geometrico puro: pdfminer
|
|
1481
|
+
risolve usando l'advance del font dal content stream PDF, info non
|
|
1482
|
+
esposta da PDFium.
|
|
1483
|
+
|
|
1484
|
+
### API
|
|
1485
|
+
|
|
1486
|
+
- **`Page#chars(inject_spaces: true)` ora è il default**. Chi vuole il
|
|
1487
|
+
comportamento "raw PDFium" (tutti i char inclusi gli spazi sintetici
|
|
1488
|
+
aggressivi) passa `inject_spaces: false`.
|
|
1489
|
+
- Il vecchio metodo privato `inject_synthetic_spaces` è stato rimosso e
|
|
1490
|
+
rimpiazzato da `rebuild_word_separators` (più descrittivo del nuovo
|
|
1491
|
+
approccio).
|
|
1492
|
+
|
|
1493
|
+
## [0.3.2] - punteggiatura preservata nelle celle tabellari
|
|
1494
|
+
|
|
1495
|
+
### Risolto
|
|
1496
|
+
|
|
1497
|
+
**`Page#chars` ora ritorna bbox "loose" di default** (`loose: true`),
|
|
1498
|
+
allineando il comportamento a quello di `pdfminer.six`. Le bbox loose
|
|
1499
|
+
sono uniformi per riga: tutti i char della stessa linea logica condividono
|
|
1500
|
+
top/bottom proporzionali alla font-size, invece dei tight glyph box che
|
|
1501
|
+
PDFium darebbe nativamente.
|
|
1502
|
+
|
|
1503
|
+
#### Perché era un problema
|
|
1504
|
+
|
|
1505
|
+
Le bbox tight rispettano il singolo glifo. Un `.` (punto decimale) ha
|
|
1506
|
+
una bbox alta ~0.85pt, mentre un `5` accanto ne ha ~7pt sulla stessa
|
|
1507
|
+
linea. I loro midpoint verticali differiscono di ~3pt — quanto basta a
|
|
1508
|
+
far cadere il `.` fuori dalla bbox cella nel filtro `Table#extract`,
|
|
1509
|
+
che usa il midpoint per decidere quali char appartengono alla cella
|
|
1510
|
+
(stessa scelta di pdfplumber).
|
|
1511
|
+
|
|
1512
|
+
Effetto sul cedolino TeamSystem: valori come `1.993,00`, `2.857,15`,
|
|
1513
|
+
`7.788,60` venivano estratti come `1 993 00`, `2 857 15`, `7 788 60` —
|
|
1514
|
+
la punteggiatura cadeva fuori. Con loose box, tutti i char della riga
|
|
1515
|
+
hanno lo stesso midpoint verticale, e i punti/virgole arrivano dentro
|
|
1516
|
+
la cella.
|
|
1517
|
+
|
|
1518
|
+
#### Confronto con pdfplumber
|
|
1519
|
+
|
|
1520
|
+
Sul cedolino di test `busta_paga.pdf`:
|
|
1521
|
+
|
|
1522
|
+
| Cella | rpdfium 0.3.1 | rpdfium 0.3.2 | pdfplumber |
|
|
1523
|
+
| -------------------- | ------------: | ------------: | ---------: |
|
|
1524
|
+
| Netto Busta | `1 993 00` | `1.993,00` | `1.993,00` |
|
|
1525
|
+
| Imponibile IRPEF MESE | `2 618 84` | `2.618,84` | `2.618,84` |
|
|
1526
|
+
| TFR Spettante | `3 446 15` | `3.446,15` | `3.446,15` |
|
|
1527
|
+
| Retr. di Fatto | `2 857 15` | `2.857,15` | `2.857,15` |
|
|
1528
|
+
|
|
1529
|
+
### Aggiunto
|
|
1530
|
+
|
|
1531
|
+
- **`Page#chars(inject_spaces: true)`**: opt-in che inietta spazi
|
|
1532
|
+
sintetici nei gap orizzontali significativi (gap > 0.85 × char width)
|
|
1533
|
+
della stessa riga. Approssima il comportamento di pdfminer.six per
|
|
1534
|
+
parole adiacenti che PDFium fonderebbe per via del kerning. Può
|
|
1535
|
+
produrre falsi positivi su font condensati. **Default `false`**:
|
|
1536
|
+
preferiamo "non spezzare parole valide" rispetto a "catturare ogni
|
|
1537
|
+
spazio mancante", in linea con la filosofia "quello che PDFium emette
|
|
1538
|
+
è la verità".
|
|
1539
|
+
|
|
1540
|
+
- Helper privato `Page#inject_synthetic_spaces` esposto come API
|
|
1541
|
+
pubblica per chi vuole post-processare i char.
|
|
1542
|
+
|
|
1543
|
+
- Cache di `Page#chars` per (loose, inject_spaces): ricostruire
|
|
1544
|
+
l'array di char è O(n) di chiamate FFI, costoso su pagine grosse.
|
|
1545
|
+
|
|
1546
|
+
### Limitazioni note
|
|
1547
|
+
|
|
1548
|
+
- Sul cedolino di test, `inject_spaces: true` recupera ~80% degli
|
|
1549
|
+
spazi inter-parola persi (es. `NETTO BUSTA`), ma introduce qualche
|
|
1550
|
+
falso positivo (es. `Sede pr incipale`). Questo è un trade-off
|
|
1551
|
+
intrinseco di PDFium che non espone l'advance del font dal content
|
|
1552
|
+
stream, l'unica metrica davvero affidabile per decidere "spazio o no".
|
|
1553
|
+
Per estrazione testuale che richiede spazi perfetti, considerare
|
|
1554
|
+
pdfminer.six (e quindi pdfplumber); per estrazione tabellare con
|
|
1555
|
+
punteggiatura preservata, rpdfium è ora allineato.
|
|
1556
|
+
|
|
1557
|
+
## [0.3.1] - discesa nei Form XObjects
|
|
1558
|
+
|
|
1559
|
+
### Risolto
|
|
1560
|
+
|
|
1561
|
+
**`Page#line_segments` ora discende ricorsivamente nei Form XObjects**
|
|
1562
|
+
applicando la matrice di trasformazione affine che li posiziona nello spazio
|
|
1563
|
+
pagina. Prima di questa fix, su PDF dove la grafica della pagina era
|
|
1564
|
+
incapsulata in un singolo Form XObject (PDF generati da TeamSystem,
|
|
1565
|
+
Zucchetti e altri gestionali italiani; molti template Word/Excel),
|
|
1566
|
+
`line_segments` ritornava un Array vuoto anche se visivamente la pagina
|
|
1567
|
+
era piena di linee e bordi cella.
|
|
1568
|
+
|
|
1569
|
+
Conseguenza diretta: `Page#vertical_lines`, `Page#horizontal_lines`, e la
|
|
1570
|
+
strategia `:lines` di `Table::Extractor` ora funzionano correttamente su
|
|
1571
|
+
questi PDF.
|
|
1572
|
+
|
|
1573
|
+
Per il cedolino TeamSystem di test (`busta_paga.pdf`), i numeri:
|
|
1574
|
+
|
|
1575
|
+
| Metrica | prima 0.3.1 | dopo 0.3.1 | pdfplumber |
|
|
1576
|
+
| ----------------- | ----------: | ---------: | ---------: |
|
|
1577
|
+
| line_segments | 0 | 525 | 420 (\*) |
|
|
1578
|
+
| horizontal_lines | 0 | 375 | 210 |
|
|
1579
|
+
| vertical_lines | 0 | 437 | 210 |
|
|
1580
|
+
| tabelle estratte | 1 (\*\*) | 1 | 1 |
|
|
1581
|
+
| dimensione tab | 1×N nonsens | 28×44 | 28×44 |
|
|
1582
|
+
|
|
1583
|
+
(\*) pdfplumber decompone i 105 rettangoli in 4 lati ciascuno =
|
|
1584
|
+
420 edges; rpdfium attualmente li conta con duplicazione (le 4 linee del
|
|
1585
|
+
contour + il close-path), per questo 525 invece di 420. La detection di
|
|
1586
|
+
celle non ne risente perché lo snap+join collassa i duplicati.
|
|
1587
|
+
(\*\*) Senza linee, rpdfium 0.3.0 con strategia `:lines` non trovava
|
|
1588
|
+
tabelle e cadeva nel fallback `:text`, producendo una "tabella" gigantesca
|
|
1589
|
+
che copriva l'intera pagina.
|
|
1590
|
+
|
|
1591
|
+
### Aggiunto
|
|
1592
|
+
|
|
1593
|
+
- Bindings `FPDFFormObj_CountObjects` e `FPDFFormObj_GetObject` per
|
|
1594
|
+
iterare i child di un Form XObject.
|
|
1595
|
+
- Helpers privati `compose_matrix`, `apply_matrix`, `read_object_matrix`
|
|
1596
|
+
per la composizione di trasformazioni affini PDF.
|
|
1597
|
+
- Test di integrazione su PDF reale (`busta_paga.pdf`) che verifica:
|
|
1598
|
+
numero minimo di line_segments, struttura della tabella anagrafica,
|
|
1599
|
+
conteggio chars nel range atteso.
|
|
1600
|
+
|
|
1601
|
+
### Compatibilità
|
|
1602
|
+
|
|
1603
|
+
- Nessuna API breaking. `line_segments` mantiene la stessa firma e lo
|
|
1604
|
+
stesso formato di output.
|
|
1605
|
+
- I PDF già funzionanti in 0.3.0 (con grafica top-level) continuano a
|
|
1606
|
+
funzionare identici: la discesa nei Form XObjects parte dal CTM
|
|
1607
|
+
identità, quindi a livello top non c'è cambiamento di coordinate.
|
|
1608
|
+
|
|
1609
|
+
## [0.3.0] - estrazione tabelle riallineata 1:1 a pdfplumber
|
|
1610
|
+
|
|
1611
|
+
### Riscritto da zero
|
|
1612
|
+
|
|
1613
|
+
L'intero pipeline tabellare è stato riscritto seguendo il sorgente di
|
|
1614
|
+
[pdfplumber/table.py](https://github.com/jsvine/pdfplumber/blob/stable/pdfplumber/table.py)
|
|
1615
|
+
e [pdfplumber/utils/text.py](https://github.com/jsvine/pdfplumber/blob/stable/pdfplumber/utils/text.py).
|
|
1616
|
+
La versione 0.2.x aveva una serie di approssimazioni che producevano errori
|
|
1617
|
+
sistematici di estrazione su PDF con layout free-form (es. cedolini
|
|
1618
|
+
TeamSystem). I bug fix specifici:
|
|
1619
|
+
|
|
1620
|
+
1. **`words_to_edges_v` clusterizza ora tre coordinate (`x0`, `x1`, centro)**
|
|
1621
|
+
invece di solo `x0`. Le colonne numeriche right-aligned (importi
|
|
1622
|
+
`1.234,56` allineati a destra) erano invisibili al clustering basato su
|
|
1623
|
+
`x0`. Aggiunta dedupe per overlap di bbox: cluster sovrapposti tengono
|
|
1624
|
+
solo il più popolato.
|
|
1625
|
+
|
|
1626
|
+
2. **`words_to_edges_h` emette DUE edges per riga** (top + bottom della
|
|
1627
|
+
bbox del cluster). Senza il bottom edge, l'ultima riga di una tabella
|
|
1628
|
+
rilevata da text-strategy non veniva mai chiusa.
|
|
1629
|
+
|
|
1630
|
+
3. **`intersections_to_cells` usa l'algoritmo `find_smallest_cell`** di
|
|
1631
|
+
pdfplumber con verifica `edge_connect` su identità d'oggetto degli
|
|
1632
|
+
edge, non su sole coordinate. Due intersezioni con la stessa `x` ma
|
|
1633
|
+
appartenenti a edge verticali distinti non producono più cella spuria.
|
|
1634
|
+
|
|
1635
|
+
4. **`cells_to_tables` usa il fixed-point su corner condivisi**, non più
|
|
1636
|
+
adjacency check coordinate-based. Filtro single-cell per scartare rumore.
|
|
1637
|
+
|
|
1638
|
+
5. **Estrazione testo da cella usa midpoint del char**, non bbox-clip via
|
|
1639
|
+
`FPDFText_GetBoundedText`. Il midpoint è il criterio identico di
|
|
1640
|
+
pdfplumber, e risolve la concatenazione cross-cell ("RETRIBUZIONEUTILE")
|
|
1641
|
+
che si verificava su PDF dove i char di celle adiacenti hanno bbox
|
|
1642
|
+
leggermente sovrapposti.
|
|
1643
|
+
|
|
1644
|
+
### Aggiunto
|
|
1645
|
+
|
|
1646
|
+
- **`Rpdfium::Util::Cluster`** (nuovo modulo): primitive di clustering 1D
|
|
1647
|
+
agglomerativo single-linkage usate da tutto il pipeline (`cluster_list`,
|
|
1648
|
+
`cluster_objects`, `objects_to_bbox`, `bbox_overlap`).
|
|
1649
|
+
|
|
1650
|
+
- **`Rpdfium::Util::WordExtractor`** (nuova classe): estrazione words da
|
|
1651
|
+
char fedele a `pdfplumber.WordExtractor`. Supporta `x_tolerance`,
|
|
1652
|
+
`y_tolerance`, `keep_blank_chars`, `extra_attrs` (split su cambio
|
|
1653
|
+
font/size).
|
|
1654
|
+
|
|
1655
|
+
- **`Rpdfium::Util::TextExtraction.extract_text`** (nuovo modulo): converte
|
|
1656
|
+
un Array di char in stringa, raggruppando per riga via clustering del
|
|
1657
|
+
`top` e per parola via gap orizzontale > x_tolerance. Equivalente a
|
|
1658
|
+
`pdfplumber.utils.text.extract_text(layout=False)`.
|
|
1659
|
+
|
|
1660
|
+
- **`Rpdfium::Table::Table`** (nuova classe): rappresenta una tabella
|
|
1661
|
+
estratta. Espone `.cells`, `.rows`, `.columns`, `.bbox`, `.extract`.
|
|
1662
|
+
L'API combacia con `pdfplumber.table.Table`.
|
|
1663
|
+
|
|
1664
|
+
- **`edge_min_length_prefilter`** (default 1.0): filtra edges troppo corti
|
|
1665
|
+
prima dello snap+join, per ridurre rumore da micro-segmenti vettoriali.
|
|
1666
|
+
|
|
1667
|
+
- **`Rpdfium.extract_tables(..., keep_blank_rows: false)`** filtra di
|
|
1668
|
+
default le righe completamente vuote che la strategia `:text` produce
|
|
1669
|
+
per costruzione (effetto del doppio edge top+bottom).
|
|
1670
|
+
|
|
1671
|
+
### API: breaking changes minori
|
|
1672
|
+
|
|
1673
|
+
- Le strategy del `Extractor` validano l'input: `vertical_strategy` e
|
|
1674
|
+
`horizontal_strategy` accettano solo `:lines` / `:lines_strict` /
|
|
1675
|
+
`:text` / `:explicit`. Valori invalidi alzano `ArgumentError`.
|
|
1676
|
+
|
|
1677
|
+
- L'oggetto restituito da `Extractor#tables` (e dall'alias `find`) non è
|
|
1678
|
+
più un Hash con `:bbox`/`:rows`/`:cols`/`:grid`, ma un'istanza di
|
|
1679
|
+
`Rpdfium::Table::Table`. Chi usa `Rpdfium.extract_tables` (top-level)
|
|
1680
|
+
vede solo strutture base (Hash con `:page` e `:rows`), invariato.
|
|
1681
|
+
|
|
1682
|
+
- `Edges.snap_horizontal` / `Edges.snap_vertical` / `Edges.join_horizontal`
|
|
1683
|
+
/ `Edges.join_vertical` / `Edges.intersections` (firma vecchia) /
|
|
1684
|
+
`Cells.from_intersections` / `Cells.group_into_tables` rimossi. I
|
|
1685
|
+
rimpiazzi sono `snap_edges`, `join_edge_group`, `merge_edges`,
|
|
1686
|
+
`filter_edges`, `edges_to_intersections`, `intersections_to_cells`,
|
|
1687
|
+
`cells_to_tables` con segnature 1:1 da pdfplumber.
|
|
1688
|
+
|
|
1689
|
+
### Fix lifecycle (race finalizer)
|
|
1690
|
+
|
|
1691
|
+
Document/Page/TextPage/Annotation/Search/Form::Environment usano ora un
|
|
1692
|
+
**state Hash condiviso tra istanza e finalizer**. Tre proprietà
|
|
1693
|
+
acquisite:
|
|
1694
|
+
|
|
1695
|
+
- **Idempotenza** (`@state[:closed]` flag): nessuna doppia chiamata a
|
|
1696
|
+
`FPDF_CloseDocument`/`FPDF_ClosePage`/etc anche se sia `close()`
|
|
1697
|
+
esplicito che il GC partono.
|
|
1698
|
+
- **No-leak della closure**: il finalizer cattura un Hash, non `self`.
|
|
1699
|
+
L'istanza può essere raccolta liberamente dal GC.
|
|
1700
|
+
- **Disarmo esplicito**: `close()` chiama `ObjectSpace.undefine_finalizer`
|
|
1701
|
+
per impedire qualsiasi esecuzione tardiva del finalizer su un handle
|
|
1702
|
+
già liberato.
|
|
1703
|
+
|
|
1704
|
+
Risolve il segfault `FPDF_CloseDocument` durante introspezione del
|
|
1705
|
+
debugger su una collection di tabelle (riportato dall'utente con
|
|
1706
|
+
ruby-debug-ide).
|
|
1707
|
+
|
|
1708
|
+
### Fix `candidate_paths` (FFI)
|
|
1709
|
+
|
|
1710
|
+
Su macOS, FFI auto-appendeva `.dylib` a path `.so`, causando il fallimento
|
|
1711
|
+
del caricamento. Ora `candidate_paths` filtra i nomi di sistema per OS
|
|
1712
|
+
host: solo `.dylib` su macOS, solo `.so` su Linux, solo `.dll` su Windows.
|
|
1713
|
+
Inoltre se `ENV["PDFIUM_LIBRARY_PATH"]` o `Rpdfium::Binary.library_path`
|
|
1714
|
+
è impostato, viene usato come unico path: nessun fallback automatico.
|
|
1715
|
+
|
|
1716
|
+
### Test
|
|
1717
|
+
|
|
1718
|
+
- 30 unit test (60 asserzioni) coprono cluster primitives, word
|
|
1719
|
+
extraction, edges (snap/join/filter/intersections/words_to_edges_v/h),
|
|
1720
|
+
cells (smallest-cell + edge identity check), table (rows/columns/bbox/
|
|
1721
|
+
extract con midpoint), extractor end-to-end con FakePage, regressione
|
|
1722
|
+
TeamSystem (no più cross-cell concatenation; words_to_edges_v sui dati
|
|
1723
|
+
reali di un cedolino italiano in formato TeamSystem).
|
|
1724
|
+
|
|
1725
|
+
## [0.2.1] - allineamento PDFium chromium/6611+
|
|
1726
|
+
|
|
1727
|
+
### Cambiato
|
|
1728
|
+
|
|
1729
|
+
- **`FPDFText_GetTextRenderMode(text_page, char_index)` rimossa dalle
|
|
1730
|
+
bindings.** Era stata rimossa upstream da PDFium in chromium/6611
|
|
1731
|
+
(luglio 2024) — chiamarla causa `undefined symbol` con i build recenti
|
|
1732
|
+
di pdfium-binaries. Riferimenti:
|
|
1733
|
+
[pypdfium2#335](https://github.com/pypdfium2-team/pypdfium2/issues/335),
|
|
1734
|
+
[pdfium-render#151](https://github.com/ajrcarey/pdfium-render/issues/151).
|
|
1735
|
+
- `Page#chars` ora ottiene `:render_mode` via il path nuovo: prima
|
|
1736
|
+
risolve il text object che contiene il char con
|
|
1737
|
+
`FPDFText_GetTextObject`, poi legge il render mode con
|
|
1738
|
+
`FPDFTextObj_GetTextRenderMode` (che era già presente nella binding
|
|
1739
|
+
ma non utilizzato a char-level). Una cache interna evita lookup
|
|
1740
|
+
ripetuti — overhead invariato anche su pagine con migliaia di char.
|
|
1741
|
+
- Su build PDFium antichi (< chromium/6611) che non espongono
|
|
1742
|
+
`FPDFText_GetTextObject`, `:render_mode` ricade a `nil` invece di
|
|
1743
|
+
far esplodere l'estrazione.
|
|
1744
|
+
|
|
1745
|
+
### Aggiunto
|
|
1746
|
+
|
|
1747
|
+
- Binding di **`FPDFText_GetTextObject(text_page, char_index)`** —
|
|
1748
|
+
rimpiazzo upstream per ottenere il text object di un char.
|
|
1749
|
+
- Binding di **`FPDFFont_GetBaseFontName(font, buffer, size)`** —
|
|
1750
|
+
ritorna il `BaseFont` entry dal dict del font (può includere prefissi
|
|
1751
|
+
di subset come `ABCDEF+Helvetica`). Firma `c_size_t` invece di
|
|
1752
|
+
`c_ulong`, secondo l'header pubblico aggiornato.
|
|
1753
|
+
- Binding di **`FPDFFont_GetFamilyName(font, buffer, size)`** — ritorna
|
|
1754
|
+
il nome famiglia "pulito".
|
|
1755
|
+
- `FPDFFont_GetFontName` mantenuta come fallback per compatibilità con
|
|
1756
|
+
build PDFium più vecchi.
|
|
1757
|
+
|
|
1758
|
+
## [0.2.0] - parità con pypdfium2
|
|
1759
|
+
|
|
1760
|
+
Espansione massiccia. La superficie di API copre ora i casi d'uso principali
|
|
1761
|
+
di pypdfium2 più l'estrazione tabellare in stile pdfplumber.
|
|
1762
|
+
|
|
1763
|
+
### Aggiunto — bindings FFI
|
|
1764
|
+
|
|
1765
|
+
- **Path segments reali** via `FPDFPath_CountSegments`,
|
|
1766
|
+
`FPDFPath_GetPathSegment`, `FPDFPathSegment_GetPoint/GetType/GetClose`.
|
|
1767
|
+
Iterazione MOVETO/LINETO/BEZIERTO con state-machine corretta per
|
|
1768
|
+
`closepath`, sostituendo l'approccio "bbox del path" della 0.1.0.
|
|
1769
|
+
- **Image objects**: `FPDFImageObj_GetImageMetadata`,
|
|
1770
|
+
`GetImagePixelSize`, `GetBitmap`, `GetRenderedBitmap`,
|
|
1771
|
+
`GetImageDataDecoded`, `GetImageDataRaw`, `GetImageFilterCount`,
|
|
1772
|
+
`GetImageFilter`.
|
|
1773
|
+
- **Annotazioni**: `FPDFPage_GetAnnotCount/GetAnnot/CloseAnnot`,
|
|
1774
|
+
`FPDFAnnot_GetSubtype/GetRect/GetStringValue/HasKey/GetLink`,
|
|
1775
|
+
`FPDFLink_GetAction/GetDest/GetURL`, `FPDFAction_GetType/GetURIPath`.
|
|
1776
|
+
- **Form fields** (read-only): `FPDFDOC_InitFormFillEnvironment` con
|
|
1777
|
+
`FPDF_FORMFILLINFO` versione 2 minimale, `FPDF_FFLDraw`,
|
|
1778
|
+
`FPDFAnnot_GetFormFieldType/Name/Value/Flags/IsChecked`,
|
|
1779
|
+
`GetOptionCount/GetOptionLabel`.
|
|
1780
|
+
- **Bookmarks** (outline): `FPDFBookmark_GetFirstChild/GetNextSibling/
|
|
1781
|
+
GetTitle/GetDest`, `FPDFDest_GetDestPageIndex`.
|
|
1782
|
+
- **Attachments**: `FPDFDoc_GetAttachmentCount/GetAttachment`,
|
|
1783
|
+
`FPDFAttachment_GetName/GetFile`.
|
|
1784
|
+
- **Structure tree** (PDF tagged): `FPDF_StructTree_GetForPage`,
|
|
1785
|
+
`CountChildren`, `GetChildAtIndex`, `GetType`, `GetTitle`.
|
|
1786
|
+
- **Search interna**: `FPDFText_FindStart/FindNext/FindPrev/FindClose`,
|
|
1787
|
+
`GetSchResultIndex`, `GetSchCount`.
|
|
1788
|
+
- **Char metadata estesa**: `FPDFText_GetLooseCharBox`,
|
|
1789
|
+
`GetCharOrigin`, `GetCharAngle`, `IsGenerated`, `IsHyphen`,
|
|
1790
|
+
`HasUnicodeMapError`, `GetFontInfo`, `GetTextRenderMode`, `GetMatrix`.
|
|
1791
|
+
- **Document**: `FPDF_GetMetaText`, `GetDocPermissions`, `GetFileVersion`,
|
|
1792
|
+
`GetFormType`, `GetPageLabel`.
|
|
1793
|
+
- **Bitmap**: `CreateEx`, `RenderPageBitmapWithMatrix`, format detection.
|
|
1794
|
+
- **Page boxes**: `MediaBox`, `CropBox`, `BleedBox`, `TrimBox`, `ArtBox`.
|
|
1795
|
+
|
|
1796
|
+
### Aggiunto — wrapper di alto livello
|
|
1797
|
+
|
|
1798
|
+
- `Rpdfium::Document` ora espone: `metadata` (Title/Author/Producer/...),
|
|
1799
|
+
`permissions` (hash di booleans per print/copy/modify/...), `file_version`,
|
|
1800
|
+
`form_type`, `has_forms?`, `outline`, `attachments`, `page_label(idx)`.
|
|
1801
|
+
- `Rpdfium::Page` ora espone:
|
|
1802
|
+
- `box(:media|:crop|:bleed|:trim|:art)`
|
|
1803
|
+
- `chars(loose: false)` — array di hash con `char`, `codepoint`, bbox,
|
|
1804
|
+
`origin_x/y`, `angle`, `fontsize`, `font`, `weight`, `render_mode`,
|
|
1805
|
+
`generated`, `hyphen`, `unicode_error`
|
|
1806
|
+
- `words(x_tolerance:, y_tolerance:)` — clustering layout-aware
|
|
1807
|
+
- `text_in_bbox(left:, top:, right:, bottom:)` — top-down coords
|
|
1808
|
+
- `line_segments` — segmenti vettoriali REALI dai path objects
|
|
1809
|
+
- `horizontal_lines`, `vertical_lines` — derivati da `line_segments`
|
|
1810
|
+
- `images` — `Image::Embedded` array
|
|
1811
|
+
- `annotations`, `links`, `form_fields`
|
|
1812
|
+
- `render(scale:, rotate:, output: :rgba|:bgra|:gray, include_annotations:,
|
|
1813
|
+
include_forms:, background:)`
|
|
1814
|
+
- `render_to_png(path)` — pure-Ruby, zero dipendenze esterne
|
|
1815
|
+
- `search(query, **opts)` — internal full-text search
|
|
1816
|
+
- `Rpdfium::Image::Embedded` con `metadata`, `pixel_size`, `bbox`,
|
|
1817
|
+
`filters`, `raw_bytes`, `decoded_bytes`, `render_bitmap`, `save(path)`
|
|
1818
|
+
(passthrough JPEG quando il filter è `DCTDecode`).
|
|
1819
|
+
- `Rpdfium::Annotation` con `subtype`, `bbox`, `[]`, `link_uri`,
|
|
1820
|
+
`link_dest_page`.
|
|
1821
|
+
- `Rpdfium::Form::{Environment, Field}` con tipi mappati (textfield,
|
|
1822
|
+
checkbox, radiobutton, combobox, listbox, signature, ...) e
|
|
1823
|
+
`readonly?`, `required?`, `checked?`, `options`.
|
|
1824
|
+
- `Rpdfium::Search` con `Enumerable`, ogni match include rects per riga.
|
|
1825
|
+
- `Rpdfium::Outline` con tree ricorsivo, `flatten` preorder, `to_h`.
|
|
1826
|
+
- `Rpdfium::Attachment` con `name`, `bytes`, `save(path)`.
|
|
1827
|
+
|
|
1828
|
+
### Aggiunto — estrazione tabellare
|
|
1829
|
+
|
|
1830
|
+
- Pipeline pdfplumber-style:
|
|
1831
|
+
1. raccolta edges (strategie `:lines`, `:text`, `:explicit`,
|
|
1832
|
+
`:lines_strict`)
|
|
1833
|
+
2. snap (cluster collineari → coord media)
|
|
1834
|
+
3. join (segmenti contigui → unico edge)
|
|
1835
|
+
4. filter per `edge_min_length`
|
|
1836
|
+
5. intersezioni h × v entro `intersection_tolerance`
|
|
1837
|
+
6. costruzione celle (4 angoli intersezioni)
|
|
1838
|
+
7. raggruppamento celle adiacenti (union-find) in tabelle
|
|
1839
|
+
8. estrazione testo per cella via `FPDFText_GetBoundedText`
|
|
1840
|
+
- Tutti i parametri di pdfplumber supportati: `snap_tolerance` (con
|
|
1841
|
+
varianti `_x`, `_y`), `join_tolerance`, `intersection_tolerance`,
|
|
1842
|
+
`edge_min_length`, `min_words_vertical`, `min_words_horizontal`,
|
|
1843
|
+
`text_tolerance`, `keep_blank_chars`.
|
|
1844
|
+
- `auto_fallback` opzionale: se `:lines` non produce nulla, riprova con
|
|
1845
|
+
`:text`.
|
|
1846
|
+
- `Rpdfium::Table::Debugger.visualize(page, output_path)` — overlay
|
|
1847
|
+
visivo (linee rosse, intersezioni verdi, tabelle blu trasparenti)
|
|
1848
|
+
equivalente di `pdfplumber.Page.debug_tablefinder()`. Implementato in
|
|
1849
|
+
Ruby puro con canvas RGBA, Bresenham, alpha blending.
|
|
1850
|
+
|
|
1851
|
+
### Aggiunto — utility
|
|
1852
|
+
|
|
1853
|
+
- `Rpdfium::IO::PNG` — writer PNG puro Ruby (zero deps), supporta
|
|
1854
|
+
RGBA 8bpc. CRC32 corretti, deflate via stdlib `zlib`.
|
|
1855
|
+
- `Raw.read_utf16_string` helper centralizzato per il pattern
|
|
1856
|
+
probe-then-fetch di PDFium (che ritorna stringhe UTF-16LE).
|
|
1857
|
+
|
|
1858
|
+
### Cambiato
|
|
1859
|
+
|
|
1860
|
+
- Coordinate top-down ovunque nelle API pubbliche (PDFium internamente
|
|
1861
|
+
è bottom-up; conversione fatta una volta sola per evitare confusione).
|
|
1862
|
+
- Il documento mantiene una cache delle pagine: `doc.page(0)` ritorna
|
|
1863
|
+
sempre la stessa istanza (le pagine sono read-only nel nostro modello).
|
|
1864
|
+
- Init/destroy della libreria ora è thread-safe (`Mutex`) e idempotente.
|
|
1865
|
+
- `Document#close` rilascia in cascata: form env → pagine cached → doc.
|
|
1866
|
+
|
|
1867
|
+
## [0.1.0]
|
|
1868
|
+
|
|
1869
|
+
Prima release: bindings minimali, text/render base, table extractor
|
|
1870
|
+
embrionale.
|