rpdfium 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1623 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ # Wrapper di pagina. Lazy-load di TextPage. Tutte le coordinate restituite
5
+ # sono nello spazio "top-down" della pagina: (0,0) è in alto a sinistra,
6
+ # x cresce verso destra, y verso il basso. PDFium usa "bottom-up" — la
7
+ # conversione avviene qui una volta sola.
8
+ class Page
9
+ attr_reader :document, :index
10
+
11
+ def initialize(document, index)
12
+ @document = document
13
+ @index = index
14
+ handle = Raw.FPDF_LoadPage(document.handle, index)
15
+ raise PageError, "Could not load page #{index}" if handle.null?
16
+
17
+ @text_page = nil
18
+ # Stato condiviso col finalizer: idempotenza su close, sopravvive al GC
19
+ # senza fare doppia chiamata FPDF_ClosePage. Tenere un riferimento a
20
+ # @document garantisce che il Document non venga raccolto prima della
21
+ # Page (FPDF_ClosePage richiede Document ancora vivo).
22
+ @state = { handle: handle, closed: false }
23
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@state))
24
+ end
25
+
26
+ def self.finalizer(state)
27
+ proc do
28
+ next if state[:closed]
29
+ next if state[:handle].null?
30
+
31
+ Raw.FPDF_ClosePage(state[:handle])
32
+ state[:closed] = true
33
+ end
34
+ end
35
+
36
+ def handle
37
+ @state[:handle]
38
+ end
39
+
40
+ # ===== Geometria =====
41
+
42
+ def width; Raw.FPDF_GetPageWidthF(@state[:handle]); end
43
+ def height; Raw.FPDF_GetPageHeightF(@state[:handle]); end
44
+
45
+ # Rotazione in gradi: 0/90/180/270
46
+ def rotation
47
+ [0, 90, 180, 270][Raw.FPDFPage_GetRotation(@state[:handle])] || 0
48
+ end
49
+
50
+ def has_transparency?
51
+ Raw.FPDFPage_HasTransparency(@state[:handle]) == 1
52
+ end
53
+
54
+ BOX_FUNCTIONS = {
55
+ media: :FPDFPage_GetMediaBox,
56
+ crop: :FPDFPage_GetCropBox,
57
+ bleed: :FPDFPage_GetBleedBox,
58
+ trim: :FPDFPage_GetTrimBox,
59
+ art: :FPDFPage_GetArtBox
60
+ }.freeze
61
+
62
+ def box(kind = :crop)
63
+ fn = BOX_FUNCTIONS[kind] or raise ArgumentError, "Unknown box: #{kind}"
64
+ l = FFI::MemoryPointer.new(:float)
65
+ b = FFI::MemoryPointer.new(:float)
66
+ r = FFI::MemoryPointer.new(:float)
67
+ t = FFI::MemoryPointer.new(:float)
68
+ return nil if Raw.send(fn, @state[:handle], l, b, r, t) == 0
69
+
70
+ { left: l.read_float, bottom: b.read_float,
71
+ right: r.read_float, top: t.read_float }
72
+ end
73
+
74
+ # Accessor pdfplumber-compatibili. Restituiscono il box come tuple
75
+ # [x0, top, x1, bottom] in coordinate top-down (lo stesso sistema
76
+ # usato da chars, edges, table cells). Ritornano nil se il box non
77
+ # è definito nel PDF (es. ArtBox o BleedBox sono spesso assenti).
78
+ #
79
+ # Esempio d'uso:
80
+ # crop = page.cropbox # → [0.0, 0.0, 595.28, 841.88] o nil
81
+ # crop != [0, 0, page.width, page.height] # PDF ha un crop esplicito
82
+ def mediabox; box_to_topdown(box(:media)); end
83
+
84
+ # PDF spec 14.11.2: se CropBox è assente, default è MediaBox. La cropbox è
85
+ # l'area "visibile" della pagina; per PDF da gestionali coincide spesso
86
+ # con la MediaBox. Pdfplumber fa il fallback automatico.
87
+ def cropbox
88
+ box_to_topdown(box(:crop)) || mediabox
89
+ end
90
+
91
+ def bleedbox; box_to_topdown(box(:bleed)); end
92
+ def trimbox; box_to_topdown(box(:trim)); end
93
+ def artbox; box_to_topdown(box(:art)); end
94
+
95
+ # ===== Testo (versione "semplice") =====
96
+
97
+ def text
98
+ tp = text_page
99
+ n = tp.char_count
100
+ return "" if n.zero?
101
+
102
+ buf = FFI::MemoryPointer.new(:ushort, n + 1)
103
+ Raw.FPDFText_GetText(tp.handle, 0, n, buf)
104
+ buf.read_bytes((n + 1) * 2).force_encoding("UTF-16LE")
105
+ .encode("UTF-8", invalid: :replace, undef: :replace)
106
+ .delete("\x00")
107
+ end
108
+
109
+ # Estrae il testo dentro una bbox arbitraria (top-down coords).
110
+ # Utile per "leggi l'intestazione di questa cella".
111
+ def text_in_bbox(left:, top:, right:, bottom:)
112
+ tp = text_page
113
+ h = height
114
+ # Converti a bottom-up per PDFium
115
+ pdf_top = h - top
116
+ pdf_bottom = h - bottom
117
+ # PDFium vuole: left, top, right, bottom dove top > bottom (PDF coords)
118
+ # Probe size:
119
+ n = Raw.FPDFText_GetBoundedText(
120
+ tp.handle, left, pdf_top, right, pdf_bottom, FFI::Pointer::NULL, 0
121
+ )
122
+ return "" if n <= 0
123
+
124
+ buf = FFI::MemoryPointer.new(:ushort, n)
125
+ Raw.FPDFText_GetBoundedText(
126
+ tp.handle, left, pdf_top, right, pdf_bottom, buf, n
127
+ )
128
+ buf.read_bytes(n * 2).force_encoding("UTF-16LE")
129
+ .encode("UTF-8", invalid: :replace, undef: :replace)
130
+ .delete("\x00")
131
+ end
132
+
133
+ # ===== Caratteri (char-level) =====
134
+
135
+ # Ritorna ogni char con metadata ricco:
136
+ # :char stringa (1 codepoint)
137
+ # :x0,:x1 bbox orizzontale
138
+ # :top,:bottom bbox verticale (top-down: top < bottom)
139
+ # :origin_x, :origin_y punto di inserimento del glifo (top-down)
140
+ # :angle angolo di rotazione del glifo (radianti)
141
+ # :fontsize taglia in punti
142
+ # :font nome font (se disponibile)
143
+ # :weight spessore (es. 400=regular, 700=bold)
144
+ # :render_mode modalità rendering (fill/stroke/invisible). Letto via
145
+ # il text object che contiene il char (PDFium non
146
+ # espone più una API char-level dopo chromium/6611).
147
+ # nil su build PDFium antichi che non supportano il
148
+ # lookup char→object.
149
+ # :generated true se inserito da PDFium (es. spazi sintetici)
150
+ # :hyphen true se trattino di sillabazione
151
+ # :unicode_error true se PDFium non ha potuto mapparlo
152
+ #
153
+ # `loose: true` (DEFAULT) usa FPDFText_GetLooseCharBox: tutti i char
154
+ # della stessa linea logica condividono la stessa bbox verticale (top/
155
+ # bottom), proporzionale alla font size invece che al singolo glifo. È
156
+ # esattamente il comportamento di pdfminer.six/pdfplumber, e l'unico
157
+ # che permette al midpoint-test in Table#extract di catturare anche i
158
+ # char di punteggiatura (`.`, `,`) insieme ai numeri allineati alla
159
+ # baseline. Con `loose: false` si ottengono le bbox "tight" del singolo
160
+ # glifo, utili per misure di layout fine ma sbagliate per il filtro
161
+ # cella tabellare.
162
+ def chars(loose: true, inject_spaces: true, lean: false)
163
+ # Cache: chars() viene chiamato una volta da Table#extract e poi
164
+ # nuovamente da WordExtractor (passando per Extractor#page_words se
165
+ # vertical/horizontal_strategy è :text). Ogni chiamata costa O(n) FFI
166
+ # roundtrip per char — costoso su pagine con migliaia di char.
167
+ cache_key = [loose, inject_spaces, lean]
168
+ @chars_cache ||= {}
169
+ return @chars_cache[cache_key] if @chars_cache.key?(cache_key)
170
+
171
+ raw = compute_chars(loose: loose, lean: lean)
172
+ result = inject_spaces ? rebuild_word_separators(raw) : raw
173
+ @chars_cache[cache_key] = result
174
+ end
175
+
176
+ # Ricostruisce gli spazi che separano le parole basandosi sulla
177
+ # GEOMETRIA dei char "veri", scartando completamente gli spazi
178
+ # sintetici di PDFium (che sono inaffidabili: PDFium li emette in
179
+ # modo aggressivo anche tra cifre di numeri come "2.895,26").
180
+ #
181
+ # Algoritmo:
182
+ # 1. Filtra via tutti i char :generated (tipicamente spazi sintetici
183
+ # con bbox degenere).
184
+ # 2. Cluster i char rimasti per riga (top tolerance 1pt).
185
+ # 3. Dentro ogni riga, sort per x0 e per ogni coppia consecutiva
186
+ # calcola gap = next.x0 - prev.x1 e char_w = (prev.w + next.w) / 2.
187
+ # Se gap > 0.275 × char_w → inserisci spazio sintetico nuovo
188
+ # (bbox normalizzata al top/bottom dei char).
189
+ #
190
+ # Soglia 0.275: tarata empiricamente su PDF TeamSystem reale.
191
+ # Distribuzione misurata: gap intra-parola max ratio 0.24, gap
192
+ # inter-parola min ratio 0.31. Classificazione 100% corretta sul
193
+ # dataset di training (1400 intra + 663 inter casi). Pdfminer.six
194
+ # usa internamente 0.1 (`word_margin`) ma con info aggiuntive
195
+ # dall'advance del font, non disponibile da PDFium.
196
+ def rebuild_word_separators(chars)
197
+ reals = chars.reject { |c| c[:generated] }
198
+ return chars if reals.empty?
199
+
200
+ # Cluster per riga, mantenendo l'ordine di top
201
+ sorted_top = reals.sort_by { |c| c[:top] }
202
+ rows = []
203
+ sorted_top.each do |c|
204
+ if rows.last && (c[:top] - rows.last.last[:top]).abs <= 1.0
205
+ rows.last << c
206
+ else
207
+ rows << [c]
208
+ end
209
+ end
210
+
211
+ result = []
212
+ rows.each do |row|
213
+ row_sorted = row.sort_by { |c| c[:x0] }
214
+ prev = nil
215
+ row_sorted.each do |c|
216
+ if prev
217
+ gap = c[:x0] - prev[:x1]
218
+
219
+ # Segnale dal content stream PDF: prev.text_obj_ends_with_space.
220
+ # Se prev NON termina un token (false), il gap è kerning interno
221
+ # → mai inserire spazio.
222
+ #
223
+ # Se prev termina un token (true), può essere:
224
+ # - vera fine parola (gap geometrico relativamente grande)
225
+ # - fine token sintattico (es. tra cifre e punteggiatura di
226
+ # un numero "2", "."), con gap piccolo.
227
+ #
228
+ # Discrimino con la soglia geometrica abbinata al "contesto"
229
+ # tipografico: se la coppia (prev_char, curr_char) sembra un
230
+ # contesto numerico (cifre + punteggiatura), uso soglia più
231
+ # alta; altrimenti soglia normale.
232
+ obj_signal_present = prev.key?(:text_obj_ends_with_space)
233
+ obj_says_continues = obj_signal_present && !prev[:text_obj_ends_with_space]
234
+
235
+ unless obj_says_continues
236
+ ref_w = best_reference_width(prev, c)
237
+ threshold_ratio = numeric_context?(prev[:char], c[:char]) ? 0.7 : 0.3
238
+ threshold = ref_w > 0 ? ref_w * threshold_ratio : 0.5
239
+ result << build_synthetic_space(prev, c) if gap > threshold
240
+ end
241
+ end
242
+ result << c
243
+ prev = c
244
+ end
245
+ end
246
+ result
247
+ end
248
+
249
+ # True se la coppia (prev_char, curr_char) è un contesto "numerico":
250
+ # cifra-punteggiatura, punteggiatura-cifra, o cifra-cifra. In questi
251
+ # casi un gap modesto è probabilmente kerning interno al numero, non
252
+ # confine di parola. Soglia più alta per evitare di spezzare numeri
253
+ # come "2.895,26" in "2 . 895 , 26".
254
+ NUMERIC_PUNCT = %w[. , ].freeze
255
+
256
+ def numeric_context?(prev_char, curr_char)
257
+ return false if prev_char.nil? || curr_char.nil?
258
+
259
+ prev_num = prev_char.match?(/\d/) || NUMERIC_PUNCT.include?(prev_char)
260
+ curr_num = curr_char.match?(/\d/) || NUMERIC_PUNCT.include?(curr_char)
261
+ prev_num && curr_num
262
+ end
263
+
264
+ # Ritorna la larghezza "di riferimento" per il calcolo del ratio
265
+ # gap/width. Preferisce l'advance (più stabile di bbox per char con
266
+ # kerning post-applied). Se uno dei due char non ha advance, fallback
267
+ # su max delle bbox-width.
268
+ def best_reference_width(a, b)
269
+ a_adv = a[:advance]
270
+ b_adv = b[:advance]
271
+ if a_adv && b_adv
272
+ [a_adv, b_adv].max
273
+ else
274
+ [(a[:x1] - a[:x0]), (b[:x1] - b[:x0])].max
275
+ end
276
+ end
277
+
278
+ def build_synthetic_space(prev, c)
279
+ {
280
+ char: " ", codepoint: 32,
281
+ x0: prev[:x1], x1: c[:x0],
282
+ top: prev[:top], bottom: prev[:bottom],
283
+ origin_x: prev[:x1], origin_y: prev[:origin_y],
284
+ angle: 0.0, fontsize: prev[:fontsize], font: prev[:font],
285
+ weight: prev[:weight], render_mode: nil,
286
+ generated: true, hyphen: false, unicode_error: false,
287
+ advance: nil, text_obj_id: nil, text_obj_ends_with_space: nil
288
+ }
289
+ end
290
+
291
+
292
+ def compute_chars(loose:, lean: false)
293
+ tp = text_page
294
+ n = tp.char_count
295
+ return [] if n.zero?
296
+
297
+ # Geometria della pagina dopo l'applicazione della rotazione PDF.
298
+ h = height
299
+ w = width
300
+ page_rotation = rotation
301
+
302
+ raw_w, raw_h = case page_rotation
303
+ when 90, 270 then [h, w]
304
+ else [w, h]
305
+ end
306
+
307
+ result = Array.new(n)
308
+
309
+ # Buffer FFI riusati tra tutte le iterazioni del loop.
310
+ # MemoryPointer.new è non-banale (~µs ciascuna), allocarne O(n) per
311
+ # char è il principale costo di compute_chars dopo le chiamate FFI.
312
+ l = FFI::MemoryPointer.new(:double)
313
+ r = FFI::MemoryPointer.new(:double)
314
+ b = FFI::MemoryPointer.new(:double)
315
+ t = FFI::MemoryPointer.new(:double)
316
+ ox = FFI::MemoryPointer.new(:double)
317
+ oy = FFI::MemoryPointer.new(:double)
318
+ rect = Raw::FS_RECTF.new
319
+ font_buf = FFI::MemoryPointer.new(:uchar, 256) unless lean
320
+ flags_buf = FFI::MemoryPointer.new(:int) unless lean
321
+ fs_buf = FFI::MemoryPointer.new(:float)
322
+ gw_buf = FFI::MemoryPointer.new(:float)
323
+ matrix = Raw::FS_MATRIX.new
324
+ text_obj_text_buf = FFI::MemoryPointer.new(:uint8, TEXT_OBJ_INITIAL_BUF_BYTES)
325
+
326
+ text_obj_cache = {}
327
+ tp_handle = tp.handle
328
+
329
+ n.times do |i|
330
+ x0, x1, y_top, y_bot = read_char_bbox(tp, i, loose, l, r, b, t, rect)
331
+ Raw.FPDFText_GetCharOrigin(tp_handle, i, ox, oy)
332
+ origin_x_raw = ox.read_double
333
+ origin_y_raw = oy.read_double
334
+
335
+ # Font name: skippato in lean (1 FFI risparmiata per char).
336
+ font_name = nil
337
+ unless lean
338
+ n_bytes = Raw.FPDFText_GetFontInfo(tp_handle, i, font_buf, 256, flags_buf)
339
+ font_name = font_buf.read_bytes(n_bytes - 1).force_encoding("UTF-8") if n_bytes > 1
340
+ end
341
+
342
+ cp = Raw.FPDFText_GetUnicode(tp_handle, i)
343
+
344
+ text_obj = begin
345
+ Raw.FPDFText_GetTextObject(tp_handle, i)
346
+ rescue Rpdfium::LoadError
347
+ nil
348
+ end
349
+
350
+ rm, font_handle, font_size_for_obj, ends_with_space =
351
+ fetch_text_obj_info(text_obj, tp, text_obj_cache,
352
+ fs_buf: fs_buf, text_buf: text_obj_text_buf)
353
+
354
+ # Advance: 2 FFI per char (GetGlyphWidth + GetMatrix). In lean
355
+ # mode skippiamo — best_reference_width fa fallback su bbox-width
356
+ # che funziona altrettanto bene per il discriminante word-boundary.
357
+ advance = if lean
358
+ nil
359
+ else
360
+ compute_glyph_advance_fast(font_handle, cp, font_size_for_obj,
361
+ tp_handle, i, gw_buf, matrix)
362
+ end
363
+
364
+ td_x0, td_x1, td_top, td_bottom, td_ox, td_oy =
365
+ apply_page_rotation_to_char(page_rotation, raw_w, raw_h,
366
+ x0, x1, y_top, y_bot,
367
+ origin_x_raw, origin_y_raw)
368
+
369
+ # In lean mode skippiamo 5 chiamate FFI per char:
370
+ # GetCharAngle, GetFontWeight, IsHyphen, HasUnicodeMapError,
371
+ # (e GetFontSize fallback se font_size_for_obj è nil).
372
+ # Su pagine con migliaia di char il risparmio è significativo
373
+ # (decine di ms). I metadata risultano nil/false, che è il valore
374
+ # neutro per il pipeline text/tables/words interno.
375
+ result[i] =
376
+ if lean
377
+ {
378
+ char: safe_codepoint(cp),
379
+ codepoint: cp,
380
+ x0: td_x0,
381
+ x1: td_x1,
382
+ top: td_top,
383
+ bottom: td_bottom,
384
+ origin_x: td_ox,
385
+ origin_y: td_oy,
386
+ angle: nil,
387
+ fontsize: font_size_for_obj,
388
+ font: nil,
389
+ weight: nil,
390
+ render_mode: rm,
391
+ generated: Raw.FPDFText_IsGenerated(tp_handle, i) == 1,
392
+ hyphen: false,
393
+ unicode_error: false,
394
+ advance: advance,
395
+ text_obj_id: text_obj && !text_obj.null? ? text_obj.address : nil,
396
+ text_obj_ends_with_space: ends_with_space
397
+ }
398
+ else
399
+ {
400
+ char: safe_codepoint(cp),
401
+ codepoint: cp,
402
+ x0: td_x0,
403
+ x1: td_x1,
404
+ top: td_top,
405
+ bottom: td_bottom,
406
+ origin_x: td_ox,
407
+ origin_y: td_oy,
408
+ angle: Raw.FPDFText_GetCharAngle(tp_handle, i),
409
+ fontsize: font_size_for_obj || Raw.FPDFText_GetFontSize(tp_handle, i),
410
+ font: font_name,
411
+ weight: Raw.FPDFText_GetFontWeight(tp_handle, i),
412
+ render_mode: rm,
413
+ generated: Raw.FPDFText_IsGenerated(tp_handle, i) == 1,
414
+ hyphen: Raw.FPDFText_IsHyphen(tp_handle, i) == 1,
415
+ unicode_error: Raw.FPDFText_HasUnicodeMapError(tp_handle, i) == 1,
416
+ advance: advance,
417
+ text_obj_id: text_obj && !text_obj.null? ? text_obj.address : nil,
418
+ text_obj_ends_with_space: ends_with_space
419
+ }
420
+ end
421
+ end
422
+ result
423
+ end
424
+
425
+ # Applica la rotazione della pagina alle coordinate di un char.
426
+ #
427
+ # Input: coord PDFium raw (bottom-up, pre-rotazione) di un bbox
428
+ # `[x0, x1, y_top, y_bot]` (con y_top > y_bot perché bottom-up) e
429
+ # di un origin point.
430
+ #
431
+ # Output: coord top-down nel sistema della pagina post-rotazione,
432
+ # nella convenzione standard di rpdfium: `[x0, x1, top, bottom]`
433
+ # con `top < bottom`. Coerente con pdfplumber.
434
+ #
435
+ # Convenzione PDFium: GetRotation = N significa che la pagina visualizzata
436
+ # è ruotata di N*90° in senso orario rispetto al sistema raw del content
437
+ # stream. PDFium restituisce le coord nel sistema raw; applichiamo la
438
+ # rotazione per allineare al rendering.
439
+ #
440
+ # Caso 0°: identità + bottom-up→top-down.
441
+ # Caso 90° CW: bbox larga in x diventa alta in y. La x_min (sinistra) raw
442
+ # coincide con il top (alto) del sistema post-rotazione.
443
+ # Caso 180°: ribalta entrambi gli assi.
444
+ # Caso 270° CW: bbox larga in x diventa alta in y, ma invertita verticalmente.
445
+ def apply_page_rotation_to_char(rotation, raw_w, raw_h,
446
+ x0, x1, y_top, y_bot,
447
+ origin_x, origin_y)
448
+ case rotation
449
+ when 0, nil
450
+ # Nessuna rotazione. Bottom-up → top-down standard.
451
+ # page_h_post == raw_h.
452
+ [x0, x1, raw_h - y_top, raw_h - y_bot,
453
+ origin_x, raw_h - origin_y]
454
+
455
+ when 90
456
+ # 90° CW. Dimensioni post-rotation: w=raw_h, h=raw_w.
457
+ # Trasformazione: x_post = y_raw, y_post = raw_w - x_raw (bottom-up).
458
+ # In top-down: top = x_min_raw, bottom = x_max_raw.
459
+ new_x0 = y_bot # piccolo y_raw → piccolo x_post
460
+ new_x1 = y_top # grande y_raw → grande x_post
461
+ new_top = x0 # piccolo x_raw → top piccolo (alto)
462
+ new_bottom = x1 # grande x_raw → bottom grande (basso)
463
+ new_ox = origin_y
464
+ new_oy = origin_x # top-down origin_y = x_raw
465
+ [new_x0, new_x1, new_top, new_bottom, new_ox, new_oy]
466
+
467
+ when 180
468
+ # 180°. Dimensioni post-rotation: invariate (raw_w × raw_h).
469
+ # Trasformazione: x_post = raw_w - x_raw, y_post = raw_h - y_raw.
470
+ # In top-down: top = y_bot_raw, bottom = y_top_raw.
471
+ new_x0 = raw_w - x1
472
+ new_x1 = raw_w - x0
473
+ new_top = y_bot # bottom raw → top td (alto)
474
+ new_bottom = y_top # top raw → bottom td (basso)
475
+ new_ox = raw_w - origin_x
476
+ new_oy = y_top.zero? ? raw_h - origin_y : raw_h - origin_y
477
+ # nota: origin in top-down post-180 = y_origin_raw
478
+ new_oy = origin_y
479
+ [new_x0, new_x1, new_top, new_bottom, new_ox, new_oy]
480
+
481
+ when 270
482
+ # 270° CW (= 90° CCW). Dimensioni post-rotation: w=raw_h, h=raw_w.
483
+ # Trasformazione: x_post = raw_h - y_raw, y_post = x_raw (bottom-up).
484
+ # In top-down: top = raw_w - x_max_raw, bottom = raw_w - x_min_raw.
485
+ new_x0 = raw_h - y_top # grande y → piccolo x_post
486
+ new_x1 = raw_h - y_bot
487
+ new_top = raw_w - x1
488
+ new_bottom = raw_w - x0
489
+ new_ox = raw_h - origin_y
490
+ new_oy = raw_w - origin_x
491
+ [new_x0, new_x1, new_top, new_bottom, new_ox, new_oy]
492
+
493
+ else
494
+ # Rotazione non standard (non multipla di 90°): fallback al
495
+ # comportamento pre-rotazione. Non dovrebbe mai succedere per
496
+ # PDF ben formati.
497
+ [x0, x1, raw_h - y_top, raw_h - y_bot,
498
+ origin_x, raw_h - origin_y]
499
+ end
500
+ end
501
+
502
+ # Cache lookup per text object. Restituisce tupla:
503
+ # [render_mode, font_handle, font_size, ends_with_space]
504
+ #
505
+ # `ends_with_space` indica se il testo dell'intero text object termina
506
+ # con uno spazio (segnale "fine token" dichiarato dal PDF). È una
507
+ # proprietà dell'oggetto, non del singolo char, quindi può essere
508
+ # calcolata una volta sola e cachata insieme agli altri campi — evita
509
+ # una chiamata FPDFTextObj_GetText per ogni char che condivide l'obj.
510
+ def fetch_text_obj_info(text_obj, tp, cache, fs_buf:, text_buf:)
511
+ return [nil, nil, nil, nil] if text_obj.nil? || text_obj.null?
512
+
513
+ addr = text_obj.address
514
+ return cache[addr] if cache.key?(addr)
515
+
516
+ rm = Raw.FPDFTextObj_GetTextRenderMode(text_obj)
517
+ font = Raw.FPDFTextObj_GetFont(text_obj)
518
+ font_handle = font.null? ? nil : font
519
+
520
+ font_size = if Raw.FPDFTextObj_GetFontSize(text_obj, fs_buf) == 1
521
+ fs_buf.read_float
522
+ end
523
+
524
+ obj_text = read_text_obj_text_fast(text_obj, tp, text_buf)
525
+ ends_with_space = obj_text&.end_with?(" ")
526
+
527
+ tuple = [rm, font_handle, font_size, ends_with_space]
528
+ cache[addr] = tuple
529
+ tuple
530
+ end
531
+
532
+ # Versione "fast" di read_text_obj_text_from: riusa il buffer passato
533
+ # invece di allocarlo. Per il 99% dei text obj il buffer iniziale da
534
+ # 256 byte basta; nel caso raro che PDFium richieda più spazio, alloca
535
+ # un buffer più grande on-demand (questa è una path rara, OK
536
+ # allocare).
537
+ def read_text_obj_text_fast(text_obj, tp, buf)
538
+ return nil if text_obj.nil? || text_obj.null?
539
+
540
+ needed = Raw.FPDFTextObj_GetText(text_obj, tp.handle, buf,
541
+ TEXT_OBJ_INITIAL_BUF_BYTES)
542
+ return nil if needed < 2
543
+
544
+ if needed > TEXT_OBJ_INITIAL_BUF_BYTES
545
+ # Path raro: text obj con > 128 char. Alloco buffer dedicato.
546
+ big_buf = FFI::MemoryPointer.new(:uint8, needed)
547
+ needed = Raw.FPDFTextObj_GetText(text_obj, tp.handle, big_buf, needed)
548
+ return nil if needed < 2
549
+
550
+ payload_bytes = needed - 2
551
+ return nil if payload_bytes <= 0
552
+
553
+ return big_buf.read_bytes(payload_bytes)
554
+ .force_encoding("UTF-16LE")
555
+ .encode("UTF-8")
556
+ .delete("\u0000")
557
+ end
558
+
559
+ payload_bytes = needed - 2
560
+ return nil if payload_bytes <= 0
561
+
562
+ buf.read_bytes(payload_bytes)
563
+ .force_encoding("UTF-16LE")
564
+ .encode("UTF-8")
565
+ .delete("\u0000")
566
+ end
567
+
568
+ # Versione "fast" di compute_glyph_advance: riusa gw_buf e matrix
569
+ # invece di allocarli per char. Stesso comportamento funzionale.
570
+ def compute_glyph_advance_fast(font, codepoint, font_size, tp_handle,
571
+ char_index, gw_buf, matrix)
572
+ return nil if font.nil? || font_size.nil?
573
+
574
+ ok = begin
575
+ Raw.FPDFFont_GetGlyphWidth(font, codepoint, font_size, gw_buf)
576
+ rescue Rpdfium::LoadError
577
+ return nil
578
+ end
579
+ return nil if ok == 0
580
+
581
+ glyph_w_font_units = gw_buf.read_float
582
+
583
+ # CTM scale: riuso la matrix in-place.
584
+ scale = if Raw.FPDFText_GetMatrix(tp_handle, char_index, matrix) == 1
585
+ matrix[:a].abs
586
+ else
587
+ 1.0
588
+ end
589
+ glyph_w_font_units * scale
590
+ end
591
+
592
+ # Buffer size iniziale per FPDFTextObj_GetText: 256 byte = 128 char UTF-16.
593
+ # Empiricamente sufficiente per ~99% dei text object reali (parole singole
594
+ # o frasi brevi). Quando un text obj è più grande, ricadiamo nel probe-then-
595
+ # fetch corretto.
596
+ TEXT_OBJ_INITIAL_BUF_BYTES = 256
597
+
598
+ # Legge il testo di un text object PDF.
599
+ #
600
+ # Firma C: `unsigned long FPDFTextObj_GetText(FPDF_PAGEOBJECT, FPDF_TEXTPAGE,
601
+ # FPDF_WCHAR* buffer, unsigned long length)` — length in BYTE, return è
602
+ # il numero di byte totali necessari (incluso null terminator), anche se
603
+ # il buffer è troppo piccolo. Pattern: prova con buffer stack-friendly,
604
+ # se PDFium ne richiede di più rialloca.
605
+ def read_text_obj_text_from(text_obj, tp, _char_index_unused = nil)
606
+ return nil if text_obj.nil? || text_obj.null?
607
+
608
+ # Prima tentativo: buffer fisso da 256 byte. Risolve il 99% dei casi.
609
+ buf = FFI::MemoryPointer.new(:uint8, TEXT_OBJ_INITIAL_BUF_BYTES)
610
+ needed = Raw.FPDFTextObj_GetText(text_obj, tp.handle, buf,
611
+ TEXT_OBJ_INITIAL_BUF_BYTES)
612
+ return nil if needed < 2
613
+
614
+ # Se PDFium ne vuole più di quanto allocato, rialloca esatto.
615
+ if needed > TEXT_OBJ_INITIAL_BUF_BYTES
616
+ buf = FFI::MemoryPointer.new(:uint8, needed)
617
+ needed = Raw.FPDFTextObj_GetText(text_obj, tp.handle, buf, needed)
618
+ return nil if needed < 2
619
+ end
620
+
621
+ # Clamp difensivo: non leggo mai più di quanto allocato.
622
+ buf_capacity = buf.size
623
+ payload_bytes = [needed - 2, buf_capacity - 2].min
624
+ return nil if payload_bytes <= 0
625
+
626
+ buf.read_bytes(payload_bytes)
627
+ .force_encoding("UTF-16LE")
628
+ .encode("UTF-8")
629
+ .delete("\u0000")
630
+ end
631
+
632
+ # Calcola l'advance del glifo in coordinate pagina, per un char
633
+ # specifico identificato da (text_page, char_index).
634
+ # Formula: glyph_width(font, codepoint, font_size) × |CTM.a|.
635
+ # Ritorna nil se l'advance non è calcolabile (font non disponibile,
636
+ # PDFium che non supporta l'API).
637
+ def compute_glyph_advance(font, codepoint, font_size, tp, char_index)
638
+ return nil if font.nil? || font_size.nil?
639
+
640
+ gw_buf = FFI::MemoryPointer.new(:float)
641
+ ok = begin
642
+ Raw.FPDFFont_GetGlyphWidth(font, codepoint, font_size, gw_buf)
643
+ rescue Rpdfium::LoadError
644
+ return nil # FPDFFont_GetGlyphWidth non disponibile in build vecchi
645
+ end
646
+ return nil if ok == 0
647
+
648
+ glyph_w_font_units = gw_buf.read_float
649
+ scale = char_ctm_scale_x(tp, char_index) || 1.0
650
+ glyph_w_font_units * scale
651
+ end
652
+
653
+ # Calcola la scala orizzontale del CTM per un char specifico.
654
+ def char_ctm_scale_x(tp, char_index)
655
+ mat = Raw::FS_MATRIX.new
656
+ return nil if Raw.FPDFText_GetMatrix(tp.handle, char_index, mat) == 0
657
+
658
+ mat[:a].abs
659
+ end
660
+
661
+ # ===== Form-aware extraction =====
662
+ #
663
+ # PDF di "moduli compilati" (F24, Comunicazione IVA, 770, ecc.) sono PDF
664
+ # di output dove il modello prestampato e i valori inseriti coesistono
665
+ # come testo grafico — nessun AcroForm, nessun tag PDF/UA. Il pipeline
666
+ # geometrico di estrazione tabelle vede il modulo intero e produce
667
+ # rumore (etichette del template mescolate ai dati).
668
+ #
669
+ # La strategia robusta su questi PDF è separare i char per "ruolo"
670
+ # usando font/altezza, che tipicamente differiscono tra il template
671
+ # (font proporzionali, dimensioni varie) e i dati inseriti dal
672
+ # gestionale (un singolo font, tipicamente Courier o Helvetica,
673
+ # una sola size).
674
+ #
675
+ # Esempio classico F24:
676
+ # Template: Futura-Light, Futura-Bold, Futura-Heavy, Times-Bold
677
+ # Dati: Courier 10.0
678
+ #
679
+ # page.font_inventory # → vede tutti i (font, height)
680
+ # page.chars_where(font: /Courier/i)
681
+ # # → solo i char dei dati inseriti
682
+ # page.lines(font: /Courier/i) # → testo dei dati riga per riga
683
+
684
+ # Distribuzione dei char per (font, altezza visiva, weight).
685
+ #
686
+ # Ritorna un Array di Hash ordinato per count decrescente:
687
+ # [{ font:, height:, weight:, count:, sample: }, ...]
688
+ #
689
+ # `height` è l'altezza visiva del char in punti (bottom - top), più
690
+ # affidabile di `fontsize` che PDFium normalizza a 1.0 quando la
691
+ # dimensione reale è nella matrice CTM (caso comune sui moduli
692
+ # generati con scaling).
693
+ #
694
+ # `sample` sono i primi 40 char di quel gruppo, per ispezione.
695
+ #
696
+ # Usalo per scegliere il filtro `chars_where`: tipicamente il font
697
+ # con più char è il template, e i font minoritari (1 solo size,
698
+ # spesso monospace) sono i dati.
699
+ def font_inventory
700
+ groups = chars.reject { |c| c[:generated] }.group_by do |c|
701
+ h = (c[:bottom] - c[:top]).round(1)
702
+ [c[:font], h, c[:weight]]
703
+ end
704
+ groups.map do |(font, height, weight), cs|
705
+ {
706
+ font: font,
707
+ height: height,
708
+ weight: weight,
709
+ count: cs.size,
710
+ sample: cs.first(40).map { |c| c[:char] }.join
711
+ }
712
+ end.sort_by { |g| -g[:count] }
713
+ end
714
+
715
+ # Filtro char generico. Ritorna i char che matchano TUTTI i predicati
716
+ # specificati (intersezione, non unione).
717
+ #
718
+ # Argomenti supportati:
719
+ # font: String esatto, Array<String>, o Regexp
720
+ # height: Float (singolo valore), Range, Array<Float>
721
+ # weight: Integer o Range
722
+ # bbox: [left, top, right, bottom] in coord top-down della pagina
723
+ # where: block che riceve l'hash char, deve ritornare truthy
724
+ #
725
+ # Tutti i parametri sono opzionali; quelli passati vengono combinati
726
+ # in AND.
727
+ #
728
+ # Tipicamente combinato con WordExtractor per estrarre testo "pulito":
729
+ #
730
+ # data_chars = page.chars_where(font: /Courier/i)
731
+ # words = Rpdfium::Util::WordExtractor.new.extract_words(data_chars)
732
+ #
733
+ # oppure usato come building block per pipeline custom.
734
+ def chars_where(font: nil, height: nil, weight: nil, bbox: nil, where: nil, **char_opts)
735
+ cs = chars(**char_opts)
736
+
737
+ cs.select do |c|
738
+ next false if font && !font_matches?(c[:font], font)
739
+ next false if height && !range_matches?((c[:bottom] - c[:top]), height)
740
+ next false if weight && !range_matches?(c[:weight], weight)
741
+ if bbox
742
+ left, top, right, bottom = bbox
743
+ hm = (c[:x0] + c[:x1]) / 2.0
744
+ vm = (c[:top] + c[:bottom]) / 2.0
745
+ next false unless hm >= left && hm < right && vm >= top && vm < bottom
746
+ end
747
+ next false if where && !where.call(c)
748
+ true
749
+ end
750
+ end
751
+
752
+ # Raggruppa i char filtrati in righe logiche e ritorna un Array di
753
+ # stringhe (una per riga, top-to-bottom, char dentro la riga
754
+ # left-to-right). Conveniente quando il PDF è un modulo compilato
755
+ # e vuoi solo i valori inseriti come righe pulite.
756
+ #
757
+ # Esempio F24:
758
+ #
759
+ # page.lines(font: /Courier/i)
760
+ # # => ["Soggetto: MANAGEMENT CONSULTING S.R.L. ( 02098120682 )",
761
+ # # "0 2 0 9 8 1 2 0 6 8 2",
762
+ # # "MANAGEMENT CONSULTING S.R.L.",
763
+ # # "1001 11 2021 499,81 0,00",
764
+ # # "1712 12 2021 32,46 0,00",
765
+ # # "1701 11 2021 0,00 295,89",
766
+ # # "532,27 295,89 236,38",
767
+ # # ...]
768
+ #
769
+ # I parametri di filtro sono gli stessi di `chars_where`. I parametri
770
+ # `x_tolerance` e `y_tolerance` controllano il WordExtractor.
771
+ #
772
+ # Il separatore inter-word è due spazi (per leggibilità su moduli con
773
+ # campi spaziati); cambialo con `separator:`.
774
+ def lines(x_tolerance: 3.0, y_tolerance: 3.0, separator: " ",
775
+ font: nil, height: nil, weight: nil, bbox: nil, where: nil,
776
+ **char_opts)
777
+ cs = chars_where(font: font, height: height, weight: weight,
778
+ bbox: bbox, where: where, **char_opts)
779
+ return [] if cs.empty?
780
+
781
+ we = Util::WordExtractor.new(x_tolerance: x_tolerance,
782
+ y_tolerance: y_tolerance)
783
+ words = we.extract_words(cs)
784
+ return [] if words.empty?
785
+
786
+ # Cluster per top (con tolleranza), poi ordina per x0 dentro la riga
787
+ rows = Util::Cluster.cluster_objects(words, :top, tolerance: y_tolerance)
788
+ rows.map do |row_words|
789
+ row_words.sort_by { |w| w[:x0] }.map { |w| w[:text] }.join(separator)
790
+ end
791
+ end
792
+
793
+ # Associa label semantiche del template ai valori inseriti sulla pagina.
794
+ # Per moduli compilati (F24, Comunicazione IVA, 770, ecc.) dove il
795
+ # template e i dati sono entrambi testo statico ma in font diversi.
796
+ #
797
+ # @param data_font [String, Regexp, Array] font del layer "dati" inseriti.
798
+ # Tipicamente Courier (F24, 770) o Helvetica (Comunicazione IVA).
799
+ # Vedi `Page#font_inventory` per identificarlo.
800
+ # Associa label semantiche del template ai valori inseriti sulla pagina.
801
+ # Primitiva per estrazione strutturata da moduli compilati dove
802
+ # template e dati coesistono come testo grafico in font diversi.
803
+ #
804
+ # **Per casi avanzati** (tabelle ripetitive, merge di word multi-cella,
805
+ # output strutturato) componi con `Util::WordMerger`,
806
+ # `Util::ColumnInference`, e configura il `Util::LabelMatcher`
807
+ # opportunamente — vedi gli esempi nella docs.
808
+ #
809
+ # @param data_font [String, Regexp, Array] font del layer "dati".
810
+ # @param template_font [String, Regexp, Array, nil] font del layer
811
+ # "template". Se nil, usa tutti i char che NON sono in `data_font`.
812
+ # @param data_filter [Proc, nil] filtro opzionale sul testo dei valori.
813
+ # @param matcher [LabelMatcher, nil] istanza preconfigurata. Se nil,
814
+ # ne crea una con i default.
815
+ # @param x_tolerance, y_tolerance [Float] tolleranze per WordExtractor.
816
+ # @param char_opts [Hash] kwargs passati a `#chars` (es. `inject_spaces:
817
+ # false` per moduli a caselline).
818
+ #
819
+ # @return [Array<Hash>] uno per valore:
820
+ # { value:, labels: { col:, row: }, geometry: {...} }
821
+ def label_value_pairs(data_font:, template_font: nil,
822
+ data_filter: nil, matcher: nil,
823
+ x_tolerance: 3.0, y_tolerance: 3.0,
824
+ **char_opts)
825
+ data_chars = chars_where(font: data_font, **char_opts)
826
+ anchor_chars =
827
+ if template_font
828
+ chars_where(font: template_font, **char_opts)
829
+ else
830
+ chars(**char_opts).reject { |c| c[:generated] }.reject do |c|
831
+ send(:font_matches?, c[:font], data_font)
832
+ end
833
+ end
834
+
835
+ we = Util::WordExtractor.new(x_tolerance: x_tolerance, y_tolerance: y_tolerance)
836
+ data_words = we.extract_words(data_chars)
837
+ data_words = data_words.select { |w| data_filter.call(w[:text]) } if data_filter
838
+ anchor_words = we.extract_words(anchor_chars)
839
+
840
+ m = matcher || Util::LabelMatcher.new
841
+ m.match(data_words, anchor_words)
842
+ end
843
+
844
+ # ===== Words =====
845
+
846
+
847
+ def words(x_tolerance: 3.0, y_tolerance: 3.0, **char_opts)
848
+ cs = chars(**char_opts)
849
+ return [] if cs.empty?
850
+
851
+ # Raggruppa in righe per y
852
+ rows = group_consecutive(cs.sort_by { |c| [c[:top], c[:x0]] }) do |a, b|
853
+ (a[:top] - b[:top]).abs <= y_tolerance
854
+ end
855
+
856
+ rows.flat_map do |row|
857
+ sorted = row.sort_by { |c| c[:x0] }
858
+ # Spezza su gap > x_tolerance o spazio esplicito
859
+ word_groups = []
860
+ buf = []
861
+ sorted.each do |c|
862
+ gap = buf.empty? ? 0.0 : (c[:x0] - buf.last[:x1])
863
+ space = c[:char].match?(/\s/) || c[:generated]
864
+ if buf.empty?
865
+ buf << c unless space
866
+ elsif space || gap > x_tolerance
867
+ word_groups << buf unless buf.empty?
868
+ buf = space ? [] : [c]
869
+ else
870
+ buf << c
871
+ end
872
+ end
873
+ word_groups << buf unless buf.empty?
874
+ word_groups.map { |g| word_from_chars(g) }
875
+ end
876
+ end
877
+
878
+ # ===== Linee vettoriali (path segments REALI) =====
879
+
880
+ # Estrae tutti i segmenti di linea (LINETO) dei path objects.
881
+ # Ritorna Array<Hash>:
882
+ # :x0,:y0,:x1,:y1 estremi (top-down)
883
+ # :stroke_width spessore tratto
884
+ # :horizontal/:vertical derivati per comodità
885
+ #
886
+ # Per le tabelle interessano principalmente i segmenti orizzontali e
887
+ # verticali "puri". Beziers e segmenti obliqui vengono ignorati di default
888
+ # (passa `include_curves: true` per averli come bbox dei loro punti).
889
+ #
890
+ # Discende ricorsivamente nei Form XObjects applicando la loro matrice
891
+ # di trasformazione. Molti PDF (TeamSystem, Zucchetti, template Excel)
892
+ # incapsulano l'intera pagina in un Form XObject — senza discesa, qui
893
+ # vedremmo zero linee anche se visivamente la pagina è piena di
894
+ # bordi/separatori. Comportamento allineato a pdfminer.six (e quindi a
895
+ # pdfplumber).
896
+ # `include_curves` true: include i Bezier come segmenti (con flag :curve).
897
+ # `include_dashed` true: include le linee tratteggiate (con flag :dashed).
898
+ # Default: false. Le tratteggiate spesso sono "guide" non-visive nei
899
+ # template di stampa e confondono la detection cellule tabella. Chi
900
+ # le vuole esplicitamente (es. drawing extraction completo) passa true.
901
+ def line_segments(include_curves: false, include_dashed: false)
902
+ # Cache per parametri: line_segments viene tipicamente chiamato 2 volte
903
+ # per pagina (da horizontal_lines E da vertical_lines), e itera tutti
904
+ # i path objects della pagina via FFI — costoso su PDF con grafica
905
+ # ricca (es. CR Banca d'Italia: ~500-1000 path obj per pagina).
906
+ cache_key = [include_curves, include_dashed]
907
+ @line_segments_cache ||= {}
908
+ return @line_segments_cache[cache_key] if @line_segments_cache.key?(cache_key)
909
+
910
+ out = []
911
+ page_rotation = rotation
912
+ raw_w, raw_h = case page_rotation
913
+ when 90, 270 then [height, width]
914
+ else [width, height]
915
+ end
916
+ ctx = { rotation: page_rotation, raw_w: raw_w, raw_h: raw_h }
917
+ collect_line_segments(@state[:handle], identity_matrix, ctx,
918
+ include_curves, out, page_object: false)
919
+ result = include_dashed ? out : out.reject { |s| s[:dashed] }
920
+ @line_segments_cache[cache_key] = result
921
+ end
922
+
923
+ private
924
+
925
+ def read_char_bbox(tp, i, loose, l, r, b, t, rect)
926
+ if loose
927
+ if Raw.FPDFText_GetLooseCharBox(tp.handle, i, rect) == 1
928
+ [rect[:left], rect[:right], rect[:top], rect[:bottom]]
929
+ else
930
+ [0.0, 0.0, 0.0, 0.0]
931
+ end
932
+ else
933
+ Raw.FPDFText_GetCharBox(tp.handle, i, l, r, b, t)
934
+ [l.read_double, r.read_double, t.read_double, b.read_double]
935
+ end
936
+ end
937
+
938
+ # Matrice identità nello spazio PDF: [1, 0, 0, 1, 0, 0]
939
+ # (a, b, c, d, e, f) → (x', y') = (a*x + c*y + e, b*x + d*y + f)
940
+ def identity_matrix
941
+ { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 }
942
+ end
943
+
944
+ # Compone due trasformazioni affini PDF: applica `child` PRIMA di `parent`
945
+ # nello spazio PDF (notazione pdfminer.six "apply_matrix_norm").
946
+ # Equivale a: result = parent * child (col-major).
947
+ def compose_matrix(parent, child)
948
+ {
949
+ a: parent[:a] * child[:a] + parent[:c] * child[:b],
950
+ b: parent[:b] * child[:a] + parent[:d] * child[:b],
951
+ c: parent[:a] * child[:c] + parent[:c] * child[:d],
952
+ d: parent[:b] * child[:c] + parent[:d] * child[:d],
953
+ e: parent[:a] * child[:e] + parent[:c] * child[:f] + parent[:e],
954
+ f: parent[:b] * child[:e] + parent[:d] * child[:f] + parent[:f]
955
+ }
956
+ end
957
+
958
+ def apply_matrix(m, x, y)
959
+ [m[:a] * x + m[:c] * y + m[:e],
960
+ m[:b] * x + m[:d] * y + m[:f]]
961
+ end
962
+
963
+ def read_object_matrix(obj)
964
+ mat = Raw::FS_MATRIX.new
965
+ return identity_matrix if Raw.FPDFPageObj_GetMatrix(obj, mat) == 0
966
+
967
+ { a: mat[:a], b: mat[:b], c: mat[:c], d: mat[:d],
968
+ e: mat[:e], f: mat[:f] }
969
+ end
970
+
971
+ # Itera oggetti di una page o di un Form XObject, applicando ricorsivamente
972
+ # la matrice di trasformazione. `parent` = handle (FPDF_PAGE alla radice o
973
+ # FPDF_PAGEOBJECT per i form xobjects). `page_object: true` se parent è un
974
+ # form xobject.
975
+ def collect_line_segments(parent, ctm, rotation_ctx, include_curves, out, page_object:)
976
+ n = if page_object
977
+ Raw.FPDFFormObj_CountObjects(parent)
978
+ else
979
+ Raw.FPDFPage_CountObjects(parent)
980
+ end
981
+
982
+ n.times do |i|
983
+ obj = if page_object
984
+ Raw.FPDFFormObj_GetObject(parent, i)
985
+ else
986
+ Raw.FPDFPage_GetObject(parent, i)
987
+ end
988
+ next if obj.null?
989
+
990
+ type = Raw.FPDFPageObj_GetType(obj)
991
+ case type
992
+ when Raw::PAGEOBJ_PATH
993
+ extract_path_segments(obj, ctm, rotation_ctx, include_curves, out)
994
+ when Raw::PAGEOBJ_FORM
995
+ # Discendi nel form xobject componendo la sua matrice col CTM
996
+ child_ctm = compose_matrix(ctm, read_object_matrix(obj))
997
+ collect_line_segments(obj, child_ctm, rotation_ctx, include_curves, out,
998
+ page_object: true)
999
+ end
1000
+ end
1001
+ end
1002
+
1003
+ def extract_path_segments(obj, ctm, rotation_ctx, include_curves, out)
1004
+ return unless object_active?(obj)
1005
+
1006
+ stroke_width = read_stroke_width(obj)
1007
+ dash_count = read_dash_count(obj)
1008
+ dashed = dash_count > 0
1009
+
1010
+ path_ctm = compose_matrix(ctm, read_object_matrix(obj))
1011
+
1012
+ seg_count = Raw.FPDFPath_CountSegments(obj)
1013
+ current = nil
1014
+ first_in_subpath = nil
1015
+
1016
+ seg_count.times do |si|
1017
+ seg = Raw.FPDFPath_GetPathSegment(obj, si)
1018
+ next if seg.null?
1019
+
1020
+ x_buf = FFI::MemoryPointer.new(:float)
1021
+ y_buf = FFI::MemoryPointer.new(:float)
1022
+ Raw.FPDFPathSegment_GetPoint(seg, x_buf, y_buf)
1023
+ local_x = x_buf.read_float
1024
+ local_y = y_buf.read_float
1025
+ x, y = apply_matrix(path_ctm, local_x, local_y)
1026
+ type = Raw.FPDFPathSegment_GetType(seg)
1027
+ closes = Raw.FPDFPathSegment_GetClose(seg) == 1
1028
+
1029
+ case type
1030
+ when Raw::SEGMENT_MOVETO
1031
+ current = [x, y]
1032
+ first_in_subpath = current.dup
1033
+ when Raw::SEGMENT_LINETO
1034
+ out << build_segment(current[0], current[1], x, y, rotation_ctx,
1035
+ stroke_width, dashed: dashed) if current
1036
+ current = [x, y]
1037
+ when Raw::SEGMENT_BEZIERTO
1038
+ if include_curves && current
1039
+ out << build_segment(current[0], current[1], x, y, rotation_ctx,
1040
+ stroke_width, dashed: dashed)
1041
+ .merge(curve: true)
1042
+ end
1043
+ current = [x, y]
1044
+ end
1045
+
1046
+ if closes && current && first_in_subpath
1047
+ out << build_segment(current[0], current[1],
1048
+ first_in_subpath[0], first_in_subpath[1],
1049
+ rotation_ctx, stroke_width, dashed: dashed)
1050
+ current = first_in_subpath.dup
1051
+ end
1052
+ end
1053
+ end
1054
+
1055
+ # FPDFPageObj_GetIsActive: ritorna true se il page object è marcato
1056
+ # attivo (visibile). Su PDF senza Optional Content, è always-true; su
1057
+ # PDF con layer disabilitati, alcuni obj possono essere inactive.
1058
+ # Fallback: se la binding non c'è o fallisce, consideriamo attivo
1059
+ # (comportamento equivalente alla versione pre-0.3.6).
1060
+ def object_active?(obj)
1061
+ active_buf = FFI::MemoryPointer.new(:int)
1062
+ return true if Raw.FPDFPageObj_GetIsActive(obj, active_buf) == 0
1063
+
1064
+ active_buf.read_int != 0
1065
+ rescue Rpdfium::LoadError
1066
+ true
1067
+ end
1068
+
1069
+ # FPDFPageObj_GetDashCount: numero di elementi del dash array. 0 =
1070
+ # linea continua, > 0 = linea tratteggiata (con N elementi
1071
+ # alternati on/off).
1072
+ def read_dash_count(obj)
1073
+ Raw.FPDFPageObj_GetDashCount(obj)
1074
+ rescue Rpdfium::LoadError
1075
+ 0
1076
+ end
1077
+
1078
+ public
1079
+
1080
+ # Linee orizzontali: dy ~ 0 entro tolleranza
1081
+ def horizontal_lines(tolerance: 0.5)
1082
+ line_segments.select { |s| (s[:y0] - s[:y1]).abs <= tolerance }
1083
+ .map { |s| { y: (s[:y0] + s[:y1]) / 2.0,
1084
+ x0: [s[:x0], s[:x1]].min,
1085
+ x1: [s[:x0], s[:x1]].max,
1086
+ stroke_width: s[:stroke_width] } }
1087
+ end
1088
+
1089
+ # Linee verticali: dx ~ 0 entro tolleranza
1090
+ def vertical_lines(tolerance: 0.5)
1091
+ line_segments.select { |s| (s[:x0] - s[:x1]).abs <= tolerance }
1092
+ .map { |s| { x: (s[:x0] + s[:x1]) / 2.0,
1093
+ top: [s[:y0], s[:y1]].min,
1094
+ bottom: [s[:y0], s[:y1]].max,
1095
+ stroke_width: s[:stroke_width] } }
1096
+ end
1097
+
1098
+ # Compat con la prima versione: bbox dei path objects (utile per
1099
+ # rectangles disegnati come bordi sottili).
1100
+ def vector_rects
1101
+ n = Raw.FPDFPage_CountObjects(@state[:handle])
1102
+ h = height
1103
+ out = []
1104
+
1105
+ l = FFI::MemoryPointer.new(:float)
1106
+ r = FFI::MemoryPointer.new(:float)
1107
+ b = FFI::MemoryPointer.new(:float)
1108
+ t = FFI::MemoryPointer.new(:float)
1109
+
1110
+ n.times do |i|
1111
+ obj = Raw.FPDFPage_GetObject(@state[:handle], i)
1112
+ next if obj.null?
1113
+ next unless Raw.FPDFPageObj_GetType(obj) == Raw::PAGEOBJ_PATH
1114
+ next unless Raw.FPDFPageObj_GetBounds(obj, l, r, b, t) == 1
1115
+
1116
+ out << { x0: l.read_float, x1: r.read_float,
1117
+ top: h - t.read_float, bottom: h - b.read_float }
1118
+ end
1119
+ out
1120
+ end
1121
+
1122
+ # ===== Marked Content (PDF tagged) =====
1123
+
1124
+ # Itera tutti i marked content del page (operatori BDC/BMC del content
1125
+ # stream PDF) raggruppando i page object per il loro mcid (Marked
1126
+ # Content ID). Utile per PDF "tagged" (PDF/UA, esport da Word/InDesign):
1127
+ # un mcid ≥ 0 identifica un'unità semantica (paragrafo, span, figura),
1128
+ # e tutti gli oggetti con lo stesso mcid appartengono allo stesso
1129
+ # tag struttura.
1130
+ #
1131
+ # Ritorna un Hash { mcid (Integer) => Array<page_object_handle> }.
1132
+ # mcid -1 (i page object senza marked content) viene OMESSO.
1133
+ #
1134
+ # Su PDF non tagged (es. la maggior parte dei PDF da gestionali
1135
+ # italiani) l'Hash è vuoto. Su PDF tagged è la fonte di verità per
1136
+ # raggruppare semanticamente char/parole — più affidabile di qualsiasi
1137
+ # euristica geometrica.
1138
+ def marked_content_regions
1139
+ out = Hash.new { |h, k| h[k] = [] }
1140
+ walk_page_objects do |obj, _ctm|
1141
+ mcid = read_marked_content_id(obj)
1142
+ out[mcid] << obj if mcid >= 0
1143
+ end
1144
+ out
1145
+ end
1146
+
1147
+ # Itera tutti i marks (BMC/BDC operators) con i loro nomi e parametri.
1148
+ # Ritorna Array<Hash> con { obj_handle, mark_name, params }.
1149
+ # Per PDF tagged, i mark_name comuni sono: "P" (paragraph),
1150
+ # "Span", "Artifact", "Figure", "TR" (table row), "TD" (table cell).
1151
+ def marked_content_inventory
1152
+ out = []
1153
+ walk_page_objects do |obj, _ctm|
1154
+ mark_count = safely_count_marks(obj)
1155
+ mark_count.times do |mi|
1156
+ mark = Raw.FPDFPageObj_GetMark(obj, mi)
1157
+ next if mark.null?
1158
+
1159
+ out << {
1160
+ obj: obj,
1161
+ mark_name: read_mark_name(mark),
1162
+ params: read_mark_params(mark)
1163
+ }
1164
+ end
1165
+ end
1166
+ out
1167
+ end
1168
+
1169
+ # ===== Links (annotation links + hit-test posizionale) =====
1170
+
1171
+ # Hit-test: ritorna il link annotation che contiene il punto (x, y)
1172
+ # in coordinate top-down della pagina. Restituisce un'istanza di
1173
+ # Annotation o nil.
1174
+ #
1175
+ # Più efficiente di iterare `links` quando si parte da una coordinata
1176
+ # (es. mapping click sul rendering → URL del link). Pdfplumber non
1177
+ # ha equivalente diretto.
1178
+ def link_at(x, y)
1179
+ # PDFium usa coord bottom-up; converto
1180
+ pdf_y = height - y
1181
+ link_handle = Raw.FPDFLink_GetLinkAtPoint(@state[:handle],
1182
+ x.to_f, pdf_y.to_f)
1183
+ return nil if link_handle.null?
1184
+
1185
+ annot_handle = Raw.FPDFLink_GetAnnot(@state[:handle], link_handle)
1186
+ return nil if annot_handle.null?
1187
+
1188
+ # Annotation richiede un index nel page; non lo abbiamo direttamente
1189
+ # qui. Iteriamo le annotation della pagina e troviamo quella col
1190
+ # rect più vicino. Per la maggior parte dei PDF è O(piccolo).
1191
+ annotations.find { |a| a.subtype == :link && annotation_contains?(a, x, y) }
1192
+ end
1193
+
1194
+
1195
+
1196
+ def images
1197
+ n = Raw.FPDFPage_CountObjects(@state[:handle])
1198
+ out = []
1199
+ n.times do |i|
1200
+ obj = Raw.FPDFPage_GetObject(@state[:handle], i)
1201
+ next if obj.null?
1202
+ next unless Raw.FPDFPageObj_GetType(obj) == Raw::PAGEOBJ_IMAGE
1203
+
1204
+ out << Image::Embedded.new(self, obj)
1205
+ end
1206
+ out
1207
+ end
1208
+
1209
+ # ===== Annotazioni =====
1210
+
1211
+ def annotations
1212
+ n = Raw.FPDFPage_GetAnnotCount(@state[:handle])
1213
+ Array.new(n) { |i| Annotation.new(self, i) }
1214
+ end
1215
+
1216
+ # Solo annotazioni link (cliccabili, esterne o interne)
1217
+ def links
1218
+ annotations.select { |a| a.subtype == :link }
1219
+ end
1220
+
1221
+ # Solo widget di form
1222
+ def form_fields
1223
+ return [] unless @document.has_forms?
1224
+
1225
+ annotations.select { |a| a.subtype == :widget }
1226
+ .map { |a| Form::Field.new(@document.form_env, a) }
1227
+ end
1228
+
1229
+ # ===== Struct Tree (PDF tagged) =====
1230
+
1231
+ # Struct tree della pagina (PDF/UA / Tagged PDF). Ritorna nil se la
1232
+ # pagina non è tagged. Per PDF da Word/LibreOffice/InDesign export
1233
+ # con accessibility tags attivati, espone la struttura logica
1234
+ # (Document → P, H1, Table, TR, TH, TD, Figure, ecc.).
1235
+ #
1236
+ # Modalità d'uso:
1237
+ #
1238
+ # # Lifecycle automatico (RAII via finalizer):
1239
+ # tree = page.struct_tree
1240
+ # tree&.walk { |el| puts el.type }
1241
+ #
1242
+ # # Lifecycle deterministico (close al fine blocco):
1243
+ # page.struct_tree do |tree|
1244
+ # tree.tables.each { |t| ... }
1245
+ # end
1246
+ #
1247
+ # Su PDF non tagged ritorna nil. Su PDF "tagged ma vuoto" (es. CR
1248
+ # Banca d'Italia, StructTreeRoot presente ma con element placeholder),
1249
+ # ritorna un Tree con `Tree#empty? == true`.
1250
+ def struct_tree
1251
+ tree = Structure::Tree.for_page(self)
1252
+ if block_given?
1253
+ begin
1254
+ yield tree
1255
+ ensure
1256
+ tree&.close
1257
+ end
1258
+ else
1259
+ tree
1260
+ end
1261
+ end
1262
+
1263
+ # ===== Rendering =====
1264
+
1265
+ # Render a bitmap. `output` può essere :rgba (default), :bgra, :gray.
1266
+ # Ritorna [w, h, bytes] dove bytes è una stringa binaria.
1267
+ # Se include_forms è true e il documento ha forms, sovrappone i widget.
1268
+ def render(scale: 2.0, rotate: 0, output: :rgba,
1269
+ include_annotations: false, include_forms: false,
1270
+ background: 0xFFFFFFFF)
1271
+ w = (width * scale).round
1272
+ h = (height * scale).round
1273
+ flags = 0
1274
+ flags |= Raw::FPDF_ANNOT if include_annotations
1275
+ flags |= Raw::FPDF_REVERSE_BYTE_ORDER if output == :rgba
1276
+ format = output == :gray ? Raw::FPDFBitmap_Gray : Raw::FPDFBitmap_BGRA
1277
+
1278
+ bitmap = Raw.FPDFBitmap_CreateEx(w, h, format, FFI::Pointer::NULL, 0)
1279
+ raise Error, "Bitmap allocation failed" if bitmap.null?
1280
+
1281
+ begin
1282
+ Raw.FPDFBitmap_FillRect(bitmap, 0, 0, w, h, background)
1283
+ Raw.FPDF_RenderPageBitmap(bitmap, @state[:handle], 0, 0, w, h,
1284
+ rotation_index(rotate), flags)
1285
+ if include_forms && @document.form_env
1286
+ Raw.FPDF_FFLDraw(@document.form_env.handle, bitmap, @state[:handle],
1287
+ 0, 0, w, h, rotation_index(rotate), flags)
1288
+ end
1289
+ stride = Raw.FPDFBitmap_GetStride(bitmap)
1290
+ buf = Raw.FPDFBitmap_GetBuffer(bitmap)
1291
+ # Lo stride può eccedere w*bpp per padding di allineamento.
1292
+ # In BGRA è quasi sempre w*4, ma rispettiamolo per sicurezza.
1293
+ bytes = buf.read_bytes(stride * h)
1294
+ [w, h, bytes, stride]
1295
+ ensure
1296
+ Raw.FPDFBitmap_Destroy(bitmap)
1297
+ end
1298
+ end
1299
+
1300
+ # Rendering diretto a PNG file. Usa Rpdfium::IO::PNG (puro Ruby, zero dep).
1301
+ def render_to_png(path, **opts)
1302
+ w, h, bytes, stride = render(output: :rgba, **opts)
1303
+ Rpdfium::IO::PNG.write(path, w, h, bytes, stride: stride)
1304
+ path
1305
+ end
1306
+
1307
+ # ===== Search =====
1308
+
1309
+ def search(query, **opts)
1310
+ Search.new(self, query, **opts)
1311
+ end
1312
+
1313
+ # ===== Internals =====
1314
+
1315
+ def text_page
1316
+ @text_page ||= TextPage.new(self)
1317
+ end
1318
+
1319
+ def close
1320
+ return if @state[:closed]
1321
+
1322
+ @text_page&.close
1323
+ Raw.FPDF_ClosePage(@state[:handle]) unless @state[:handle].null?
1324
+ @state[:handle] = FFI::Pointer::NULL
1325
+ @state[:closed] = true
1326
+ ObjectSpace.undefine_finalizer(self)
1327
+ end
1328
+
1329
+ private
1330
+
1331
+ # Match helper per il parametro `font:` di chars_where/lines.
1332
+ def font_matches?(actual_font, pattern)
1333
+ return false if actual_font.nil?
1334
+
1335
+ case pattern
1336
+ when String then actual_font == pattern
1337
+ when Regexp then actual_font.match?(pattern)
1338
+ when Array then pattern.any? { |p| font_matches?(actual_font, p) }
1339
+ else false
1340
+ end
1341
+ end
1342
+
1343
+ # Match helper per parametri numerici (`height:`, `weight:`).
1344
+ # Accetta singolo valore, Range, o Array<Numeric>. Per singolo valore
1345
+ # numeric usa tolleranza 0.05 (utile per height in punti).
1346
+ def range_matches?(actual, spec)
1347
+ return false if actual.nil?
1348
+
1349
+ case spec
1350
+ when Range then spec.cover?(actual)
1351
+ when Array then spec.any? { |s| range_matches?(actual, s) }
1352
+ when Numeric then (actual - spec).abs < 0.1
1353
+ else false
1354
+ end
1355
+ end
1356
+
1357
+ # Converte un box PDFium {left, bottom, right, top} in coord bottom-up
1358
+ # alla tuple top-down [x0, top, x1, bottom] usata dal resto della
1359
+ # libreria. Ritorna nil se il box è nil (box assente sul PDF).
1360
+ # Itera tutti i page object della pagina ricorsivamente (discendendo
1361
+ # nei Form XObjects), passando al block ogni (obj, ctm_corrente).
1362
+ # Stessa logica di walk di collect_line_segments ma astratta — utile
1363
+ # per altre operazioni a livello di obj (marked content, etc).
1364
+ def walk_page_objects(handle = @state[:handle], ctm = identity_matrix,
1365
+ is_form: false, &block)
1366
+ n = is_form ? Raw.FPDFFormObj_CountObjects(handle) : Raw.FPDFPage_CountObjects(handle)
1367
+ n.times do |i|
1368
+ obj = is_form ? Raw.FPDFFormObj_GetObject(handle, i) : Raw.FPDFPage_GetObject(handle, i)
1369
+ next if obj.null?
1370
+
1371
+ block.call(obj, ctm)
1372
+
1373
+ if Raw.FPDFPageObj_GetType(obj) == Raw::PAGEOBJ_FORM
1374
+ child_ctm = compose_matrix(ctm, read_object_matrix(obj))
1375
+ walk_page_objects(obj, child_ctm, is_form: true, &block)
1376
+ end
1377
+ end
1378
+ end
1379
+
1380
+ def read_marked_content_id(obj)
1381
+ Raw.FPDFPageObj_GetMarkedContentID(obj)
1382
+ rescue Rpdfium::LoadError
1383
+ -1
1384
+ end
1385
+
1386
+ def safely_count_marks(obj)
1387
+ Raw.FPDFPageObj_CountMarks(obj)
1388
+ rescue Rpdfium::LoadError
1389
+ 0
1390
+ end
1391
+
1392
+ def read_mark_name(mark)
1393
+ out_len = FFI::MemoryPointer.new(:ulong)
1394
+ buf_bytes = 256
1395
+ name_buf = FFI::MemoryPointer.new(:uint8, buf_bytes)
1396
+ return nil if Raw.FPDFPageObjMark_GetName(mark, name_buf, buf_bytes,
1397
+ out_len) == 0
1398
+
1399
+ needed = out_len.read_ulong
1400
+ return nil if needed < 2
1401
+
1402
+ # Clamp: se needed eccede il buffer, leggo solo quanto allocato (e
1403
+ # mi pace che la stringa sia troncata: il caso è patologico). Senza
1404
+ # clamp → IndexError su mark name eccezionalmente lunghi.
1405
+ payload_bytes = [needed - 2, buf_bytes - 2].min
1406
+ return nil if payload_bytes <= 0
1407
+
1408
+ name_buf.read_bytes(payload_bytes)
1409
+ .force_encoding("UTF-16LE")
1410
+ .encode("UTF-8")
1411
+ .delete("\u0000")
1412
+ end
1413
+
1414
+ def read_mark_params(mark)
1415
+ params = {}
1416
+ count = Raw.FPDFPageObjMark_CountParams(mark)
1417
+ count.times do |pi|
1418
+ key = read_mark_param_key(mark, pi)
1419
+ next if key.nil? || key.empty?
1420
+
1421
+ # Tipo del valore: 0=Null, 1=Int, 2=String, 3=Blob, 4=Dict (ignorato)
1422
+ type = Raw.FPDFPageObjMark_GetParamValueType(mark, key)
1423
+ params[key] = case type
1424
+ when 1 then read_mark_param_int(mark, key)
1425
+ when 2, 3 then read_mark_param_string(mark, key)
1426
+ end
1427
+ end
1428
+ params
1429
+ end
1430
+
1431
+ def read_mark_param_key(mark, index)
1432
+ out_len = FFI::MemoryPointer.new(:ulong)
1433
+ buf_bytes = 128
1434
+ key_buf = FFI::MemoryPointer.new(:uint8, buf_bytes)
1435
+ return nil if Raw.FPDFPageObjMark_GetParamKey(mark, index,
1436
+ key_buf, buf_bytes,
1437
+ out_len) == 0
1438
+
1439
+ needed = out_len.read_ulong
1440
+ return nil if needed < 2
1441
+
1442
+ payload_bytes = [needed - 2, buf_bytes - 2].min
1443
+ return nil if payload_bytes <= 0
1444
+
1445
+ key_buf.read_bytes(payload_bytes)
1446
+ .force_encoding("UTF-16LE")
1447
+ .encode("UTF-8")
1448
+ .delete("\u0000")
1449
+ end
1450
+
1451
+ def read_mark_param_int(mark, key)
1452
+ buf = FFI::MemoryPointer.new(:int)
1453
+ return nil if Raw.FPDFPageObjMark_GetParamIntValue(mark, key, buf) == 0
1454
+
1455
+ buf.read_int
1456
+ end
1457
+
1458
+ def read_mark_param_string(mark, key)
1459
+ out_len = FFI::MemoryPointer.new(:ulong)
1460
+ buf_bytes = 512
1461
+ val_buf = FFI::MemoryPointer.new(:uint8, buf_bytes)
1462
+ return nil if Raw.FPDFPageObjMark_GetParamStringValue(mark, key,
1463
+ val_buf, buf_bytes,
1464
+ out_len) == 0
1465
+
1466
+ needed = out_len.read_ulong
1467
+ return nil if needed < 2
1468
+
1469
+ payload_bytes = [needed - 2, buf_bytes - 2].min
1470
+ return nil if payload_bytes <= 0
1471
+
1472
+ val_buf.read_bytes(payload_bytes)
1473
+ .force_encoding("UTF-16LE")
1474
+ .encode("UTF-8")
1475
+ .delete("\u0000")
1476
+ end
1477
+
1478
+ def annotation_contains?(annot, x, y)
1479
+ rect = annot.rect
1480
+ return false unless rect
1481
+
1482
+ x >= rect[:x0] && x <= rect[:x1] && y >= rect[:top] && y <= rect[:bottom]
1483
+ end
1484
+
1485
+ def box_to_topdown(box)
1486
+ return nil unless box
1487
+
1488
+ page_h = height
1489
+ [box[:left], page_h - box[:top],
1490
+ box[:right], page_h - box[:bottom]]
1491
+ end
1492
+
1493
+ def safe_codepoint(cp)
1494
+ return "" if cp.zero?
1495
+ return "" if cp > 0x10FFFF || (0xD800..0xDFFF).cover?(cp)
1496
+
1497
+ [cp].pack("U")
1498
+ rescue RangeError, ArgumentError
1499
+ ""
1500
+ end
1501
+
1502
+ def read_stroke_width(obj)
1503
+ buf = FFI::MemoryPointer.new(:float)
1504
+ return 1.0 if Raw.FPDFPageObj_GetStrokeWidth(obj, buf) == 0
1505
+
1506
+ buf.read_float
1507
+ end
1508
+
1509
+ # Costruisce un segmento dalla coppia di endpoint nello spazio raw
1510
+ # PDFium (bottom-up, pre-rotazione). Applica la rotazione della pagina
1511
+ # per restituire coord top-down nel sistema post-rotation, coerente
1512
+ # con il sistema usato da `chars`.
1513
+ def build_segment(x0, y0, x1, y1, rotation_ctx, stroke_width, dashed: false)
1514
+ r = rotation_ctx[:rotation]
1515
+ raw_w = rotation_ctx[:raw_w]
1516
+ raw_h = rotation_ctx[:raw_h]
1517
+
1518
+ nx0, ny0 = apply_page_rotation_to_point(r, raw_w, raw_h, x0, y0)
1519
+ nx1, ny1 = apply_page_rotation_to_point(r, raw_w, raw_h, x1, y1)
1520
+
1521
+ {
1522
+ x0: nx0, y0: ny0,
1523
+ x1: nx1, y1: ny1,
1524
+ stroke_width: stroke_width,
1525
+ dashed: dashed
1526
+ }
1527
+ end
1528
+
1529
+ # Trasforma un singolo punto (x, y) dal sistema raw PDFium (bottom-up)
1530
+ # al sistema top-down post-rotation della pagina.
1531
+ def apply_page_rotation_to_point(rotation, raw_w, raw_h, x, y)
1532
+ case rotation
1533
+ when 0, nil
1534
+ [x, raw_h - y] # bottom-up → top-down
1535
+ when 90
1536
+ [y, x] # 90° CW
1537
+ when 180
1538
+ [raw_w - x, y]
1539
+ when 270
1540
+ [raw_h - y, raw_w - x]
1541
+ else
1542
+ [x, raw_h - y]
1543
+ end
1544
+ end
1545
+
1546
+ # Raggruppa elementi consecutivi se un blocco li considera equivalenti.
1547
+ def group_consecutive(arr)
1548
+ groups = []
1549
+ current = []
1550
+ arr.each do |elem|
1551
+ if current.empty? || yield(current.last, elem)
1552
+ current << elem
1553
+ else
1554
+ groups << current
1555
+ current = [elem]
1556
+ end
1557
+ end
1558
+ groups << current unless current.empty?
1559
+ groups
1560
+ end
1561
+
1562
+ def word_from_chars(chars)
1563
+ {
1564
+ text: chars.map { |c| c[:char] }.join,
1565
+ x0: chars.first[:x0],
1566
+ x1: chars.last[:x1],
1567
+ top: chars.map { |c| c[:top] }.min,
1568
+ bottom: chars.map { |c| c[:bottom] }.max,
1569
+ fontsize: chars.first[:fontsize],
1570
+ font: chars.first[:font],
1571
+ chars: chars
1572
+ }
1573
+ end
1574
+
1575
+ def rotation_index(rotate)
1576
+ case rotate
1577
+ when 0 then 0
1578
+ when 90 then 1
1579
+ when 180 then 2
1580
+ when 270 then 3
1581
+ else (rotate / 90) % 4
1582
+ end
1583
+ end
1584
+ end
1585
+
1586
+ # Wrapper per FPDF_TEXTPAGE
1587
+ class TextPage
1588
+ def initialize(page)
1589
+ handle = Raw.FPDFText_LoadPage(page.handle)
1590
+ raise PageError, "Could not load text page" if handle.null?
1591
+
1592
+ @state = { handle: handle, closed: false }
1593
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@state))
1594
+ end
1595
+
1596
+ def self.finalizer(state)
1597
+ proc do
1598
+ next if state[:closed]
1599
+ next if state[:handle].null?
1600
+
1601
+ Raw.FPDFText_ClosePage(state[:handle])
1602
+ state[:closed] = true
1603
+ end
1604
+ end
1605
+
1606
+ def handle
1607
+ @state[:handle]
1608
+ end
1609
+
1610
+ def char_count
1611
+ Raw.FPDFText_CountChars(@state[:handle])
1612
+ end
1613
+
1614
+ def close
1615
+ return if @state[:closed]
1616
+
1617
+ Raw.FPDFText_ClosePage(@state[:handle]) unless @state[:handle].null?
1618
+ @state[:handle] = FFI::Pointer::NULL
1619
+ @state[:closed] = true
1620
+ ObjectSpace.undefine_finalizer(self)
1621
+ end
1622
+ end
1623
+ end