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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ # Ricerca testuale interna alla pagina, basata su FPDFText_Find*.
5
+ # Mantiene lo stato (cursor) e supporta forward/backward.
6
+ #
7
+ # Esempio:
8
+ # page.search("totale").each_match { |m| p m[:bbox], m[:text] }
9
+ class Search
10
+ include Enumerable
11
+
12
+ def initialize(page, query, match_case: false, whole_word: false, start_index: 0)
13
+ @page = page
14
+ @query = query
15
+ @start_index = start_index
16
+ flags = 0
17
+ flags |= Raw::FPDF_MATCHCASE if match_case
18
+ flags |= Raw::FPDF_MATCHWHOLEWORD if whole_word
19
+
20
+ utf16 = query.encode("UTF-16LE") + "\x00\x00".b
21
+ @query_buf = FFI::MemoryPointer.new(:uchar, utf16.bytesize)
22
+ @query_buf.put_bytes(0, utf16)
23
+
24
+ handle = Raw.FPDFText_FindStart(@page.text_page.handle, @query_buf,
25
+ flags, start_index)
26
+ raise Error, "FindStart failed" if handle.null?
27
+
28
+ @state = { handle: handle, closed: false }
29
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@state))
30
+ end
31
+
32
+ def self.finalizer(state)
33
+ proc do
34
+ next if state[:closed]
35
+ next if state[:handle].null?
36
+
37
+ Raw.FPDFText_FindClose(state[:handle])
38
+ state[:closed] = true
39
+ end
40
+ end
41
+
42
+ def handle
43
+ @state[:handle]
44
+ end
45
+
46
+ # Itera tutte le occorrenze in avanti. Ritorna hash con :char_index, :length,
47
+ # :text, :rects (array di bbox top-down: una per riga di testo).
48
+ def each_match
49
+ return enum_for(:each_match) unless block_given?
50
+
51
+ while Raw.FPDFText_FindNext(@state[:handle]) == 1
52
+ yield current_match
53
+ end
54
+ end
55
+ alias each each_match
56
+
57
+ def current_match
58
+ idx = Raw.FPDFText_GetSchResultIndex(@state[:handle])
59
+ n = Raw.FPDFText_GetSchCount(@state[:handle])
60
+ {
61
+ char_index: idx,
62
+ length: n,
63
+ text: extract_text(idx, n),
64
+ rects: extract_rects(idx, n)
65
+ }
66
+ end
67
+
68
+ def close
69
+ return if @state[:closed]
70
+
71
+ Raw.FPDFText_FindClose(@state[:handle]) unless @state[:handle].null?
72
+ @state[:handle] = FFI::Pointer::NULL
73
+ @state[:closed] = true
74
+ ObjectSpace.undefine_finalizer(self)
75
+ end
76
+
77
+ private
78
+
79
+ def extract_text(idx, n)
80
+ buf = FFI::MemoryPointer.new(:ushort, n + 1)
81
+ Raw.FPDFText_GetText(@page.text_page.handle, idx, n, buf)
82
+ buf.read_bytes((n + 1) * 2).force_encoding("UTF-16LE")
83
+ .encode("UTF-8", invalid: :replace, undef: :replace)
84
+ .delete("\x00")
85
+ end
86
+
87
+ def extract_rects(idx, n)
88
+ cnt = Raw.FPDFText_CountRects(@page.text_page.handle, idx, n)
89
+ h = @page.height
90
+ Array.new(cnt) do |ri|
91
+ l = FFI::MemoryPointer.new(:double)
92
+ t = FFI::MemoryPointer.new(:double)
93
+ r = FFI::MemoryPointer.new(:double)
94
+ b = FFI::MemoryPointer.new(:double)
95
+ Raw.FPDFText_GetRect(@page.text_page.handle, ri, l, t, r, b)
96
+ { x0: l.read_double, x1: r.read_double,
97
+ top: h - t.read_double, bottom: h - b.read_double }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ # File embedded nel PDF (allegati). PDFium li espone via FPDFDoc_GetAttachment.
5
+ class Attachment
6
+ attr_reader :document, :index, :handle
7
+
8
+ def initialize(document, index)
9
+ @document = document
10
+ @index = index
11
+ @handle = Raw.FPDFDoc_GetAttachment(document.handle, index)
12
+ raise Error, "Attachment #{index} not found" if @handle.null?
13
+ end
14
+
15
+ def name
16
+ Raw.read_utf16_string(:FPDFAttachment_GetName, @handle)
17
+ end
18
+
19
+ # Ritorna i bytes del file allegato. Pattern probe-then-fetch.
20
+ def bytes
21
+ out_size = FFI::MemoryPointer.new(:ulong)
22
+ Raw.FPDFAttachment_GetFile(@handle, FFI::Pointer::NULL, 0, out_size)
23
+ n = out_size.read_ulong
24
+ return "" if n.zero?
25
+
26
+ buf = FFI::MemoryPointer.new(:uchar, n)
27
+ Raw.FPDFAttachment_GetFile(@handle, buf, n, out_size)
28
+ # Leggo n byte (la dimensione del MIO buffer), non out_size.read_ulong:
29
+ # PDFium può aggiornare out_size con un valore diverso da n (es. dim
30
+ # totale necessaria) che leggerebbe oltre il buffer → IndexError.
31
+ # Se la write effettiva è < n, riempie il resto con NUL.
32
+ buf.read_bytes(n)
33
+ end
34
+
35
+ def save(path)
36
+ File.binwrite(path, bytes)
37
+ path
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ module Structure
5
+ # Element di un PDF tagged StructTree.
6
+ #
7
+ # Un Element rappresenta un nodo della struttura logica del documento:
8
+ # `Document`, `P` (paragrafo), `H1`..`H6` (headings), `Table`, `TR`,
9
+ # `TH`, `TD`, `Figure`, `Span`, `Lbl`, `LI`, `Caption`, ecc. Vedi
10
+ # PDF spec §14.8 per la tassonomia completa.
11
+ #
12
+ # Gli element non hanno una vita autonoma: appartengono al Tree che li
13
+ # ha generati. Quando il Tree viene chiuso, gli element diventano
14
+ # invalidi. Non chiamare metodi su un element dopo `tree.close`.
15
+ #
16
+ # Tutti i metodi sono read-only: PDFium non espone API per modificare
17
+ # il StructTree (è una struttura "di sola lettura" anche nel suo C API
18
+ # pubblico).
19
+ class Element
20
+ attr_reader :handle, :tree
21
+
22
+ def initialize(tree, handle)
23
+ @tree = tree
24
+ @handle = handle
25
+ end
26
+
27
+ # Tipo strutturale dell'element (es. "P", "H1", "Table", "TR", "TD").
28
+ # Nil se PDFium non riesce a leggerlo (element placeholder).
29
+ def type
30
+ read_utf16_string(:FPDF_StructElement_GetType)
31
+ end
32
+
33
+ # Tipo dell'oggetto PDF sottostante: di solito "StructElem", ma può
34
+ # essere "MCR" (Marked Content Reference) o "OBJR" (Object Reference)
35
+ # per nodi specializzati. La maggior parte degli utenti usa `type`.
36
+ def obj_type
37
+ read_utf16_string(:FPDF_StructElement_GetObjType)
38
+ end
39
+
40
+ # Title attribute (raro, usato in alcuni documenti per dare un nome
41
+ # parlante all'element, es. "Capitolo 1").
42
+ def title
43
+ read_utf16_string(:FPDF_StructElement_GetTitle)
44
+ end
45
+
46
+ # ID univoco dell'element (se dichiarato nel /ID dictionary del
47
+ # StructTreeRoot). Permette riferimenti cross-element (es. Headers
48
+ # attribute di una cella TD che punta a un TH per id).
49
+ def id
50
+ read_utf16_string(:FPDF_StructElement_GetID)
51
+ end
52
+
53
+ # Lingua dichiarata sull'element (es. "it-IT", "en-US"). Ereditata
54
+ # dal parent se non sovrascritta. Utile per pipeline language-aware.
55
+ def lang
56
+ read_utf16_string(:FPDF_StructElement_GetLang)
57
+ end
58
+
59
+ # ActualText: override del testo "logico" per l'element. Risolve
60
+ # legature (PDF mostra `fi` ma actual_text dice "fi"), simboli math
61
+ # ("∫" → "integral"), abbreviazioni. Se presente, ha priorità sul
62
+ # testo grafico per accessibility e ricerca.
63
+ def actual_text
64
+ read_utf16_string(:FPDF_StructElement_GetActualText)
65
+ end
66
+
67
+ # AltText: testo alternativo per Figure / Formula / immagini. PDF/UA
68
+ # richiede che ogni Figure abbia un alt_text non vuoto.
69
+ def alt_text
70
+ read_utf16_string(:FPDF_StructElement_GetAltText)
71
+ end
72
+
73
+ # Expansion text per abbreviazioni (es. element type "Span" con
74
+ # contenuto "Dr." e expansion "Doctor"). Usato per text-to-speech.
75
+ def expansion
76
+ read_utf16_string(:FPDF_StructElement_GetExpansion)
77
+ end
78
+
79
+ # Marked Content IDs collegati a questo element. Un element ha tipicamente
80
+ # 1 MCID (es. una `<P>` ha tutto il testo del paragrafo dentro un BDC con
81
+ # mcid=N) oppure 0 (element strutturale puro: `<Document>`, `<Table>`,
82
+ # `<TR>` — i loro MCID stanno nei figli foglia).
83
+ #
84
+ # Per collegare un MCID al testo della pagina: leggi i page object e
85
+ # raggruppa per `FPDFPageObj_GetMarkedContentID`. Vedi `Element#text`.
86
+ def marked_content_ids
87
+ first = Raw.FPDF_StructElement_GetMarkedContentID(@handle)
88
+ count = Raw.FPDF_StructElement_GetMarkedContentIdCount(@handle)
89
+ # Casi: GetMarkedContentIdCount ritorna -1 quando non ci sono MCID
90
+ # diretti (element strutturale). GetMarkedContentID ritorna -1
91
+ # nello stesso caso.
92
+ return [] if count <= 0 && first < 0
93
+
94
+ # Quando esiste un solo MCID, GetMarkedContentIdCount può ritornare
95
+ # 0 o -1 mentre GetMarkedContentID dà il valore. Coalescenza:
96
+ if count <= 0
97
+ first >= 0 ? [first] : []
98
+ else
99
+ (0...count).filter_map do |i|
100
+ mcid = Raw.FPDF_StructElement_GetMarkedContentIdAtIndex(@handle, i)
101
+ mcid >= 0 ? mcid : nil
102
+ end
103
+ end
104
+ end
105
+
106
+ # Figli diretti dell'element. Ordinati come dichiarati nel PDF
107
+ # (top-to-bottom, left-to-right per reading order).
108
+ def children
109
+ n = Raw.FPDF_StructElement_CountChildren(@handle)
110
+ return [] if n <= 0
111
+
112
+ (0...n).filter_map do |i|
113
+ child_handle = Raw.FPDF_StructElement_GetChildAtIndex(@handle, i)
114
+ child_handle.null? ? nil : Element.new(@tree, child_handle)
115
+ end
116
+ end
117
+
118
+ # Parent. Nil per gli element root (figli diretti del StructTree).
119
+ def parent
120
+ h = Raw.FPDF_StructElement_GetParent(@handle)
121
+ return nil if h.null?
122
+
123
+ Element.new(@tree, h)
124
+ end
125
+
126
+ # Walk depth-first dell'intero sub-tree a partire da questo element.
127
+ # Visita prima self, poi ricorsivamente i figli.
128
+ # Senza block ritorna un Enumerator.
129
+ def walk(&block)
130
+ return enum_for(:walk) unless block
131
+
132
+ yield self
133
+ children.each { |c| c.walk(&block) }
134
+ end
135
+
136
+ # Foglie del sub-tree (element senza figli). Sono i nodi che
137
+ # tipicamente hanno il MCID diretto.
138
+ def leaves
139
+ return [self] if children.empty?
140
+
141
+ children.flat_map(&:leaves)
142
+ end
143
+
144
+ # Testo dell'element, ricostruito dalla pagina via MCID. Risoluzione:
145
+ # 1. Se `actual_text` è presente, lo usa (gestisce legature/abbreviazioni).
146
+ # 2. Altrimenti raccoglie tutti gli MCID del sub-tree (questo element
147
+ # + ricorsivamente i figli) e concatena il testo dei page objects
148
+ # con quei MCID, in document order.
149
+ #
150
+ # Per element strutturali puri (`Table`, `TR`) il testo è la
151
+ # concatenazione di tutti i discendenti — utile come "summary".
152
+ def text
153
+ return actual_text if actual_text && !actual_text.empty?
154
+
155
+ # Raccoglie MCID di tutto il sub-tree depth-first
156
+ all_mcids = []
157
+ walk { |el| all_mcids.concat(el.marked_content_ids) }
158
+ return "" if all_mcids.empty?
159
+
160
+ mcid_map = @tree.send(:mcid_text_map)
161
+ all_mcids.filter_map { |id| mcid_map[id] }.join
162
+ end
163
+
164
+ # Attributi PDF strutturali. Ritorna un Hash { name => value } con
165
+ # tutti gli attributi dichiarati su questo element (RowSpan, ColSpan,
166
+ # Scope, Headers, BBox, ecc.). I valori sono Ruby-native: Integer,
167
+ # Float, String, true/false, o Array per attributi "Headers" che
168
+ # contengono liste di ID.
169
+ def attributes
170
+ result = {}
171
+ attr_count = Raw.FPDF_StructElement_GetAttributeCount(@handle)
172
+ return result if attr_count <= 0
173
+
174
+ (0...attr_count).each do |ai|
175
+ attr = Raw.FPDF_StructElement_GetAttributeAtIndex(@handle, ai)
176
+ next if attr.null?
177
+
178
+ key_count = Raw.FPDF_StructElement_Attr_GetCount(attr)
179
+ (0...key_count).each do |ki|
180
+ name = read_attr_name(attr, ki)
181
+ next if name.nil? || name.empty?
182
+
183
+ value = read_attr_value(attr, name)
184
+ result[name] = value unless value.nil?
185
+ end
186
+ end
187
+ result
188
+ end
189
+
190
+ def to_s
191
+ parts = ["<#{type || obj_type || '?'}>"]
192
+ mcids = marked_content_ids
193
+ parts << "mcid=#{mcids.first}" if mcids.size == 1
194
+ parts << "mcids=#{mcids.inspect}" if mcids.size > 1
195
+ parts << "lang=#{lang.inspect}" if lang
196
+ parts << "actual_text=#{actual_text.inspect[0, 30]}" if actual_text
197
+ parts << "alt_text=#{alt_text.inspect[0, 30]}" if alt_text
198
+ parts.join(" ")
199
+ end
200
+
201
+ def inspect
202
+ "#<Rpdfium::Structure::Element #{self}>"
203
+ end
204
+
205
+ private
206
+
207
+ # Helper UTF-16 string read con probe-then-fetch corretto. PDFium
208
+ # restituisce il numero di byte necessari (incluso null terminator),
209
+ # anche se il buffer è troppo piccolo.
210
+ def read_utf16_string(fn_name)
211
+ needed = Raw.send(fn_name, @handle, FFI::Pointer::NULL, 0)
212
+ return nil if needed < 2
213
+
214
+ buf = FFI::MemoryPointer.new(:uint8, needed)
215
+ written = Raw.send(fn_name, @handle, buf, needed)
216
+ return nil if written < 2
217
+
218
+ # Clamp: leggi al massimo il buffer allocato meno il null terminator.
219
+ payload = [written - 2, needed - 2].min
220
+ return nil if payload <= 0
221
+
222
+ s = buf.read_bytes(payload)
223
+ .force_encoding("UTF-16LE")
224
+ .encode("UTF-8")
225
+ .delete("\u0000")
226
+ s.empty? ? nil : s
227
+ end
228
+
229
+ def read_attr_name(attr, index)
230
+ len_buf = FFI::MemoryPointer.new(:ulong)
231
+ name_buf = FFI::MemoryPointer.new(:uint8, 128)
232
+ ok = Raw.FPDF_StructElement_Attr_GetName(attr, index, name_buf, 128, len_buf)
233
+ return nil if ok == 0
234
+
235
+ n = len_buf.read_ulong
236
+ return nil if n.zero?
237
+
238
+ # GetName ritorna ASCII (latin-1), non UTF-16
239
+ name_buf.read_bytes(n).force_encoding("UTF-8").delete("\u0000")
240
+ end
241
+
242
+ def read_attr_value(attr, name)
243
+ val_handle = Raw.FPDF_StructElement_Attr_GetValue(attr, name)
244
+ return nil if val_handle.null?
245
+
246
+ type = Raw.FPDF_StructElement_Attr_GetType(val_handle)
247
+ # Type codes da fpdf_structtree.h:
248
+ # 1 = Boolean, 2 = Number, 3 = String, 4 = Blob,
249
+ # 5 = Name, 6 = Array, 7 = Dictionary
250
+ case type
251
+ when 1 # Boolean
252
+ buf = FFI::MemoryPointer.new(:int)
253
+ Raw.FPDF_StructElement_Attr_GetBooleanValue(val_handle, buf) == 1 ? buf.read_int != 0 : nil
254
+ when 2 # Number
255
+ buf = FFI::MemoryPointer.new(:float)
256
+ Raw.FPDF_StructElement_Attr_GetNumberValue(val_handle, buf) == 1 ? buf.read_float : nil
257
+ when 3, 5 # String / Name
258
+ read_attr_string_value(val_handle)
259
+ when 4 # Blob (raw bytes)
260
+ read_attr_blob_value(val_handle)
261
+ when 6 # Array → ricorsivamente raccolgo i figli
262
+ n = Raw.FPDF_StructElement_Attr_CountChildren(val_handle)
263
+ (0...n).filter_map do |i|
264
+ child = Raw.FPDF_StructElement_Attr_GetChildAtIndex(val_handle, i)
265
+ next nil if child.null?
266
+
267
+ # Per ogni child applico la stessa lettura via type. Ma non ho
268
+ # un "name" per accedere a Attr_GetValue su un child; il child
269
+ # È già una FPDF_STRUCTELEMENT_ATTR_VALUE. Leggi direttamente.
270
+ read_attr_value_handle(child)
271
+ end
272
+ else
273
+ nil
274
+ end
275
+ end
276
+
277
+ def read_attr_value_handle(val_handle)
278
+ type = Raw.FPDF_StructElement_Attr_GetType(val_handle)
279
+ case type
280
+ when 1
281
+ buf = FFI::MemoryPointer.new(:int)
282
+ Raw.FPDF_StructElement_Attr_GetBooleanValue(val_handle, buf) == 1 ? buf.read_int != 0 : nil
283
+ when 2
284
+ buf = FFI::MemoryPointer.new(:float)
285
+ Raw.FPDF_StructElement_Attr_GetNumberValue(val_handle, buf) == 1 ? buf.read_float : nil
286
+ when 3, 5
287
+ read_attr_string_value(val_handle)
288
+ when 4
289
+ read_attr_blob_value(val_handle)
290
+ else
291
+ nil
292
+ end
293
+ end
294
+
295
+ def read_attr_string_value(val_handle)
296
+ len_buf = FFI::MemoryPointer.new(:ulong)
297
+ # Probe size
298
+ Raw.FPDF_StructElement_Attr_GetStringValue(val_handle,
299
+ FFI::Pointer::NULL, 0, len_buf)
300
+ n = len_buf.read_ulong
301
+ return nil if n < 2
302
+
303
+ buf = FFI::MemoryPointer.new(:uint8, n)
304
+ ok = Raw.FPDF_StructElement_Attr_GetStringValue(val_handle, buf, n, len_buf)
305
+ return nil if ok == 0
306
+
307
+ written = len_buf.read_ulong
308
+ payload = [written - 2, n - 2].min
309
+ return nil if payload <= 0
310
+
311
+ buf.read_bytes(payload).force_encoding("UTF-16LE")
312
+ .encode("UTF-8").delete("\u0000")
313
+ end
314
+
315
+ def read_attr_blob_value(val_handle)
316
+ len_buf = FFI::MemoryPointer.new(:ulong)
317
+ Raw.FPDF_StructElement_Attr_GetBlobValue(val_handle,
318
+ FFI::Pointer::NULL, 0, len_buf)
319
+ n = len_buf.read_ulong
320
+ return nil if n.zero?
321
+
322
+ buf = FFI::MemoryPointer.new(:uint8, n)
323
+ ok = Raw.FPDF_StructElement_Attr_GetBlobValue(val_handle, buf, n, len_buf)
324
+ return nil if ok == 0
325
+
326
+ buf.read_bytes(len_buf.read_ulong)
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpdfium
4
+ # Albero di bookmark (outline) del documento. Costruito ricorsivamente.
5
+ class Outline
6
+ attr_reader :title, :page_index, :children
7
+
8
+ def initialize(title, page_index, children)
9
+ @title = title
10
+ @page_index = page_index
11
+ @children = children
12
+ end
13
+
14
+ def self.from_document(document)
15
+ first = Raw.FPDFBookmark_GetFirstChild(document.handle, FFI::Pointer::NULL)
16
+ build_siblings(document, first)
17
+ end
18
+
19
+ def self.build_siblings(doc, bookmark_handle)
20
+ result = []
21
+ ptr = bookmark_handle
22
+ until ptr.null?
23
+ title = Raw.read_utf16_string(:FPDFBookmark_GetTitle, ptr)
24
+ dest = Raw.FPDFBookmark_GetDest(doc.handle, ptr)
25
+ idx = dest.null? ? nil : Raw.FPDFDest_GetDestPageIndex(doc.handle, dest)
26
+ idx = nil if idx == -1
27
+ children_handle = Raw.FPDFBookmark_GetFirstChild(doc.handle, ptr)
28
+ children = build_siblings(doc, children_handle)
29
+ result << new(title, idx, children)
30
+ ptr = Raw.FPDFBookmark_GetNextSibling(doc.handle, ptr)
31
+ end
32
+ result
33
+ end
34
+
35
+ # Iteratore flat preorder: utile per generare un sommario lineare.
36
+ def self.flatten(outline_tree, depth = 0, &block)
37
+ outline_tree.each do |item|
38
+ block.call(item, depth)
39
+ flatten(item.children, depth + 1, &block)
40
+ end
41
+ end
42
+
43
+ def to_h
44
+ { title: @title, page: @page_index,
45
+ children: @children.map(&:to_h) }
46
+ end
47
+ end
48
+ end