pdfrb 0.7.1 → 0.7.11

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/RELEASE.md +17 -11
  4. data/data/pdfrb/layout/hyphenation_en.txt +408 -0
  5. data/lib/pdfrb/color/default_profile.rb +213 -0
  6. data/lib/pdfrb/color/icc_validator.rb +69 -0
  7. data/lib/pdfrb/color.rb +2 -0
  8. data/lib/pdfrb/conformance/ltv.rb +112 -0
  9. data/lib/pdfrb/conformance/pades.rb +198 -0
  10. data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
  11. data/lib/pdfrb/conformance/pdf_a.rb +123 -0
  12. data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
  13. data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
  14. data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
  15. data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
  16. data/lib/pdfrb/conformance/pdf_x.rb +66 -1
  17. data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
  18. data/lib/pdfrb/conformance.rb +8 -0
  19. data/lib/pdfrb/content/parser.rb +82 -0
  20. data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
  21. data/lib/pdfrb/digital_signature.rb +1 -0
  22. data/lib/pdfrb/document.rb +29 -0
  23. data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
  24. data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
  25. data/lib/pdfrb/encryption/v5_writer.rb +108 -0
  26. data/lib/pdfrb/encryption.rb +3 -0
  27. data/lib/pdfrb/font_loader/type3.rb +65 -0
  28. data/lib/pdfrb/font_loader.rb +1 -0
  29. data/lib/pdfrb/image_loader/gif.rb +246 -0
  30. data/lib/pdfrb/image_loader/tiff.rb +257 -0
  31. data/lib/pdfrb/image_loader.rb +2 -0
  32. data/lib/pdfrb/layout/font_fallback.rb +203 -0
  33. data/lib/pdfrb/layout/hyphenation.rb +120 -0
  34. data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
  35. data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
  36. data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
  37. data/lib/pdfrb/layout/polygon_frame.rb +106 -0
  38. data/lib/pdfrb/layout/table_box.rb +154 -23
  39. data/lib/pdfrb/layout/text_shaper.rb +129 -0
  40. data/lib/pdfrb/layout.rb +7 -0
  41. data/lib/pdfrb/source/linearization_reader.rb +76 -0
  42. data/lib/pdfrb/source/recovery.rb +65 -1
  43. data/lib/pdfrb/source/tokenizer.rb +21 -0
  44. data/lib/pdfrb/source.rb +1 -0
  45. data/lib/pdfrb/task/thumbnail.rb +133 -0
  46. data/lib/pdfrb/task.rb +1 -0
  47. data/lib/pdfrb/version.rb +1 -1
  48. metadata +28 -2
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Layout
5
+ # Polygon-aware Frame: extends Frame to compute available area
6
+ # against an arbitrary polygon shape, not just a rectangle.
7
+ # Useful for L-shaped text regions, text-around-image, and
8
+ # irregular page layouts.
9
+ #
10
+ # The default Frame.find_available_area always returns the
11
+ # top-left rectangle; this subclass finds the largest inscribed
12
+ # rectangle of (width × height) inside the polygon, scanning
13
+ # row-by-row to find the leftmost x where the requested box fits
14
+ # at the requested height.
15
+ class PolygonFrame < Frame
16
+ DEFAULT_STEP = 1.0
17
+
18
+ attr_reader :polygon
19
+
20
+ # @param polygon [Array<Array<Numeric>>] array of [x, y] vertex
21
+ # pairs. The polygon is implicitly closed.
22
+ # @param step [Numeric] scan resolution in PDF units; smaller
23
+ # steps find tighter fits but take longer.
24
+ def initialize(left:, bottom:, width:, height:, polygon: nil, step: DEFAULT_STEP)
25
+ super(left: left, bottom: bottom, width: width, height: height)
26
+ @polygon = polygon || [[left, bottom], [left + width, bottom],
27
+ [left + width, bottom + height],
28
+ [left, bottom + height]]
29
+ @step = step.to_f
30
+ end
31
+
32
+ # Find the next available area inside the polygon that fits
33
+ # (width, height). Walks row by row from the top of the
34
+ # bounding box downward; at each row y, scans x from left
35
+ # toward the right edge, returning the first (x, y, w, h)
36
+ # position where all four corners of (x, y - h, w, h) are
37
+ # inside the polygon.
38
+ def find_available_area(width, height)
39
+ w = width.to_f
40
+ h = height.to_f
41
+ return nil unless w.positive? && h.positive?
42
+
43
+ bbox_top = bounding_box_top
44
+ bbox_bottom = bounding_box_bottom
45
+ bbox_left = bounding_box_left
46
+ bbox_right = bounding_box_right
47
+
48
+ y = bbox_top
49
+ while y - h >= bbox_bottom
50
+ x = bbox_left
51
+ while x + w <= bbox_right
52
+ return [x, y, w, h] if contains_rectangle?(x, y - h, w, h)
53
+
54
+ x += @step
55
+ end
56
+ y -= @step
57
+ end
58
+ nil
59
+ end
60
+
61
+ def bounding_box_top
62
+ @polygon.map { |_x, y| y }.max.to_f
63
+ end
64
+
65
+ def bounding_box_bottom
66
+ @polygon.map { |_x, y| y }.min.to_f
67
+ end
68
+
69
+ def bounding_box_left
70
+ @polygon.map { |x, _y| x }.min.to_f
71
+ end
72
+
73
+ def bounding_box_right
74
+ @polygon.map { |x, _y| x }.max.to_f
75
+ end
76
+
77
+ # Point-in-polygon test (ray casting).
78
+ def contains_point?(x, y)
79
+ inside = false
80
+ n = @polygon.length
81
+ i = 0
82
+ j = n - 1
83
+ while i < n
84
+ xi, yi = @polygon[i]
85
+ xj, yj = @polygon[j]
86
+ if ((yi > y) != (yj > y)) &&
87
+ (x < ((xj - xi) * (y - yi) / (yj - yi)) + xi)
88
+ inside = !inside
89
+ end
90
+ j = i
91
+ i += 1
92
+ end
93
+ inside
94
+ end
95
+
96
+ def contains_rectangle?(x, y, w, h)
97
+ [
98
+ [x, y],
99
+ [x + w, y],
100
+ [x, y + h],
101
+ [x + w, y + h],
102
+ ].all? { |px, py| contains_point?(px, py) }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -1,47 +1,64 @@
1
- # frozen_string_literal: true
1
+ # frozen_string literal: true
2
2
 
3
3
  module Pdfrb
4
4
  module Layout
5
5
  # A table of cells. Each cell holds a Box. Column widths are auto
6
6
  # unless specified; row heights computed from content.
7
+ #
8
+ # Cells may declare +colspan+ / +rowspan+ to occupy multiple
9
+ # columns or rows. Cell entries can be plain Boxes (treated as
10
+ # 1x1 cells) or +TableBox::Cell+ instances created via
11
+ # +TableBox.cell(box, colspan:, rowspan:+).
7
12
  class TableBox < Box
13
+ Cell = Struct.new(:box, :colspan, :rowspan, keyword_init: true) do
14
+ def initialize(box:, colspan: 1, rowspan: 1)
15
+ super
16
+ end
17
+
18
+ def fit?(*args); box.fit?(*args); end
19
+
20
+ def draw(*args); box.draw(*args); end
21
+
22
+ def height; box.height; end
23
+
24
+ def width; box.width; end
25
+ end
26
+
8
27
  attr_reader :rows, :column_widths
9
28
 
29
+ # Build a Cell wrapping +box+ with the given spans.
30
+ def self.cell(box, colspan: 1, rowspan: 1)
31
+ Cell.new(box: box, colspan: colspan, rowspan: rowspan)
32
+ end
33
+
10
34
  def initialize(rows:, column_widths: nil, **)
11
35
  super(**)
12
- @rows = rows
36
+ @rows = rows.map { |row| row.map { |c| wrap_cell(c) } }
13
37
  @column_widths = column_widths
14
38
  end
15
39
 
16
40
  def fit?(available_width, available_height)
17
41
  compute_column_widths(available_width)
18
- remaining = available_height
19
- @rows.each do |row|
20
- row_heights = []
21
- row.each_with_index do |cell, col|
22
- cell_w = @column_widths[col]
23
- cell.fit?(cell_w, remaining) or return false
24
- row_heights << cell.height
25
- end
26
- row_h = row_heights.max || 0
27
- remaining -= row_h
28
- end
29
- @width = available_width
30
- @height = available_height - remaining
31
- true
42
+ layout_grid!
43
+ fit_rows!(available_height)
44
+ @height <= available_height
32
45
  end
33
46
 
34
47
  def draw_content(canvas, x, y)
35
48
  offset_y = y
36
- @rows.each do |row|
37
- max_h = 0
49
+ row_heights.each_with_index do |row_h, row_index|
38
50
  offset_x = x
39
- row.each_with_index do |cell, col|
40
- cell.draw(canvas, offset_x, offset_y)
41
- offset_x += @column_widths[col]
42
- max_h = cell.height if cell.height > max_h
51
+ @column_widths.each_with_index do |_w, col_index|
52
+ cell = cell_at(row_index, col_index)
53
+ if cell && origin?(row_index, col_index, cell)
54
+ span_w = spanned_width(col_index, cell.colspan)
55
+ span_h = spanned_height(row_index, cell.rowspan)
56
+ cell.box.fit?(span_w, span_h)
57
+ cell.draw(canvas, offset_x, offset_y)
58
+ end
59
+ offset_x += @column_widths[col_index]
43
60
  end
44
- offset_y -= max_h
61
+ offset_y -= row_h
45
62
  end
46
63
  end
47
64
 
@@ -49,8 +66,23 @@ module Pdfrb
49
66
  @rows.empty?
50
67
  end
51
68
 
69
+ # Height of each row, indexed by row position. Populated by
70
+ # +fit?+.
71
+ def row_heights
72
+ @row_heights || []
73
+ end
74
+
52
75
  private
53
76
 
77
+ def wrap_cell(value)
78
+ return value if value.is_a?(Cell)
79
+
80
+ box = value.is_a?(::Hash) ? value[:box] : value
81
+ colspan = value.is_a?(::Hash) ? (value[:colspan] || 1) : 1
82
+ rowspan = value.is_a?(::Hash) ? (value[:rowspan] || 1) : 1
83
+ Cell.new(box: box, colspan: colspan, rowspan: rowspan)
84
+ end
85
+
54
86
  def compute_column_widths(available_width)
55
87
  return @column_widths = equal_widths(available_width) if @column_widths.nil?
56
88
 
@@ -61,6 +93,105 @@ module Pdfrb
61
93
  col_count = @rows.first&.size || 1
62
94
  Array.new(col_count, available_width / col_count)
63
95
  end
96
+
97
+ # Build a (row, col) -> Cell map accounting for spans, so any
98
+ # cell covered by a span from above or to the left resolves to
99
+ # the same Cell instance. @grid[r][c] = the originating Cell.
100
+ def layout_grid!
101
+ @grid = ::Array.new(@rows.size) { ::Array.new(column_count) }
102
+ @origins = {} # [r, c] -> true if this is the top-left of a Cell
103
+ @rows.each_with_index do |row, r|
104
+ c = 0
105
+ row.each do |cell|
106
+ c = next_free_column(r, c)
107
+ place_cell(cell, r, c)
108
+ @origins[[r, c]] = true
109
+ c += cell.colspan
110
+ end
111
+ end
112
+ end
113
+
114
+ def next_free_column(row, col)
115
+ col += 1 while @grid[row] && @grid[row][col]
116
+ col
117
+ end
118
+
119
+ def place_cell(cell, row, col)
120
+ cell.rowspan.times do |dr|
121
+ cell.colspan.times do |dc|
122
+ r = row + dr
123
+ c = col + dc
124
+ next unless @grid[r] && c < @grid[r].size
125
+
126
+ @grid[r][c] = cell
127
+ end
128
+ end
129
+ end
130
+
131
+ def column_count
132
+ @column_widths.length
133
+ end
134
+
135
+ def cell_at(row, col)
136
+ @grid[row]&.[](col)
137
+ end
138
+
139
+ def origin?(row, col, cell)
140
+ # The cell is the origin if the grid position matches the
141
+ # cell's top-left (i.e. the position above and to the left
142
+ # belongs to a different cell or none).
143
+ return true if row.zero? && col.zero?
144
+
145
+ above = row.positive? ? @grid[row - 1][col] : nil
146
+ left = col.positive? ? @grid[row][col - 1] : nil
147
+ above != cell && left != cell
148
+ end
149
+ alias is_origin? origin?
150
+
151
+ def spanned_width(start_col, colspan)
152
+ span = colspan
153
+ @column_widths[start_col, span].sum
154
+ end
155
+
156
+ def spanned_height(start_row, rowspan)
157
+ span = rowspan
158
+ @row_heights[start_row, span]&.sum || 0
159
+ end
160
+
161
+ def fit_rows!(available_height)
162
+ @row_heights = compute_row_heights(available_height)
163
+ @height = @row_heights.sum
164
+ @width = @column_widths.sum
165
+ self
166
+ end
167
+
168
+ # First pass: compute the natural height of each row by taking
169
+ # the max content height across cells in that row, ignoring
170
+ # rowspan contributions to non-origin rows.
171
+ def compute_row_heights(available_height)
172
+ heights = ::Array.new(@rows.size, 0)
173
+ @rows.each_with_index do |row, r|
174
+ row.each_with_index do |_cell, _c|
175
+ # Handled via grid below for span correctness.
176
+ end
177
+ # Use the grid to skip span-occupied cells.
178
+ column_count.times do |c|
179
+ cell = @grid[r][c]
180
+ next unless cell && origin?(r, c, cell)
181
+
182
+ span_w = spanned_width(c, cell.colspan)
183
+ cell.fit?(span_w, available_height)
184
+ cell_h = cell.height.to_f
185
+ # Distribute the height across the rows the cell spans.
186
+ distribute = [(cell_h / cell.rowspan).ceil, 1].max
187
+ cell.rowspan.times do |dr|
188
+ rr = r + dr
189
+ heights[rr] = [heights[rr], distribute].max if heights[rr]
190
+ end
191
+ end
192
+ end
193
+ heights
194
+ end
64
195
  end
65
196
  end
66
197
  end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Layout
5
+ # Text shaping interface. Pure-Ruby shapers don't exist for the
6
+ # complexity of OpenType GSUB/GPOS — this module defines the
7
+ # contract that a real shaper (HarfBuzz, fribidi, etc.) must
8
+ # satisfy. Callers can plug in a shaper implementation via
9
+ # Pdfrb::Layout::TextShaper.implementation = MyShaper.
10
+ #
11
+ # The default implementation is grapheme-cluster-aware: it keeps
12
+ # each user-perceived character (a base + combining marks, a
13
+ # Hangul syllable, an emoji-ZWJ sequence, etc.) together as one
14
+ # cluster so the TextLayouter measures and breaks at cluster
15
+ # boundaries, never inside one.
16
+ module TextShaper
17
+ @implementation = nil
18
+
19
+ ShapedRun = Struct.new(:codepoints, :clusters, :advances,
20
+ :cluster_starts, keyword_init: true)
21
+
22
+ class << self
23
+ attr_accessor :implementation
24
+
25
+ # Shape +text+ for +font+ at +size+. Returns a ShapedRun.
26
+ #
27
+ # When an +implementation+ is registered, delegates to it.
28
+ # Otherwise runs the default grapheme-cluster shaper.
29
+ def shape(text, font: nil, size: 12, direction: :ltr)
30
+ if implementation
31
+ return implementation.shape(text, font: font, size: size,
32
+ direction: direction)
33
+ end
34
+
35
+ default_shape(text, size, direction)
36
+ end
37
+
38
+ private
39
+
40
+ # Default shaper: one cluster per grapheme. Each cluster's
41
+ # advance is its base-codepoint width (combining marks add
42
+ # zero). Falls back to size/2 when font metrics aren't
43
+ # available.
44
+ def default_shape(text, size, _direction)
45
+ clusters = text.to_s.grapheme_clusters.to_a
46
+ codepoints = []
47
+ cluster_indices = []
48
+ advances = []
49
+ cluster_starts = []
50
+
51
+ clusters.each_with_index do |cluster, cluster_idx|
52
+ cluster_starts << codepoints.length
53
+ cps = cluster.codepoints.to_a
54
+ cps.each do |cp|
55
+ codepoints << cp
56
+ cluster_indices << cluster_idx
57
+ advances << advance_for(cp, size, cps)
58
+ end
59
+ end
60
+
61
+ ShapedRun.new(
62
+ codepoints: codepoints,
63
+ clusters: cluster_indices,
64
+ advances: advances,
65
+ cluster_starts: cluster_starts
66
+ )
67
+ end
68
+
69
+ # Per-codepoint advance. Combining marks (Unicode general
70
+ # category M*) contribute 0; everything else gets size/2
71
+ # as a heuristic unless real metrics are available.
72
+ def advance_for(codepoint, size, _cluster_cps)
73
+ return 0.0 if combining_mark?(codepoint)
74
+ return size * 0.6 if wide?(codepoint)
75
+
76
+ # Heuristic average advance for ASCII range.
77
+ size / 2.0
78
+ end
79
+
80
+ # Combining marks: Unicode general categories Mn, Mc, Me.
81
+ # Subset of common ranges; the full list is in
82
+ # UnicodeData.txt.
83
+ def combining_mark?(codepoint)
84
+ ranges = [
85
+ 0x0300..0x036F, # Combining Diacritical Marks
86
+ 0x0483..0x0489,
87
+ 0x0591..0x05BD,
88
+ 0x05BF..0x05BF,
89
+ 0x05C1..0x05C2, 0x05C4..0x05C5, 0x05C7..0x05C7,
90
+ 0x0610..0x061A,
91
+ 0x064B..0x065F,
92
+ 0x0670..0x0670,
93
+ 0x06D6..0x06DC,
94
+ 0x06DF..0x06E4,
95
+ 0x06E7..0x06E8,
96
+ 0x06EA..0x06ED,
97
+ 0x0711..0x0711,
98
+ 0x0730..0x074A,
99
+ 0x0900..0x0903,
100
+ 0x093A..0x094F,
101
+ 0x0951..0x0957,
102
+ 0x20D0..0x20FF, # Combining marks for symbols
103
+ 0xFE00..0xFE0F # Variation selectors
104
+ ]
105
+ ranges.any? { |r| r.cover?(codepoint) }
106
+ end
107
+
108
+ # Wide characters: CJK + emoji. These typically take 1em
109
+ # advance (double-width of a Latin letter).
110
+ def wide?(codepoint)
111
+ ranges = [
112
+ 0x1100..0x115F, # Hangul Jamo
113
+ 0x2E80..0x303E, # CJK Radicals
114
+ 0x3040..0x33BF, # Hiragana, Katakana, CJK
115
+ 0x3400..0x4DBF, # CJK Extension A
116
+ 0x4E00..0x9FFF, # CJK Unified Ideographs
117
+ 0xA000..0xA4CF, # Yi
118
+ 0xAC00..0xD7A3, # Hangul Syllables
119
+ 0xF900..0xFAFF, # CJK Compatibility Ideographs
120
+ 0xFE30..0xFE4F, # CJK Compatibility Forms
121
+ 0x1F000..0x1FAFF, # Emoji + extensions
122
+ 0x20000..0x2FFFF, # CJK Extension B+
123
+ ]
124
+ ranges.any? { |r| r.cover?(codepoint) }
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
data/lib/pdfrb/layout.rb CHANGED
@@ -15,6 +15,7 @@ module Pdfrb
15
15
  autoload :ImageBox, "pdfrb/layout/image_box"
16
16
  autoload :ListBox, "pdfrb/layout/list_box"
17
17
  autoload :TableBox, "pdfrb/layout/table_box"
18
+ autoload :MultiPageTableBox, "pdfrb/layout/multi_page_table_box"
18
19
  autoload :ColumnBox, "pdfrb/layout/column_box"
19
20
  autoload :InlineBox, "pdfrb/layout/inline_box"
20
21
  autoload :BoxFitter, "pdfrb/layout/box_fitter"
@@ -23,6 +24,12 @@ module Pdfrb
23
24
  autoload :TextFragment, "pdfrb/layout/text_fragment"
24
25
  autoload :Line, "pdfrb/layout/line"
25
26
  autoload :Bidi, "pdfrb/layout/bidi"
27
+ autoload :Hyphenation, "pdfrb/layout/hyphenation"
28
+ autoload :TextShaper, "pdfrb/layout/text_shaper"
29
+ autoload :PolygonFrame, "pdfrb/layout/polygon_frame"
30
+ autoload :FontFallback, "pdfrb/layout/font_fallback"
31
+ autoload :JustificationKashidas, "pdfrb/layout/justification_kashidas"
32
+ autoload :MultiCellTextLayout, "pdfrb/layout/multi_cell_text_layout"
26
33
  autoload :RomanNumeral, "pdfrb/layout/list_box"
27
34
  end
28
35
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Source
5
+ # Reads the Linearization dictionary (Annex F) that lives on the
6
+ # first indirect object of a linearized PDF. Exposes the
7
+ # page-one fast-path offset (/O), end of first page (/E), number
8
+ # of pages (/N), and the hint stream offset (/T) so callers can
9
+ # resolve page 1 without parsing the rest of the file.
10
+ #
11
+ # A non-linearized PDF returns nil from .detect.
12
+ class LinearizationReader
13
+ LinearizationInfo = Struct.new(
14
+ :linearized, # 1.0 marker
15
+ :file_length, # /L
16
+ :first_page_obj_offset, # /O
17
+ :first_page_end_offset, # /E
18
+ :page_count, # /N
19
+ :hint_stream_offset, # /T
20
+ :primary_hint_offset, # /H [offset, length]
21
+ keyword_init: true
22
+ )
23
+
24
+ class << self
25
+ # Parse the first indirect object at offset 0 in +io+ and
26
+ # return a LinearizationInfo if it's a Linearization dict,
27
+ # nil otherwise.
28
+ def detect(io)
29
+ io.seek(0, IO::SEEK_SET)
30
+ header = io.read(1024).to_s
31
+ return nil unless header.start_with?("%PDF-")
32
+
33
+ # Skip the header line, then look for the first "N G obj"
34
+ # marker. The Linearization dict is on the first indirect
35
+ # object in a linearized file.
36
+ io.seek(0, IO::SEEK_SET)
37
+ tok = Pdfrb::Source::Tokenizer.new(io)
38
+ parser = Pdfrb::Source::Parser.new(tok, document: nil)
39
+ first_obj = begin
40
+ parser.parse_indirect_object
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ return nil unless first_obj
45
+
46
+ dict = dictionary_value(first_obj)
47
+ return nil unless dict && dict[:Linearized]
48
+
49
+ build_info(dict)
50
+ end
51
+
52
+ private
53
+
54
+ def dictionary_value(obj)
55
+ return obj.value if obj.is_a?(Pdfrb::Model::Cos::Dictionary)
56
+ return obj.value if obj.is_a?(Pdfrb::Model::Cos::Stream)
57
+
58
+ obj if obj.is_a?(::Hash)
59
+ end
60
+
61
+ def build_info(dict)
62
+ hint = dict[:H]
63
+ LinearizationInfo.new(
64
+ linearized: dict[:Linearized].to_s,
65
+ file_length: dict[:L]&.to_i,
66
+ first_page_obj_offset: dict[:O]&.to_i,
67
+ first_page_end_offset: dict[:E]&.to_i,
68
+ page_count: dict[:N]&.to_i,
69
+ hint_stream_offset: dict[:T]&.to_i,
70
+ primary_hint_offset: hint.is_a?(::Array) ? hint : nil
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -6,9 +6,23 @@ module Pdfrb
6
6
  # patterns and synthesise an XrefSection from the offsets found.
7
7
  # Triggered when the xref table is missing or points to a wrong
8
8
  # offset.
9
+ #
10
+ # Extended recovery capabilities:
11
+ # * Cross-reference table reconstruction (the original mode).
12
+ # * Trailer recovery: find /Root and /Encrypt by scanning for
13
+ # the keys directly when the trailer is corrupt.
14
+ # * Hybrid xref detection: read both xref table and xref stream
15
+ # when both are present.
16
+ # * Object-stream reconstruction: rebuild an XrefSection from a
17
+ # /Type /ObjStm stream when its header is intact but the
18
+ # xref stream is missing.
9
19
  module Recovery
10
20
  OBJ_PATTERN = /(\d+)\s+(\d+)\s+obj\b/.freeze
11
- private_constant :OBJ_PATTERN
21
+ TRAILER_ROOT_PATTERN = %r{/Root\s+(\d+)\s+(\d+)\s+R}.freeze
22
+ TRAILER_ENCRYPT_PATTERN = %r{/Encrypt\s+(\d+)\s+(\d+)\s+R}.freeze
23
+ TRAILER_INFO_PATTERN = %r{/Info\s+(\d+)\s+(\d+)\s+R}.freeze
24
+ private_constant :OBJ_PATTERN, :TRAILER_ROOT_PATTERN,
25
+ :TRAILER_ENCRYPT_PATTERN, :TRAILER_INFO_PATTERN
12
26
 
13
27
  module_function
14
28
 
@@ -30,6 +44,56 @@ module Pdfrb
30
44
  end
31
45
  section
32
46
  end
47
+
48
+ # Scan the file for /Root, /Info, /Encrypt references when the
49
+ # trailer dict is unreadable. Returns a Hash with :Root, :Info,
50
+ # :Encrypt keys as References (or nil per key).
51
+ def recover_trailer_references(io)
52
+ io.seek(0, IO::SEEK_SET)
53
+ data = io.read.to_s
54
+ data.force_encoding(Encoding::BINARY)
55
+
56
+ root_match = data.match(TRAILER_ROOT_PATTERN)
57
+ info_match = data.match(TRAILER_INFO_PATTERN)
58
+ encrypt_match = data.match(TRAILER_ENCRYPT_PATTERN)
59
+
60
+ {
61
+ Root: root_match ? Pdfrb::Model::Reference.new(root_match[1].to_i, root_match[2].to_i) : nil,
62
+ Info: info_match ? Pdfrb::Model::Reference.new(info_match[1].to_i, info_match[2].to_i) : nil,
63
+ Encrypt: encrypt_match ? Pdfrb::Model::Reference.new(encrypt_match[1].to_i, encrypt_match[2].to_i) : nil,
64
+ }
65
+ end
66
+
67
+ # Detect hybrid xref: PDF 1.7+ allows both a classical xref
68
+ # table AND an xref stream in the same revision, referenced
69
+ # from the table via /XRefStm. Returns true if the file has
70
+ # both. Pure-Ruby: scans for `startxref` followed by both
71
+ # `xref` and a Type=XRef stream object.
72
+ def hybrid_xref?(io)
73
+ io.seek(0, IO::SEEK_SET)
74
+ data = io.read.to_s
75
+ data.force_encoding(Encoding::BINARY)
76
+
77
+ has_table = data.include?("\nxref\n")
78
+ has_stream = data.match?(/\/Type\s*\/XRef\b/)
79
+ has_table && has_stream
80
+ end
81
+
82
+ # Reconstruct an XrefSection from a /Type /ObjStm stream by
83
+ # reading its /N + /First + decompressed body. Used when the
84
+ # outer xref stream is corrupt but a known ObjStm is intact.
85
+ def rebuild_from_object_stream(objstm, document)
86
+ return nil unless objstm
87
+
88
+ pairs = ObjectStreamReader.read(objstm, document)
89
+ section = XrefSection.new
90
+ pairs.each_key do |oid|
91
+ # Mark as compressed in objstm 0 (placeholder; caller
92
+ # fills in the real objstm_oid).
93
+ section.add_compressed(oid, 0, 0, objstm.oid)
94
+ end
95
+ section
96
+ end
33
97
  end
34
98
  end
35
99
  end
@@ -52,6 +52,27 @@ module Pdfrb
52
52
  self
53
53
  end
54
54
 
55
+ # Whether the underlying IO is at end of stream.
56
+ def eof?
57
+ @io.eof?
58
+ end
59
+
60
+ # Read one byte from the underlying IO. Returns nil at EOF.
61
+ def read_byte
62
+ b = @io.getbyte
63
+ @pos += 1 if b
64
+ b
65
+ end
66
+
67
+ # Skip whitespace bytes (NUL, HT, LF, FF, CR, SP).
68
+ def skip_whitespace
69
+ while (b = peek_byte)
70
+ break unless WHITESPACE_BYTES.include?(b)
71
+
72
+ advance_byte
73
+ end
74
+ end
75
+
55
76
  private
56
77
 
57
78
  def fill_lookahead(n)
data/lib/pdfrb/source.rb CHANGED
@@ -20,5 +20,6 @@ module Pdfrb
20
20
  autoload :ObjectStreamReader, "pdfrb/source/object_stream_reader"
21
21
  autoload :Recovery, "pdfrb/source/recovery"
22
22
  autoload :LinearizationDetection, "pdfrb/source/linearization_detection"
23
+ autoload :LinearizationReader, "pdfrb/source/linearization_reader"
23
24
  end
24
25
  end