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.
@@ -2,28 +2,29 @@
2
2
 
3
3
  module Rpdfium
4
4
  module Util
5
- # Estrae "words" da una lista di char, fedelmente a pdfplumber.WordExtractor.
5
+ # Extracts "words" from a list of chars, faithfully to
6
+ # pdfplumber.WordExtractor.
6
7
  #
7
- # Algoritmo:
8
- # 1. Ordina i char per (top, x0): righe top-to-bottom, char left-to-right
9
- # dentro ogni riga.
10
- # 2. Cluster per top con `y_tolerance` → "righe logiche" di char.
11
- # 3. Dentro ogni riga, cluster per gap orizzontale: due char sono nella
12
- # stessa word se `next.x0 - prev.x1 <= x_tolerance`. Anche un char
13
- # whitespace separa la word (a meno che `keep_blank_chars`).
14
- # 4. Per ogni cluster di char emette una word: text concatenato, bbox.
8
+ # Algorithm:
9
+ # 1. Sort the chars by (top, x0): rows top-to-bottom, chars
10
+ # left-to-right within each row.
11
+ # 2. Cluster by top with `y_tolerance` → "logical rows" of chars.
12
+ # 3. Within each row, cluster by horizontal gap: two chars belong to
13
+ # the same word if `next.x0 - prev.x1 <= x_tolerance`. A whitespace
14
+ # char also separates the word (unless `keep_blank_chars`).
15
+ # 4. For each cluster of chars, emit a word: concatenated text, bbox.
15
16
  #
16
- # Differenze da pdfplumber (semplificazioni accettabili per il nostro uso):
17
- # - Non gestiamo `line_dir`/`char_dir` rotated (testo ruotato non
18
- # orizzontale ltr): non rilevante per i casi d'uso correnti.
19
- # - Non gestiamo `use_text_flow` (ordering basato sul content stream):
20
- # i nostri char arrivano già da PDFium nell'ordine geometrico via
21
- # `chars` (top, x0).
22
- # - Non gestiamo `expand_ligatures`: PDFium di solito espande i
23
- # codepoint correttamente già a livello char.
17
+ # Differences from pdfplumber (simplifications acceptable for our use):
18
+ # - We do not handle rotated `line_dir`/`char_dir` (text rotated away
19
+ # from horizontal ltr): not relevant for current use cases.
20
+ # - We do not handle `use_text_flow` (ordering based on the content
21
+ # stream): our chars already arrive from PDFium in geometric order
22
+ # via `chars` (top, x0).
23
+ # - We do not handle `expand_ligatures`: PDFium usually expands the
24
+ # codepoints correctly already at the char level.
24
25
  #
25
- # Queste differenze sono documentate; se mai necessarie si aggiungono
26
- # come feature toggles senza cambiare il path di default.
26
+ # These differences are documented; if ever needed they can be added
27
+ # as feature toggles without changing the default path.
27
28
  class WordExtractor
28
29
  DEFAULT_X_TOLERANCE = 3.0
29
30
  DEFAULT_Y_TOLERANCE = 3.0
@@ -40,13 +41,13 @@ module Rpdfium
40
41
  @extra_attrs = extra_attrs || []
41
42
  end
42
43
 
43
- # Restituisce un Array di Hash: { text:, x0:, x1:, top:, bottom:, chars: }.
44
- # Se `extra_attrs` è non vuoto, ogni word splitta anche al cambio di
45
- # questi attributi (es. fontname/size diversi word diverse).
44
+ # Returns an Array of Hash: { text:, x0:, x1:, top:, bottom:, chars: }.
45
+ # If `extra_attrs` is non-empty, each word also splits when these
46
+ # attributes change (e.g. different fontname/size → different words).
46
47
  def extract_words(chars)
47
48
  return [] if chars.empty?
48
49
 
49
- # Fast path: 1 solo char → 1 word triviale (se non whitespace).
50
+ # Fast path: a single char → 1 trivial word (if not whitespace).
50
51
  if chars.size == 1
51
52
  c = chars.first
52
53
  return [] if blank?(c) && !@keep_blank_chars
@@ -54,35 +55,35 @@ module Rpdfium
54
55
  return [build_word([c])]
55
56
  end
56
57
 
57
- # 1. Ordina per (top, x0). Top-down, left-to-right.
58
+ # 1. Sort by (top, x0). Top-down, left-to-right.
58
59
  sorted = chars.sort_by { |c| [c[:top], c[:x0]] }
59
60
 
60
- # 2. Cluster in righe per `top`.
61
- # `presorted: true`: sorted è già ordinato per [top, x0], quindi
62
- # implicitamente anche per top — cluster_objects salta il proprio
63
- # sort interno.
61
+ # 2. Cluster into rows by `top`.
62
+ # `presorted: true`: sorted is already ordered by [top, x0], hence
63
+ # implicitly also by top — cluster_objects skips its own internal
64
+ # sort.
64
65
  rows = Cluster.cluster_objects(sorted, :top,
65
66
  tolerance: @y_tolerance,
66
67
  presorted: true)
67
68
 
68
69
  words = []
69
70
  rows.each do |row|
70
- # Re-sort per x0 dentro ogni riga clusterizzata.
71
+ # Re-sort by x0 within each clustered row.
71
72
  #
72
- # NOTA: in linea di principio l'input `sorted` è già ordinato per
73
- # [top, x0], quindi i cluster di top dovrebbero essere già in
74
- # ordine x0. MA il sort globale `[top, x0]` rispetta strettamente
75
- # l'ordine per top — se due char della stessa riga visiva hanno
76
- # top diversi entro tolerance (es. la "i" minuscola spesso ha
77
- # top più alto di 0.008pt rispetto alle altre lettere a causa di
78
- # come PDFium calcola la bbox), il sort globale li interfoglia.
79
- # Il cluster_objects per :top non riordina internamente i char,
80
- # quindi un char con top leggermente minore finisce DAVANTI a
81
- # tutte le altre lettere della parola.
73
+ # NOTE: in principle the input `sorted` is already ordered by
74
+ # [top, x0], so the top clusters should already be in x0 order.
75
+ # BUT the global sort `[top, x0]` strictly respects the order by
76
+ # top — if two chars of the same visual row have different tops
77
+ # within tolerance (e.g. the lowercase "i" often has a top higher
78
+ # by 0.008pt than the other letters because of how PDFium computes
79
+ # the bbox), the global sort interleaves them. cluster_objects by
80
+ # :top does not internally reorder the chars, so a char with a
81
+ # slightly lower top ends up AHEAD of all the other letters of the
82
+ # word.
82
83
  #
83
- # Esempio reale: "Categoria" dove "i" ha top=414.9789 e le altre
84
- # 414.9869 → output `iCategora` invece di `Categoria`.
85
- # Il fix è semplicemente ri-sortare per x0 dentro la riga.
84
+ # Real example: "Categoria" where "i" has top=414.9789 and the
85
+ # others 414.9869 → output `iCategora` instead of `Categoria`.
86
+ # The fix is simply to re-sort by x0 within the row.
86
87
  row_sorted = row.sort_by { |c| c[:x0] }
87
88
 
88
89
  word_chars = []
@@ -91,8 +92,8 @@ module Rpdfium
91
92
  words << build_word(word_chars) unless word_chars.empty?
92
93
  word_chars = []
93
94
  end
94
- # Whitespace: per default lo usiamo come separatore (lo scartiamo).
95
- # Con keep_blank_chars=true lo includiamo nella word corrente.
95
+ # Whitespace: by default we use it as a separator (we discard it).
96
+ # With keep_blank_chars=true we include it in the current word.
96
97
  if blank?(c) && !@keep_blank_chars
97
98
  words << build_word(word_chars) unless word_chars.empty?
98
99
  word_chars = []
@@ -111,15 +112,15 @@ module Rpdfium
111
112
  def char_begins_new_word?(prev, curr)
112
113
  return false if prev.nil?
113
114
 
114
- # Gap orizzontale (PDF font hinting può dare overlap leggero, max 0)
115
+ # Horizontal gap (PDF font hinting may give a slight overlap, max 0)
115
116
  gap = curr[:x0] - prev[:x1]
116
117
  return true if gap > @x_tolerance
117
118
 
118
- # Cambio di riga (può succedere se y_tolerance è grande ma due
119
- # char sono comunque su righe diverse)
119
+ # Row change (can happen if y_tolerance is large but two chars are
120
+ # nonetheless on different rows)
120
121
  return true if (curr[:top] - prev[:top]).abs > @y_tolerance
121
122
 
122
- # Cambio di un extra_attr richiesto
123
+ # Change of a required extra_attr
123
124
  @extra_attrs.any? { |attr| prev[attr] != curr[attr] }
124
125
  end
125
126
 
@@ -2,30 +2,30 @@
2
2
 
3
3
  module Rpdfium
4
4
  module Util
5
- # Fonde word adiacenti sulla stessa riga in un'unica word con bbox
6
- # aggregata e text concatenato.
5
+ # Merges adjacent words on the same row into a single word with an
6
+ # aggregated bbox and concatenated text.
7
7
  #
8
- # Tre strategie disponibili come metodi separati:
8
+ # Three strategies are available as separate methods:
9
9
  #
10
- # - `merge_by_proximity` — fonde tutte le word adiacenti che soddisfano
11
- # il criterio di vicinanza. Strategia base.
10
+ # - `merge_by_proximity` — merges all adjacent words that satisfy the
11
+ # proximity criterion. Base strategy.
12
12
  #
13
- # - `merge_by_label` — fonde solo word che condividono la stessa "label"
14
- # (chiave esterna calcolata dal chiamante). Utile per preservare la
15
- # semantica quando label diverse cadono sulla stessa riga (es. flag
16
- # in colonne adiacenti).
13
+ # - `merge_by_label` — merges only words that share the same "label"
14
+ # (external key computed by the caller). Useful for preserving
15
+ # semantics when different labels fall on the same row (e.g. flags
16
+ # in adjacent columns).
17
17
  #
18
- # - `merge_unlabeled` — fonde solo word "orfane" (label nil) lasciando
19
- # intatte quelle con label. Inverso di merge_by_label.
18
+ # - `merge_unlabeled` — merges only "orphan" words (label nil), leaving
19
+ # labeled ones intact. Inverse of merge_by_label.
20
20
  #
21
- # Tutte ritornano una nuova lista di word, con quelle fuse rappresentate
22
- # come hash `{ text:, x0:, x1:, top:, bottom: }`.
21
+ # All return a new list of words, with merged ones represented as the
22
+ # hash `{ text:, x0:, x1:, top:, bottom: }`.
23
23
  #
24
- # @example merge per proximity
24
+ # @example merge by proximity
25
25
  # merger = Rpdfium::Util::WordMerger.new(x_gap: 20.0, y_tol: 3.0)
26
26
  # merged = merger.merge_by_proximity(words)
27
27
  #
28
- # @example merge per label, con label fornita dal chiamante
28
+ # @example merge by label, with the label provided by the caller
29
29
  # labels_by_word = words.each_with_object({}) { |w, h| h[w] = compute_label(w) }
30
30
  # merged = merger.merge_by_label(words, labels_by_word)
31
31
  class WordMerger
@@ -37,21 +37,22 @@ module Rpdfium
37
37
  @y_tol = y_tol
38
38
  end
39
39
 
40
- # Fonde tutte le word adiacenti (stessa riga + gap orizzontale ≤ x_gap).
40
+ # Merges all adjacent words (same row + horizontal gap ≤ x_gap).
41
41
  def merge_by_proximity(words)
42
42
  merge_groups(words) { |a, b| true }
43
43
  end
44
44
 
45
- # Fonde solo word con la stessa label.
46
- # @param labels_by_word [Hash] mapping word → label (qualunque tipo).
47
- # Word con stessa label vengono fuse, word con label diverse no.
45
+ # Merges only words with the same label.
46
+ # @param labels_by_word [Hash] mapping word → label (any type).
47
+ # Words with the same label are merged; words with different
48
+ # labels are not.
48
49
  def merge_by_label(words, labels_by_word)
49
50
  merge_groups(words) do |a, b|
50
51
  labels_by_word[a] == labels_by_word[b]
51
52
  end
52
53
  end
53
54
 
54
- # Fonde solo word con label nil (orfane).
55
+ # Merges only words with a nil label (orphans).
55
56
  def merge_unlabeled(words, labels_by_word)
56
57
  merge_groups(words) do |a, b|
57
58
  labels_by_word[a].nil? && labels_by_word[b].nil?
@@ -60,10 +61,10 @@ module Rpdfium
60
61
 
61
62
  private
62
63
 
63
- # Algoritmo generico di merging: scorre i word ordinati per (top, x0)
64
- # e li raggruppa quando soddisfano sia il criterio geometrico
65
- # (stessa riga e gap orizzontale stretto) che il predicato `yield`
66
- # fornito dal chiamante.
64
+ # Generic merging algorithm: iterates over the words sorted by
65
+ # (top, x0) and groups them when they satisfy both the geometric
66
+ # criterion (same row and narrow horizontal gap) and the `yield`
67
+ # predicate provided by the caller.
67
68
  def merge_groups(words)
68
69
  return [] if words.empty?
69
70
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rpdfium
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.3"
5
5
  end
data/lib/rpdfium.rb CHANGED
@@ -3,9 +3,9 @@
3
3
  require_relative "rpdfium/version"
4
4
  require_relative "rpdfium/errors"
5
5
 
6
- # Carica la gemma companion rpdfium-binary se presente: deve avvenire PRIMA
7
- # di raw.rb, che chiama ffi_lib al momento del require e interroga
8
- # Rpdfium::Binary.library_path per trovare il path assoluto al .so/.dylib.
6
+ # Loads the companion gem rpdfium-binary if present: this must happen BEFORE
7
+ # raw.rb, which calls ffi_lib at require time and queries
8
+ # Rpdfium::Binary.library_path to find the absolute path to the .so/.dylib.
9
9
  begin
10
10
  require "rpdfium/binary"
11
11
  rescue LoadError
@@ -54,22 +54,24 @@ module Rpdfium
54
54
  Document.open(input, password: password, &block)
55
55
  end
56
56
 
57
- # Estrai tutto il testo di tutte le pagine, una stringa per pagina.
57
+ # Extract all the text of all pages, one string per page.
58
58
  def self.extract_text(input, password: nil)
59
- open(input, password: password) { |doc| doc.map(&:text) }
59
+ open(input, password: password) do |doc|
60
+ doc.each_page_streaming.map(&:text)
61
+ end
60
62
  end
61
63
 
62
- # Estrai tutte le tabelle di tutte le pagine.
63
- # Ritorna Array<{ page: Integer, rows: Array<Array<String>> }>.
64
+ # Extract all the tables of all pages.
65
+ # Returns Array<{ page: Integer, rows: Array<Array<String>> }>.
64
66
  #
65
- # `keep_blank_rows: false` (default) elimina le righe completamente vuote
66
- # che la strategia `:text` di words_to_edges_h genera per costruzione (ogni
67
- # riga visiva produce due edges, top + bottom, e tra coppie di edges
68
- # adiacenti si formano "righe spurie" di altezza pari al gap interlinea).
69
- # Con `keep_blank_rows: true` ottieni l'output grezzo di Table#extract.
67
+ # `keep_blank_rows: false` (default) removes the completely empty rows
68
+ # that the `:text` strategy of words_to_edges_h generates by construction (each
69
+ # visual row produces two edges, top + bottom, and between pairs of adjacent
70
+ # edges "spurious rows" form, with a height equal to the line gap).
71
+ # With `keep_blank_rows: true` you get the raw output of Table#extract.
70
72
  def self.extract_tables(input, password: nil, keep_blank_rows: false, **opts)
71
73
  open(input, password: password) do |doc|
72
- doc.flat_map do |page|
74
+ doc.each_page_streaming.flat_map do |page|
73
75
  Table::Extractor.new(page, **opts).extract.map do |rows|
74
76
  rows = rows.reject { |r| r.all? { |c| c.nil? || c.empty? } } unless keep_blank_rows
75
77
  { page: page.index, rows: rows }
@@ -78,11 +80,11 @@ module Rpdfium
78
80
  end
79
81
  end
80
82
 
81
- # Renderizza ogni pagina in un PNG dentro output_dir.
83
+ # Render each page to a PNG inside output_dir.
82
84
  def self.render_to_pngs(input, output_dir:, scale: 2.0, password: nil)
83
85
  Dir.mkdir(output_dir) unless Dir.exist?(output_dir)
84
86
  open(input, password: password) do |doc|
85
- doc.map do |page|
87
+ doc.each_page_streaming.map do |page|
86
88
  path = File.join(output_dir, format("page_%04d.png", page.index + 1))
87
89
  page.render_to_png(path, scale: scale)
88
90
  path
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rpdfium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roberto Scinocca