rpdfium 0.4.1 → 0.4.3

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