rpdfium 0.4.1 → 0.4.2

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 CHANGED
@@ -1,164 +1,216 @@
1
1
  # Changelog
2
2
 
3
- Tutte le modifiche notevoli a questo progetto.
4
- Il formato segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/).
3
+ All notable changes to this project are documented in this file.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ > Entries for versions prior to 0.3.10 are available in the project's
7
+ > Git history.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.4.2] - 2026-06-15
12
+
13
+ ### Added
14
+
15
+ - **Benchmark suite gains a fifth, heaviest tier: `05_academic.pdf`
16
+ (520 pages).** A synthetic academic paper that stresses every code path at
17
+ scale — condensed two-column body text (small font, negative character
18
+ spacing, sub-100% horizontal scaling), ~104 ruled tables (counted in the
19
+ ground truth) interleaved with borderless appendix tables, embedded figure
20
+ images, footnotes, and a mix of academic annotations (citation links,
21
+ highlights, margin notes). Generated by `benchmark/generate_pdfs.rb` and
22
+ scored against `pdfs/expected.json` like the other tiers. On this tier
23
+ `Rpdfium.extract_text` runs in **706 ms / 69 MB** vs pdfplumber's
24
+ **57.15 s / 5537 MB** (~81× faster, ~80× less memory) and beats raw
25
+ pypdfium2 on both axes thanks to page streaming. README and the benchmark
26
+ site pages were updated with the full numbers.
27
+
28
+ ### Performance
29
+
30
+ - **`Rpdfium.extract_tables` is ~30–35% faster on text-heavy pages.** The
31
+ table/word pipeline now pulls chars via a new `Page#chars(geometry: true)`
32
+ fast path. On top of the existing `lean` mode it also skips the per-char
33
+ `FPDFText_GetCharOrigin` read and the per-char angle/font/weight/render-mode
34
+ work, applies the page rotation inline (no intermediate tuple), and emits a
35
+ minimal per-char hash with only the fields the pipeline reads. The
36
+ content-stream "token end" signal (`text_obj_ends_with_space`, used by
37
+ `rebuild_word_separators` to avoid splitting numbers like `2.895,26`) is
38
+ preserved, so extracted table contents are byte-for-byte identical. Measured
39
+ on the synthetic benchmark corpus: heavy page set 731 → 484 ms, complex
40
+ 131 → 110 ms.
41
+
42
+ ### Fixed
43
+
44
+ - **`Page#font_inventory` split round glyphs into spurious groups**: heights
45
+ were keyed by `round(1)`, so a glyph whose loose box overshoots the cap line
46
+ by ~0.1pt (`O`, `S`, `C`) fell into a separate height bucket from the rest of
47
+ its line — producing garbled samples like `CDICE FISCALE` with every `O`
48
+ missing, and inflating the group count. Heights are now clustered within a
49
+ `height_tolerance` (default 0.5pt, single-linkage, per font+weight) and
50
+ samples are emitted in document order.
51
+ - **`Annotation#link_uri` returned garbled text**: `FPDFAction_GetURIPath`
52
+ returns 7-bit ASCII bytes, unlike most PDFium getters which return
53
+ UTF-16LE. The bytes were being decoded as UTF-16, producing CJK garbage
54
+ for every external link URI. Added `Raw.read_ascii_string` and switched
55
+ `link_uri` to it.
5
56
 
6
57
  ## [0.4.1] - 2026-05-26
7
58
 
8
- ### Corretto
59
+ ### Fixed
9
60
 
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.
61
+ - **Loading on Linux with `rpdfium-binary`**: `rpdfium.rb` now executes
62
+ `require "rpdfium/binary"` before `raw.rb`. Previously `ffi_lib` was
63
+ invoked before `Rpdfium::Binary` had been defined, causing a fallback
64
+ to the system library names (`pdfium`, `libpdfium.so`) and a
65
+ `LoadError` on environments without a globally installed PDFium.
15
66
 
16
- ## [0.4.0] - refactor verso primitive componibili
67
+ ## [0.4.0] - refactor toward composable primitives
17
68
 
18
69
  ### ⚠️ Breaking changes
19
70
 
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).
71
+ `Page#label_value_pairs` reverts to being a **minimal primitive**: it
72
+ returns an `Array<Hash>` of raw pairs with no application-level merging
73
+ options. The `merge_adjacent:`, `as_hash:`, and `boxed_layout:` options
74
+ are **removed** (they were domain logic grafted onto the extraction
75
+ primitive).
25
76
 
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)`
77
+ For users who relied on these options:
31
78
 
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.
79
+ - `merge_adjacent: :smart` compose manually with `Util::WordMerger`
80
+ - `as_hash: true` convert the result in the caller
81
+ - `boxed_layout: true` pass `x_tolerance: 15.0, inject_spaces: false`
82
+ directly, and create `LabelMatcher.new(row_max_dx: 400.0)`
35
83
 
36
- ### Aggiunto: `Util::WordMerger`
84
+ The **application-specific adapters** for Italian Revenue Agency forms
85
+ (Modello 770, Comunicazione IVA) are now provided as **external
86
+ examples** under `examples/adapters/` (see below), not as part of the
87
+ gem.
37
88
 
38
- Primitiva di merging configurabile, con tre strategie esplicite:
89
+ ### Added: `Util::WordMerger`
90
+
91
+ A configurable merging primitive with three explicit strategies:
39
92
 
40
93
  ```ruby
41
94
  merger = Rpdfium::Util::WordMerger.new(x_gap: 20.0, y_tol: 3.0)
42
95
 
43
- # Fonde tutte le word adiacenti
96
+ # Merge all adjacent words
44
97
  merger.merge_by_proximity(words)
45
98
 
46
- # Fonde solo word con stessa label (mapping word → label fornito dal chiamante)
99
+ # Merge only words sharing the same label (word → label mapping supplied by the caller)
47
100
  merger.merge_by_label(words, labels_by_word)
48
101
 
49
- # Fonde solo word con label nil (orfane)
102
+ # Merge only words with a nil label (orphans)
50
103
  merger.merge_unlabeled(words, labels_by_word)
51
104
  ```
52
105
 
53
- ### Aggiunto: `Util::ColumnInference`
106
+ ### Added: `Util::ColumnInference`
54
107
 
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:
108
+ A primitive for inferring data columns on non-tabular PDFs (prestamped
109
+ forms, layouts whose values are aligned by position but lack ruling
110
+ lines). The algorithm proceeds in three steps:
58
111
 
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)
112
+ 1. Cluster by X coordinate (`x0` left-aligned OR `x1` right-aligned)
113
+ 2. Split on anomalous vertical gaps
114
+ 3. Filter by density (coefficient of variation of the gaps)
62
115
 
63
116
  ```ruby
64
117
  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
118
+ x_tolerance: 3.0, # X cluster tolerance
119
+ min_size: 3, # at least 3 values per column
120
+ cv_threshold: 0.15 # regular gaps
68
121
  )
69
122
 
70
123
  columns = inference.infer(words)
71
124
  # => [[word1, word2, ...], [word1, word2, ...]]
72
125
  ```
73
126
 
74
- ### `Util::LabelMatcher` ora compone con `ColumnInference`
127
+ ### `Util::LabelMatcher` now composes with `ColumnInference`
75
128
 
76
129
  ```ruby
77
- # Senza riassegnazione (comportamento 0.3.15)
130
+ # Without reassignment (0.3.15 behavior)
78
131
  matcher = Rpdfium::Util::LabelMatcher.new
79
132
 
80
- # Con riassegnazione per colonne ripetitive (ex repeat_headers)
133
+ # With reassignment for repeating columns (formerly repeat_headers)
81
134
  matcher = Rpdfium::Util::LabelMatcher.new(
82
135
  column_inference: Rpdfium::Util::ColumnInference.new
83
136
  )
84
137
  ```
85
138
 
86
- Il flag `repeat_headers:` non esiste piùsi passa direttamente un
87
- oggetto `ColumnInference` configurato (o `nil` per disabilitare).
139
+ The `repeat_headers:` flag no longer exists a configured
140
+ `ColumnInference` object is passed directly (or `nil` to disable it).
88
141
 
89
- ### Adapter applicativi (esempi esterni)
142
+ ### Application adapters (external examples)
90
143
 
91
- Distribuiti in `examples/adapters/`, NON parte della gem. Mostrano
92
- come comporre le primitive per casi specifici:
144
+ Distributed under `examples/adapters/`, **not** part of the gem. They
145
+ illustrate how to compose the primitives for specific cases:
93
146
 
94
- - **`Modello770Reader`** (per Dichiarazione sostituti d'imposta)
95
- - **`LiquidazioneIVAReader`** (per Comunicazione Liquidazioni IVA)
147
+ - **`Modello770Reader`** (for the withholding-agent declaration)
148
+ - **`LiquidazioneIVAReader`** (for the periodic VAT settlement
149
+ communication)
96
150
 
97
- Ognuno è uno script Ruby standalone con classe ~100 righe. Da
98
- copiare nel proprio progetto e adattare se serve.
151
+ Each is a standalone Ruby script with a class of roughly 100 lines,
152
+ intended to be copied into your own project and adapted as needed.
99
153
 
100
- ### Filosofia
154
+ ### Philosophy
101
155
 
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.
156
+ The rpdfium gem provides general-purpose primitives for reading PDFs.
157
+ The **application logic specific to a given form** (knowing that the 770
158
+ has the ST/SV/SX sections, that the VAT form uses single-digit boxes
159
+ with a comma painted graphically by the template) belongs in the
160
+ **consumer's code**, not in the gem.
107
161
 
108
- Le primitive `WordMerger`, `ColumnInference`, `LabelMatcher` sono
109
- **componibili**: ogni caso d'uso compone una pipeline specifica.
162
+ The `WordMerger`, `ColumnInference`, and `LabelMatcher` primitives are
163
+ **composable**: each use case composes a specific pipeline.
110
164
 
111
- ### Non-regressione
165
+ ### Regression testing
112
166
 
113
- Tutti i test core passano. F24, busta_paga, cu.pdf, complex,
114
- sample invariati. Le primitive nuove sono testate con assert
115
- dedicati.
167
+ All core tests pass. F24, busta_paga, cu.pdf, complex, and sample are
168
+ unchanged. The new primitives are covered by dedicated assertions.
116
169
 
117
- ### Migration guide da 0.3.19
170
+ ### Migration guide from 0.3.19
118
171
 
119
172
  ```ruby
120
- # Prima (0.3.19):
173
+ # Before (0.3.19):
121
174
  page.label_value_pairs(
122
175
  data_font: "Courier",
123
176
  merge_adjacent: :smart,
124
177
  as_hash: true
125
178
  )
126
179
 
127
- # Dopo (0.4.0): usa l'adapter Modello770Reader (vedi examples/) o
128
- # componi a mano:
180
+ # After (0.4.0): use the Modello770Reader adapter (see examples/) or
181
+ # compose manually:
129
182
  matcher = Rpdfium::Util::LabelMatcher.new(
130
183
  column_inference: Rpdfium::Util::ColumnInference.new
131
184
  )
132
185
  pairs = page.label_value_pairs(data_font: "Courier", matcher: matcher)
133
- # poi merge custom + hash conversion nel tuo codice
186
+ # then apply custom merging + hash conversion in your own code
134
187
  ```
135
188
 
136
- ## [0.3.19] - estrazione su moduli a caselline (boxed_layout)
189
+ ## [0.3.19] - extraction on box-per-digit forms (boxed_layout)
137
190
 
138
- ### Aggiunto: `label_value_pairs(boxed_layout: true)`
191
+ ### Added: `label_value_pairs(boxed_layout: true)`
139
192
 
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.
193
+ Some Italian prestamped forms (the periodic VAT settlement
194
+ communication, specific sections of the Modello Redditi) use a layout
195
+ with **a separate box for each digit**: the VAT number `01234567890` is
196
+ printed as 11 boxes, and the amount `15.357,78` is rendered as
197
+ `15.357 7 8` the integer part, the comma painted by the template, and
198
+ the two decimal digits each in a box roughly 10pt apart.
146
199
 
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).
200
+ The default `label_value_pairs` did not recognize these numbers as
201
+ single values: it split `15.357,78` into three separate words
202
+ (`15.357`, `7`, `8`) because PDFium automatically inserted a "generated"
203
+ space between the boxes (gap > 5pt → treated as a word separator).
151
204
 
152
- ### Soluzione: flag `boxed_layout: true`
205
+ ### Solution: the `boxed_layout: true` flag
153
206
 
154
- Configura automaticamente i parametri adatti:
207
+ It automatically configures the appropriate parameters:
155
208
 
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)
209
+ - **`inject_spaces: false`** on characters (no PDFium-generated spaces
210
+ that split the boxes)
211
+ - **`x_tolerance: 15.0`** (typical gap between boxes is ~1013pt)
212
+ - **`row_max_dx: 400.0`** on the LabelMatcher (labels on VP forms sit on
213
+ the left, while values in the DEBITS/CREDITS column are 250+pt away)
162
214
 
163
215
  ```ruby
164
216
  Rpdfium.open("iva.pdf") do |doc|
@@ -166,33 +218,33 @@ Rpdfium.open("iva.pdf") do |doc|
166
218
  data_font: "Helvetica",
167
219
  merge_adjacent: :smart,
168
220
  as_hash: true,
169
- boxed_layout: true # ← nuova opzione
221
+ boxed_layout: true # ← new option
170
222
  )
171
223
  end
172
224
  ```
173
225
 
174
- ### Risultato sul modulo IVAPagina 2 (Quadro VP)
226
+ ### Result on the VAT form Page 2 (Section VP)
175
227
 
176
- **Prima (0.3.18)**:
228
+ **Before (0.3.18):**
177
229
 
178
230
  ```ruby
179
231
  {
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
232
+ "CODICE FISCALE" => "0 2 0 9", # split by the boxes
233
+ "Operazioni straordinarie" => "5.455 8", # wrong label, number split
234
+ "," => ["2", "1"], # template comma read as a label
235
+ "IVA esigibile" => "3.378 7 2", # split
236
+ "CREDITI" => "1.132 7", # label is the sub-header, not semantic
185
237
  ...
186
238
  }
187
239
  ```
188
240
 
189
- **Adesso (0.3.19) con `boxed_layout: true`**:
241
+ **Now (0.3.19) with `boxed_layout: true`:**
190
242
 
191
243
  ```ruby
192
244
  {
193
245
  "CODICE FISCALE" => "01234567890", # ✓
194
246
  "Mod. N." => "01", # ✓
195
- "PERIODO DI RIFERIMENTO" => "04", # mese aprile
247
+ "PERIODO DI RIFERIMENTO" => "04", # month: April
196
248
  "VP2 Totale operazioni attive (al netto dell'IVA)" => "15.35778", # € 15.357,78
197
249
  "VP3 Totale operazioni passive (al netto dell'IVA)" => "5.45582", # € 5.455,82
198
250
  "VP4 IVA esigibile" => "3.37872", # € 3.378,72
@@ -202,10 +254,10 @@ end
202
254
  }
203
255
  ```
204
256
 
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.
257
+ Numeric values are concatenated without the decimal comma (the comma is
258
+ graphical in the template, not part of the data layer). The consumer can
259
+ format it in post-processing: the last two characters are the decimals,
260
+ the remainder is the integer part.
209
261
 
210
262
  ```ruby
211
263
  def parse_eur_amount(s)
@@ -215,88 +267,89 @@ parse_eur_amount("15.35778") # => "15.357,78"
215
267
  parse_eur_amount("2.24601") # => "2.246,01"
216
268
  ```
217
269
 
218
- ### Quando usarlo
270
+ ### When to use it
271
+
272
+ Enable `boxed_layout: true` when the form presents:
219
273
 
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
274
+ - Tax codes / VAT numbers with digits in visible boxes
275
+ - Amounts with a graphical decimal comma and a box per digit
276
+ - Dates in DD MM YYYY format with separate boxes
277
+ - In general: Italian Revenue Agency PDFs with a cell-based background
225
278
 
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
279
+ Keep the default `false` for:
231
280
 
232
- ### Non-regressione
281
+ - Printed F24 (compact Courier without boxes)
282
+ - Modello 770 sections ST/SV (compact free text)
283
+ - Payslips with standard tables
284
+ - Any case where characters are already contiguous
233
285
 
234
- 15/15 test passano. Il default `boxed_layout: false` mantiene il
235
- comportamento 0.3.18 byte-per-byte.
286
+ ### Regression testing
287
+
288
+ ✅ 15/15 tests pass. The default `boxed_layout: false` preserves the
289
+ 0.3.18 behavior byte for byte.
236
290
 
237
291
  ### API compatibility
238
292
 
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.
293
+ No breaking changes. You may pass `inject_spaces:`, `x_tolerance:`, and
294
+ other kwargs separately for fine-grained control, or use the
295
+ `boxed_layout: true` combination as a shortcut.
242
296
 
243
- ## [0.3.18] - propagazione intestazioni su tabelle ripetitive
297
+ ## [0.3.18] - header propagation across repeating tables
244
298
 
245
- ### Fixato: intestazioni di colonna non propagate alle righe successive
299
+ ### Fixed: column headers not propagated to subsequent rows
246
300
 
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).
301
+ On forms with **repeating tables** (the ST/SV sections of the 770, the
302
+ Treasury/INPS/Regions sections of a multi-row F24, etc.) the column
303
+ headers are printed **only once** at the top of the table and are
304
+ implied for all subsequent rows (ST2, ST3, ..., ST13).
251
305
 
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`).
306
+ In earlier versions `label_value_pairs` limited label→value matching to
307
+ `col_max_dy=80pt`: sufficient for the first row (ST2), but subsequent
308
+ rows (more than 80pt from the header) ended up under wrong or spurious
309
+ labels (e.g. `ST5: [04 2021, 455,46]` instead of `Periodo di
310
+ riferimento: 04 2021` + `Importo versato: 455,46`).
257
311
 
258
- ### Soluzione: pass di riassegnazione per colonne
312
+ ### Solution: a column reassignment pass
259
313
 
260
- Il `LabelMatcher` ora ha una terza fase **`reassign_by_columns`**:
314
+ The `LabelMatcher` now has a third phase, **`reassign_by_columns`**:
261
315
 
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.
316
+ 1. **Identify the data columns**: cluster values by their `x0`
317
+ coordinate (left-aligned, e.g. tax codes) **and** by `x1`
318
+ (right-aligned, e.g. numeric amounts "1.227,70" and "499,81" have
319
+ different x0 but the same x1). Values on prestamped forms are often
320
+ right-aligned; both alignments are needed to cover all cases.
267
321
 
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.
322
+ 2. **Split columns on vertical gaps**: if two consecutive values in the
323
+ same x-cluster have a vertical gap > 3× the median gap (or > 40pt),
324
+ they are separated into distinct columns. This resolves cases such as
325
+ "tax code at the top of the page + table below" that share the same x
326
+ but are distinct sections.
273
327
 
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).
328
+ 3. **Filter by density**: a genuine repeating-table column has regularly
329
+ equispaced values. Measure the coefficient of variation of the gaps
330
+ (`CV = std_dev/mean`). The threshold is tight: `CV < 0.15` (very
331
+ regular spacing). This excludes false positives such as the five
332
+ right-aligned F24 balances (SALDO A-B, C-D, E-F, G-H, M-N: same x1
333
+ but different sections, CV = 0.26).
280
334
 
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.
335
+ 4. **Find the canonical header**: for each identified data column, look
336
+ for the template label **immediately above** `col_top` (the top of
337
+ the column's first value). That label is the canonical header.
285
338
 
286
- 5. **Propaga**: assegna l'header canonico a TUTTI i valori della
287
- colonna, anche quelli oltre `col_max_dy` dall'header originale.
339
+ 5. **Propagate**: assign the canonical header to ALL values in the
340
+ column, even those beyond `col_max_dy` from the original header.
288
341
 
289
- ### Risultato sul 770 Quadro ST
342
+ ### Result on the 770 Section ST
290
343
 
291
- Pagina 4 prima (0.3.17):
344
+ Page 4 before (0.3.17):
292
345
 
293
346
  ```ruby
294
347
  {
295
348
  "Periodo di riferimento mese anno" => "01 2021",
296
- "Ritenute operate" => "394,13", # solo ST2
349
+ "Ritenute operate" => "394,13", # ST2 only
297
350
  "Importo versato" => "394,13",
298
- "Codice tributo 11" => ["1001", "443,73", "1001", "405,96"], # mescolato
299
- "ST5" => ["04 2021", "455,46"], # label spuria
351
+ "Codice tributo 11" => ["1001", "443,73", "1001", "405,96"], # mixed
352
+ "ST5" => ["04 2021", "455,46"], # spurious label
300
353
  "ST6" => ["05 2021", "407,40"],
301
354
  "ST7" => ["06 2021", "1.227,70"],
302
355
  # ...
@@ -304,7 +357,7 @@ Pagina 4 prima (0.3.17):
304
357
  }
305
358
  ```
306
359
 
307
- Adesso (0.3.18):
360
+ Now (0.3.18):
308
361
 
309
362
  ```ruby
310
363
  {
@@ -316,115 +369,116 @@ Adesso (0.3.18):
316
369
  "394,13", "443,73", "405,96", "455,46", "407,40", "1.227,70",
317
370
  "367,74", "520,00", "463,37", "451,32", "499,81", "32,46"
318
371
  ],
319
- "Importo versato" => [...stessi 12 importi...],
320
- "Codice tributo 11" => ["1001", "1001", ..., "1001", "1712"], # 12 codici
372
+ "Importo versato" => [...same 12 amounts...],
373
+ "Codice tributo 11" => ["1001", "1001", ..., "1001", "1712"], # 12 codes
321
374
  "Data di versamento giorno mese anno 14" => [
322
375
  "16 02 2021", "16 03 2021", ..., "16 12 2021"
323
376
  ]
324
- # NO più label spurie ST5/ST7/ST13
377
+ # NO more spurious ST5/ST7/ST13 labels
325
378
  }
326
379
  ```
327
380
 
328
- ### Parametri configurabili
381
+ ### Configurable parameters
329
382
 
330
- `Rpdfium::Util::LabelMatcher.new` accetta tre nuovi parametri:
383
+ `Rpdfium::Util::LabelMatcher.new` accepts three new parameters:
331
384
 
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.
385
+ - `repeat_headers:` (default `true`) — enable/disable column
386
+ reassignment. Pass `false` to restore the 0.3.17 behavior.
387
+ - `column_x_tolerance:` (default `3.0`) — X tolerance for treating two
388
+ values as being "in the same column".
389
+ - `min_column_size:` (default `3`) minimum number of values required
390
+ to recognize a column as repeating.
339
391
 
340
392
  ```ruby
341
393
  matcher = Rpdfium::Util::LabelMatcher.new(
342
394
  repeat_headers: true,
343
- column_x_tolerance: 2.0, # cluster più stretto
344
- min_column_size: 5 # solo colonne con 5+ righe
395
+ column_x_tolerance: 2.0, # tighter cluster
396
+ min_column_size: 5 # only columns with 5+ rows
345
397
  )
346
398
  page.label_value_pairs(data_font: "Courier", matcher: matcher, ...)
347
399
  ```
348
400
 
349
- ### Non-regressione
401
+ ### Regression testing
402
+
403
+ ✅ 15/15 tests pass:
350
404
 
351
- ✅ 15/15 test passano:
352
405
  - busta_paga, cu.pdf rotation 90°, sample, complex
353
406
  - 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)
407
+ - F24 1.615,90 → "SALDO (M-N) +/–" (final balance, not confused with the
408
+ section balances SALDO A-B / C-D)
356
409
  - F24 532,27 → "importi a debito versati" (TOTALE A)
357
410
  - 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)
411
+ - 770 p4 12 tax codes + 12 amounts + 12 dates + 12 months
412
+ - 770 p4 NO spurious ST5/ST13 labels
413
+ - 770 p4 CODICE FISCALE → the single code only (not the whole column)
361
414
 
362
415
  ### API compatibility
363
416
 
364
- Nessuna breaking change. `repeat_headers: false` ripristina il
365
- comportamento 0.3.17 per chi preferisce.
417
+ No breaking changes. `repeat_headers: false` restores the 0.3.17
418
+ behavior for those who prefer it.
366
419
 
367
- ## [0.3.17] - precisione label-value su moduli a colonne strette
420
+ ## [0.3.17] - label-value precision on narrow-column forms
368
421
 
369
- ### Fixato: valori "wide" attraversano label sbagliate
422
+ ### Fixed: "wide" values crossing into wrong labels
370
423
 
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.
424
+ On prestamped forms with adjacent narrow template columns (the classic
425
+ case: 770 page 2 with "Cognome o Denominazione" / "Nome" /
426
+ "Dichiarazione integrativa" / "Protocollo dichiarazione inviata" laid
427
+ out on the same line), a value that semantically belongs to the first
428
+ field but extends graphically beyond its box (e.g. "Azienda S.R.L."
429
+ written across the whole line) was split into three entries under
430
+ different labels.
378
431
 
379
- `Page#label_value_pairs(merge_adjacent: :smart, ...)` ora:
432
+ `Page#label_value_pairs(merge_adjacent: :smart, ...)` now:
380
433
 
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".
434
+ 1. **Final tight-merge pass**: after the existing by_label and
435
+ by_proximity passes, a third pass joins words with a horizontal gap
436
+ ≤ 10pt on the exact same line (tops differing by < 1pt), even if they
437
+ fall under different column labels. The threshold is below the
438
+ typical inter-column gap (> 15pt) but above intra-word kerning
439
+ (< 5pt), so only "naturally joined" strings are recognized.
388
440
 
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.
441
+ 2. **Label for wide values uses the left edge**: for values wider than
442
+ 60pt (typical of merged strings), the `LabelMatcher` now looks for
443
+ the column label using the value's **left edge** (with a small 5pt
444
+ offset) instead of its midpoint. This way a denomination that begins
445
+ under "Cognome o Denominazione" keeps that label even when it extends
446
+ beyond.
395
447
 
396
- Risultato sul 770 pagina 2:
448
+ Result on 770 page 2:
397
449
 
398
450
  ```ruby
399
- # Prima (0.3.16):
451
+ # Before (0.3.16):
400
452
  {
401
- "Cognome o Denominazione" => "Azienda", # spezzata
402
- "Dichiarazione integrativa" => "CONSULTING", # sbagliata
403
- "Protocollo dichiarazione inviata" => "S.R.L." # sbagliata
453
+ "Cognome o Denominazione" => "Azienda", # split
454
+ "Dichiarazione integrativa" => "CONSULTING", # wrong
455
+ "Protocollo dichiarazione inviata" => "S.R.L." # wrong
404
456
  }
405
457
 
406
- # Adesso (0.3.17):
458
+ # Now (0.3.17):
407
459
  {
408
460
  "Cognome o Denominazione" => "Azienda S.R.L." # ✓
409
461
  }
410
462
  ```
411
463
 
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
464
+ The three passes are configurable separately:
415
465
 
416
- ### Fixato: marcatori grafici di colonna catturati come label
466
+ - `merge_x_gap:` (default 20.0) gap for by_label and by_proximity
467
+ - `merge_tight_x_gap:` (default 10.0) — gap for the tight-merge
417
468
 
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", ...]`.
469
+ ### Fixed: graphical column markers captured as labels
422
470
 
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.
471
+ On the ST/SV sections of the 770, the template prints small numbers
472
+ "11", "14", "15", "16" as graphical column markers (indices of the
473
+ positions in the form). These were captured as semantic labels by the
474
+ LabelMatcher, producing useless entries such as `"16" => ["443,73",
475
+ "405,96", ...]`.
476
+
477
+ `Util::LabelMatcher` now ignores, by default, labels matching
478
+ `/\A\d{1,3}\z|\A[IVX]{1,5}\z/` — short numbers and short Roman numerals,
479
+ typical column markers. Configurable via
480
+ `LabelMatcher.new(ignore_label_pattern: ...)`. Pass `nil` to disable the
481
+ filter, or your own Regexp for a custom pattern.
428
482
 
429
483
  Default:
430
484
 
@@ -433,66 +487,66 @@ matcher = Rpdfium::Util::LabelMatcher.new
433
487
  # ignore_label_pattern: /\A\d{1,3}\z|\A[IVX]{1,5}\z/
434
488
 
435
489
  matcher = Rpdfium::Util::LabelMatcher.new(ignore_label_pattern: nil)
436
- # nessun filtro, comportamento 0.3.16
490
+ # no filter, 0.3.16 behavior
437
491
 
438
492
  matcher = Rpdfium::Util::LabelMatcher.new(ignore_label_pattern: /\AXX\z/)
439
- # filtro custom
493
+ # custom filter
440
494
  ```
441
495
 
442
- ### Risultato finale sul 770
496
+ ### Final result on the 770
443
497
 
444
- Confronto pagine principali prima/dopo:
498
+ Comparison of the main pages, before/after:
445
499
 
446
- | Pagina | Prima (0.3.16) | Adesso (0.3.17) |
500
+ | Page | Before (0.3.16) | Now (0.3.17) |
447
501
  | --- | --- | --- |
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) |
502
+ | 2 | "Cognome": "Azienda" + 2 wrong entries | "Cognome o Denominazione": "Azienda S.R.L." |
503
+ | 4 (Section ST) | "16": [443,73, 405,96, ...] (marker) | "Sospensione COVID Importo sospeso": [...] (real label) |
504
+ | 4 | "14": [16, 16, 17, ...] (marker) | "Data di versamento giorno mese anno": [16 02 2021, ...] |
505
+ | 4 | "11": [1001, 443,73, ...] (marker) | "Codice tributo": [1001, ...] (real label) |
452
506
 
453
- ### Non-regressione
507
+ ### Regression testing
454
508
 
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).
509
+ ✅ 16/16 tests pass (busta_paga, F24, cu.pdf rotation 90°, cu.pdf small
510
+ font, sample, complex, and all the new assertions on the 770).
457
511
 
458
512
  ### API compatibility
459
513
 
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.
514
+ No breaking changes. The new parameters (`merge_tight_x_gap`,
515
+ `ignore_label_pattern`) have sensible defaults. Disabling the filter
516
+ with `ignore_label_pattern: nil` restores the 0.3.16 behavior.
463
517
 
464
- ## [0.3.16] - estrazione strutturata su moduli multi-pagina
518
+ ## [0.3.16] - structured extraction on multi-page forms
465
519
 
466
- ### Aggiunto: `label_value_pairs(merge_adjacent:, as_hash:)`
520
+ ### Added: `label_value_pairs(merge_adjacent:, as_hash:)`
467
521
 
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).
522
+ Two new options on `Page#label_value_pairs` that transform the output
523
+ from a "raw list of pairs" into a **structured `{label => value}` map
524
+ ready to consume**, handling correctly both point fields (checkboxes,
525
+ codes) and multi-word free text (denominations, addresses, headers).
472
526
 
473
- ### `merge_adjacent` — 3 strategie selezionabili
527
+ ### `merge_adjacent` — 3 selectable strategies
474
528
 
475
- - **`false` (default)**: nessuna unione. Una word PDF = una entry.
476
- Comportamento 0.3.15.
529
+ - **`false` (default)**: no merging. One PDF word = one entry. 0.3.15
530
+ behavior.
477
531
 
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).
532
+ - **`true` or `:by_label`**: merges only adjacent words sharing the same
533
+ column label. Preserves checkboxes under distinct labels (e.g. on the
534
+ 770, the X marks of the completed ST/SV/SX sections remain separate
535
+ because each has its own label).
482
536
 
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).
537
+ - **`:by_proximity`**: merges all adjacent words regardless of label.
538
+ For headers with free text (e.g. "Soggetto: Azienda S.R.L. (
539
+ 01234567890 )" becomes a single entry).
486
540
 
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à).
541
+ - **`:smart` (recommended for complex forms)**: combines the two
542
+ by_label for words with a label, by_proximity for **orphan** words
543
+ with no label. Works automatically on forms that mix text headers
544
+ (Soggetto), tables with checkboxes (ST/SV/SX), and single fields (tax
545
+ code, activity code).
492
546
 
493
- ### `as_hash: true` — output strutturato
547
+ ### `as_hash: true` — structured output
494
548
 
495
- Trasforma `Array<Hash>` in `Hash` chiavi-valore:
549
+ Transforms `Array<Hash>` into a key-value `Hash`:
496
550
 
497
551
  ```ruby
498
552
  Rpdfium.open("770.pdf") do |doc|
@@ -518,14 +572,14 @@ end
518
572
  # }
519
573
  ```
520
574
 
521
- Quando la label è la stessa per più valori, l'output diventa un Array:
522
- `"Codice fiscale" => ["01234567890", "01234567890"]`.
575
+ When the same label applies to several values, the output becomes an
576
+ Array: `"Codice fiscale" => ["01234567890", "01234567890"]`.
523
577
 
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.
578
+ Words with no assignable label (e.g. headers at the top of the page with
579
+ no reference template) collect under the `"_unlabeled"` key as an Array
580
+ of strings.
527
581
 
528
- ### Esempio: estrazione completa di un Modello 770
582
+ ### Example: full extraction of a Modello 770
529
583
 
530
584
  ```ruby
531
585
  Rpdfium.open("770.pdf") do |doc|
@@ -539,20 +593,20 @@ Rpdfium.open("770.pdf") do |doc|
539
593
  merge_adjacent: :smart,
540
594
  as_hash: true
541
595
  )
542
- puts "=== Pagina #{i + 1} ==="
596
+ puts "=== Page #{i + 1} ==="
543
597
  h.each { |k, v| puts " #{k}: #{v.inspect}" }
544
598
  end
545
599
  end
546
600
  ```
547
601
 
548
- Output reale su modello 770 (3 prime pagine):
602
+ Actual output on a Modello 770 (first 3 pages):
549
603
 
550
604
  ```
551
- === Pagina 1 ===
605
+ === Page 1 ===
552
606
  _unlabeled: ["Soggetto: Azienda S.R.L. ( 01234567890 )",
553
607
  "Identificativo dichiarazione: 11111111111 - 0000002 del 22/10/2022"]
554
608
 
555
- === Pagina 2 ===
609
+ === Page 2 ===
556
610
  Codice fiscale: ["01234567890", "01234567890"]
557
611
  Codice attività: "999999"
558
612
  Indirizzo di posta elettronica/PEC: "AZIENDA@PEC.IT"
@@ -565,7 +619,7 @@ Output reale su modello 770 (3 prime pagine):
565
619
  Tipologia invio: "2"
566
620
  GESTIONE SEPARATA Dipendente Autonomo: "X"
567
621
 
568
- === Pagina 3 ===
622
+ === Page 3 ===
569
623
  Codice fiscale: "01234567890"
570
624
  Codice fiscale dell'incaricato: "01877150696"
571
625
  giorno mese: "01 10"
@@ -573,67 +627,68 @@ Output reale su modello 770 (3 prime pagine):
573
627
  _unlabeled: ["2", "Firma Presente"]
574
628
  ```
575
629
 
576
- ### `merge_x_gap` per tarare il merge
630
+ ### `merge_x_gap` to tune merging
631
+
632
+ The `merge_x_gap:` parameter controls the maximum gap (in points)
633
+ between adjacent words for them to be considered "joined". Default 20.0.
634
+ Increase it for forms with widely spaced fields (page-centered headers).
577
635
 
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).
636
+ ### `best_label_for` heuristic (internal)
581
637
 
582
- ### Heuristica `best_label_for` (interna)
638
+ When a value has both a `col` and a `row` label, the automatic choice
639
+ prefers:
583
640
 
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")
641
+ - `row` if it is a short identifying label ("ST", "Codice fiscale")
642
+ - `col` when it is more descriptive ("importi a debito versati")
588
643
 
589
- Per controllo fine, usa la API base senza `as_hash: true` e leggi
590
- direttamente `p[:labels][:col]` e `p[:labels][:row]`.
644
+ For fine-grained control, use the base API without `as_hash: true` and
645
+ read `p[:labels][:col]` and `p[:labels][:row]` directly.
591
646
 
592
- ### Non-regressione
647
+ ### Regression testing
593
648
 
594
- ✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex, F24, IVA
595
- tutti i test invariati.
649
+ ✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex, F24, IVA
650
+ all tests unchanged.
596
651
 
597
- Il default di `merge_adjacent: false` mantiene il comportamento
598
- 0.3.15 byte-per-byte. La 0.3.16 è purely additiva.
652
+ The default `merge_adjacent: false` preserves the 0.3.15 behavior byte
653
+ for byte. 0.3.16 is purely additive.
599
654
 
600
655
  ### API compatibility
601
656
 
602
- Nessuna breaking change.
657
+ No breaking changes.
603
658
 
604
- ## [0.3.15] - associazione label-valore su moduli compilati
659
+ ## [0.3.15] - label-value association on completed forms
605
660
 
606
- ### Aggiunto: `Page#label_value_pairs` e `Util::LabelMatcher`
661
+ ### Added: `Page#label_value_pairs` and `Util::LabelMatcher`
607
662
 
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.
663
+ On "completed form" PDFs (F24, VAT communications, Modello 770, income
664
+ tax returns), the three APIs introduced in 0.3.14 (`font_inventory`,
665
+ `chars_where`, `lines`) make it possible to **separate** the template
666
+ layer from the data. 0.3.15 goes further: it **semantically associates**
667
+ each entered value with its label in the template, so the user need not
668
+ know the form's geometry in advance.
614
669
 
615
- ### Come funziona
670
+ ### How it works
616
671
 
617
- L'algoritmo opera in tre step:
672
+ The algorithm operates in three steps:
618
673
 
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
674
+ 1. **Cluster the template into coherent labels** — template words that
675
+ are geometrically close (adjacent on the same line, or on successive
676
+ lines overlapping in x) are merged into a single label. For example:
677
+ "importi", "a", "debito", "versati" → the single label `"importi a
623
678
  debito versati"`.
624
679
 
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").
680
+ 2. **For each entered value, look for two kinds of label**:
681
+ - `col` — the label ABOVE in the same column (x overlapping the
682
+ value, bottom < value top, the vertically nearest one chosen).
683
+ Typical role: field/column name.
684
+ - `row` — the label to the LEFT on the same line (y overlapping, x1 <
685
+ value x0, the horizontally nearest one chosen). Typical role: row
686
+ identifier ("TOTALE A", "SALDO").
632
687
 
633
- 3. **Ritorna** una mappatura `{ value:, labels: { col:, row: }, geometry: }`
634
- per ogni valore.
688
+ 3. **Return** a mapping `{ value:, labels: { col:, row: }, geometry: }`
689
+ for each value.
635
690
 
636
- ### Esempio: F24
691
+ ### Example: F24
637
692
 
638
693
  ```ruby
639
694
  Rpdfium.open("f24.pdf") do |doc|
@@ -658,12 +713,12 @@ Output:
658
713
  2021 → col: "anno di riferimento"
659
714
  532,27 → col: "importi a debito versati", row: "A" (TOTALE A)
660
715
  236,38 → col: "SALDO (A-B) +/–", row: "B"
661
- 1.253,00 → col: "importi a debito versati" (sezione INPS)
716
+ 1.253,00 → col: "importi a debito versati" (INPS section)
662
717
  1.341,00 → col: "SALDO (C-D) +/–", row: "D"
663
- 1.615,90 → col: "SALDO (M-N) +/–", row: "EURO +" (saldo finale)
718
+ 1.615,90 → col: "SALDO (M-N) +/–", row: "EURO +" (final balance)
664
719
  ```
665
720
 
666
- ### Esempio: Modello 770 Quadro ST
721
+ ### Example: Modello 770 Section ST
667
722
 
668
723
  ```ruby
669
724
  Rpdfium.open("770.pdf") do |doc|
@@ -678,21 +733,21 @@ end
678
733
  # 16 → col: "Data di versamento giorno mese anno"
679
734
  ```
680
735
 
681
- ### `Util::LabelMatcher` come classe autonoma
736
+ ### `Util::LabelMatcher` as a standalone class
682
737
 
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:
738
+ For advanced cases (e.g. matching on a subset of a page, with tuned
739
+ thresholds, or reused across multiple pages) the logic is exposed as a
740
+ separate class:
686
741
 
687
742
  ```ruby
688
743
  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
744
+ col_max_dy: 50.0, # max distance from label above -> value
745
+ row_max_dx: 150.0, # max distance from label on the left -> value
746
+ col_x_tolerance: 5.0, # x overlap required for an "above" label
747
+ row_y_tolerance: 1.0, # y overlap required for a "left" label
748
+ cluster_same_row_dy: 4.0, # cluster tolerance, words on the same line
694
749
  cluster_same_row_dx: 12.0,
695
- cluster_adj_row_dy: 4.0 # tolleranza cluster word righe adiacenti
750
+ cluster_adj_row_dy: 4.0 # cluster tolerance, words on adjacent lines
696
751
  )
697
752
 
698
753
  data_words = Rpdfium::Util::WordExtractor.new.extract_words(page.chars_where(font: "Courier"))
@@ -700,59 +755,58 @@ anchor_words = Rpdfium::Util::WordExtractor.new.extract_words(page.chars_where(f
700
755
 
701
756
  pairs = matcher.match(data_words, anchor_words)
702
757
 
703
- # Bonus: ispeziona quali label il matcher costruisce
758
+ # Bonus: inspect which labels the matcher builds
704
759
  labels = matcher.cluster_anchors(anchor_words)
705
760
  ```
706
761
 
707
- ### Limitazioni note
762
+ ### Known limitations
708
763
 
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).
764
+ - **Box-per-digit fields**: on forms with fields such as tax codes or
765
+ Italian numbers split across separate boxes (`0 2 0 9 8 1 2 0 6 8 2`,
766
+ `15.357 , 7 8`), the word extractor does not join the digits.
767
+ Increasing `x_tolerance` helps, but is a tradeoff: the definitive fix
768
+ requires consumer-side post-processing.
769
+ - **Overly wide labels**: sometimes the cluster joins adjacent labels
770
+ that would be better kept distinct. The default thresholds work on
771
+ most Italian Revenue Agency/INPS forms; tune the `LabelMatcher`
772
+ parameters for different layouts.
773
+ - **"Abundant" labels**: for values very close to the margin, the labels
774
+ found are obvious but uninformative. Filtering the pairs with a
775
+ selective `data_filter` helps (for example: only numbers with a
776
+ comma).
721
777
 
722
- ### Non-regressione
778
+ ### Regression testing
723
779
 
724
- ✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex — tutti
725
- i test invariati.
780
+ ✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex — all tests
781
+ unchanged.
726
782
 
727
783
  ### API compatibility
728
784
 
729
- Nessuna breaking change. Le API 0.3.14 (`font_inventory`,
730
- `chars_where`, `lines`) restano invariate. `Util::LabelMatcher` è
731
- una nuova classe additiva.
785
+ No breaking changes. The 0.3.14 APIs (`font_inventory`, `chars_where`,
786
+ `lines`) are unchanged. `Util::LabelMatcher` is a new additive class.
732
787
 
733
- ## [0.3.14] - estrazione form-aware tramite font filtering
788
+ ## [0.3.14] - form-aware extraction via font filtering
734
789
 
735
- ### Aggiunto: `Page#font_inventory`, `Page#chars_where`, `Page#lines`
790
+ ### Added: `Page#font_inventory`, `Page#chars_where`, `Page#lines`
736
791
 
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).
792
+ Three new APIs for extracting data from "completed form" PDFs — F24, VAT
793
+ communications, Modello 770, income tax returns, and in general any PDF
794
+ produced by accounting software in which the form's graphical template
795
+ and the user-entered data coexist as static text (no AcroForm, no PDF/UA
796
+ tags).
742
797
 
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).
798
+ On these PDFs the `Table::Extractor` pipeline produces a lot of noise
799
+ because it sees the entire form (template labels + data) as a grid of
800
+ tables. The semantic solution is to separate characters by "role" using
801
+ font/height: typically the template uses proportional fonts (Futura,
802
+ Helvetica, Times) while the data entered by the software uses a single
803
+ font (usually Courier, or Helvetica at a specific size).
750
804
 
751
805
  ### `Page#font_inventory`
752
806
 
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:
807
+ Distribution of characters by `(font, height, weight)`, sorted by
808
+ descending count. Useful for empirically discovering which font the data
809
+ uses on an unknown form:
756
810
 
757
811
  ```ruby
758
812
  page.font_inventory.first(5).each do |g|
@@ -765,34 +819,34 @@ end
765
819
  # Futura-Bold h=11.7 w=868 | 169 char | "CONTRIBUENTESEZIONE ERARIOSEZIONE INPSSE"
766
820
  ```
767
821
 
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).
822
+ `height` is the visual height of the character in points (more reliable
823
+ than `fontsize`, which PDFium normalizes to 1.0 when the actual size is
824
+ in the CTM matrix a frequent case on scaled forms).
771
825
 
772
826
  ### `Page#chars_where(font:, height:, weight:, bbox:, where:)`
773
827
 
774
- Filtro generico sui char. Tutti i parametri sono opzionali e
775
- combinabili in AND:
828
+ A generic filter over characters. All parameters are optional and
829
+ combinable in AND:
776
830
 
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
831
+ - `font:` exact String, Array of Strings, or Regexp
832
+ - `height:` Float (with 0.1pt tolerance), Range, or Array
833
+ - `weight:` Integer or Range
834
+ - `bbox:` `[left, top, right, bottom]` in top-down coordinates
835
+ - `where:` block for arbitrary filters
782
836
 
783
837
  ```ruby
784
838
  data_chars = page.chars_where(font: "Courier")
785
- # oppure
839
+ # or
786
840
  data_chars = page.chars_where(font: /courier/i, height: 8.0..12.0)
787
- # oppure con bbox
841
+ # or with bbox
788
842
  sezione_erario = page.chars_where(font: "Courier", bbox: [0, 250, 595, 400])
789
843
  ```
790
844
 
791
845
  ### `Page#lines(font:, ...)`
792
846
 
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):
847
+ A high-level helper that combines `chars_where` + WordExtractor + per-row
848
+ clustering. Returns an Array of strings, one per row (top-to-bottom,
849
+ characters within a row left-to-right):
796
850
 
797
851
  ```ruby
798
852
  # F24
@@ -814,57 +868,59 @@ end
814
868
  # ]
815
869
  ```
816
870
 
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
871
+ It works equally well on:
872
+
873
+ - **F24**: tax codes, debit/credit amounts, separate sections
874
+ - **VAT communication**: Section VP amounts (active/passive operations,
875
+ collectible/deductible VAT, due/credit)
876
+ - **Modello 770**: month-by-month withholdings with tax codes and
877
+ payment dates
878
+ - **Income tax returns (SP, PF, SC)**: registry data and sections
824
879
 
825
- ### Tradeoff e limitazioni
880
+ ### Tradeoffs and limitations
826
881
 
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:
882
+ `Page#lines` returns **readable** rows, not already structured ones. On
883
+ forms with a separate box per digit (e.g. the tax code `0 2 0 9 8 1 2 0
884
+ 6 8 2`, or Italian numbers with a separate decimals box `15.357,78`
885
+ `15.357 7 8`), the visual gaps between boxes exceed the default
886
+ `x_tolerance` and the rows come out "spaced". Solutions:
832
887
 
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)
888
+ 1. Increase `x_tolerance` for those specific filters (e.g. 8.0)
889
+ 2. Post-process the rows to recognize the form's pattern (specific to
890
+ each model)
836
891
 
837
- La libreria fornisce le primitive composable; l'interpretazione del
838
- modulo specifico resta al chiamante perché ogni modello ha layout
839
- diverso.
892
+ The library provides composable primitives; interpretation of the
893
+ specific form is left to the caller, because each model has a different
894
+ layout.
840
895
 
841
- ### Non-regressione
896
+ ### Regression testing
842
897
 
843
898
  ✅ busta_paga.pdf: `1.993,00`, `COGNOME E NOME`, `NETTO BUSTA`
844
899
  ✅ cu.pdf p1 rotation 90°: `BANCA NAZIONALE`, `Categoria`
845
- ✅ cu.pdf p199 small font: `Categoria` (no `iCategora`)
900
+ ✅ cu.pdf p199 small font: `Categoria` (not `iCategora`)
846
901
  ✅ sample.pdf, complex.pdf
847
902
 
848
903
  ### API compatibility
849
904
 
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.
905
+ No breaking changes. The three new APIs are additive. The existing
906
+ `Table::Extractor` pipeline continues to work unchanged for those who
907
+ have "real" tables with borders.
853
908
 
854
- ## [0.3.13] - `Page#struct_tree`: struttura semantica dei PDF tagged
909
+ ## [0.3.13] - `Page#struct_tree`: semantic structure of tagged PDFs
855
910
 
856
- ### Aggiunto: lettura del PDF Structure Tree
911
+ ### Added: reading the PDF Structure Tree
857
912
 
858
- Nuova API `Page#struct_tree` che espone la struttura logica dei PDF
859
- tagged (PDF/UA, esport accessibility-friendly da Word/LibreOffice/InDesign).
913
+ A new `Page#struct_tree` API that exposes the logical structure of
914
+ tagged PDFs (PDF/UA, accessibility-friendly exports from
915
+ Word/LibreOffice/InDesign).
860
916
 
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.
917
+ For tagged documents it offers access to the content **completely
918
+ independent of geometry**: for each element of the tree you can obtain
919
+ its structural type (`P`, `H1`, `Table`, `TR`, `TH`, `TD`, `Figure`,
920
+ etc.), the text it comprises, the structural PDF attributes, and the
921
+ links via Marked Content ID to the page content.
866
922
 
867
- ### Esempio: estrazione tabella zero-geometria
923
+ ### Example: zero-geometry table extraction
868
924
 
869
925
  ```ruby
870
926
  page.struct_tree do |tree|
@@ -877,71 +933,71 @@ page.struct_tree do |tree|
877
933
  end
878
934
  end
879
935
  end
880
- # → ["Region", "Revenue", "Growth"] (TH — riga header)
881
- # → ["Italy", "1.250.000", "+12%"] (TD — riga dati)
936
+ # → ["Region", "Revenue", "Growth"] (TH — header row)
937
+ # → ["Italy", "1.250.000", "+12%"] (TD — data row)
882
938
  # → ["France", "980.000", "+8%"]
883
939
  # → ["Germany", "2.100.000", "+15%"]
884
940
  ```
885
941
 
886
- Vantaggi rispetto al pipeline geometrico `Table::Extractor`:
942
+ Advantages over the geometric `Table::Extractor` pipeline:
887
943
 
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
944
+ - distinguishes header (`TH`) from data (`TD`) — information the
945
+ geometric pipeline loses
946
+ - works on tables **without lines** (text-only) or with partial lines
947
+ - recognizes row/col spans via `<TD>.attributes` (`RowSpan`, `ColSpan`)
948
+ - zero clustering heuristics
893
949
 
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.
950
+ Limitation: requires a tagged PDF. Most PDFs from Italian accounting
951
+ software (TeamSystem, Zucchetti, Italian banks) are NOT tagged. For
952
+ those, the existing geometric pipeline remains the only option.
897
953
 
898
- ### API completa
954
+ ### Full API
899
955
 
900
956
  ```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
957
+ tree = page.struct_tree # → Tree or nil
958
+ tree.empty? # true if the tree is structurally empty
959
+ tree.roots # → [Element, ...] tree roots (usually 1 "Document")
960
+ tree.walk { |el| ... } # depth-first iteration
905
961
  tree.walk.to_a # Enumerator
906
962
  tree.find_all(type: "P") # filter by type
907
- tree.tables # shortcut per find_all(type: "Table")
963
+ tree.tables # shortcut for find_all(type: "Table")
908
964
 
909
965
  element.type # "P", "Table", "TR", "TD", ...
910
966
  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.
967
+ element.parent # → Element or nil
968
+ element.text # reconstructs text from MCID + ActualText
969
+ element.actual_text # /ActualText attribute (override for ligatures, math)
970
+ element.alt_text # /Alt (for Figure/Formula)
971
+ element.lang # "it-IT", "en-US", etc.
916
972
  element.marked_content_ids # → [Integer]
917
973
  element.attributes # → { name => value } (RowSpan, ColSpan, ...)
918
- element.walk { |el| ... } # depth-first del sub-tree
919
- element.leaves # foglie (elements senza children)
974
+ element.walk { |el| ... } # depth-first over the sub-tree
975
+ element.leaves # leaves (elements without children)
920
976
  ```
921
977
 
922
978
  ### Lifecycle
923
979
 
924
- Il tree è "owning" — chiamare `FPDF_StructTree_Close` lo dealloca.
980
+ The tree is "owning" — calling `FPDF_StructTree_Close` deallocates it.
925
981
 
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.
982
+ - **Implicit lifecycle (zero-config)**: never close it explicitly.
983
+ PDFium deallocates the tree when the document is closed. The tree
984
+ stays in memory until then (it can be ~MB on large PDFs, but there is
985
+ no persistent leak).
986
+ - **Deterministic lifecycle**: use the block form
987
+ `page.struct_tree do |tree| ... end`. On exit from the block the tree
988
+ is closed, even in case of an exception.
933
989
 
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.
990
+ **Design choice**: we do not use `ObjectSpace.define_finalizer` for the
991
+ tree. The document may be closed before the tree (e.g. inside a
992
+ `Rpdfium.open do |doc| ... end` block), and the GC finalizer would call
993
+ `FPDF_StructTree_Close` on already-freed memory → use-after-free →
994
+ segfault. Leaving cleanup to `FPDF_CloseDocument` is always safe;
995
+ explicit cleanup via `tree.close` or the block form is safe as long as
996
+ the document is still alive.
941
997
 
942
- ### 24 binding C-level mancanti aggiunti
998
+ ### 24 missing C-level bindings added
943
999
 
944
- Oltre agli 8 binding di base (già presenti):
1000
+ In addition to the 8 base bindings (already present):
945
1001
 
946
1002
  - `FPDF_StructElement_GetParent`, `GetID`, `GetLang`, `GetObjType`
947
1003
  - `FPDF_StructElement_GetActualText`, `GetAltText`, `GetExpansion`
@@ -953,69 +1009,69 @@ Oltre agli 8 binding di base (già presenti):
953
1009
  `GetBooleanValue`, `GetNumberValue`, `GetStringValue`, `GetBlobValue`,
954
1010
  `CountChildren`, `GetChildAtIndex`
955
1011
 
956
- Tutte esposte via `Rpdfium::Raw.FPDF_*` per chi vuole bypassare i
957
- wrapper.
1012
+ All exposed via `Rpdfium::Raw.FPDF_*` for those who want to bypass the
1013
+ wrappers.
958
1014
 
959
- ### Tre stati possibili di `page.struct_tree`
1015
+ ### Three possible states of `page.struct_tree`
960
1016
 
961
- | Caso | `page.struct_tree` ritorna |
1017
+ | Case | `page.struct_tree` returns |
962
1018
  | --- | --- |
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 |
1019
+ | Untagged PDF | `nil` |
1020
+ | Tagged but empty PDF (e.g. Bank of Italy CR, 717 placeholder) | Tree with `empty? == true` |
1021
+ | Properly tagged PDF (Word/LibreOffice export) | Navigable tree |
966
1022
 
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.
1023
+ Verified on the 4 test PDFs: busta_paga/sample/complex → nil; cu.pdf
1024
+ p1 → empty; PDF generated via `soffice --convert-to pdf` → full tree
1025
+ with `<Document>` → nested `<P>`/`<Table>`/`<TR>`/`<TH>`/`<TD>`.
970
1026
 
971
- ### Non-regressione
1027
+ ### Regression testing
972
1028
 
973
- Tutti i casi di test esistenti continuano a funzionare:
1029
+ All existing test cases continue to work:
974
1030
 
975
1031
  - ✅ busta_paga.pdf: `1.993,00`, `COGNOME E NOME`, `NETTO BUSTA`
976
1032
  - ✅ cu.pdf rotation 90° p1: `BANCA NAZIONALE`, `Categoria`
977
- - ✅ cu.pdf p199: `Categoria` (no `iCategora`)
1033
+ - ✅ cu.pdf p199: `Categoria` (not `iCategora`)
978
1034
  - ✅ sample/complex.pdf
979
1035
 
980
- ## [0.3.12] - ottimizzazioni performance estrazione tabelle
1036
+ ## [0.3.12] - table extraction performance optimizations
981
1037
 
982
- (vedi note di rilascio precedenti)
1038
+ (See the previous release notes.)
983
1039
 
984
- ## [0.3.11] - opzione `cell_padding` per char fuori bordo cella
1040
+ ## [0.3.11] - `cell_padding` option for characters outside the cell border
985
1041
 
986
- ### Aggiunto: `Table#extract(cell_padding: N)` per recuperare char border-line
1042
+ ### Added: `Table#extract(cell_padding: N)` to recover border-line characters
987
1043
 
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:"`.
1044
+ On some PDFs (Bank of Italy CR, table headers) the first character of a
1045
+ cell is drawn **slightly outside** the cell's own border. For example:
1046
+ the capital `I` of "Intermediario:" has `x0=24.0` but the cell starts at
1047
+ `x=25.6` (the `I` protrudes 1.6 points to the left of the border). The
1048
+ midpoint filter (identical to pdfplumber) computes `h_mid = 25.25` and
1049
+ excludes the `I` because it is < 25.6, producing `"ntermediario:"`.
994
1050
 
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.
1051
+ Pdfplumber has **exactly the same problem** (verified on the PDF): the
1052
+ midpoint filter is a common design decision. We can, however, offer a
1053
+ middle ground.
998
1054
 
999
- ### Nuova API
1055
+ ### New API
1000
1056
 
1001
1057
  ```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
1058
+ table.extract # default: pdfplumber-compatible
1059
+ table.extract(cell_padding: 2.0) # recover characters protruding up
1060
+ # to 2pt beyond the left/top borders
1005
1061
  ```
1006
1062
 
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).
1063
+ `cell_padding` extends each cell's bbox to the **left** and **upward**
1064
+ by N points before applying the midpoint filter. Default 0.0 = behavior
1065
+ identical to before (and to pdfplumber).
1010
1066
 
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.
1067
+ The padding is asymmetric (only the left/top borders, not right/bottom)
1068
+ to avoid capturing characters shared with adjacent cells: if both
1069
+ neighboring cells expanded on all sides, a character between them would
1070
+ end up in both. By limiting the padding to the "inner-left" and
1071
+ "inner-top" borders, a character outside the left border ends up only in
1072
+ the cell to its right, where it most likely belongs.
1017
1073
 
1018
- ### Risultato sul PDF problematico
1074
+ ### Result on the problematic PDF
1019
1075
 
1020
1076
  ```
1021
1077
  ext = Rpdfium::Table::Extractor.new(page, ...)
@@ -1023,848 +1079,76 @@ ext.tables.first.extract # → ["ntermediario:", "BANCA NA
1023
1079
  ext.tables.first.extract(cell_padding: 2.0) # → ["Intermediario:", "BANCA NAZIONALE..."]
1024
1080
  ```
1025
1081
 
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
1082
+ ### Regression testing
1090
1083
 
1091
- Tutti i PDF di test continuano a funzionare correttamente:
1084
+ All test PDFs continue to work correctly with the default `cell_padding`
1085
+ (0.0):
1092
1086
 
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
1087
+ - ✅ busta_paga.pdf (numbers, words with spaces, NETTO BUSTA)
1088
+ - ✅ cu.pdf p. 1 (rotation 90°): Categoria, RISCHI AUTOLIQUIDANTI, 172.136
1089
+ - ✅ cu.pdf p. 199 (rotation 0°, small font): Categoria, Tipo Attività
1090
+ - ✅ sample.pdf (Lorem ipsum) + complex.pdf (>200k characters)
1098
1091
 
1099
- ## [0.3.8] - supporto pagine ruotate (90°, 180°, 270°)
1092
+ With `cell_padding: 2.0` on cu.pdf p. 1:
1100
1093
 
1101
- ### Risolto: estrazione completamente errata su PDF con `Page#rotation != 0`
1094
+ - ✅ "Intermediario:" recovered in full
1095
+ - ✅ No duplicated numeric value (172.136 appears 3 times, as expected)
1102
1096
 
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:
1097
+ ## [0.3.10] - bugfix: character order in cells with near-equal `top`
1108
1098
 
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
1099
+ ### Fixed: scrambled words such as `iCategora` instead of `Categoria`
1118
1100
 
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.
1101
+ On some PDFs (example: Bank of Italy CR, p. 199+ with a small font),
1102
+ table cells came out with characters reordered incorrectly:
1164
1103
 
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.
1104
+ | Expected | Wrong output |
1105
+ | --------------- | -------------------- |
1106
+ | `Categoria` | `iCategora` |
1107
+ | `Localizzazione`| `iLoca li zzazone i` |
1108
+ | `Tipo Attività` | `iTpo i Attvt i i à` |
1173
1109
 
1174
- ## [0.3.7] - bugfix critico: buffer overrun in `read_text_obj_text_from`
1110
+ Pattern: the character `i` (and occasionally others with a thin
1111
+ x-height) was moved to the start of the word or scattered.
1175
1112
 
1176
- ### Risolto: IndexError "Memory access offset=0 size=N out of bounds"
1113
+ ### Cause
1177
1114
 
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:
1115
+ A regression of the 0.3.9 optimization in `WordExtractor#extract_words`.
1182
1116
 
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
- ```
1117
+ The optimization assumed that, after `chars.sort_by { |c| [c[:top],
1118
+ c[:x0]] }` + `Cluster.cluster_objects(:top)`, each "row" cluster was
1119
+ already internally sorted by x0 — so the inner `row.sort_by { |c|
1120
+ c[:x0] }` was removed as redundant.
1189
1121
 
1190
- Sui PDF tipici di gestionali italiani (cedolini TeamSystem) il bug NON
1191
- si manifestava perché ogni text object 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).
1122
+ The assumption is **false** when two characters on the same visual row
1123
+ have slightly different `top` values (e.g. the lowercase `i` of
1124
+ `Categoria` has `top=414.9789`, the other letters `top=414.9869`, a
1125
+ difference of 0.008pt). PDFium often assigns character bboxes slightly
1126
+ different ascender/descender tops for hinting/anti-aliasing reasons. The
1127
+ difference is graphically invisible but significant for the sort.
1195
1128
 
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.
1129
+ Effect: the global `[top, x0]` sort places the `i` (smaller top)
1130
+ **before all the other letters** of the word, regardless of x0. The
1131
+ `cluster_objects` then groups all characters on the same row (within
1132
+ y_tolerance=3.0) but does not reorder internally. So when iterating over
1133
+ the row, the `i` is read first and ends up at the start.
1213
1134
 
1214
1135
  ### Fix
1215
1136
 
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 |
1137
+ Restored the `row_sorted = row.sort_by { |c| c[:x0] }` inside the row
1138
+ loop. The 0.3.9 optimization was valid only for the case of perfectly
1139
+ identical tops; it is not valid in general.
1283
1140
 
1284
- ### Nuove API pubbliche di alto livello
1141
+ The added computational cost is marginal: an O(n log n) sort on short
1142
+ rows (~50 characters), dominated by the per-character FFI roundtrip
1143
+ overhead of the preceding phase. Empirically verified: `extract_text`
1144
+ time on 20 pages of complex.pdf unchanged (~80ms).
1285
1145
 
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).
1146
+ ### Regression testing
1296
1147
 
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").
1148
+ All test PDFs continue to work correctly:
1300
1149
 
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.
1150
+ - ✅ busta_paga.pdf: numbers (`1.993,00`, `2.895,26`), word spacing (`COGNOME E NOME`, `NETTO BUSTA`)
1151
+ - sample.pdf: Lorem ipsum (2913 characters)
1152
+ - ✅ complex.pdf (85 pages): 224,645 characters total
1153
+ - ✅ cu.pdf p. 1 (rotation 90°): `BANCA NAZIONALE DEL LAVORO`, `Categoria`, numeric values
1154
+ - ✅ **cu.pdf p. 199** (rotation 0°, small font): `Categoria`, `Localizzazione`, `Tipo Attività`, `Accordato Operativo` — all intact