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