rpdfium 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +601 -1317
- data/README.md +73 -78
- data/lib/rpdfium/annotation/annotation.rb +10 -8
- data/lib/rpdfium/document.rb +49 -22
- data/lib/rpdfium/errors.rb +2 -2
- data/lib/rpdfium/form/form.rb +9 -9
- data/lib/rpdfium/image/embedded.rb +17 -16
- data/lib/rpdfium/io/png.rb +9 -9
- data/lib/rpdfium/page.rb +562 -527
- data/lib/rpdfium/raw.rb +216 -203
- data/lib/rpdfium/search/search.rb +5 -5
- data/lib/rpdfium/structure/attachment.rb +6 -6
- data/lib/rpdfium/structure/element.rb +74 -74
- data/lib/rpdfium/structure/outline.rb +2 -2
- data/lib/rpdfium/structure/tree.rb +56 -55
- data/lib/rpdfium/table/cells.rb +36 -33
- data/lib/rpdfium/table/debugger.rb +12 -12
- data/lib/rpdfium/table/edges.rb +51 -49
- data/lib/rpdfium/table/extractor.rb +35 -34
- data/lib/rpdfium/table/table.rb +65 -62
- data/lib/rpdfium/util/cluster.rb +35 -33
- data/lib/rpdfium/util/column_inference.rb +34 -32
- data/lib/rpdfium/util/label_matcher.rb +30 -30
- data/lib/rpdfium/util/text_extraction.rb +15 -15
- data/lib/rpdfium/util/word_extractor.rb +49 -48
- data/lib/rpdfium/util/word_merger.rb +25 -24
- data/lib/rpdfium/version.rb +1 -1
- data/lib/rpdfium.rb +17 -15
- metadata +1 -1
data/CHANGELOG.md
CHANGED
|
@@ -1,164 +1,216 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
> Entries for versions prior to 0.3.10 are available in the project's
|
|
7
|
+
> Git history.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.4.2] - 2026-06-15
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Benchmark suite gains a fifth, heaviest tier: `05_academic.pdf`
|
|
16
|
+
(520 pages).** A synthetic academic paper that stresses every code path at
|
|
17
|
+
scale — condensed two-column body text (small font, negative character
|
|
18
|
+
spacing, sub-100% horizontal scaling), ~104 ruled tables (counted in the
|
|
19
|
+
ground truth) interleaved with borderless appendix tables, embedded figure
|
|
20
|
+
images, footnotes, and a mix of academic annotations (citation links,
|
|
21
|
+
highlights, margin notes). Generated by `benchmark/generate_pdfs.rb` and
|
|
22
|
+
scored against `pdfs/expected.json` like the other tiers. On this tier
|
|
23
|
+
`Rpdfium.extract_text` runs in **706 ms / 69 MB** vs pdfplumber's
|
|
24
|
+
**57.15 s / 5537 MB** (~81× faster, ~80× less memory) and beats raw
|
|
25
|
+
pypdfium2 on both axes thanks to page streaming. README and the benchmark
|
|
26
|
+
site pages were updated with the full numbers.
|
|
27
|
+
|
|
28
|
+
### Performance
|
|
29
|
+
|
|
30
|
+
- **`Rpdfium.extract_tables` is ~30–35% faster on text-heavy pages.** The
|
|
31
|
+
table/word pipeline now pulls chars via a new `Page#chars(geometry: true)`
|
|
32
|
+
fast path. On top of the existing `lean` mode it also skips the per-char
|
|
33
|
+
`FPDFText_GetCharOrigin` read and the per-char angle/font/weight/render-mode
|
|
34
|
+
work, applies the page rotation inline (no intermediate tuple), and emits a
|
|
35
|
+
minimal per-char hash with only the fields the pipeline reads. The
|
|
36
|
+
content-stream "token end" signal (`text_obj_ends_with_space`, used by
|
|
37
|
+
`rebuild_word_separators` to avoid splitting numbers like `2.895,26`) is
|
|
38
|
+
preserved, so extracted table contents are byte-for-byte identical. Measured
|
|
39
|
+
on the synthetic benchmark corpus: heavy page set 731 → 484 ms, complex
|
|
40
|
+
131 → 110 ms.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **`Page#font_inventory` split round glyphs into spurious groups**: heights
|
|
45
|
+
were keyed by `round(1)`, so a glyph whose loose box overshoots the cap line
|
|
46
|
+
by ~0.1pt (`O`, `S`, `C`) fell into a separate height bucket from the rest of
|
|
47
|
+
its line — producing garbled samples like `CDICE FISCALE` with every `O`
|
|
48
|
+
missing, and inflating the group count. Heights are now clustered within a
|
|
49
|
+
`height_tolerance` (default 0.5pt, single-linkage, per font+weight) and
|
|
50
|
+
samples are emitted in document order.
|
|
51
|
+
- **`Annotation#link_uri` returned garbled text**: `FPDFAction_GetURIPath`
|
|
52
|
+
returns 7-bit ASCII bytes, unlike most PDFium getters which return
|
|
53
|
+
UTF-16LE. The bytes were being decoded as UTF-16, producing CJK garbage
|
|
54
|
+
for every external link URI. Added `Raw.read_ascii_string` and switched
|
|
55
|
+
`link_uri` to it.
|
|
5
56
|
|
|
6
57
|
## [0.4.1] - 2026-05-26
|
|
7
58
|
|
|
8
|
-
###
|
|
59
|
+
### Fixed
|
|
9
60
|
|
|
10
|
-
- **
|
|
11
|
-
`require "rpdfium/binary"`
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
`LoadError`
|
|
61
|
+
- **Loading on Linux with `rpdfium-binary`**: `rpdfium.rb` now executes
|
|
62
|
+
`require "rpdfium/binary"` before `raw.rb`. Previously `ffi_lib` was
|
|
63
|
+
invoked before `Rpdfium::Binary` had been defined, causing a fallback
|
|
64
|
+
to the system library names (`pdfium`, `libpdfium.so`) and a
|
|
65
|
+
`LoadError` on environments without a globally installed PDFium.
|
|
15
66
|
|
|
16
|
-
## [0.4.0] - refactor
|
|
67
|
+
## [0.4.0] - refactor toward composable primitives
|
|
17
68
|
|
|
18
69
|
### ⚠️ Breaking changes
|
|
19
70
|
|
|
20
|
-
`Page#label_value_pairs`
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
71
|
+
`Page#label_value_pairs` reverts to being a **minimal primitive**: it
|
|
72
|
+
returns an `Array<Hash>` of raw pairs with no application-level merging
|
|
73
|
+
options. The `merge_adjacent:`, `as_hash:`, and `boxed_layout:` options
|
|
74
|
+
are **removed** (they were domain logic grafted onto the extraction
|
|
75
|
+
primitive).
|
|
25
76
|
|
|
26
|
-
|
|
27
|
-
- `merge_adjacent: :smart` → componi a mano con `Util::WordMerger`
|
|
28
|
-
- `as_hash: true` → converti il risultato nel chiamante
|
|
29
|
-
- `boxed_layout: true` → passa direttamente `x_tolerance: 15.0,
|
|
30
|
-
inject_spaces: false` + crea `LabelMatcher.new(row_max_dx: 400.0)`
|
|
77
|
+
For users who relied on these options:
|
|
31
78
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`
|
|
79
|
+
- `merge_adjacent: :smart` → compose manually with `Util::WordMerger`
|
|
80
|
+
- `as_hash: true` → convert the result in the caller
|
|
81
|
+
- `boxed_layout: true` → pass `x_tolerance: 15.0, inject_spaces: false`
|
|
82
|
+
directly, and create `LabelMatcher.new(row_max_dx: 400.0)`
|
|
35
83
|
|
|
36
|
-
|
|
84
|
+
The **application-specific adapters** for Italian Revenue Agency forms
|
|
85
|
+
(Modello 770, Comunicazione IVA) are now provided as **external
|
|
86
|
+
examples** under `examples/adapters/` (see below), not as part of the
|
|
87
|
+
gem.
|
|
37
88
|
|
|
38
|
-
|
|
89
|
+
### Added: `Util::WordMerger`
|
|
90
|
+
|
|
91
|
+
A configurable merging primitive with three explicit strategies:
|
|
39
92
|
|
|
40
93
|
```ruby
|
|
41
94
|
merger = Rpdfium::Util::WordMerger.new(x_gap: 20.0, y_tol: 3.0)
|
|
42
95
|
|
|
43
|
-
#
|
|
96
|
+
# Merge all adjacent words
|
|
44
97
|
merger.merge_by_proximity(words)
|
|
45
98
|
|
|
46
|
-
#
|
|
99
|
+
# Merge only words sharing the same label (word → label mapping supplied by the caller)
|
|
47
100
|
merger.merge_by_label(words, labels_by_word)
|
|
48
101
|
|
|
49
|
-
#
|
|
102
|
+
# Merge only words with a nil label (orphans)
|
|
50
103
|
merger.merge_unlabeled(words, labels_by_word)
|
|
51
104
|
```
|
|
52
105
|
|
|
53
|
-
###
|
|
106
|
+
### Added: `Util::ColumnInference`
|
|
54
107
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
108
|
+
A primitive for inferring data columns on non-tabular PDFs (prestamped
|
|
109
|
+
forms, layouts whose values are aligned by position but lack ruling
|
|
110
|
+
lines). The algorithm proceeds in three steps:
|
|
58
111
|
|
|
59
|
-
1. Cluster
|
|
60
|
-
2.
|
|
61
|
-
3.
|
|
112
|
+
1. Cluster by X coordinate (`x0` left-aligned OR `x1` right-aligned)
|
|
113
|
+
2. Split on anomalous vertical gaps
|
|
114
|
+
3. Filter by density (coefficient of variation of the gaps)
|
|
62
115
|
|
|
63
116
|
```ruby
|
|
64
117
|
inference = Rpdfium::Util::ColumnInference.new(
|
|
65
|
-
x_tolerance: 3.0, #
|
|
66
|
-
min_size: 3, #
|
|
67
|
-
cv_threshold: 0.15 #
|
|
118
|
+
x_tolerance: 3.0, # X cluster tolerance
|
|
119
|
+
min_size: 3, # at least 3 values per column
|
|
120
|
+
cv_threshold: 0.15 # regular gaps
|
|
68
121
|
)
|
|
69
122
|
|
|
70
123
|
columns = inference.infer(words)
|
|
71
124
|
# => [[word1, word2, ...], [word1, word2, ...]]
|
|
72
125
|
```
|
|
73
126
|
|
|
74
|
-
### `Util::LabelMatcher`
|
|
127
|
+
### `Util::LabelMatcher` now composes with `ColumnInference`
|
|
75
128
|
|
|
76
129
|
```ruby
|
|
77
|
-
#
|
|
130
|
+
# Without reassignment (0.3.15 behavior)
|
|
78
131
|
matcher = Rpdfium::Util::LabelMatcher.new
|
|
79
132
|
|
|
80
|
-
#
|
|
133
|
+
# With reassignment for repeating columns (formerly repeat_headers)
|
|
81
134
|
matcher = Rpdfium::Util::LabelMatcher.new(
|
|
82
135
|
column_inference: Rpdfium::Util::ColumnInference.new
|
|
83
136
|
)
|
|
84
137
|
```
|
|
85
138
|
|
|
86
|
-
|
|
87
|
-
|
|
139
|
+
The `repeat_headers:` flag no longer exists — a configured
|
|
140
|
+
`ColumnInference` object is passed directly (or `nil` to disable it).
|
|
88
141
|
|
|
89
|
-
###
|
|
142
|
+
### Application adapters (external examples)
|
|
90
143
|
|
|
91
|
-
|
|
92
|
-
|
|
144
|
+
Distributed under `examples/adapters/`, **not** part of the gem. They
|
|
145
|
+
illustrate how to compose the primitives for specific cases:
|
|
93
146
|
|
|
94
|
-
- **`Modello770Reader`** (
|
|
95
|
-
- **`LiquidazioneIVAReader`** (
|
|
147
|
+
- **`Modello770Reader`** (for the withholding-agent declaration)
|
|
148
|
+
- **`LiquidazioneIVAReader`** (for the periodic VAT settlement
|
|
149
|
+
communication)
|
|
96
150
|
|
|
97
|
-
|
|
98
|
-
|
|
151
|
+
Each is a standalone Ruby script with a class of roughly 100 lines,
|
|
152
|
+
intended to be copied into your own project and adapted as needed.
|
|
99
153
|
|
|
100
|
-
###
|
|
154
|
+
### Philosophy
|
|
101
155
|
|
|
102
|
-
|
|
103
|
-
**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
156
|
+
The rpdfium gem provides general-purpose primitives for reading PDFs.
|
|
157
|
+
The **application logic specific to a given form** (knowing that the 770
|
|
158
|
+
has the ST/SV/SX sections, that the VAT form uses single-digit boxes
|
|
159
|
+
with a comma painted graphically by the template) belongs in the
|
|
160
|
+
**consumer's code**, not in the gem.
|
|
107
161
|
|
|
108
|
-
|
|
109
|
-
**
|
|
162
|
+
The `WordMerger`, `ColumnInference`, and `LabelMatcher` primitives are
|
|
163
|
+
**composable**: each use case composes a specific pipeline.
|
|
110
164
|
|
|
111
|
-
###
|
|
165
|
+
### Regression testing
|
|
112
166
|
|
|
113
|
-
✅
|
|
114
|
-
|
|
115
|
-
dedicati.
|
|
167
|
+
✅ All core tests pass. F24, busta_paga, cu.pdf, complex, and sample are
|
|
168
|
+
unchanged. The new primitives are covered by dedicated assertions.
|
|
116
169
|
|
|
117
|
-
### Migration guide
|
|
170
|
+
### Migration guide from 0.3.19
|
|
118
171
|
|
|
119
172
|
```ruby
|
|
120
|
-
#
|
|
173
|
+
# Before (0.3.19):
|
|
121
174
|
page.label_value_pairs(
|
|
122
175
|
data_font: "Courier",
|
|
123
176
|
merge_adjacent: :smart,
|
|
124
177
|
as_hash: true
|
|
125
178
|
)
|
|
126
179
|
|
|
127
|
-
#
|
|
128
|
-
#
|
|
180
|
+
# After (0.4.0): use the Modello770Reader adapter (see examples/) or
|
|
181
|
+
# compose manually:
|
|
129
182
|
matcher = Rpdfium::Util::LabelMatcher.new(
|
|
130
183
|
column_inference: Rpdfium::Util::ColumnInference.new
|
|
131
184
|
)
|
|
132
185
|
pairs = page.label_value_pairs(data_font: "Courier", matcher: matcher)
|
|
133
|
-
#
|
|
186
|
+
# then apply custom merging + hash conversion in your own code
|
|
134
187
|
```
|
|
135
188
|
|
|
136
|
-
## [0.3.19] -
|
|
189
|
+
## [0.3.19] - extraction on box-per-digit forms (boxed_layout)
|
|
137
190
|
|
|
138
|
-
###
|
|
191
|
+
### Added: `label_value_pairs(boxed_layout: true)`
|
|
139
192
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
193
|
+
Some Italian prestamped forms (the periodic VAT settlement
|
|
194
|
+
communication, specific sections of the Modello Redditi) use a layout
|
|
195
|
+
with **a separate box for each digit**: the VAT number `01234567890` is
|
|
196
|
+
printed as 11 boxes, and the amount `15.357,78` is rendered as
|
|
197
|
+
`15.357 7 8` — the integer part, the comma painted by the template, and
|
|
198
|
+
the two decimal digits each in a box roughly 10pt apart.
|
|
146
199
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
`8`)
|
|
150
|
-
|
|
200
|
+
The default `label_value_pairs` did not recognize these numbers as
|
|
201
|
+
single values: it split `15.357,78` into three separate words
|
|
202
|
+
(`15.357`, `7`, `8`) because PDFium automatically inserted a "generated"
|
|
203
|
+
space between the boxes (gap > 5pt → treated as a word separator).
|
|
151
204
|
|
|
152
|
-
###
|
|
205
|
+
### Solution: the `boxed_layout: true` flag
|
|
153
206
|
|
|
154
|
-
|
|
207
|
+
It automatically configures the appropriate parameters:
|
|
155
208
|
|
|
156
|
-
- **`inject_spaces: false`**
|
|
157
|
-
|
|
158
|
-
- **`x_tolerance: 15.0`** (gap
|
|
159
|
-
- **`row_max_dx: 400.0`**
|
|
160
|
-
|
|
161
|
-
di distanza)
|
|
209
|
+
- **`inject_spaces: false`** on characters (no PDFium-generated spaces
|
|
210
|
+
that split the boxes)
|
|
211
|
+
- **`x_tolerance: 15.0`** (typical gap between boxes is ~10–13pt)
|
|
212
|
+
- **`row_max_dx: 400.0`** on the LabelMatcher (labels on VP forms sit on
|
|
213
|
+
the left, while values in the DEBITS/CREDITS column are 250+pt away)
|
|
162
214
|
|
|
163
215
|
```ruby
|
|
164
216
|
Rpdfium.open("iva.pdf") do |doc|
|
|
@@ -166,33 +218,33 @@ Rpdfium.open("iva.pdf") do |doc|
|
|
|
166
218
|
data_font: "Helvetica",
|
|
167
219
|
merge_adjacent: :smart,
|
|
168
220
|
as_hash: true,
|
|
169
|
-
boxed_layout: true # ←
|
|
221
|
+
boxed_layout: true # ← new option
|
|
170
222
|
)
|
|
171
223
|
end
|
|
172
224
|
```
|
|
173
225
|
|
|
174
|
-
###
|
|
226
|
+
### Result on the VAT form — Page 2 (Section VP)
|
|
175
227
|
|
|
176
|
-
**
|
|
228
|
+
**Before (0.3.18):**
|
|
177
229
|
|
|
178
230
|
```ruby
|
|
179
231
|
{
|
|
180
|
-
"CODICE FISCALE" => "0 2 0 9", #
|
|
181
|
-
"Operazioni straordinarie" => "5.455 8", # label
|
|
182
|
-
"," => ["2", "1"], #
|
|
183
|
-
"IVA esigibile" => "3.378 7 2", #
|
|
184
|
-
"CREDITI" => "1.132 7", # label
|
|
232
|
+
"CODICE FISCALE" => "0 2 0 9", # split by the boxes
|
|
233
|
+
"Operazioni straordinarie" => "5.455 8", # wrong label, number split
|
|
234
|
+
"," => ["2", "1"], # template comma read as a label
|
|
235
|
+
"IVA esigibile" => "3.378 7 2", # split
|
|
236
|
+
"CREDITI" => "1.132 7", # label is the sub-header, not semantic
|
|
185
237
|
...
|
|
186
238
|
}
|
|
187
239
|
```
|
|
188
240
|
|
|
189
|
-
**
|
|
241
|
+
**Now (0.3.19) with `boxed_layout: true`:**
|
|
190
242
|
|
|
191
243
|
```ruby
|
|
192
244
|
{
|
|
193
245
|
"CODICE FISCALE" => "01234567890", # ✓
|
|
194
246
|
"Mod. N." => "01", # ✓
|
|
195
|
-
"PERIODO DI RIFERIMENTO" => "04", #
|
|
247
|
+
"PERIODO DI RIFERIMENTO" => "04", # month: April
|
|
196
248
|
"VP2 Totale operazioni attive (al netto dell'IVA)" => "15.35778", # € 15.357,78
|
|
197
249
|
"VP3 Totale operazioni passive (al netto dell'IVA)" => "5.45582", # € 5.455,82
|
|
198
250
|
"VP4 IVA esigibile" => "3.37872", # € 3.378,72
|
|
@@ -202,10 +254,10 @@ end
|
|
|
202
254
|
}
|
|
203
255
|
```
|
|
204
256
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
257
|
+
Numeric values are concatenated without the decimal comma (the comma is
|
|
258
|
+
graphical in the template, not part of the data layer). The consumer can
|
|
259
|
+
format it in post-processing: the last two characters are the decimals,
|
|
260
|
+
the remainder is the integer part.
|
|
209
261
|
|
|
210
262
|
```ruby
|
|
211
263
|
def parse_eur_amount(s)
|
|
@@ -215,88 +267,89 @@ parse_eur_amount("15.35778") # => "15.357,78"
|
|
|
215
267
|
parse_eur_amount("2.24601") # => "2.246,01"
|
|
216
268
|
```
|
|
217
269
|
|
|
218
|
-
###
|
|
270
|
+
### When to use it
|
|
271
|
+
|
|
272
|
+
Enable `boxed_layout: true` when the form presents:
|
|
219
273
|
|
|
220
|
-
|
|
221
|
-
-
|
|
222
|
-
-
|
|
223
|
-
-
|
|
224
|
-
- Generalmente: PDF Agenzia delle Entrate con sfondo a celle
|
|
274
|
+
- Tax codes / VAT numbers with digits in visible boxes
|
|
275
|
+
- Amounts with a graphical decimal comma and a box per digit
|
|
276
|
+
- Dates in DD MM YYYY format with separate boxes
|
|
277
|
+
- In general: Italian Revenue Agency PDFs with a cell-based background
|
|
225
278
|
|
|
226
|
-
|
|
227
|
-
- F24 stampato (Courier compatto senza caselle)
|
|
228
|
-
- Modello 770 quadri ST/SV (testo libero compatto)
|
|
229
|
-
- Buste paga / cedolini con tabelle standard
|
|
230
|
-
- Tutti i casi dove i char sono già contigui
|
|
279
|
+
Keep the default `false` for:
|
|
231
280
|
|
|
232
|
-
|
|
281
|
+
- Printed F24 (compact Courier without boxes)
|
|
282
|
+
- Modello 770 sections ST/SV (compact free text)
|
|
283
|
+
- Payslips with standard tables
|
|
284
|
+
- Any case where characters are already contiguous
|
|
233
285
|
|
|
234
|
-
|
|
235
|
-
|
|
286
|
+
### Regression testing
|
|
287
|
+
|
|
288
|
+
✅ 15/15 tests pass. The default `boxed_layout: false` preserves the
|
|
289
|
+
0.3.18 behavior byte for byte.
|
|
236
290
|
|
|
237
291
|
### API compatibility
|
|
238
292
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
293
|
+
No breaking changes. You may pass `inject_spaces:`, `x_tolerance:`, and
|
|
294
|
+
other kwargs separately for fine-grained control, or use the
|
|
295
|
+
`boxed_layout: true` combination as a shortcut.
|
|
242
296
|
|
|
243
|
-
## [0.3.18] -
|
|
297
|
+
## [0.3.18] - header propagation across repeating tables
|
|
244
298
|
|
|
245
|
-
###
|
|
299
|
+
### Fixed: column headers not propagated to subsequent rows
|
|
246
300
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
301
|
+
On forms with **repeating tables** (the ST/SV sections of the 770, the
|
|
302
|
+
Treasury/INPS/Regions sections of a multi-row F24, etc.) the column
|
|
303
|
+
headers are printed **only once** at the top of the table and are
|
|
304
|
+
implied for all subsequent rows (ST2, ST3, ..., ST13).
|
|
251
305
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
306
|
+
In earlier versions `label_value_pairs` limited label→value matching to
|
|
307
|
+
`col_max_dy=80pt`: sufficient for the first row (ST2), but subsequent
|
|
308
|
+
rows (more than 80pt from the header) ended up under wrong or spurious
|
|
309
|
+
labels (e.g. `ST5: [04 2021, 455,46]` instead of `Periodo di
|
|
310
|
+
riferimento: 04 2021` + `Importo versato: 455,46`).
|
|
257
311
|
|
|
258
|
-
###
|
|
312
|
+
### Solution: a column reassignment pass
|
|
259
313
|
|
|
260
|
-
|
|
314
|
+
The `LabelMatcher` now has a third phase, **`reassign_by_columns`**:
|
|
261
315
|
|
|
262
|
-
1. **
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
316
|
+
1. **Identify the data columns**: cluster values by their `x0`
|
|
317
|
+
coordinate (left-aligned, e.g. tax codes) **and** by `x1`
|
|
318
|
+
(right-aligned, e.g. numeric amounts — "1.227,70" and "499,81" have
|
|
319
|
+
different x0 but the same x1). Values on prestamped forms are often
|
|
320
|
+
right-aligned; both alignments are needed to cover all cases.
|
|
267
321
|
|
|
268
|
-
2. **
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
"
|
|
272
|
-
|
|
322
|
+
2. **Split columns on vertical gaps**: if two consecutive values in the
|
|
323
|
+
same x-cluster have a vertical gap > 3× the median gap (or > 40pt),
|
|
324
|
+
they are separated into distinct columns. This resolves cases such as
|
|
325
|
+
"tax code at the top of the page + table below" that share the same x
|
|
326
|
+
but are distinct sections.
|
|
273
327
|
|
|
274
|
-
3. **
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
328
|
+
3. **Filter by density**: a genuine repeating-table column has regularly
|
|
329
|
+
equispaced values. Measure the coefficient of variation of the gaps
|
|
330
|
+
(`CV = std_dev/mean`). The threshold is tight: `CV < 0.15` (very
|
|
331
|
+
regular spacing). This excludes false positives such as the five
|
|
332
|
+
right-aligned F24 balances (SALDO A-B, C-D, E-F, G-H, M-N: same x1
|
|
333
|
+
but different sections, CV = 0.26).
|
|
280
334
|
|
|
281
|
-
4. **
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
canonica.
|
|
335
|
+
4. **Find the canonical header**: for each identified data column, look
|
|
336
|
+
for the template label **immediately above** `col_top` (the top of
|
|
337
|
+
the column's first value). That label is the canonical header.
|
|
285
338
|
|
|
286
|
-
5. **
|
|
287
|
-
|
|
339
|
+
5. **Propagate**: assign the canonical header to ALL values in the
|
|
340
|
+
column, even those beyond `col_max_dy` from the original header.
|
|
288
341
|
|
|
289
|
-
###
|
|
342
|
+
### Result on the 770 Section ST
|
|
290
343
|
|
|
291
|
-
|
|
344
|
+
Page 4 before (0.3.17):
|
|
292
345
|
|
|
293
346
|
```ruby
|
|
294
347
|
{
|
|
295
348
|
"Periodo di riferimento mese anno" => "01 2021",
|
|
296
|
-
"Ritenute operate" => "394,13", #
|
|
349
|
+
"Ritenute operate" => "394,13", # ST2 only
|
|
297
350
|
"Importo versato" => "394,13",
|
|
298
|
-
"Codice tributo 11" => ["1001", "443,73", "1001", "405,96"], #
|
|
299
|
-
"ST5" => ["04 2021", "455,46"], # label
|
|
351
|
+
"Codice tributo 11" => ["1001", "443,73", "1001", "405,96"], # mixed
|
|
352
|
+
"ST5" => ["04 2021", "455,46"], # spurious label
|
|
300
353
|
"ST6" => ["05 2021", "407,40"],
|
|
301
354
|
"ST7" => ["06 2021", "1.227,70"],
|
|
302
355
|
# ...
|
|
@@ -304,7 +357,7 @@ Pagina 4 prima (0.3.17):
|
|
|
304
357
|
}
|
|
305
358
|
```
|
|
306
359
|
|
|
307
|
-
|
|
360
|
+
Now (0.3.18):
|
|
308
361
|
|
|
309
362
|
```ruby
|
|
310
363
|
{
|
|
@@ -316,115 +369,116 @@ Adesso (0.3.18):
|
|
|
316
369
|
"394,13", "443,73", "405,96", "455,46", "407,40", "1.227,70",
|
|
317
370
|
"367,74", "520,00", "463,37", "451,32", "499,81", "32,46"
|
|
318
371
|
],
|
|
319
|
-
"Importo versato" => [...
|
|
320
|
-
"Codice tributo 11" => ["1001", "1001", ..., "1001", "1712"], # 12
|
|
372
|
+
"Importo versato" => [...same 12 amounts...],
|
|
373
|
+
"Codice tributo 11" => ["1001", "1001", ..., "1001", "1712"], # 12 codes
|
|
321
374
|
"Data di versamento giorno mese anno 14" => [
|
|
322
375
|
"16 02 2021", "16 03 2021", ..., "16 12 2021"
|
|
323
376
|
]
|
|
324
|
-
# NO
|
|
377
|
+
# NO more spurious ST5/ST7/ST13 labels
|
|
325
378
|
}
|
|
326
379
|
```
|
|
327
380
|
|
|
328
|
-
###
|
|
381
|
+
### Configurable parameters
|
|
329
382
|
|
|
330
|
-
`Rpdfium::Util::LabelMatcher.new`
|
|
383
|
+
`Rpdfium::Util::LabelMatcher.new` accepts three new parameters:
|
|
331
384
|
|
|
332
|
-
- `repeat_headers:` (default `true`) —
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
riconoscere una colonna come ripetitiva.
|
|
385
|
+
- `repeat_headers:` (default `true`) — enable/disable column
|
|
386
|
+
reassignment. Pass `false` to restore the 0.3.17 behavior.
|
|
387
|
+
- `column_x_tolerance:` (default `3.0`) — X tolerance for treating two
|
|
388
|
+
values as being "in the same column".
|
|
389
|
+
- `min_column_size:` (default `3`) — minimum number of values required
|
|
390
|
+
to recognize a column as repeating.
|
|
339
391
|
|
|
340
392
|
```ruby
|
|
341
393
|
matcher = Rpdfium::Util::LabelMatcher.new(
|
|
342
394
|
repeat_headers: true,
|
|
343
|
-
column_x_tolerance: 2.0, # cluster
|
|
344
|
-
min_column_size: 5 #
|
|
395
|
+
column_x_tolerance: 2.0, # tighter cluster
|
|
396
|
+
min_column_size: 5 # only columns with 5+ rows
|
|
345
397
|
)
|
|
346
398
|
page.label_value_pairs(data_font: "Courier", matcher: matcher, ...)
|
|
347
399
|
```
|
|
348
400
|
|
|
349
|
-
###
|
|
401
|
+
### Regression testing
|
|
402
|
+
|
|
403
|
+
✅ 15/15 tests pass:
|
|
350
404
|
|
|
351
|
-
✅ 15/15 test passano:
|
|
352
405
|
- busta_paga, cu.pdf rotation 90°, sample, complex
|
|
353
406
|
- F24 499,81 → "importi a debito versati"
|
|
354
|
-
- F24 1.615,90 → "SALDO (M-N) +/–" (
|
|
355
|
-
|
|
407
|
+
- F24 1.615,90 → "SALDO (M-N) +/–" (final balance, not confused with the
|
|
408
|
+
section balances SALDO A-B / C-D)
|
|
356
409
|
- F24 532,27 → "importi a debito versati" (TOTALE A)
|
|
357
410
|
- 770 p2 "Cognome o Denominazione" → "Azienda S.R.L."
|
|
358
|
-
- 770 p4 12
|
|
359
|
-
- 770 p4 NO
|
|
360
|
-
- 770 p4 CODICE FISCALE →
|
|
411
|
+
- 770 p4 12 tax codes + 12 amounts + 12 dates + 12 months
|
|
412
|
+
- 770 p4 NO spurious ST5/ST13 labels
|
|
413
|
+
- 770 p4 CODICE FISCALE → the single code only (not the whole column)
|
|
361
414
|
|
|
362
415
|
### API compatibility
|
|
363
416
|
|
|
364
|
-
|
|
365
|
-
|
|
417
|
+
No breaking changes. `repeat_headers: false` restores the 0.3.17
|
|
418
|
+
behavior for those who prefer it.
|
|
366
419
|
|
|
367
|
-
## [0.3.17] -
|
|
420
|
+
## [0.3.17] - label-value precision on narrow-column forms
|
|
368
421
|
|
|
369
|
-
###
|
|
422
|
+
### Fixed: "wide" values crossing into wrong labels
|
|
370
423
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
"Dichiarazione integrativa" / "Protocollo dichiarazione inviata"
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
424
|
+
On prestamped forms with adjacent narrow template columns (the classic
|
|
425
|
+
case: 770 page 2 with "Cognome o Denominazione" / "Nome" /
|
|
426
|
+
"Dichiarazione integrativa" / "Protocollo dichiarazione inviata" laid
|
|
427
|
+
out on the same line), a value that semantically belongs to the first
|
|
428
|
+
field but extends graphically beyond its box (e.g. "Azienda S.R.L."
|
|
429
|
+
written across the whole line) was split into three entries under
|
|
430
|
+
different labels.
|
|
378
431
|
|
|
379
|
-
`Page#label_value_pairs(merge_adjacent: :smart, ...)`
|
|
432
|
+
`Page#label_value_pairs(merge_adjacent: :smart, ...)` now:
|
|
380
433
|
|
|
381
|
-
1. **
|
|
382
|
-
by_proximity
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
"naturalmente unite".
|
|
434
|
+
1. **Final tight-merge pass**: after the existing by_label and
|
|
435
|
+
by_proximity passes, a third pass joins words with a horizontal gap
|
|
436
|
+
≤ 10pt on the exact same line (tops differing by < 1pt), even if they
|
|
437
|
+
fall under different column labels. The threshold is below the
|
|
438
|
+
typical inter-column gap (> 15pt) but above intra-word kerning
|
|
439
|
+
(< 5pt), so only "naturally joined" strings are recognized.
|
|
388
440
|
|
|
389
|
-
2. **Label
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
offset
|
|
393
|
-
|
|
394
|
-
|
|
441
|
+
2. **Label for wide values uses the left edge**: for values wider than
|
|
442
|
+
60pt (typical of merged strings), the `LabelMatcher` now looks for
|
|
443
|
+
the column label using the value's **left edge** (with a small 5pt
|
|
444
|
+
offset) instead of its midpoint. This way a denomination that begins
|
|
445
|
+
under "Cognome o Denominazione" keeps that label even when it extends
|
|
446
|
+
beyond.
|
|
395
447
|
|
|
396
|
-
|
|
448
|
+
Result on 770 page 2:
|
|
397
449
|
|
|
398
450
|
```ruby
|
|
399
|
-
#
|
|
451
|
+
# Before (0.3.16):
|
|
400
452
|
{
|
|
401
|
-
"Cognome o Denominazione" => "Azienda", #
|
|
402
|
-
"Dichiarazione integrativa" => "CONSULTING", #
|
|
403
|
-
"Protocollo dichiarazione inviata" => "S.R.L." #
|
|
453
|
+
"Cognome o Denominazione" => "Azienda", # split
|
|
454
|
+
"Dichiarazione integrativa" => "CONSULTING", # wrong
|
|
455
|
+
"Protocollo dichiarazione inviata" => "S.R.L." # wrong
|
|
404
456
|
}
|
|
405
457
|
|
|
406
|
-
#
|
|
458
|
+
# Now (0.3.17):
|
|
407
459
|
{
|
|
408
460
|
"Cognome o Denominazione" => "Azienda S.R.L." # ✓
|
|
409
461
|
}
|
|
410
462
|
```
|
|
411
463
|
|
|
412
|
-
|
|
413
|
-
- `merge_x_gap:` (default 20.0) — gap by_label e by_proximity
|
|
414
|
-
- `merge_tight_x_gap:` (default 10.0) — gap del tight-merge
|
|
464
|
+
The three passes are configurable separately:
|
|
415
465
|
|
|
416
|
-
|
|
466
|
+
- `merge_x_gap:` (default 20.0) — gap for by_label and by_proximity
|
|
467
|
+
- `merge_tight_x_gap:` (default 10.0) — gap for the tight-merge
|
|
417
468
|
|
|
418
|
-
|
|
419
|
-
"16" come marcatori grafici delle colonne (indici delle posizioni nel
|
|
420
|
-
form). Venivano catturati come label semantiche del LabelMatcher,
|
|
421
|
-
producendo entry inutili come `"16" => ["443,73", "405,96", ...]`.
|
|
469
|
+
### Fixed: graphical column markers captured as labels
|
|
422
470
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
471
|
+
On the ST/SV sections of the 770, the template prints small numbers
|
|
472
|
+
"11", "14", "15", "16" as graphical column markers (indices of the
|
|
473
|
+
positions in the form). These were captured as semantic labels by the
|
|
474
|
+
LabelMatcher, producing useless entries such as `"16" => ["443,73",
|
|
475
|
+
"405,96", ...]`.
|
|
476
|
+
|
|
477
|
+
`Util::LabelMatcher` now ignores, by default, labels matching
|
|
478
|
+
`/\A\d{1,3}\z|\A[IVX]{1,5}\z/` — short numbers and short Roman numerals,
|
|
479
|
+
typical column markers. Configurable via
|
|
480
|
+
`LabelMatcher.new(ignore_label_pattern: ...)`. Pass `nil` to disable the
|
|
481
|
+
filter, or your own Regexp for a custom pattern.
|
|
428
482
|
|
|
429
483
|
Default:
|
|
430
484
|
|
|
@@ -433,66 +487,66 @@ matcher = Rpdfium::Util::LabelMatcher.new
|
|
|
433
487
|
# ignore_label_pattern: /\A\d{1,3}\z|\A[IVX]{1,5}\z/
|
|
434
488
|
|
|
435
489
|
matcher = Rpdfium::Util::LabelMatcher.new(ignore_label_pattern: nil)
|
|
436
|
-
#
|
|
490
|
+
# no filter, 0.3.16 behavior
|
|
437
491
|
|
|
438
492
|
matcher = Rpdfium::Util::LabelMatcher.new(ignore_label_pattern: /\AXX\z/)
|
|
439
|
-
#
|
|
493
|
+
# custom filter
|
|
440
494
|
```
|
|
441
495
|
|
|
442
|
-
###
|
|
496
|
+
### Final result on the 770
|
|
443
497
|
|
|
444
|
-
|
|
498
|
+
Comparison of the main pages, before/after:
|
|
445
499
|
|
|
446
|
-
|
|
|
500
|
+
| Page | Before (0.3.16) | Now (0.3.17) |
|
|
447
501
|
| --- | --- | --- |
|
|
448
|
-
| 2 | "Cognome": "Azienda" + 2
|
|
449
|
-
| 4 (
|
|
450
|
-
| 4 | "14": [16, 16, 17, ...] (
|
|
451
|
-
| 4 | "11": [1001, 443,73, ...] (
|
|
502
|
+
| 2 | "Cognome": "Azienda" + 2 wrong entries | "Cognome o Denominazione": "Azienda S.R.L." |
|
|
503
|
+
| 4 (Section ST) | "16": [443,73, 405,96, ...] (marker) | "Sospensione COVID Importo sospeso": [...] (real label) |
|
|
504
|
+
| 4 | "14": [16, 16, 17, ...] (marker) | "Data di versamento giorno mese anno": [16 02 2021, ...] |
|
|
505
|
+
| 4 | "11": [1001, 443,73, ...] (marker) | "Codice tributo": [1001, ...] (real label) |
|
|
452
506
|
|
|
453
|
-
###
|
|
507
|
+
### Regression testing
|
|
454
508
|
|
|
455
|
-
✅ 16/16
|
|
456
|
-
|
|
509
|
+
✅ 16/16 tests pass (busta_paga, F24, cu.pdf rotation 90°, cu.pdf small
|
|
510
|
+
font, sample, complex, and all the new assertions on the 770).
|
|
457
511
|
|
|
458
512
|
### API compatibility
|
|
459
513
|
|
|
460
|
-
|
|
461
|
-
`ignore_label_pattern`)
|
|
462
|
-
|
|
514
|
+
No breaking changes. The new parameters (`merge_tight_x_gap`,
|
|
515
|
+
`ignore_label_pattern`) have sensible defaults. Disabling the filter
|
|
516
|
+
with `ignore_label_pattern: nil` restores the 0.3.16 behavior.
|
|
463
517
|
|
|
464
|
-
## [0.3.16] -
|
|
518
|
+
## [0.3.16] - structured extraction on multi-page forms
|
|
465
519
|
|
|
466
|
-
###
|
|
520
|
+
### Added: `label_value_pairs(merge_adjacent:, as_hash:)`
|
|
467
521
|
|
|
468
|
-
|
|
469
|
-
"
|
|
470
|
-
|
|
471
|
-
|
|
522
|
+
Two new options on `Page#label_value_pairs` that transform the output
|
|
523
|
+
from a "raw list of pairs" into a **structured `{label => value}` map
|
|
524
|
+
ready to consume**, handling correctly both point fields (checkboxes,
|
|
525
|
+
codes) and multi-word free text (denominations, addresses, headers).
|
|
472
526
|
|
|
473
|
-
### `merge_adjacent` — 3
|
|
527
|
+
### `merge_adjacent` — 3 selectable strategies
|
|
474
528
|
|
|
475
|
-
- **`false` (default)**:
|
|
476
|
-
|
|
529
|
+
- **`false` (default)**: no merging. One PDF word = one entry. 0.3.15
|
|
530
|
+
behavior.
|
|
477
531
|
|
|
478
|
-
- **`true`
|
|
479
|
-
label
|
|
480
|
-
X
|
|
481
|
-
|
|
532
|
+
- **`true` or `:by_label`**: merges only adjacent words sharing the same
|
|
533
|
+
column label. Preserves checkboxes under distinct labels (e.g. on the
|
|
534
|
+
770, the X marks of the completed ST/SV/SX sections remain separate
|
|
535
|
+
because each has its own label).
|
|
482
536
|
|
|
483
|
-
- **`:by_proximity`**:
|
|
484
|
-
|
|
485
|
-
|
|
537
|
+
- **`:by_proximity`**: merges all adjacent words regardless of label.
|
|
538
|
+
For headers with free text (e.g. "Soggetto: Azienda S.R.L. (
|
|
539
|
+
01234567890 )" becomes a single entry).
|
|
486
540
|
|
|
487
|
-
- **`:smart` (
|
|
488
|
-
by_label
|
|
489
|
-
label.
|
|
490
|
-
|
|
491
|
-
|
|
541
|
+
- **`:smart` (recommended for complex forms)**: combines the two —
|
|
542
|
+
by_label for words with a label, by_proximity for **orphan** words
|
|
543
|
+
with no label. Works automatically on forms that mix text headers
|
|
544
|
+
(Soggetto), tables with checkboxes (ST/SV/SX), and single fields (tax
|
|
545
|
+
code, activity code).
|
|
492
546
|
|
|
493
|
-
### `as_hash: true` — output
|
|
547
|
+
### `as_hash: true` — structured output
|
|
494
548
|
|
|
495
|
-
|
|
549
|
+
Transforms `Array<Hash>` into a key-value `Hash`:
|
|
496
550
|
|
|
497
551
|
```ruby
|
|
498
552
|
Rpdfium.open("770.pdf") do |doc|
|
|
@@ -518,14 +572,14 @@ end
|
|
|
518
572
|
# }
|
|
519
573
|
```
|
|
520
574
|
|
|
521
|
-
|
|
522
|
-
`"Codice fiscale" => ["01234567890", "01234567890"]`.
|
|
575
|
+
When the same label applies to several values, the output becomes an
|
|
576
|
+
Array: `"Codice fiscale" => ["01234567890", "01234567890"]`.
|
|
523
577
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
578
|
+
Words with no assignable label (e.g. headers at the top of the page with
|
|
579
|
+
no reference template) collect under the `"_unlabeled"` key as an Array
|
|
580
|
+
of strings.
|
|
527
581
|
|
|
528
|
-
###
|
|
582
|
+
### Example: full extraction of a Modello 770
|
|
529
583
|
|
|
530
584
|
```ruby
|
|
531
585
|
Rpdfium.open("770.pdf") do |doc|
|
|
@@ -539,20 +593,20 @@ Rpdfium.open("770.pdf") do |doc|
|
|
|
539
593
|
merge_adjacent: :smart,
|
|
540
594
|
as_hash: true
|
|
541
595
|
)
|
|
542
|
-
puts "===
|
|
596
|
+
puts "=== Page #{i + 1} ==="
|
|
543
597
|
h.each { |k, v| puts " #{k}: #{v.inspect}" }
|
|
544
598
|
end
|
|
545
599
|
end
|
|
546
600
|
```
|
|
547
601
|
|
|
548
|
-
|
|
602
|
+
Actual output on a Modello 770 (first 3 pages):
|
|
549
603
|
|
|
550
604
|
```
|
|
551
|
-
===
|
|
605
|
+
=== Page 1 ===
|
|
552
606
|
_unlabeled: ["Soggetto: Azienda S.R.L. ( 01234567890 )",
|
|
553
607
|
"Identificativo dichiarazione: 11111111111 - 0000002 del 22/10/2022"]
|
|
554
608
|
|
|
555
|
-
===
|
|
609
|
+
=== Page 2 ===
|
|
556
610
|
Codice fiscale: ["01234567890", "01234567890"]
|
|
557
611
|
Codice attività: "999999"
|
|
558
612
|
Indirizzo di posta elettronica/PEC: "AZIENDA@PEC.IT"
|
|
@@ -565,7 +619,7 @@ Output reale su modello 770 (3 prime pagine):
|
|
|
565
619
|
Tipologia invio: "2"
|
|
566
620
|
GESTIONE SEPARATA Dipendente Autonomo: "X"
|
|
567
621
|
|
|
568
|
-
===
|
|
622
|
+
=== Page 3 ===
|
|
569
623
|
Codice fiscale: "01234567890"
|
|
570
624
|
Codice fiscale dell'incaricato: "01877150696"
|
|
571
625
|
giorno mese: "01 10"
|
|
@@ -573,67 +627,68 @@ Output reale su modello 770 (3 prime pagine):
|
|
|
573
627
|
_unlabeled: ["2", "Firma Presente"]
|
|
574
628
|
```
|
|
575
629
|
|
|
576
|
-
### `merge_x_gap`
|
|
630
|
+
### `merge_x_gap` to tune merging
|
|
631
|
+
|
|
632
|
+
The `merge_x_gap:` parameter controls the maximum gap (in points)
|
|
633
|
+
between adjacent words for them to be considered "joined". Default 20.0.
|
|
634
|
+
Increase it for forms with widely spaced fields (page-centered headers).
|
|
577
635
|
|
|
578
|
-
|
|
579
|
-
adiacenti per essere considerate "unite". Default 20.0. Aumentalo per
|
|
580
|
-
moduli con campi molto spaziati (header centrati su pagina).
|
|
636
|
+
### `best_label_for` heuristic (internal)
|
|
581
637
|
|
|
582
|
-
|
|
638
|
+
When a value has both a `col` and a `row` label, the automatic choice
|
|
639
|
+
prefers:
|
|
583
640
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
- `row` se è una label breve identificatrice ("ST", "Codice fiscale")
|
|
587
|
-
- `col` quando è più descrittiva ("importi a debito versati")
|
|
641
|
+
- `row` if it is a short identifying label ("ST", "Codice fiscale")
|
|
642
|
+
- `col` when it is more descriptive ("importi a debito versati")
|
|
588
643
|
|
|
589
|
-
|
|
590
|
-
|
|
644
|
+
For fine-grained control, use the base API without `as_hash: true` and
|
|
645
|
+
read `p[:labels][:col]` and `p[:labels][:row]` directly.
|
|
591
646
|
|
|
592
|
-
###
|
|
647
|
+
### Regression testing
|
|
593
648
|
|
|
594
|
-
✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex, F24, IVA
|
|
595
|
-
|
|
649
|
+
✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex, F24, IVA —
|
|
650
|
+
all tests unchanged.
|
|
596
651
|
|
|
597
|
-
✅
|
|
598
|
-
|
|
652
|
+
✅ The default `merge_adjacent: false` preserves the 0.3.15 behavior byte
|
|
653
|
+
for byte. 0.3.16 is purely additive.
|
|
599
654
|
|
|
600
655
|
### API compatibility
|
|
601
656
|
|
|
602
|
-
|
|
657
|
+
No breaking changes.
|
|
603
658
|
|
|
604
|
-
## [0.3.15] -
|
|
659
|
+
## [0.3.15] - label-value association on completed forms
|
|
605
660
|
|
|
606
|
-
###
|
|
661
|
+
### Added: `Page#label_value_pairs` and `Util::LabelMatcher`
|
|
607
662
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
663
|
+
On "completed form" PDFs (F24, VAT communications, Modello 770, income
|
|
664
|
+
tax returns), the three APIs introduced in 0.3.14 (`font_inventory`,
|
|
665
|
+
`chars_where`, `lines`) make it possible to **separate** the template
|
|
666
|
+
layer from the data. 0.3.15 goes further: it **semantically associates**
|
|
667
|
+
each entered value with its label in the template, so the user need not
|
|
668
|
+
know the form's geometry in advance.
|
|
614
669
|
|
|
615
|
-
###
|
|
670
|
+
### How it works
|
|
616
671
|
|
|
617
|
-
|
|
672
|
+
The algorithm operates in three steps:
|
|
618
673
|
|
|
619
|
-
1. **Cluster
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
"importi", "a", "debito", "versati" → label
|
|
674
|
+
1. **Cluster the template into coherent labels** — template words that
|
|
675
|
+
are geometrically close (adjacent on the same line, or on successive
|
|
676
|
+
lines overlapping in x) are merged into a single label. For example:
|
|
677
|
+
"importi", "a", "debito", "versati" → the single label `"importi a
|
|
623
678
|
debito versati"`.
|
|
624
679
|
|
|
625
|
-
2. **
|
|
626
|
-
- `col` — label
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
- `row` — label
|
|
630
|
-
value x0,
|
|
631
|
-
|
|
680
|
+
2. **For each entered value, look for two kinds of label**:
|
|
681
|
+
- `col` — the label ABOVE in the same column (x overlapping the
|
|
682
|
+
value, bottom < value top, the vertically nearest one chosen).
|
|
683
|
+
Typical role: field/column name.
|
|
684
|
+
- `row` — the label to the LEFT on the same line (y overlapping, x1 <
|
|
685
|
+
value x0, the horizontally nearest one chosen). Typical role: row
|
|
686
|
+
identifier ("TOTALE A", "SALDO").
|
|
632
687
|
|
|
633
|
-
3. **
|
|
634
|
-
|
|
688
|
+
3. **Return** a mapping `{ value:, labels: { col:, row: }, geometry: }`
|
|
689
|
+
for each value.
|
|
635
690
|
|
|
636
|
-
###
|
|
691
|
+
### Example: F24
|
|
637
692
|
|
|
638
693
|
```ruby
|
|
639
694
|
Rpdfium.open("f24.pdf") do |doc|
|
|
@@ -658,12 +713,12 @@ Output:
|
|
|
658
713
|
2021 → col: "anno di riferimento"
|
|
659
714
|
532,27 → col: "importi a debito versati", row: "A" (TOTALE A)
|
|
660
715
|
236,38 → col: "SALDO (A-B) +/–", row: "B"
|
|
661
|
-
1.253,00 → col: "importi a debito versati" (
|
|
716
|
+
1.253,00 → col: "importi a debito versati" (INPS section)
|
|
662
717
|
1.341,00 → col: "SALDO (C-D) +/–", row: "D"
|
|
663
|
-
1.615,90 → col: "SALDO (M-N) +/–", row: "EURO +" (
|
|
718
|
+
1.615,90 → col: "SALDO (M-N) +/–", row: "EURO +" (final balance)
|
|
664
719
|
```
|
|
665
720
|
|
|
666
|
-
###
|
|
721
|
+
### Example: Modello 770 Section ST
|
|
667
722
|
|
|
668
723
|
```ruby
|
|
669
724
|
Rpdfium.open("770.pdf") do |doc|
|
|
@@ -678,21 +733,21 @@ end
|
|
|
678
733
|
# 16 → col: "Data di versamento giorno mese anno"
|
|
679
734
|
```
|
|
680
735
|
|
|
681
|
-
### `Util::LabelMatcher`
|
|
736
|
+
### `Util::LabelMatcher` as a standalone class
|
|
682
737
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
738
|
+
For advanced cases (e.g. matching on a subset of a page, with tuned
|
|
739
|
+
thresholds, or reused across multiple pages) the logic is exposed as a
|
|
740
|
+
separate class:
|
|
686
741
|
|
|
687
742
|
```ruby
|
|
688
743
|
matcher = Rpdfium::Util::LabelMatcher.new(
|
|
689
|
-
col_max_dy: 50.0, # max
|
|
690
|
-
row_max_dx: 150.0, # max
|
|
691
|
-
col_x_tolerance: 5.0, # overlap
|
|
692
|
-
row_y_tolerance: 1.0, # overlap
|
|
693
|
-
cluster_same_row_dy: 4.0, #
|
|
744
|
+
col_max_dy: 50.0, # max distance from label above -> value
|
|
745
|
+
row_max_dx: 150.0, # max distance from label on the left -> value
|
|
746
|
+
col_x_tolerance: 5.0, # x overlap required for an "above" label
|
|
747
|
+
row_y_tolerance: 1.0, # y overlap required for a "left" label
|
|
748
|
+
cluster_same_row_dy: 4.0, # cluster tolerance, words on the same line
|
|
694
749
|
cluster_same_row_dx: 12.0,
|
|
695
|
-
cluster_adj_row_dy: 4.0 #
|
|
750
|
+
cluster_adj_row_dy: 4.0 # cluster tolerance, words on adjacent lines
|
|
696
751
|
)
|
|
697
752
|
|
|
698
753
|
data_words = Rpdfium::Util::WordExtractor.new.extract_words(page.chars_where(font: "Courier"))
|
|
@@ -700,59 +755,58 @@ anchor_words = Rpdfium::Util::WordExtractor.new.extract_words(page.chars_where(f
|
|
|
700
755
|
|
|
701
756
|
pairs = matcher.match(data_words, anchor_words)
|
|
702
757
|
|
|
703
|
-
# Bonus:
|
|
758
|
+
# Bonus: inspect which labels the matcher builds
|
|
704
759
|
labels = matcher.cluster_anchors(anchor_words)
|
|
705
760
|
```
|
|
706
761
|
|
|
707
|
-
###
|
|
762
|
+
### Known limitations
|
|
708
763
|
|
|
709
|
-
- **
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
- **
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
- **
|
|
719
|
-
|
|
720
|
-
`data_filter`
|
|
764
|
+
- **Box-per-digit fields**: on forms with fields such as tax codes or
|
|
765
|
+
Italian numbers split across separate boxes (`0 2 0 9 8 1 2 0 6 8 2`,
|
|
766
|
+
`15.357 , 7 8`), the word extractor does not join the digits.
|
|
767
|
+
Increasing `x_tolerance` helps, but is a tradeoff: the definitive fix
|
|
768
|
+
requires consumer-side post-processing.
|
|
769
|
+
- **Overly wide labels**: sometimes the cluster joins adjacent labels
|
|
770
|
+
that would be better kept distinct. The default thresholds work on
|
|
771
|
+
most Italian Revenue Agency/INPS forms; tune the `LabelMatcher`
|
|
772
|
+
parameters for different layouts.
|
|
773
|
+
- **"Abundant" labels**: for values very close to the margin, the labels
|
|
774
|
+
found are obvious but uninformative. Filtering the pairs with a
|
|
775
|
+
selective `data_filter` helps (for example: only numbers with a
|
|
776
|
+
comma).
|
|
721
777
|
|
|
722
|
-
###
|
|
778
|
+
### Regression testing
|
|
723
779
|
|
|
724
|
-
✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex —
|
|
725
|
-
|
|
780
|
+
✅ busta_paga.pdf, cu.pdf p1, cu.pdf p199, sample, complex — all tests
|
|
781
|
+
unchanged.
|
|
726
782
|
|
|
727
783
|
### API compatibility
|
|
728
784
|
|
|
729
|
-
|
|
730
|
-
`
|
|
731
|
-
una nuova classe additiva.
|
|
785
|
+
No breaking changes. The 0.3.14 APIs (`font_inventory`, `chars_where`,
|
|
786
|
+
`lines`) are unchanged. `Util::LabelMatcher` is a new additive class.
|
|
732
787
|
|
|
733
|
-
## [0.3.14] -
|
|
788
|
+
## [0.3.14] - form-aware extraction via font filtering
|
|
734
789
|
|
|
735
|
-
###
|
|
790
|
+
### Added: `Page#font_inventory`, `Page#chars_where`, `Page#lines`
|
|
736
791
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
792
|
+
Three new APIs for extracting data from "completed form" PDFs — F24, VAT
|
|
793
|
+
communications, Modello 770, income tax returns, and in general any PDF
|
|
794
|
+
produced by accounting software in which the form's graphical template
|
|
795
|
+
and the user-entered data coexist as static text (no AcroForm, no PDF/UA
|
|
796
|
+
tags).
|
|
742
797
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
size specifica).
|
|
798
|
+
On these PDFs the `Table::Extractor` pipeline produces a lot of noise
|
|
799
|
+
because it sees the entire form (template labels + data) as a grid of
|
|
800
|
+
tables. The semantic solution is to separate characters by "role" using
|
|
801
|
+
font/height: typically the template uses proportional fonts (Futura,
|
|
802
|
+
Helvetica, Times) while the data entered by the software uses a single
|
|
803
|
+
font (usually Courier, or Helvetica at a specific size).
|
|
750
804
|
|
|
751
805
|
### `Page#font_inventory`
|
|
752
806
|
|
|
753
|
-
|
|
754
|
-
count
|
|
755
|
-
|
|
807
|
+
Distribution of characters by `(font, height, weight)`, sorted by
|
|
808
|
+
descending count. Useful for empirically discovering which font the data
|
|
809
|
+
uses on an unknown form:
|
|
756
810
|
|
|
757
811
|
```ruby
|
|
758
812
|
page.font_inventory.first(5).each do |g|
|
|
@@ -765,34 +819,34 @@ end
|
|
|
765
819
|
# Futura-Bold h=11.7 w=868 | 169 char | "CONTRIBUENTESEZIONE ERARIOSEZIONE INPSSE"
|
|
766
820
|
```
|
|
767
821
|
|
|
768
|
-
`height`
|
|
769
|
-
`fontsize
|
|
770
|
-
|
|
822
|
+
`height` is the visual height of the character in points (more reliable
|
|
823
|
+
than `fontsize`, which PDFium normalizes to 1.0 when the actual size is
|
|
824
|
+
in the CTM matrix — a frequent case on scaled forms).
|
|
771
825
|
|
|
772
826
|
### `Page#chars_where(font:, height:, weight:, bbox:, where:)`
|
|
773
827
|
|
|
774
|
-
|
|
775
|
-
|
|
828
|
+
A generic filter over characters. All parameters are optional and
|
|
829
|
+
combinable in AND:
|
|
776
830
|
|
|
777
|
-
- `font:` String
|
|
778
|
-
- `height:` Float (
|
|
779
|
-
- `weight:` Integer
|
|
780
|
-
- `bbox:` `[left, top, right, bottom]` in
|
|
781
|
-
- `where:` block
|
|
831
|
+
- `font:` exact String, Array of Strings, or Regexp
|
|
832
|
+
- `height:` Float (with 0.1pt tolerance), Range, or Array
|
|
833
|
+
- `weight:` Integer or Range
|
|
834
|
+
- `bbox:` `[left, top, right, bottom]` in top-down coordinates
|
|
835
|
+
- `where:` block for arbitrary filters
|
|
782
836
|
|
|
783
837
|
```ruby
|
|
784
838
|
data_chars = page.chars_where(font: "Courier")
|
|
785
|
-
#
|
|
839
|
+
# or
|
|
786
840
|
data_chars = page.chars_where(font: /courier/i, height: 8.0..12.0)
|
|
787
|
-
#
|
|
841
|
+
# or with bbox
|
|
788
842
|
sezione_erario = page.chars_where(font: "Courier", bbox: [0, 250, 595, 400])
|
|
789
843
|
```
|
|
790
844
|
|
|
791
845
|
### `Page#lines(font:, ...)`
|
|
792
846
|
|
|
793
|
-
|
|
794
|
-
clustering
|
|
795
|
-
|
|
847
|
+
A high-level helper that combines `chars_where` + WordExtractor + per-row
|
|
848
|
+
clustering. Returns an Array of strings, one per row (top-to-bottom,
|
|
849
|
+
characters within a row left-to-right):
|
|
796
850
|
|
|
797
851
|
```ruby
|
|
798
852
|
# F24
|
|
@@ -814,57 +868,59 @@ end
|
|
|
814
868
|
# ]
|
|
815
869
|
```
|
|
816
870
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
- **
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
871
|
+
It works equally well on:
|
|
872
|
+
|
|
873
|
+
- **F24**: tax codes, debit/credit amounts, separate sections
|
|
874
|
+
- **VAT communication**: Section VP amounts (active/passive operations,
|
|
875
|
+
collectible/deductible VAT, due/credit)
|
|
876
|
+
- **Modello 770**: month-by-month withholdings with tax codes and
|
|
877
|
+
payment dates
|
|
878
|
+
- **Income tax returns (SP, PF, SC)**: registry data and sections
|
|
824
879
|
|
|
825
|
-
###
|
|
880
|
+
### Tradeoffs and limitations
|
|
826
881
|
|
|
827
|
-
`Page#lines`
|
|
828
|
-
|
|
829
|
-
8
|
|
830
|
-
`15.357
|
|
831
|
-
`x_tolerance`
|
|
882
|
+
`Page#lines` returns **readable** rows, not already structured ones. On
|
|
883
|
+
forms with a separate box per digit (e.g. the tax code `0 2 0 9 8 1 2 0
|
|
884
|
+
6 8 2`, or Italian numbers with a separate decimals box `15.357,78` →
|
|
885
|
+
`15.357 7 8`), the visual gaps between boxes exceed the default
|
|
886
|
+
`x_tolerance` and the rows come out "spaced". Solutions:
|
|
832
887
|
|
|
833
|
-
1.
|
|
834
|
-
2. Post-
|
|
835
|
-
|
|
888
|
+
1. Increase `x_tolerance` for those specific filters (e.g. 8.0)
|
|
889
|
+
2. Post-process the rows to recognize the form's pattern (specific to
|
|
890
|
+
each model)
|
|
836
891
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
892
|
+
The library provides composable primitives; interpretation of the
|
|
893
|
+
specific form is left to the caller, because each model has a different
|
|
894
|
+
layout.
|
|
840
895
|
|
|
841
|
-
###
|
|
896
|
+
### Regression testing
|
|
842
897
|
|
|
843
898
|
✅ busta_paga.pdf: `1.993,00`, `COGNOME E NOME`, `NETTO BUSTA`
|
|
844
899
|
✅ cu.pdf p1 rotation 90°: `BANCA NAZIONALE`, `Categoria`
|
|
845
|
-
✅ cu.pdf p199 small font: `Categoria` (
|
|
900
|
+
✅ cu.pdf p199 small font: `Categoria` (not `iCategora`)
|
|
846
901
|
✅ sample.pdf, complex.pdf
|
|
847
902
|
|
|
848
903
|
### API compatibility
|
|
849
904
|
|
|
850
|
-
|
|
851
|
-
`Table::Extractor`
|
|
852
|
-
|
|
905
|
+
No breaking changes. The three new APIs are additive. The existing
|
|
906
|
+
`Table::Extractor` pipeline continues to work unchanged for those who
|
|
907
|
+
have "real" tables with borders.
|
|
853
908
|
|
|
854
|
-
## [0.3.13] - `Page#struct_tree`:
|
|
909
|
+
## [0.3.13] - `Page#struct_tree`: semantic structure of tagged PDFs
|
|
855
910
|
|
|
856
|
-
###
|
|
911
|
+
### Added: reading the PDF Structure Tree
|
|
857
912
|
|
|
858
|
-
|
|
859
|
-
tagged (PDF/UA,
|
|
913
|
+
A new `Page#struct_tree` API that exposes the logical structure of
|
|
914
|
+
tagged PDFs (PDF/UA, accessibility-friendly exports from
|
|
915
|
+
Word/LibreOffice/InDesign).
|
|
860
916
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
917
|
+
For tagged documents it offers access to the content **completely
|
|
918
|
+
independent of geometry**: for each element of the tree you can obtain
|
|
919
|
+
its structural type (`P`, `H1`, `Table`, `TR`, `TH`, `TD`, `Figure`,
|
|
920
|
+
etc.), the text it comprises, the structural PDF attributes, and the
|
|
921
|
+
links via Marked Content ID to the page content.
|
|
866
922
|
|
|
867
|
-
###
|
|
923
|
+
### Example: zero-geometry table extraction
|
|
868
924
|
|
|
869
925
|
```ruby
|
|
870
926
|
page.struct_tree do |tree|
|
|
@@ -877,71 +933,71 @@ page.struct_tree do |tree|
|
|
|
877
933
|
end
|
|
878
934
|
end
|
|
879
935
|
end
|
|
880
|
-
# → ["Region", "Revenue", "Growth"] (TH —
|
|
881
|
-
# → ["Italy", "1.250.000", "+12%"] (TD —
|
|
936
|
+
# → ["Region", "Revenue", "Growth"] (TH — header row)
|
|
937
|
+
# → ["Italy", "1.250.000", "+12%"] (TD — data row)
|
|
882
938
|
# → ["France", "980.000", "+8%"]
|
|
883
939
|
# → ["Germany", "2.100.000", "+15%"]
|
|
884
940
|
```
|
|
885
941
|
|
|
886
|
-
|
|
942
|
+
Advantages over the geometric `Table::Extractor` pipeline:
|
|
887
943
|
|
|
888
|
-
-
|
|
889
|
-
|
|
890
|
-
-
|
|
891
|
-
-
|
|
892
|
-
- zero
|
|
944
|
+
- distinguishes header (`TH`) from data (`TD`) — information the
|
|
945
|
+
geometric pipeline loses
|
|
946
|
+
- works on tables **without lines** (text-only) or with partial lines
|
|
947
|
+
- recognizes row/col spans via `<TD>.attributes` (`RowSpan`, `ColSpan`)
|
|
948
|
+
- zero clustering heuristics
|
|
893
949
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
950
|
+
Limitation: requires a tagged PDF. Most PDFs from Italian accounting
|
|
951
|
+
software (TeamSystem, Zucchetti, Italian banks) are NOT tagged. For
|
|
952
|
+
those, the existing geometric pipeline remains the only option.
|
|
897
953
|
|
|
898
|
-
### API
|
|
954
|
+
### Full API
|
|
899
955
|
|
|
900
956
|
```ruby
|
|
901
|
-
tree = page.struct_tree # → Tree
|
|
902
|
-
tree.empty? # true
|
|
903
|
-
tree.roots # → [Element, ...]
|
|
904
|
-
tree.walk { |el| ... } #
|
|
957
|
+
tree = page.struct_tree # → Tree or nil
|
|
958
|
+
tree.empty? # true if the tree is structurally empty
|
|
959
|
+
tree.roots # → [Element, ...] tree roots (usually 1 "Document")
|
|
960
|
+
tree.walk { |el| ... } # depth-first iteration
|
|
905
961
|
tree.walk.to_a # Enumerator
|
|
906
962
|
tree.find_all(type: "P") # filter by type
|
|
907
|
-
tree.tables # shortcut
|
|
963
|
+
tree.tables # shortcut for find_all(type: "Table")
|
|
908
964
|
|
|
909
965
|
element.type # "P", "Table", "TR", "TD", ...
|
|
910
966
|
element.children # → [Element, ...]
|
|
911
|
-
element.parent # → Element
|
|
912
|
-
element.text #
|
|
913
|
-
element.actual_text # /ActualText attribute (override
|
|
914
|
-
element.alt_text # /Alt (
|
|
915
|
-
element.lang # "it-IT", "en-US",
|
|
967
|
+
element.parent # → Element or nil
|
|
968
|
+
element.text # reconstructs text from MCID + ActualText
|
|
969
|
+
element.actual_text # /ActualText attribute (override for ligatures, math)
|
|
970
|
+
element.alt_text # /Alt (for Figure/Formula)
|
|
971
|
+
element.lang # "it-IT", "en-US", etc.
|
|
916
972
|
element.marked_content_ids # → [Integer]
|
|
917
973
|
element.attributes # → { name => value } (RowSpan, ColSpan, ...)
|
|
918
|
-
element.walk { |el| ... } # depth-first
|
|
919
|
-
element.leaves #
|
|
974
|
+
element.walk { |el| ... } # depth-first over the sub-tree
|
|
975
|
+
element.leaves # leaves (elements without children)
|
|
920
976
|
```
|
|
921
977
|
|
|
922
978
|
### Lifecycle
|
|
923
979
|
|
|
924
|
-
|
|
980
|
+
The tree is "owning" — calling `FPDF_StructTree_Close` deallocates it.
|
|
925
981
|
|
|
926
|
-
- **
|
|
927
|
-
PDFium
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
- **
|
|
931
|
-
`page.struct_tree do |tree| ... end`.
|
|
932
|
-
|
|
982
|
+
- **Implicit lifecycle (zero-config)**: never close it explicitly.
|
|
983
|
+
PDFium deallocates the tree when the document is closed. The tree
|
|
984
|
+
stays in memory until then (it can be ~MB on large PDFs, but there is
|
|
985
|
+
no persistent leak).
|
|
986
|
+
- **Deterministic lifecycle**: use the block form
|
|
987
|
+
`page.struct_tree do |tree| ... end`. On exit from the block the tree
|
|
988
|
+
is closed, even in case of an exception.
|
|
933
989
|
|
|
934
|
-
**
|
|
935
|
-
tree.
|
|
936
|
-
|
|
937
|
-
`FPDF_StructTree_Close`
|
|
938
|
-
segfault.
|
|
939
|
-
|
|
940
|
-
|
|
990
|
+
**Design choice**: we do not use `ObjectSpace.define_finalizer` for the
|
|
991
|
+
tree. The document may be closed before the tree (e.g. inside a
|
|
992
|
+
`Rpdfium.open do |doc| ... end` block), and the GC finalizer would call
|
|
993
|
+
`FPDF_StructTree_Close` on already-freed memory → use-after-free →
|
|
994
|
+
segfault. Leaving cleanup to `FPDF_CloseDocument` is always safe;
|
|
995
|
+
explicit cleanup via `tree.close` or the block form is safe as long as
|
|
996
|
+
the document is still alive.
|
|
941
997
|
|
|
942
|
-
### 24
|
|
998
|
+
### 24 missing C-level bindings added
|
|
943
999
|
|
|
944
|
-
|
|
1000
|
+
In addition to the 8 base bindings (already present):
|
|
945
1001
|
|
|
946
1002
|
- `FPDF_StructElement_GetParent`, `GetID`, `GetLang`, `GetObjType`
|
|
947
1003
|
- `FPDF_StructElement_GetActualText`, `GetAltText`, `GetExpansion`
|
|
@@ -953,69 +1009,69 @@ Oltre agli 8 binding di base (già presenti):
|
|
|
953
1009
|
`GetBooleanValue`, `GetNumberValue`, `GetStringValue`, `GetBlobValue`,
|
|
954
1010
|
`CountChildren`, `GetChildAtIndex`
|
|
955
1011
|
|
|
956
|
-
|
|
957
|
-
|
|
1012
|
+
All exposed via `Rpdfium::Raw.FPDF_*` for those who want to bypass the
|
|
1013
|
+
wrappers.
|
|
958
1014
|
|
|
959
|
-
###
|
|
1015
|
+
### Three possible states of `page.struct_tree`
|
|
960
1016
|
|
|
961
|
-
|
|
|
1017
|
+
| Case | `page.struct_tree` returns |
|
|
962
1018
|
| --- | --- |
|
|
963
|
-
| PDF
|
|
964
|
-
|
|
|
965
|
-
|
|
|
1019
|
+
| Untagged PDF | `nil` |
|
|
1020
|
+
| Tagged but empty PDF (e.g. Bank of Italy CR, 717 placeholder) | Tree with `empty? == true` |
|
|
1021
|
+
| Properly tagged PDF (Word/LibreOffice export) | Navigable tree |
|
|
966
1022
|
|
|
967
|
-
|
|
968
|
-
p1 → empty; PDF
|
|
969
|
-
|
|
1023
|
+
Verified on the 4 test PDFs: busta_paga/sample/complex → nil; cu.pdf
|
|
1024
|
+
p1 → empty; PDF generated via `soffice --convert-to pdf` → full tree
|
|
1025
|
+
with `<Document>` → nested `<P>`/`<Table>`/`<TR>`/`<TH>`/`<TD>`.
|
|
970
1026
|
|
|
971
|
-
###
|
|
1027
|
+
### Regression testing
|
|
972
1028
|
|
|
973
|
-
|
|
1029
|
+
All existing test cases continue to work:
|
|
974
1030
|
|
|
975
1031
|
- ✅ busta_paga.pdf: `1.993,00`, `COGNOME E NOME`, `NETTO BUSTA`
|
|
976
1032
|
- ✅ cu.pdf rotation 90° p1: `BANCA NAZIONALE`, `Categoria`
|
|
977
|
-
- ✅ cu.pdf p199: `Categoria` (
|
|
1033
|
+
- ✅ cu.pdf p199: `Categoria` (not `iCategora`)
|
|
978
1034
|
- ✅ sample/complex.pdf
|
|
979
1035
|
|
|
980
|
-
## [0.3.12] -
|
|
1036
|
+
## [0.3.12] - table extraction performance optimizations
|
|
981
1037
|
|
|
982
|
-
(
|
|
1038
|
+
(See the previous release notes.)
|
|
983
1039
|
|
|
984
|
-
## [0.3.11] -
|
|
1040
|
+
## [0.3.11] - `cell_padding` option for characters outside the cell border
|
|
985
1041
|
|
|
986
|
-
###
|
|
1042
|
+
### Added: `Table#extract(cell_padding: N)` to recover border-line characters
|
|
987
1043
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1044
|
+
On some PDFs (Bank of Italy CR, table headers) the first character of a
|
|
1045
|
+
cell is drawn **slightly outside** the cell's own border. For example:
|
|
1046
|
+
the capital `I` of "Intermediario:" has `x0=24.0` but the cell starts at
|
|
1047
|
+
`x=25.6` (the `I` protrudes 1.6 points to the left of the border). The
|
|
1048
|
+
midpoint filter (identical to pdfplumber) computes `h_mid = 25.25` and
|
|
1049
|
+
excludes the `I` because it is < 25.6, producing `"ntermediario:"`.
|
|
994
1050
|
|
|
995
|
-
Pdfplumber
|
|
996
|
-
|
|
997
|
-
|
|
1051
|
+
Pdfplumber has **exactly the same problem** (verified on the PDF): the
|
|
1052
|
+
midpoint filter is a common design decision. We can, however, offer a
|
|
1053
|
+
middle ground.
|
|
998
1054
|
|
|
999
|
-
###
|
|
1055
|
+
### New API
|
|
1000
1056
|
|
|
1001
1057
|
```ruby
|
|
1002
|
-
table.extract # default: pdfplumber-
|
|
1003
|
-
table.extract(cell_padding: 2.0) #
|
|
1004
|
-
#
|
|
1058
|
+
table.extract # default: pdfplumber-compatible
|
|
1059
|
+
table.extract(cell_padding: 2.0) # recover characters protruding up
|
|
1060
|
+
# to 2pt beyond the left/top borders
|
|
1005
1061
|
```
|
|
1006
1062
|
|
|
1007
|
-
`cell_padding`
|
|
1008
|
-
|
|
1009
|
-
|
|
1063
|
+
`cell_padding` extends each cell's bbox to the **left** and **upward**
|
|
1064
|
+
by N points before applying the midpoint filter. Default 0.0 = behavior
|
|
1065
|
+
identical to before (and to pdfplumber).
|
|
1010
1066
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1067
|
+
The padding is asymmetric (only the left/top borders, not right/bottom)
|
|
1068
|
+
to avoid capturing characters shared with adjacent cells: if both
|
|
1069
|
+
neighboring cells expanded on all sides, a character between them would
|
|
1070
|
+
end up in both. By limiting the padding to the "inner-left" and
|
|
1071
|
+
"inner-top" borders, a character outside the left border ends up only in
|
|
1072
|
+
the cell to its right, where it most likely belongs.
|
|
1017
1073
|
|
|
1018
|
-
###
|
|
1074
|
+
### Result on the problematic PDF
|
|
1019
1075
|
|
|
1020
1076
|
```
|
|
1021
1077
|
ext = Rpdfium::Table::Extractor.new(page, ...)
|
|
@@ -1023,848 +1079,76 @@ ext.tables.first.extract # → ["ntermediario:", "BANCA NA
|
|
|
1023
1079
|
ext.tables.first.extract(cell_padding: 2.0) # → ["Intermediario:", "BANCA NAZIONALE..."]
|
|
1024
1080
|
```
|
|
1025
1081
|
|
|
1026
|
-
###
|
|
1027
|
-
|
|
1028
|
-
Tutti i PDF di test continuano a funzionare correttamente con cell_padding
|
|
1029
|
-
default (0.0):
|
|
1030
|
-
|
|
1031
|
-
- ✅ busta_paga.pdf (numeri, parole con spazi, NETTO BUSTA)
|
|
1032
|
-
- ✅ cu.pdf pag. 1 (rotation 90°): Categoria, RISCHI AUTOLIQUIDANTI, 172.136
|
|
1033
|
-
- ✅ cu.pdf pag. 199 (rotation 0°, font piccolo): Categoria, Tipo Attività
|
|
1034
|
-
- ✅ sample.pdf (Lorem ipsum) + complex.pdf (>200k char)
|
|
1035
|
-
|
|
1036
|
-
Con `cell_padding: 2.0` su cu.pdf pag. 1:
|
|
1037
|
-
- ✅ "Intermediario:" recuperato per intero
|
|
1038
|
-
- ✅ Nessun valore numerico duplicato (172.136 appare 3 volte come atteso)
|
|
1039
|
-
|
|
1040
|
-
## [0.3.10] - bugfix: ordine char nelle celle con `top` quasi-uguale
|
|
1041
|
-
|
|
1042
|
-
### Risolto: parole scrambled tipo `iCategora` invece di `Categoria`
|
|
1043
|
-
|
|
1044
|
-
Su alcuni PDF (esempio: CR Banca d'Italia, pag. 199+ con font piccolo)
|
|
1045
|
-
le celle delle tabelle uscivano con char riordinati in modo errato:
|
|
1046
|
-
|
|
1047
|
-
| Atteso | Output errato |
|
|
1048
|
-
| ------------- | -------------------- |
|
|
1049
|
-
| `Categoria` | `iCategora` |
|
|
1050
|
-
| `Localizzazione` | `iLoca li zzazone i` |
|
|
1051
|
-
| `Tipo Attività` | `iTpo i Attvt i i à` |
|
|
1052
|
-
|
|
1053
|
-
Pattern: il char `i` (e occasionalmente altri con `x-height` sottile)
|
|
1054
|
-
veniva spostato all'inizio della parola o disperso.
|
|
1055
|
-
|
|
1056
|
-
### Causa
|
|
1057
|
-
|
|
1058
|
-
Regressione dell'ottimizzazione 0.3.9 in `WordExtractor#extract_words`.
|
|
1059
|
-
|
|
1060
|
-
L'ottimizzazione partiva dal presupposto che, dopo `chars.sort_by { |c|
|
|
1061
|
-
[c[:top], c[:x0]] }` + `Cluster.cluster_objects(:top)`, ogni cluster
|
|
1062
|
-
"riga" fosse già ordinato internamente per x0 — quindi il `row.sort_by
|
|
1063
|
-
{ |c| c[:x0] }` interno era eliminato come ridondante.
|
|
1064
|
-
|
|
1065
|
-
Il presupposto è **falso** quando due char della stessa riga visiva hanno
|
|
1066
|
-
`top` leggermente diversi (es. la `i` minuscola di `Categoria` ha
|
|
1067
|
-
`top=414.9789`, le altre lettere `top=414.9869`, differenza 0.008pt).
|
|
1068
|
-
PDFium spesso assegna alle bbox di char ascender/descender top
|
|
1069
|
-
leggermente diversi per ragioni di hinting/anti-aliasing. La
|
|
1070
|
-
differenza è invisibile graficamente ma rilevante per il sort.
|
|
1071
|
-
|
|
1072
|
-
Effetto: il sort globale `[top, x0]` mette la `i` (top minore) **prima
|
|
1073
|
-
di tutte le altre lettere** della parola, indipendentemente da x0. Il
|
|
1074
|
-
`cluster_objects` poi raggruppa tutti i char nella stessa riga (entro
|
|
1075
|
-
y_tolerance=3.0), ma non riordina internamente. Quindi all'iterazione
|
|
1076
|
-
della riga, la `i` viene letta per prima e finisce all'inizio.
|
|
1077
|
-
|
|
1078
|
-
### Fix
|
|
1079
|
-
|
|
1080
|
-
Ripristinato il `row_sorted = row.sort_by { |c| c[:x0] }` dentro il
|
|
1081
|
-
loop delle righe. L'ottimizzazione 0.3.9 era valida solo per il caso
|
|
1082
|
-
di top perfettamente identici; non lo è in generale.
|
|
1083
|
-
|
|
1084
|
-
Il costo computazionale aggiunto è marginale: un sort O(n log n) su
|
|
1085
|
-
righe corte (~50 char), dominato dall'overhead dell'FFI roundtrip per
|
|
1086
|
-
char nella fase precedente. Verificato empiricamente: tempo
|
|
1087
|
-
`extract_text` su 20 pagine di complex.pdf invariato (~80ms).
|
|
1088
|
-
|
|
1089
|
-
### Test di non-regressione
|
|
1082
|
+
### Regression testing
|
|
1090
1083
|
|
|
1091
|
-
|
|
1084
|
+
All test PDFs continue to work correctly with the default `cell_padding`
|
|
1085
|
+
(0.0):
|
|
1092
1086
|
|
|
1093
|
-
- ✅ busta_paga.pdf
|
|
1094
|
-
- ✅
|
|
1095
|
-
- ✅
|
|
1096
|
-
- ✅
|
|
1097
|
-
- ✅ **cu.pdf pag. 199** (rotation 0°, font piccolo): `Categoria`, `Localizzazione`, `Tipo Attività`, `Accordato Operativo` — tutti integri
|
|
1087
|
+
- ✅ busta_paga.pdf (numbers, words with spaces, NETTO BUSTA)
|
|
1088
|
+
- ✅ cu.pdf p. 1 (rotation 90°): Categoria, RISCHI AUTOLIQUIDANTI, 172.136
|
|
1089
|
+
- ✅ cu.pdf p. 199 (rotation 0°, small font): Categoria, Tipo Attività
|
|
1090
|
+
- ✅ sample.pdf (Lorem ipsum) + complex.pdf (>200k characters)
|
|
1098
1091
|
|
|
1099
|
-
|
|
1092
|
+
With `cell_padding: 2.0` on cu.pdf p. 1:
|
|
1100
1093
|
|
|
1101
|
-
|
|
1094
|
+
- ✅ "Intermediario:" recovered in full
|
|
1095
|
+
- ✅ No duplicated numeric value (172.136 appears 3 times, as expected)
|
|
1102
1096
|
|
|
1103
|
-
|
|
1104
|
-
ruotate 90° CW per essere visualizzate landscape ma "logicamente"
|
|
1105
|
-
portrait), `Page#chars` ritornava bbox nel sistema **raw** PDFium
|
|
1106
|
-
(pre-rotazione), mentre PDFium stesso esponeva `width`/`height`
|
|
1107
|
-
**post-rotazione**. Il mismatch causava:
|
|
1097
|
+
## [0.3.10] - bugfix: character order in cells with near-equal `top`
|
|
1108
1098
|
|
|
1109
|
-
|
|
1110
|
-
tra char della stessa parola (perché il testo era "verticale" nel
|
|
1111
|
-
sistema raw)
|
|
1112
|
-
- L'estrazione tabelle produceva celle illeggibili: testo letto
|
|
1113
|
-
carattere per carattere a rovescio (`.A/.P/.S/O/R/O/V/A/L/L/E/D/E/L/A`
|
|
1114
|
-
invece di `BANCA NAZIONALE DEL LAVORO S.P.A.`)
|
|
1115
|
-
- I segmenti di linea (`line_segments`) erano nel sistema raw, mentre
|
|
1116
|
-
i char erano (parzialmente) nel sistema post-rotation, rendendo
|
|
1117
|
-
impossibile il match cellule/contenuti
|
|
1099
|
+
### Fixed: scrambled words such as `iCategora` instead of `Categoria`
|
|
1118
1100
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
Tre interventi simmetrici per uniformare il sistema di coordinate:
|
|
1122
|
-
|
|
1123
|
-
1. **`compute_chars`**: applica la rotazione della pagina a ogni bbox di
|
|
1124
|
-
char (e all'origin point) prima di restituirli. Le coord sono ora
|
|
1125
|
-
sempre top-down nel sistema della pagina post-rotazione, allineate
|
|
1126
|
-
col rendering visivo. Coerente con pdfplumber.
|
|
1127
|
-
|
|
1128
|
-
2. **`line_segments`**: stesso trattamento agli endpoint dei segmenti.
|
|
1129
|
-
Il `build_segment` ora riceve un `rotation_ctx` invece del solo
|
|
1130
|
-
`page_h`, e trasforma entrambi i punti del segmento.
|
|
1131
|
-
|
|
1132
|
-
3. **Helper `apply_page_rotation_to_char` e `apply_page_rotation_to_point`**:
|
|
1133
|
-
centralizzano la matematica delle 4 rotazioni canoniche (0°, 90° CW,
|
|
1134
|
-
180°, 270° CW). Per rotation = 0 il comportamento è identico al pre-
|
|
1135
|
-
0.3.8 (semplice bottom-up → top-down).
|
|
1136
|
-
|
|
1137
|
-
### Verifica
|
|
1138
|
-
|
|
1139
|
-
Test di non-regressione su 4 PDF:
|
|
1140
|
-
|
|
1141
|
-
| PDF | Rotation | Risultato |
|
|
1142
|
-
| --- | -------: | --------- |
|
|
1143
|
-
| busta_paga.pdf (cedolino TeamSystem) | 0° | Invariato — tutti i valori critici (`1.993,00`, `COGNOME E NOME`, `NETTO BUSTA`) preservati |
|
|
1144
|
-
| sample.pdf | 0° | Invariato (Lorem ipsum, 2913 char) |
|
|
1145
|
-
| complex.pdf (85 pag) | 0° | Invariato (224.645 char totali) |
|
|
1146
|
-
| **cu.pdf (CR Banca d'Italia, 226 pag)** | **90° CW** | **Estrazione ora corretta** |
|
|
1147
|
-
|
|
1148
|
-
Esempio CU pagina 1 dopo il fix:
|
|
1149
|
-
|
|
1150
|
-
```
|
|
1151
|
-
=== Tabella 0 ===
|
|
1152
|
-
['ntermediario:', 'BANCA NAZIONALE DEL LAVORO S.P.A.']
|
|
1153
|
-
|
|
1154
|
-
=== Tabella 1 ===
|
|
1155
|
-
Header: ['Categoria', 'Localizzazione', 'Durata/Residua', 'Divisa',
|
|
1156
|
-
'Import Export', 'Tipo Attività', 'Stato Rapporto',
|
|
1157
|
-
'Tipo/Garanzia', 'Ruolo/Affidato', 'Accordato',
|
|
1158
|
-
'Accordato/Operativo', 'Utilizzato', 'Importo/Garantito']
|
|
1159
|
-
Row 1: ['RISCHI/AUTOLIQUIDANTI', 'TERMOLI', 'FINO A 1/ANNO', ...,
|
|
1160
|
-
'0', '172.136', '172.136', '172.136', '0']
|
|
1161
|
-
```
|
|
1162
|
-
|
|
1163
|
-
Coincide cella-per-cella con pdfplumber sullo stesso PDF.
|
|
1101
|
+
On some PDFs (example: Bank of Italy CR, p. 199+ with a small font),
|
|
1102
|
+
table cells came out with characters reordered incorrectly:
|
|
1164
1103
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
`
|
|
1169
|
-
|
|
1170
|
-
erano in coord raw (sbagliate), ora in coord post-rotation (corrette,
|
|
1171
|
-
allineate al rendering visivo). Per PDF con rotation = 0 (la stragrande
|
|
1172
|
-
maggioranza) non c'è alcuna differenza.
|
|
1104
|
+
| Expected | Wrong output |
|
|
1105
|
+
| --------------- | -------------------- |
|
|
1106
|
+
| `Categoria` | `iCategora` |
|
|
1107
|
+
| `Localizzazione`| `iLoca li zzazone i` |
|
|
1108
|
+
| `Tipo Attività` | `iTpo i Attvt i i à` |
|
|
1173
1109
|
|
|
1174
|
-
|
|
1110
|
+
Pattern: the character `i` (and occasionally others with a thin
|
|
1111
|
+
x-height) was moved to the start of the word or scattered.
|
|
1175
1112
|
|
|
1176
|
-
###
|
|
1113
|
+
### Cause
|
|
1177
1114
|
|
|
1178
|
-
|
|
1179
|
-
con `IndexError` quando un text object PDF conteneva una stringa più
|
|
1180
|
-
lunga di 128 byte (parole come `consectetuer`, `Phasellus`, frasi intere
|
|
1181
|
-
da rivista). Lo stack trace tipico:
|
|
1115
|
+
A regression of the 0.3.9 optimization in `WordExtractor#extract_words`.
|
|
1182
1116
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
page.rb:343 in block in Page#compute_chars
|
|
1188
|
-
```
|
|
1117
|
+
The optimization assumed that, after `chars.sort_by { |c| [c[:top],
|
|
1118
|
+
c[:x0]] }` + `Cluster.cluster_objects(:top)`, each "row" cluster was
|
|
1119
|
+
already internally sorted by x0 — so the inner `row.sort_by { |c|
|
|
1120
|
+
c[:x0] }` was removed as redundant.
|
|
1189
1121
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1122
|
+
The assumption is **false** when two characters on the same visual row
|
|
1123
|
+
have slightly different `top` values (e.g. the lowercase `i` of
|
|
1124
|
+
`Categoria` has `top=414.9789`, the other letters `top=414.9869`, a
|
|
1125
|
+
difference of 0.008pt). PDFium often assigns character bboxes slightly
|
|
1126
|
+
different ascender/descender tops for hinting/anti-aliasing reasons. The
|
|
1127
|
+
difference is graphically invisible but significant for the sort.
|
|
1195
1128
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
```c
|
|
1202
|
-
unsigned long FPDFTextObj_GetText(FPDF_PAGEOBJECT, FPDF_TEXTPAGE,
|
|
1203
|
-
FPDF_WCHAR* buffer, unsigned long length);
|
|
1204
|
-
```
|
|
1205
|
-
|
|
1206
|
-
dove **`length` è in BYTE** (non in count di uint16) e il return è il
|
|
1207
|
-
numero di byte **totali necessari** per scrivere il testo, anche se il
|
|
1208
|
-
buffer è troppo piccolo. Stavamo allocando 64 uint16 (= 128 byte),
|
|
1209
|
-
passando `64` come length (interpretato da PDFium come 64 BYTE = 32
|
|
1210
|
-
uint16!), e poi leggendo `(nbytes - 1) * 2` byte dal buffer dove `nbytes`
|
|
1211
|
-
era il return-value, che eccedeva il buffer allocato. Tre bug
|
|
1212
|
-
sovrapposti.
|
|
1129
|
+
Effect: the global `[top, x0]` sort places the `i` (smaller top)
|
|
1130
|
+
**before all the other letters** of the word, regardless of x0. The
|
|
1131
|
+
`cluster_objects` then groups all characters on the same row (within
|
|
1132
|
+
y_tolerance=3.0) but does not reorder internally. So when iterating over
|
|
1133
|
+
the row, the `i` is read first and ends up at the start.
|
|
1213
1134
|
|
|
1214
1135
|
### Fix
|
|
1215
1136
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
dei text obj reali).
|
|
1220
|
-
2. Se PDFium ne richiede di più (`needed > buf_capacity`), rialloco
|
|
1221
|
-
esatto e rileggo.
|
|
1222
|
-
3. Clamp finale: leggo `min(needed - 2, buf_capacity - 2)` byte, mai
|
|
1223
|
-
oltre quanto effettivamente allocato. Difesa-in-profondità.
|
|
1224
|
-
|
|
1225
|
-
Il costo extra di FFI nei casi tipici è zero (il buffer iniziale basta);
|
|
1226
|
-
solo per text obj > 256 byte serve una seconda chiamata.
|
|
1227
|
-
|
|
1228
|
-
### Bug latenti collaterali fixati
|
|
1229
|
-
|
|
1230
|
-
Stesso pattern di buffer-overrun era presente in 3 altri helper aggiunti
|
|
1231
|
-
nella 0.3.6:
|
|
1232
|
-
|
|
1233
|
-
- `read_mark_name` (buffer 128 uint16)
|
|
1234
|
-
- `read_mark_param_key` (buffer 64 uint16)
|
|
1235
|
-
- `read_mark_param_string` (buffer 256 uint16)
|
|
1236
|
-
|
|
1237
|
-
Mai stati hit in produzione perché i mark name / param sono tipicamente
|
|
1238
|
-
brevi ("Span", "Artifact", "MCID"), ma la patologia esisteva.
|
|
1239
|
-
Risolti tutti con lo stesso clamp.
|
|
1240
|
-
|
|
1241
|
-
Anche `Structure::Attachment#bytes` aveva un pattern analogo: leggeva
|
|
1242
|
-
`buf.read_bytes(out_size.read_ulong)` dopo la seconda chiamata, dove
|
|
1243
|
-
`out_size` poteva eccedere il buffer. Cambiato a `buf.read_bytes(n)`
|
|
1244
|
-
con `n` = dimensione effettivamente allocata.
|
|
1245
|
-
|
|
1246
|
-
### Test
|
|
1247
|
-
|
|
1248
|
-
Smoke test esteso su `sample.pdf`, `complex.pdf` (60 MB / 85 pagine),
|
|
1249
|
-
e `busta_paga.pdf`: tutte le API pubbliche (`chars`, `words`, `text`,
|
|
1250
|
-
`line_segments`, `mediabox`, `cropbox`, `annotations`, `images`,
|
|
1251
|
-
`marked_content_regions`, `marked_content_inventory`, `extract_tables`,
|
|
1252
|
-
`attachments`) verdi su tutti e tre i PDF.
|
|
1253
|
-
|
|
1254
|
-
Tutti i valori critici di non-regressione preservati:
|
|
1255
|
-
`1.993,00`, `2.895,26`, `COGNOME E NOME`, `MATRICOLA INPS`,
|
|
1256
|
-
`NETTO BUSTA`, Lorem ipsum su sample, 224.645 char su complex.
|
|
1257
|
-
|
|
1258
|
-
## [0.3.6] - copertura binding pubbliche PDFium
|
|
1259
|
-
|
|
1260
|
-
### Aggiunto: 52 binding pubbliche PDFium mancanti
|
|
1261
|
-
|
|
1262
|
-
L'inventario sistematico dell'API pubblica PDFium (455 simboli esportati
|
|
1263
|
-
dal binario ufficiale) ha rivelato 319 funzioni non ancora attaccate.
|
|
1264
|
-
Selezionate 52 ad alto valore per una libreria di estrazione PDF generalista,
|
|
1265
|
-
escludendo i setter (mutation), gli event handler form-fill (mouse/keyboard)
|
|
1266
|
-
e API niche (thumbnail, JS actions). Tutte sono getter e tutti i tipi
|
|
1267
|
-
ritornati sono FFI-safe.
|
|
1268
|
-
|
|
1269
|
-
Distribuzione per categoria:
|
|
1270
|
-
|
|
1271
|
-
| Categoria | Binding | Aiuta a... |
|
|
1272
|
-
| ---------------------- | ------: | ---------- |
|
|
1273
|
-
| Page geometry | 5 | Sapere mediabox/cropbox/bleed/trim/art (pdfplumber-compat) |
|
|
1274
|
-
| PageObject state | 5 | Filtrare oggetti nascosti, distinguere linee tratteggiate |
|
|
1275
|
-
| Marked Content | 9 | Raggruppare semanticamente char in PDF tagged (PDF/UA) |
|
|
1276
|
-
| Catalog/Doc metadata | 2 | Language, PageMode |
|
|
1277
|
-
| Links + hit-test | 7 | API posizionale `link_at(x, y)`, mapping link → text range |
|
|
1278
|
-
| Actions/Destinations | 6 | Outline navigation completa |
|
|
1279
|
-
| Font extras | 4 | Font data raw, glyph path vettoriale |
|
|
1280
|
-
| Text page extras | 3 | Char ↔ text index mapping per ricerca |
|
|
1281
|
-
| Annotation extras | 7 | Flags/colors/border/AP/file attachment / quad points |
|
|
1282
|
-
| Attachment metadata | 4 | Subtype, key-value custom metadata |
|
|
1137
|
+
Restored the `row_sorted = row.sort_by { |c| c[:x0] }` inside the row
|
|
1138
|
+
loop. The 0.3.9 optimization was valid only for the case of perfectly
|
|
1139
|
+
identical tops; it is not valid in general.
|
|
1283
1140
|
|
|
1284
|
-
|
|
1141
|
+
The added computational cost is marginal: an O(n log n) sort on short
|
|
1142
|
+
rows (~50 characters), dominated by the per-character FFI roundtrip
|
|
1143
|
+
overhead of the preceding phase. Empirically verified: `extract_text`
|
|
1144
|
+
time on 20 pages of complex.pdf unchanged (~80ms).
|
|
1285
1145
|
|
|
1286
|
-
|
|
1287
|
-
pdfplumber-compatibili. Ritornano tuple `[x0, top, x1, bottom]` in
|
|
1288
|
-
coordinate top-down (coerenti con `chars`, `edges`, `cells`). `cropbox`
|
|
1289
|
-
fa fallback automatico su mediabox se assente, come prescrive PDF spec
|
|
1290
|
-
14.11.2. Ritornano `nil` se il box non è definito.
|
|
1291
|
-
|
|
1292
|
-
- **`Page#marked_content_regions`** → Hash `{mcid => [page_objects]}`.
|
|
1293
|
-
Raggruppa gli oggetti per Marked Content ID. Vuoto su PDF non-tagged
|
|
1294
|
-
(gestionali italiani); su PDF tagged è il modo più affidabile di
|
|
1295
|
-
ottenere unità semantiche (paragrafi, span, celle tabella).
|
|
1146
|
+
### Regression testing
|
|
1296
1147
|
|
|
1297
|
-
|
|
1298
|
-
`:mark_name`, `:params`. Per inspection di Tagged PDF (nomi tipici:
|
|
1299
|
-
"Span", "P", "TR", "TD", "Artifact", "Figure").
|
|
1148
|
+
All test PDFs continue to work correctly:
|
|
1300
1149
|
|
|
1301
|
-
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
-
|
|
1306
|
-
— nuovo flag `include_dashed`. **Default cambiato a `false`**: le
|
|
1307
|
-
linee tratteggiate sono spesso "guide non-printing" che confondono la
|
|
1308
|
-
detection di cellule tabella. Chi le vuole esplicitamente (drawing
|
|
1309
|
-
extraction completo) passa `include_dashed: true`. I segment hanno
|
|
1310
|
-
ora il campo `:dashed` (bool).
|
|
1311
|
-
|
|
1312
|
-
- **PageObject inactive automaticamente skippati** in line_segments:
|
|
1313
|
-
oggetti con Optional Content disabilitato non finiscono più nell'output.
|
|
1314
|
-
Su PDF normali (sempre attivi) il comportamento è invariato.
|
|
1315
|
-
|
|
1316
|
-
### Bug fix collaterali
|
|
1317
|
-
|
|
1318
|
-
- Rimossa duplicazione di `FPDFText_GetMatrix` (era attached due volte;
|
|
1319
|
-
FFI dava warning ma una sola definizione era effettiva). La binding
|
|
1320
|
-
resta solo nella sezione Text page (riga ~351 di `raw.rb`).
|
|
1321
|
-
- Tutti gli helper `read_*` per marked content sono in `begin/rescue
|
|
1322
|
-
Rpdfium::LoadError` per supportare build PDFium più vecchi senza
|
|
1323
|
-
introdurre regressioni.
|
|
1324
|
-
|
|
1325
|
-
### Casi border-line text extraction
|
|
1326
|
-
|
|
1327
|
-
**Non risolti** sui PDF da gestionali italiani (TeamSystem, Zucchetti):
|
|
1328
|
-
parole come `Sede pr i nc` (`Sede principale`), `Imp i ega to`
|
|
1329
|
-
(`Impiegato`), `IMPONIBILE INAILMESE` (`IMPONIBILE INAIL MESE`) restano
|
|
1330
|
-
spezzate o fuse perché il content stream PDF emette quei char con
|
|
1331
|
-
kerning interno (operatori `TJ` con valori intermedi) che PDFium consuma
|
|
1332
|
-
internamente per il rendering ma non espone via API C pubblica.
|
|
1333
|
-
|
|
1334
|
-
Le binding `FPDFDICT_*` che permetterebbero di accedere al content stream
|
|
1335
|
-
raw (e ottenere il kerning, come fa pdfminer.six) **non esistono nel
|
|
1336
|
-
PDFium ufficiale di Google/Chromium**. Esistono solo nel fork commerciale
|
|
1337
|
-
Pdfium.NET di Patagames Software, non utilizzabile sotto licenza
|
|
1338
|
-
open-source. Le 421 simboli `FPDF*` esportati dal binario bblanchon
|
|
1339
|
-
sono stati verificati: nessun `FPDFDICT_*`.
|
|
1340
|
-
|
|
1341
|
-
I marked content (`FPDFPageObj_GetMark` / `CountMarks`) **sono** la via
|
|
1342
|
-
ufficiale per accedere alla struttura semantica, ma richiedono che il
|
|
1343
|
-
PDF sia stato generato come Tagged PDF. I PDF da gestionali italiani
|
|
1344
|
-
non lo sono. Per PDF da Word/InDesign/InEsign-style tagged, le nuove
|
|
1345
|
-
API ora coprono il caso.
|
|
1346
|
-
|
|
1347
|
-
## [0.3.5] - Ottimizzazioni
|
|
1348
|
-
|
|
1349
|
-
### Migliorato: ridotta computazione e semplificati branch condizionali
|
|
1350
|
-
|
|
1351
|
-
## [0.3.4] - advance del glifo, identità text-object, segnale fine-token
|
|
1352
|
-
|
|
1353
|
-
### Aggiunto: bindings e nuove proprietà sui char
|
|
1354
|
-
|
|
1355
|
-
Tre binding PDFium fondamentali che mancavano:
|
|
1356
|
-
|
|
1357
|
-
- **`FPDFFont_GetGlyphWidth(font, glyph_cp, font_size, *float)`** — larghezza
|
|
1358
|
-
nominale del glifo nel font program. Equivale concettualmente alla
|
|
1359
|
-
metric che pdfminer.six legge dal font dictionary del PDF.
|
|
1360
|
-
|
|
1361
|
-
- **`FPDFFont_GetAscent` / `FPDFFont_GetDescent`** — metriche font in
|
|
1362
|
-
unità del font program, utili per baseline e leading detection.
|
|
1363
|
-
|
|
1364
|
-
- **`FPDFText_GetMatrix(textpage, char_index, *FS_MATRIX)`** — matrice di
|
|
1365
|
-
trasformazione (CTM) applicata al char. Componente `:a` è la scala
|
|
1366
|
-
orizzontale font→pagina.
|
|
1367
|
-
|
|
1368
|
-
Queste binding sono ora esposte come API pubbliche di `Rpdfium::Raw` ed
|
|
1369
|
-
utilizzate internamente per arricchire ogni char con tre nuove proprietà:
|
|
1370
|
-
|
|
1371
|
-
| Proprietà | Tipo | Significato |
|
|
1372
|
-
| -------------------------- | -------- | ----------- |
|
|
1373
|
-
| `:advance` | Float? | Larghezza nominale del glifo in coordinate pagina, calcolata come `glyph_width × |CTM.a|`. Più stabile della `bbox_width` per char con kerning post-applied. |
|
|
1374
|
-
| `:text_obj_id` | Integer? | Identificatore stabile (pointer address) del text object contenente questo char. Tutti i char dello stesso text obj condividono lo stesso ID — utile per raggruppare semanticamente char correlati a livello content-stream. |
|
|
1375
|
-
| `:text_obj_ends_with_space` | bool? | True se il content stream PDF ha emesso uno spazio finale dopo questo char (es. fine di un token testuale). Segnale di "fine token" dichiarato dal PDF — non sempre coincidente con fine parola visiva, ma utile come indizio. |
|
|
1376
|
-
|
|
1377
|
-
### Migliorato: rebuild_word_separators usa i nuovi segnali
|
|
1378
|
-
|
|
1379
|
-
`Page#chars` (con `inject_spaces: true`, default) ora ricostruisce i word
|
|
1380
|
-
boundary combinando:
|
|
1381
|
-
|
|
1382
|
-
1. **Veto duro**: se `prev[:text_obj_ends_with_space] == false`, nessuno
|
|
1383
|
-
spazio viene inserito anche con gap geometrico grande. È kerning
|
|
1384
|
-
interno a un token dichiarato dal PDF.
|
|
1385
|
-
|
|
1386
|
-
2. **Threshold dinamica**: per i candidati ammessi (prev fine-token o
|
|
1387
|
-
segnale assente), uso soglia geometrica `gap > 0.3 × max_advance`
|
|
1388
|
-
come default, alzata a `0.7 × max_advance` se il contesto è numerico
|
|
1389
|
-
(cifre o punteggiatura `.`/`,`). Questa euristica preserva i numeri
|
|
1390
|
-
`2.895,26`/`1.993,00` interi mentre recupera la maggior parte degli
|
|
1391
|
-
spazi tra parole.
|
|
1392
|
-
|
|
1393
|
-
### Confronto con pdfplumber sul PDF di test
|
|
1394
|
-
|
|
1395
|
-
Recuperi netti rispetto alla 0.3.3:
|
|
1396
|
-
|
|
1397
|
-
| Cella | rpdfium 0.3.3 | rpdfium 0.3.4 | pdfplumber |
|
|
1398
|
-
| ---------------------- | ------------: | ------------: | ---------: |
|
|
1399
|
-
| COGNOME E NOME | `COGNOME ENOME` | `COGNOME E NOME` ✓ | `COGNOME E NOME` |
|
|
1400
|
-
| MATRICOLA INPS | `MATRICOLAINPS` | `MATRICOLA INPS` ✓ | `MATRICOLA INPS` |
|
|
1401
|
-
| POSIZIONE INAIL | `POSIZIONE INAIL` ✓ | `POSIZIONE INAIL` ✓ | `POSIZIONE INAIL` |
|
|
1402
|
-
| DATA NASCITA | `DATANASCITA` | `DATA NASCITA` ✓ | `DATA NASCITA` |
|
|
1403
|
-
| CODICE FISCALE | `CODICE FISCALE` ✓ | `CODICE FISCALE` ✓ | `CODICE FISCALE` |
|
|
1404
|
-
| COMUNE DI RESIDENZA | `COMUNEDI RESIDENZA` | `COMUNE DI RESIDENZA` ✓ | `COMUNE DI RESIDENZA` |
|
|
1405
|
-
| DATA ASSUNZIONE | `DATAASSUNZIONE` | `DATA ASSUNZIONE` ✓ | `DATA ASSUNZIONE` |
|
|
1406
|
-
| QUALIFICA INPS | `QUALIFICAINPS` | `QUALIFICA INPS` ✓ | `QUALIFICA INPS` |
|
|
1407
|
-
| TIPO RAPPORTO | `TIPORAPPORTO` | `TIPO RAPPORTO` ✓ | `TIPO RAPPORTO` |
|
|
1408
|
-
| RETR. DI FATTO | `RETR.DI FATTO` | `RETR. DI FATTO` ✓ | `RETR. DI FATTO` |
|
|
1409
|
-
| CCNL APPLICATO | `CCNLAPPLICATO` | `CCNL APPLICATO` ✓ | `CCNL APPLICATO` |
|
|
1410
|
-
| ADD. REG. ANNO DOVUTA | `ADD. REG.ANNODOVUTA` | `ADD. REG. ANNO DOVUTA` ✓ | `ADD. REG. ANNO DOVUTA` |
|
|
1411
|
-
| ADD. COM. ANNO DOVUTA | `ADD. COM.ANNODOVUTA` | `ADD. COM. ANNO DOVUTA` ✓ | `ADD. COM. ANNO DOVUTA` |
|
|
1412
|
-
| BONUS IRPEF ANNO | `BONUS IRPEFANNO` | `BONUS IRPEF ANNO` ✓ | `BONUS IRPEF ANNO` |
|
|
1413
|
-
| 2.857,15 (e altri num) | `2.857,15` ✓ | `2.857,15` ✓ | `2.857,15` |
|
|
1414
|
-
|
|
1415
|
-
Casi border-line residui (PDFium non emette il segnale fine-token):
|
|
1416
|
-
`Sede pr i nc`, `Imp i ega to`, `IMPONIBILE INAILMESE`. Pdfminer.six li
|
|
1417
|
-
gestisce perché legge gli operatori `TJ` con kerning dal content stream
|
|
1418
|
-
raw, info che PDFium consuma internamente e non espone via API pubblica.
|
|
1419
|
-
|
|
1420
|
-
### Test
|
|
1421
|
-
|
|
1422
|
-
- 30 unit test + 8 test di integrazione su PDF reale, tutti verdi.
|
|
1423
|
-
- Nuovi test: presenza di `:advance`, `:text_obj_id`,
|
|
1424
|
-
`:text_obj_ends_with_space` su char reali.
|
|
1425
|
-
|
|
1426
|
-
## [0.3.3] - ricostruzione word boundary geometry-based
|
|
1427
|
-
|
|
1428
|
-
### Risolto
|
|
1429
|
-
|
|
1430
|
-
**`Page#chars` ricostruisce gli spazi tra parole basandosi sulla geometria
|
|
1431
|
-
dei char**, invece di affidarsi agli spazi sintetici di PDFium (che sono
|
|
1432
|
-
inaffidabili: PDFium li emette aggressivamente anche tra cifre di numeri).
|
|
1433
|
-
|
|
1434
|
-
#### Perché era un problema
|
|
1435
|
-
|
|
1436
|
-
PDFium ha due comportamenti patologici sui spazi sintetici:
|
|
1437
|
-
|
|
1438
|
-
1. **Bbox degenere**: gli spazi tra parole hanno `top == bottom == baseline`,
|
|
1439
|
-
non in linea con i char circostanti. Il cluster per riga in
|
|
1440
|
-
`extract_text` li scartava, e parole adiacenti come `COGNOME E NOME`
|
|
1441
|
-
si fondevano in `COGNOMEENOME`.
|
|
1442
|
-
|
|
1443
|
-
2. **Falsi positivi sui numeri**: PDFium inserisce uno spazio sintetico
|
|
1444
|
-
tra OGNI cifra e la punteggiatura di un numero. `2.895,26` aveva
|
|
1445
|
-
spazi tra `2/.`, `./8`, `5/,`, `,/2`. Se li accettavamo, l'output
|
|
1446
|
-
diventava `2 . 895 , 26`.
|
|
1447
|
-
|
|
1448
|
-
#### La fix
|
|
1449
|
-
|
|
1450
|
-
Ho buttato via tutti gli spazi sintetici di PDFium e ricostruito i
|
|
1451
|
-
word boundary basandomi solo sulla geometria dei char "veri":
|
|
1452
|
-
`gap > 0.4 × max(prev_width, next_width)` → spazio. La soglia 0.4 con
|
|
1453
|
-
`max_w` (non `avg_w`) è cruciale: i char di punteggiatura come `.` e `,`
|
|
1454
|
-
sono più stretti delle cifre, e usare la media gonfierebbe i ratio dei
|
|
1455
|
-
gap intra-numero. Usando il max delle due larghezze, il numero
|
|
1456
|
-
`2.895,26` ha tutti i gap intra-numero con ratio < 0.35, mentre i veri
|
|
1457
|
-
gap inter-parola hanno ratio > 0.45.
|
|
1458
|
-
|
|
1459
|
-
Soglia 0.4 tarata empiricamente sui dati TeamSystem reali (1400 casi
|
|
1460
|
-
intra + 663 inter), con classificazione corretta al 100% sui casi
|
|
1461
|
-
non-borderline.
|
|
1462
|
-
|
|
1463
|
-
#### Confronto col PDF di test
|
|
1464
|
-
|
|
1465
|
-
| Cella | rpdfium 0.3.2 | rpdfium 0.3.3 | pdfplumber |
|
|
1466
|
-
| ------------------------ | ------------: | ------------: | ----------: |
|
|
1467
|
-
| Imponibile IRPEF Mese | `2.618,84` | `2.618,84` | `2.618,84` |
|
|
1468
|
-
| Netto Busta | `NETTOBUSTA/1.993,00` | `NETTOBUSTA/1.993,00` | `NETTO BUSTA/1.993,00` |
|
|
1469
|
-
| COGNOME E NOME | `COGNOMEENOME` | `COGNOME ENOME` | `COGNOME E NOME` |
|
|
1470
|
-
| MATRICOLA INPS | `MATRICOLAINPS` | `MATRICOLAINPS` | `MATRICOLA INPS` |
|
|
1471
|
-
| POSIZIONE INAIL | `POSIZIONEINAIL` | `POSIZIONE INAIL` | `POSIZIONE INAIL` |
|
|
1472
|
-
| RETR. DI FATTO | `RETR.DIFATTO` | `RETR.DI FATTO` | `RETR. DI FATTO` |
|
|
1473
|
-
| GIORNO DI RIPOSO | `GIORNODIRIPOSO` | `GIORNO DI RIPOSO` | `GIORNO DI RIPOSO` |
|
|
1474
|
-
| ONERI DED. | `ONERIDED.` | `ONERIDED.` | `ONERI DED.` |
|
|
1475
|
-
|
|
1476
|
-
La 0.3.3 recupera la maggior parte degli spazi inter-parola (vedi
|
|
1477
|
-
`POSIZIONE INAIL`, `GIORNO DI RIPOSO`, `RETR.DI FATTO`). Restano persi
|
|
1478
|
-
alcuni casi border-line dove il gap visivo è genuinamente piccolo
|
|
1479
|
-
(`COGNOME E NOME` che ha `E` molto vicina a `NOME`). Questi sono al
|
|
1480
|
-
limite delle possibilità di un algoritmo geometrico puro: pdfminer
|
|
1481
|
-
risolve usando l'advance del font dal content stream PDF, info non
|
|
1482
|
-
esposta da PDFium.
|
|
1483
|
-
|
|
1484
|
-
### API
|
|
1485
|
-
|
|
1486
|
-
- **`Page#chars(inject_spaces: true)` ora è il default**. Chi vuole il
|
|
1487
|
-
comportamento "raw PDFium" (tutti i char inclusi gli spazi sintetici
|
|
1488
|
-
aggressivi) passa `inject_spaces: false`.
|
|
1489
|
-
- Il vecchio metodo privato `inject_synthetic_spaces` è stato rimosso e
|
|
1490
|
-
rimpiazzato da `rebuild_word_separators` (più descrittivo del nuovo
|
|
1491
|
-
approccio).
|
|
1492
|
-
|
|
1493
|
-
## [0.3.2] - punteggiatura preservata nelle celle tabellari
|
|
1494
|
-
|
|
1495
|
-
### Risolto
|
|
1496
|
-
|
|
1497
|
-
**`Page#chars` ora ritorna bbox "loose" di default** (`loose: true`),
|
|
1498
|
-
allineando il comportamento a quello di `pdfminer.six`. Le bbox loose
|
|
1499
|
-
sono uniformi per riga: tutti i char della stessa linea logica condividono
|
|
1500
|
-
top/bottom proporzionali alla font-size, invece dei tight glyph box che
|
|
1501
|
-
PDFium darebbe nativamente.
|
|
1502
|
-
|
|
1503
|
-
#### Perché era un problema
|
|
1504
|
-
|
|
1505
|
-
Le bbox tight rispettano il singolo glifo. Un `.` (punto decimale) ha
|
|
1506
|
-
una bbox alta ~0.85pt, mentre un `5` accanto ne ha ~7pt sulla stessa
|
|
1507
|
-
linea. I loro midpoint verticali differiscono di ~3pt — quanto basta a
|
|
1508
|
-
far cadere il `.` fuori dalla bbox cella nel filtro `Table#extract`,
|
|
1509
|
-
che usa il midpoint per decidere quali char appartengono alla cella
|
|
1510
|
-
(stessa scelta di pdfplumber).
|
|
1511
|
-
|
|
1512
|
-
Effetto sul cedolino TeamSystem: valori come `1.993,00`, `2.857,15`,
|
|
1513
|
-
`7.788,60` venivano estratti come `1 993 00`, `2 857 15`, `7 788 60` —
|
|
1514
|
-
la punteggiatura cadeva fuori. Con loose box, tutti i char della riga
|
|
1515
|
-
hanno lo stesso midpoint verticale, e i punti/virgole arrivano dentro
|
|
1516
|
-
la cella.
|
|
1517
|
-
|
|
1518
|
-
#### Confronto con pdfplumber
|
|
1519
|
-
|
|
1520
|
-
Sul cedolino di test `busta_paga.pdf`:
|
|
1521
|
-
|
|
1522
|
-
| Cella | rpdfium 0.3.1 | rpdfium 0.3.2 | pdfplumber |
|
|
1523
|
-
| -------------------- | ------------: | ------------: | ---------: |
|
|
1524
|
-
| Netto Busta | `1 993 00` | `1.993,00` | `1.993,00` |
|
|
1525
|
-
| Imponibile IRPEF MESE | `2 618 84` | `2.618,84` | `2.618,84` |
|
|
1526
|
-
| TFR Spettante | `3 446 15` | `3.446,15` | `3.446,15` |
|
|
1527
|
-
| Retr. di Fatto | `2 857 15` | `2.857,15` | `2.857,15` |
|
|
1528
|
-
|
|
1529
|
-
### Aggiunto
|
|
1530
|
-
|
|
1531
|
-
- **`Page#chars(inject_spaces: true)`**: opt-in che inietta spazi
|
|
1532
|
-
sintetici nei gap orizzontali significativi (gap > 0.85 × char width)
|
|
1533
|
-
della stessa riga. Approssima il comportamento di pdfminer.six per
|
|
1534
|
-
parole adiacenti che PDFium fonderebbe per via del kerning. Può
|
|
1535
|
-
produrre falsi positivi su font condensati. **Default `false`**:
|
|
1536
|
-
preferiamo "non spezzare parole valide" rispetto a "catturare ogni
|
|
1537
|
-
spazio mancante", in linea con la filosofia "quello che PDFium emette
|
|
1538
|
-
è la verità".
|
|
1539
|
-
|
|
1540
|
-
- Helper privato `Page#inject_synthetic_spaces` esposto come API
|
|
1541
|
-
pubblica per chi vuole post-processare i char.
|
|
1542
|
-
|
|
1543
|
-
- Cache di `Page#chars` per (loose, inject_spaces): ricostruire
|
|
1544
|
-
l'array di char è O(n) di chiamate FFI, costoso su pagine grosse.
|
|
1545
|
-
|
|
1546
|
-
### Limitazioni note
|
|
1547
|
-
|
|
1548
|
-
- Sul cedolino di test, `inject_spaces: true` recupera ~80% degli
|
|
1549
|
-
spazi inter-parola persi (es. `NETTO BUSTA`), ma introduce qualche
|
|
1550
|
-
falso positivo (es. `Sede pr incipale`). Questo è un trade-off
|
|
1551
|
-
intrinseco di PDFium che non espone l'advance del font dal content
|
|
1552
|
-
stream, l'unica metrica davvero affidabile per decidere "spazio o no".
|
|
1553
|
-
Per estrazione testuale che richiede spazi perfetti, considerare
|
|
1554
|
-
pdfminer.six (e quindi pdfplumber); per estrazione tabellare con
|
|
1555
|
-
punteggiatura preservata, rpdfium è ora allineato.
|
|
1556
|
-
|
|
1557
|
-
## [0.3.1] - discesa nei Form XObjects
|
|
1558
|
-
|
|
1559
|
-
### Risolto
|
|
1560
|
-
|
|
1561
|
-
**`Page#line_segments` ora discende ricorsivamente nei Form XObjects**
|
|
1562
|
-
applicando la matrice di trasformazione affine che li posiziona nello spazio
|
|
1563
|
-
pagina. Prima di questa fix, su PDF dove la grafica della pagina era
|
|
1564
|
-
incapsulata in un singolo Form XObject (PDF generati da TeamSystem,
|
|
1565
|
-
Zucchetti e altri gestionali italiani; molti template Word/Excel),
|
|
1566
|
-
`line_segments` ritornava un Array vuoto anche se visivamente la pagina
|
|
1567
|
-
era piena di linee e bordi cella.
|
|
1568
|
-
|
|
1569
|
-
Conseguenza diretta: `Page#vertical_lines`, `Page#horizontal_lines`, e la
|
|
1570
|
-
strategia `:lines` di `Table::Extractor` ora funzionano correttamente su
|
|
1571
|
-
questi PDF.
|
|
1572
|
-
|
|
1573
|
-
Per il cedolino TeamSystem di test (`busta_paga.pdf`), i numeri:
|
|
1574
|
-
|
|
1575
|
-
| Metrica | prima 0.3.1 | dopo 0.3.1 | pdfplumber |
|
|
1576
|
-
| ----------------- | ----------: | ---------: | ---------: |
|
|
1577
|
-
| line_segments | 0 | 525 | 420 (\*) |
|
|
1578
|
-
| horizontal_lines | 0 | 375 | 210 |
|
|
1579
|
-
| vertical_lines | 0 | 437 | 210 |
|
|
1580
|
-
| tabelle estratte | 1 (\*\*) | 1 | 1 |
|
|
1581
|
-
| dimensione tab | 1×N nonsens | 28×44 | 28×44 |
|
|
1582
|
-
|
|
1583
|
-
(\*) pdfplumber decompone i 105 rettangoli in 4 lati ciascuno =
|
|
1584
|
-
420 edges; rpdfium attualmente li conta con duplicazione (le 4 linee del
|
|
1585
|
-
contour + il close-path), per questo 525 invece di 420. La detection di
|
|
1586
|
-
celle non ne risente perché lo snap+join collassa i duplicati.
|
|
1587
|
-
(\*\*) Senza linee, rpdfium 0.3.0 con strategia `:lines` non trovava
|
|
1588
|
-
tabelle e cadeva nel fallback `:text`, producendo una "tabella" gigantesca
|
|
1589
|
-
che copriva l'intera pagina.
|
|
1590
|
-
|
|
1591
|
-
### Aggiunto
|
|
1592
|
-
|
|
1593
|
-
- Bindings `FPDFFormObj_CountObjects` e `FPDFFormObj_GetObject` per
|
|
1594
|
-
iterare i child di un Form XObject.
|
|
1595
|
-
- Helpers privati `compose_matrix`, `apply_matrix`, `read_object_matrix`
|
|
1596
|
-
per la composizione di trasformazioni affini PDF.
|
|
1597
|
-
- Test di integrazione su PDF reale (`busta_paga.pdf`) che verifica:
|
|
1598
|
-
numero minimo di line_segments, struttura della tabella anagrafica,
|
|
1599
|
-
conteggio chars nel range atteso.
|
|
1600
|
-
|
|
1601
|
-
### Compatibilità
|
|
1602
|
-
|
|
1603
|
-
- Nessuna API breaking. `line_segments` mantiene la stessa firma e lo
|
|
1604
|
-
stesso formato di output.
|
|
1605
|
-
- I PDF già funzionanti in 0.3.0 (con grafica top-level) continuano a
|
|
1606
|
-
funzionare identici: la discesa nei Form XObjects parte dal CTM
|
|
1607
|
-
identità, quindi a livello top non c'è cambiamento di coordinate.
|
|
1608
|
-
|
|
1609
|
-
## [0.3.0] - estrazione tabelle riallineata 1:1 a pdfplumber
|
|
1610
|
-
|
|
1611
|
-
### Riscritto da zero
|
|
1612
|
-
|
|
1613
|
-
L'intero pipeline tabellare è stato riscritto seguendo il sorgente di
|
|
1614
|
-
[pdfplumber/table.py](https://github.com/jsvine/pdfplumber/blob/stable/pdfplumber/table.py)
|
|
1615
|
-
e [pdfplumber/utils/text.py](https://github.com/jsvine/pdfplumber/blob/stable/pdfplumber/utils/text.py).
|
|
1616
|
-
La versione 0.2.x aveva una serie di approssimazioni che producevano errori
|
|
1617
|
-
sistematici di estrazione su PDF con layout free-form (es. cedolini
|
|
1618
|
-
TeamSystem). I bug fix specifici:
|
|
1619
|
-
|
|
1620
|
-
1. **`words_to_edges_v` clusterizza ora tre coordinate (`x0`, `x1`, centro)**
|
|
1621
|
-
invece di solo `x0`. Le colonne numeriche right-aligned (importi
|
|
1622
|
-
`1.234,56` allineati a destra) erano invisibili al clustering basato su
|
|
1623
|
-
`x0`. Aggiunta dedupe per overlap di bbox: cluster sovrapposti tengono
|
|
1624
|
-
solo il più popolato.
|
|
1625
|
-
|
|
1626
|
-
2. **`words_to_edges_h` emette DUE edges per riga** (top + bottom della
|
|
1627
|
-
bbox del cluster). Senza il bottom edge, l'ultima riga di una tabella
|
|
1628
|
-
rilevata da text-strategy non veniva mai chiusa.
|
|
1629
|
-
|
|
1630
|
-
3. **`intersections_to_cells` usa l'algoritmo `find_smallest_cell`** di
|
|
1631
|
-
pdfplumber con verifica `edge_connect` su identità d'oggetto degli
|
|
1632
|
-
edge, non su sole coordinate. Due intersezioni con la stessa `x` ma
|
|
1633
|
-
appartenenti a edge verticali distinti non producono più cella spuria.
|
|
1634
|
-
|
|
1635
|
-
4. **`cells_to_tables` usa il fixed-point su corner condivisi**, non più
|
|
1636
|
-
adjacency check coordinate-based. Filtro single-cell per scartare rumore.
|
|
1637
|
-
|
|
1638
|
-
5. **Estrazione testo da cella usa midpoint del char**, non bbox-clip via
|
|
1639
|
-
`FPDFText_GetBoundedText`. Il midpoint è il criterio identico di
|
|
1640
|
-
pdfplumber, e risolve la concatenazione cross-cell ("RETRIBUZIONEUTILE")
|
|
1641
|
-
che si verificava su PDF dove i char di celle adiacenti hanno bbox
|
|
1642
|
-
leggermente sovrapposti.
|
|
1643
|
-
|
|
1644
|
-
### Aggiunto
|
|
1645
|
-
|
|
1646
|
-
- **`Rpdfium::Util::Cluster`** (nuovo modulo): primitive di clustering 1D
|
|
1647
|
-
agglomerativo single-linkage usate da tutto il pipeline (`cluster_list`,
|
|
1648
|
-
`cluster_objects`, `objects_to_bbox`, `bbox_overlap`).
|
|
1649
|
-
|
|
1650
|
-
- **`Rpdfium::Util::WordExtractor`** (nuova classe): estrazione words da
|
|
1651
|
-
char fedele a `pdfplumber.WordExtractor`. Supporta `x_tolerance`,
|
|
1652
|
-
`y_tolerance`, `keep_blank_chars`, `extra_attrs` (split su cambio
|
|
1653
|
-
font/size).
|
|
1654
|
-
|
|
1655
|
-
- **`Rpdfium::Util::TextExtraction.extract_text`** (nuovo modulo): converte
|
|
1656
|
-
un Array di char in stringa, raggruppando per riga via clustering del
|
|
1657
|
-
`top` e per parola via gap orizzontale > x_tolerance. Equivalente a
|
|
1658
|
-
`pdfplumber.utils.text.extract_text(layout=False)`.
|
|
1659
|
-
|
|
1660
|
-
- **`Rpdfium::Table::Table`** (nuova classe): rappresenta una tabella
|
|
1661
|
-
estratta. Espone `.cells`, `.rows`, `.columns`, `.bbox`, `.extract`.
|
|
1662
|
-
L'API combacia con `pdfplumber.table.Table`.
|
|
1663
|
-
|
|
1664
|
-
- **`edge_min_length_prefilter`** (default 1.0): filtra edges troppo corti
|
|
1665
|
-
prima dello snap+join, per ridurre rumore da micro-segmenti vettoriali.
|
|
1666
|
-
|
|
1667
|
-
- **`Rpdfium.extract_tables(..., keep_blank_rows: false)`** filtra di
|
|
1668
|
-
default le righe completamente vuote che la strategia `:text` produce
|
|
1669
|
-
per costruzione (effetto del doppio edge top+bottom).
|
|
1670
|
-
|
|
1671
|
-
### API: breaking changes minori
|
|
1672
|
-
|
|
1673
|
-
- Le strategy del `Extractor` validano l'input: `vertical_strategy` e
|
|
1674
|
-
`horizontal_strategy` accettano solo `:lines` / `:lines_strict` /
|
|
1675
|
-
`:text` / `:explicit`. Valori invalidi alzano `ArgumentError`.
|
|
1676
|
-
|
|
1677
|
-
- L'oggetto restituito da `Extractor#tables` (e dall'alias `find`) non è
|
|
1678
|
-
più un Hash con `:bbox`/`:rows`/`:cols`/`:grid`, ma un'istanza di
|
|
1679
|
-
`Rpdfium::Table::Table`. Chi usa `Rpdfium.extract_tables` (top-level)
|
|
1680
|
-
vede solo strutture base (Hash con `:page` e `:rows`), invariato.
|
|
1681
|
-
|
|
1682
|
-
- `Edges.snap_horizontal` / `Edges.snap_vertical` / `Edges.join_horizontal`
|
|
1683
|
-
/ `Edges.join_vertical` / `Edges.intersections` (firma vecchia) /
|
|
1684
|
-
`Cells.from_intersections` / `Cells.group_into_tables` rimossi. I
|
|
1685
|
-
rimpiazzi sono `snap_edges`, `join_edge_group`, `merge_edges`,
|
|
1686
|
-
`filter_edges`, `edges_to_intersections`, `intersections_to_cells`,
|
|
1687
|
-
`cells_to_tables` con segnature 1:1 da pdfplumber.
|
|
1688
|
-
|
|
1689
|
-
### Fix lifecycle (race finalizer)
|
|
1690
|
-
|
|
1691
|
-
Document/Page/TextPage/Annotation/Search/Form::Environment usano ora un
|
|
1692
|
-
**state Hash condiviso tra istanza e finalizer**. Tre proprietà
|
|
1693
|
-
acquisite:
|
|
1694
|
-
|
|
1695
|
-
- **Idempotenza** (`@state[:closed]` flag): nessuna doppia chiamata a
|
|
1696
|
-
`FPDF_CloseDocument`/`FPDF_ClosePage`/etc anche se sia `close()`
|
|
1697
|
-
esplicito che il GC partono.
|
|
1698
|
-
- **No-leak della closure**: il finalizer cattura un Hash, non `self`.
|
|
1699
|
-
L'istanza può essere raccolta liberamente dal GC.
|
|
1700
|
-
- **Disarmo esplicito**: `close()` chiama `ObjectSpace.undefine_finalizer`
|
|
1701
|
-
per impedire qualsiasi esecuzione tardiva del finalizer su un handle
|
|
1702
|
-
già liberato.
|
|
1703
|
-
|
|
1704
|
-
Risolve il segfault `FPDF_CloseDocument` durante introspezione del
|
|
1705
|
-
debugger su una collection di tabelle (riportato dall'utente con
|
|
1706
|
-
ruby-debug-ide).
|
|
1707
|
-
|
|
1708
|
-
### Fix `candidate_paths` (FFI)
|
|
1709
|
-
|
|
1710
|
-
Su macOS, FFI auto-appendeva `.dylib` a path `.so`, causando il fallimento
|
|
1711
|
-
del caricamento. Ora `candidate_paths` filtra i nomi di sistema per OS
|
|
1712
|
-
host: solo `.dylib` su macOS, solo `.so` su Linux, solo `.dll` su Windows.
|
|
1713
|
-
Inoltre se `ENV["PDFIUM_LIBRARY_PATH"]` o `Rpdfium::Binary.library_path`
|
|
1714
|
-
è impostato, viene usato come unico path: nessun fallback automatico.
|
|
1715
|
-
|
|
1716
|
-
### Test
|
|
1717
|
-
|
|
1718
|
-
- 30 unit test (60 asserzioni) coprono cluster primitives, word
|
|
1719
|
-
extraction, edges (snap/join/filter/intersections/words_to_edges_v/h),
|
|
1720
|
-
cells (smallest-cell + edge identity check), table (rows/columns/bbox/
|
|
1721
|
-
extract con midpoint), extractor end-to-end con FakePage, regressione
|
|
1722
|
-
TeamSystem (no più cross-cell concatenation; words_to_edges_v sui dati
|
|
1723
|
-
reali di un cedolino italiano in formato TeamSystem).
|
|
1724
|
-
|
|
1725
|
-
## [0.2.1] - allineamento PDFium chromium/6611+
|
|
1726
|
-
|
|
1727
|
-
### Cambiato
|
|
1728
|
-
|
|
1729
|
-
- **`FPDFText_GetTextRenderMode(text_page, char_index)` rimossa dalle
|
|
1730
|
-
bindings.** Era stata rimossa upstream da PDFium in chromium/6611
|
|
1731
|
-
(luglio 2024) — chiamarla causa `undefined symbol` con i build recenti
|
|
1732
|
-
di pdfium-binaries. Riferimenti:
|
|
1733
|
-
[pypdfium2#335](https://github.com/pypdfium2-team/pypdfium2/issues/335),
|
|
1734
|
-
[pdfium-render#151](https://github.com/ajrcarey/pdfium-render/issues/151).
|
|
1735
|
-
- `Page#chars` ora ottiene `:render_mode` via il path nuovo: prima
|
|
1736
|
-
risolve il text object che contiene il char con
|
|
1737
|
-
`FPDFText_GetTextObject`, poi legge il render mode con
|
|
1738
|
-
`FPDFTextObj_GetTextRenderMode` (che era già presente nella binding
|
|
1739
|
-
ma non utilizzato a char-level). Una cache interna evita lookup
|
|
1740
|
-
ripetuti — overhead invariato anche su pagine con migliaia di char.
|
|
1741
|
-
- Su build PDFium antichi (< chromium/6611) che non espongono
|
|
1742
|
-
`FPDFText_GetTextObject`, `:render_mode` ricade a `nil` invece di
|
|
1743
|
-
far esplodere l'estrazione.
|
|
1744
|
-
|
|
1745
|
-
### Aggiunto
|
|
1746
|
-
|
|
1747
|
-
- Binding di **`FPDFText_GetTextObject(text_page, char_index)`** —
|
|
1748
|
-
rimpiazzo upstream per ottenere il text object di un char.
|
|
1749
|
-
- Binding di **`FPDFFont_GetBaseFontName(font, buffer, size)`** —
|
|
1750
|
-
ritorna il `BaseFont` entry dal dict del font (può includere prefissi
|
|
1751
|
-
di subset come `ABCDEF+Helvetica`). Firma `c_size_t` invece di
|
|
1752
|
-
`c_ulong`, secondo l'header pubblico aggiornato.
|
|
1753
|
-
- Binding di **`FPDFFont_GetFamilyName(font, buffer, size)`** — ritorna
|
|
1754
|
-
il nome famiglia "pulito".
|
|
1755
|
-
- `FPDFFont_GetFontName` mantenuta come fallback per compatibilità con
|
|
1756
|
-
build PDFium più vecchi.
|
|
1757
|
-
|
|
1758
|
-
## [0.2.0] - parità con pypdfium2
|
|
1759
|
-
|
|
1760
|
-
Espansione massiccia. La superficie di API copre ora i casi d'uso principali
|
|
1761
|
-
di pypdfium2 più l'estrazione tabellare in stile pdfplumber.
|
|
1762
|
-
|
|
1763
|
-
### Aggiunto — bindings FFI
|
|
1764
|
-
|
|
1765
|
-
- **Path segments reali** via `FPDFPath_CountSegments`,
|
|
1766
|
-
`FPDFPath_GetPathSegment`, `FPDFPathSegment_GetPoint/GetType/GetClose`.
|
|
1767
|
-
Iterazione MOVETO/LINETO/BEZIERTO con state-machine corretta per
|
|
1768
|
-
`closepath`, sostituendo l'approccio "bbox del path" della 0.1.0.
|
|
1769
|
-
- **Image objects**: `FPDFImageObj_GetImageMetadata`,
|
|
1770
|
-
`GetImagePixelSize`, `GetBitmap`, `GetRenderedBitmap`,
|
|
1771
|
-
`GetImageDataDecoded`, `GetImageDataRaw`, `GetImageFilterCount`,
|
|
1772
|
-
`GetImageFilter`.
|
|
1773
|
-
- **Annotazioni**: `FPDFPage_GetAnnotCount/GetAnnot/CloseAnnot`,
|
|
1774
|
-
`FPDFAnnot_GetSubtype/GetRect/GetStringValue/HasKey/GetLink`,
|
|
1775
|
-
`FPDFLink_GetAction/GetDest/GetURL`, `FPDFAction_GetType/GetURIPath`.
|
|
1776
|
-
- **Form fields** (read-only): `FPDFDOC_InitFormFillEnvironment` con
|
|
1777
|
-
`FPDF_FORMFILLINFO` versione 2 minimale, `FPDF_FFLDraw`,
|
|
1778
|
-
`FPDFAnnot_GetFormFieldType/Name/Value/Flags/IsChecked`,
|
|
1779
|
-
`GetOptionCount/GetOptionLabel`.
|
|
1780
|
-
- **Bookmarks** (outline): `FPDFBookmark_GetFirstChild/GetNextSibling/
|
|
1781
|
-
GetTitle/GetDest`, `FPDFDest_GetDestPageIndex`.
|
|
1782
|
-
- **Attachments**: `FPDFDoc_GetAttachmentCount/GetAttachment`,
|
|
1783
|
-
`FPDFAttachment_GetName/GetFile`.
|
|
1784
|
-
- **Structure tree** (PDF tagged): `FPDF_StructTree_GetForPage`,
|
|
1785
|
-
`CountChildren`, `GetChildAtIndex`, `GetType`, `GetTitle`.
|
|
1786
|
-
- **Search interna**: `FPDFText_FindStart/FindNext/FindPrev/FindClose`,
|
|
1787
|
-
`GetSchResultIndex`, `GetSchCount`.
|
|
1788
|
-
- **Char metadata estesa**: `FPDFText_GetLooseCharBox`,
|
|
1789
|
-
`GetCharOrigin`, `GetCharAngle`, `IsGenerated`, `IsHyphen`,
|
|
1790
|
-
`HasUnicodeMapError`, `GetFontInfo`, `GetTextRenderMode`, `GetMatrix`.
|
|
1791
|
-
- **Document**: `FPDF_GetMetaText`, `GetDocPermissions`, `GetFileVersion`,
|
|
1792
|
-
`GetFormType`, `GetPageLabel`.
|
|
1793
|
-
- **Bitmap**: `CreateEx`, `RenderPageBitmapWithMatrix`, format detection.
|
|
1794
|
-
- **Page boxes**: `MediaBox`, `CropBox`, `BleedBox`, `TrimBox`, `ArtBox`.
|
|
1795
|
-
|
|
1796
|
-
### Aggiunto — wrapper di alto livello
|
|
1797
|
-
|
|
1798
|
-
- `Rpdfium::Document` ora espone: `metadata` (Title/Author/Producer/...),
|
|
1799
|
-
`permissions` (hash di booleans per print/copy/modify/...), `file_version`,
|
|
1800
|
-
`form_type`, `has_forms?`, `outline`, `attachments`, `page_label(idx)`.
|
|
1801
|
-
- `Rpdfium::Page` ora espone:
|
|
1802
|
-
- `box(:media|:crop|:bleed|:trim|:art)`
|
|
1803
|
-
- `chars(loose: false)` — array di hash con `char`, `codepoint`, bbox,
|
|
1804
|
-
`origin_x/y`, `angle`, `fontsize`, `font`, `weight`, `render_mode`,
|
|
1805
|
-
`generated`, `hyphen`, `unicode_error`
|
|
1806
|
-
- `words(x_tolerance:, y_tolerance:)` — clustering layout-aware
|
|
1807
|
-
- `text_in_bbox(left:, top:, right:, bottom:)` — top-down coords
|
|
1808
|
-
- `line_segments` — segmenti vettoriali REALI dai path objects
|
|
1809
|
-
- `horizontal_lines`, `vertical_lines` — derivati da `line_segments`
|
|
1810
|
-
- `images` — `Image::Embedded` array
|
|
1811
|
-
- `annotations`, `links`, `form_fields`
|
|
1812
|
-
- `render(scale:, rotate:, output: :rgba|:bgra|:gray, include_annotations:,
|
|
1813
|
-
include_forms:, background:)`
|
|
1814
|
-
- `render_to_png(path)` — pure-Ruby, zero dipendenze esterne
|
|
1815
|
-
- `search(query, **opts)` — internal full-text search
|
|
1816
|
-
- `Rpdfium::Image::Embedded` con `metadata`, `pixel_size`, `bbox`,
|
|
1817
|
-
`filters`, `raw_bytes`, `decoded_bytes`, `render_bitmap`, `save(path)`
|
|
1818
|
-
(passthrough JPEG quando il filter è `DCTDecode`).
|
|
1819
|
-
- `Rpdfium::Annotation` con `subtype`, `bbox`, `[]`, `link_uri`,
|
|
1820
|
-
`link_dest_page`.
|
|
1821
|
-
- `Rpdfium::Form::{Environment, Field}` con tipi mappati (textfield,
|
|
1822
|
-
checkbox, radiobutton, combobox, listbox, signature, ...) e
|
|
1823
|
-
`readonly?`, `required?`, `checked?`, `options`.
|
|
1824
|
-
- `Rpdfium::Search` con `Enumerable`, ogni match include rects per riga.
|
|
1825
|
-
- `Rpdfium::Outline` con tree ricorsivo, `flatten` preorder, `to_h`.
|
|
1826
|
-
- `Rpdfium::Attachment` con `name`, `bytes`, `save(path)`.
|
|
1827
|
-
|
|
1828
|
-
### Aggiunto — estrazione tabellare
|
|
1829
|
-
|
|
1830
|
-
- Pipeline pdfplumber-style:
|
|
1831
|
-
1. raccolta edges (strategie `:lines`, `:text`, `:explicit`,
|
|
1832
|
-
`:lines_strict`)
|
|
1833
|
-
2. snap (cluster collineari → coord media)
|
|
1834
|
-
3. join (segmenti contigui → unico edge)
|
|
1835
|
-
4. filter per `edge_min_length`
|
|
1836
|
-
5. intersezioni h × v entro `intersection_tolerance`
|
|
1837
|
-
6. costruzione celle (4 angoli intersezioni)
|
|
1838
|
-
7. raggruppamento celle adiacenti (union-find) in tabelle
|
|
1839
|
-
8. estrazione testo per cella via `FPDFText_GetBoundedText`
|
|
1840
|
-
- Tutti i parametri di pdfplumber supportati: `snap_tolerance` (con
|
|
1841
|
-
varianti `_x`, `_y`), `join_tolerance`, `intersection_tolerance`,
|
|
1842
|
-
`edge_min_length`, `min_words_vertical`, `min_words_horizontal`,
|
|
1843
|
-
`text_tolerance`, `keep_blank_chars`.
|
|
1844
|
-
- `auto_fallback` opzionale: se `:lines` non produce nulla, riprova con
|
|
1845
|
-
`:text`.
|
|
1846
|
-
- `Rpdfium::Table::Debugger.visualize(page, output_path)` — overlay
|
|
1847
|
-
visivo (linee rosse, intersezioni verdi, tabelle blu trasparenti)
|
|
1848
|
-
equivalente di `pdfplumber.Page.debug_tablefinder()`. Implementato in
|
|
1849
|
-
Ruby puro con canvas RGBA, Bresenham, alpha blending.
|
|
1850
|
-
|
|
1851
|
-
### Aggiunto — utility
|
|
1852
|
-
|
|
1853
|
-
- `Rpdfium::IO::PNG` — writer PNG puro Ruby (zero deps), supporta
|
|
1854
|
-
RGBA 8bpc. CRC32 corretti, deflate via stdlib `zlib`.
|
|
1855
|
-
- `Raw.read_utf16_string` helper centralizzato per il pattern
|
|
1856
|
-
probe-then-fetch di PDFium (che ritorna stringhe UTF-16LE).
|
|
1857
|
-
|
|
1858
|
-
### Cambiato
|
|
1859
|
-
|
|
1860
|
-
- Coordinate top-down ovunque nelle API pubbliche (PDFium internamente
|
|
1861
|
-
è bottom-up; conversione fatta una volta sola per evitare confusione).
|
|
1862
|
-
- Il documento mantiene una cache delle pagine: `doc.page(0)` ritorna
|
|
1863
|
-
sempre la stessa istanza (le pagine sono read-only nel nostro modello).
|
|
1864
|
-
- Init/destroy della libreria ora è thread-safe (`Mutex`) e idempotente.
|
|
1865
|
-
- `Document#close` rilascia in cascata: form env → pagine cached → doc.
|
|
1866
|
-
|
|
1867
|
-
## [0.1.0]
|
|
1868
|
-
|
|
1869
|
-
Prima release: bindings minimali, text/render base, table extractor
|
|
1870
|
-
embrionale.
|
|
1150
|
+
- ✅ busta_paga.pdf: numbers (`1.993,00`, `2.895,26`), word spacing (`COGNOME E NOME`, `NETTO BUSTA`)
|
|
1151
|
+
- ✅ sample.pdf: Lorem ipsum (2913 characters)
|
|
1152
|
+
- ✅ complex.pdf (85 pages): 224,645 characters total
|
|
1153
|
+
- ✅ cu.pdf p. 1 (rotation 90°): `BANCA NAZIONALE DEL LAVORO`, `Categoria`, numeric values
|
|
1154
|
+
- ✅ **cu.pdf p. 199** (rotation 0°, small font): `Categoria`, `Localizzazione`, `Tipo Attività`, `Accordato Operativo` — all intact
|