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