pdfrb 0.6.0 → 0.7.10

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -0
  3. data/RELEASE.md +52 -33
  4. data/data/pdfrb/afm/Courier-Bold.afm +342 -342
  5. data/data/pdfrb/afm/Courier-BoldOblique.afm +342 -342
  6. data/data/pdfrb/afm/Courier-Oblique.afm +342 -342
  7. data/data/pdfrb/afm/Courier.afm +342 -342
  8. data/data/pdfrb/afm/Helvetica-Bold.afm +2827 -2827
  9. data/data/pdfrb/afm/Helvetica-BoldOblique.afm +2827 -2827
  10. data/data/pdfrb/afm/Helvetica-Oblique.afm +3051 -3051
  11. data/data/pdfrb/afm/Helvetica.afm +3051 -3051
  12. data/data/pdfrb/afm/Symbol.afm +213 -213
  13. data/data/pdfrb/afm/Times-Bold.afm +2588 -2588
  14. data/data/pdfrb/afm/Times-BoldItalic.afm +2384 -2384
  15. data/data/pdfrb/afm/Times-Italic.afm +2667 -2667
  16. data/data/pdfrb/afm/Times-Roman.afm +2419 -2419
  17. data/data/pdfrb/afm/ZapfDingbats.afm +225 -225
  18. data/data/pdfrb/layout/hyphenation_en.txt +408 -0
  19. data/lib/pdfrb/color/default_profile.rb +213 -0
  20. data/lib/pdfrb/color/icc_validator.rb +69 -0
  21. data/lib/pdfrb/color.rb +2 -0
  22. data/lib/pdfrb/conformance/ltv.rb +112 -0
  23. data/lib/pdfrb/conformance/pades.rb +198 -0
  24. data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
  25. data/lib/pdfrb/conformance/pdf_a.rb +123 -0
  26. data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
  27. data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
  28. data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
  29. data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
  30. data/lib/pdfrb/conformance/pdf_x.rb +66 -1
  31. data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
  32. data/lib/pdfrb/conformance.rb +8 -0
  33. data/lib/pdfrb/content/canvas.rb +137 -0
  34. data/lib/pdfrb/content/operators/inline_image.rb +53 -0
  35. data/lib/pdfrb/content/operators.rb +1 -0
  36. data/lib/pdfrb/content/parser.rb +82 -0
  37. data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
  38. data/lib/pdfrb/digital_signature.rb +1 -0
  39. data/lib/pdfrb/document.rb +29 -0
  40. data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
  41. data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
  42. data/lib/pdfrb/encryption/v5_writer.rb +108 -0
  43. data/lib/pdfrb/encryption.rb +3 -0
  44. data/lib/pdfrb/font_loader/type3.rb +65 -0
  45. data/lib/pdfrb/font_loader.rb +1 -0
  46. data/lib/pdfrb/image_loader/gif.rb +246 -0
  47. data/lib/pdfrb/image_loader/png.rb +76 -19
  48. data/lib/pdfrb/image_loader/tiff.rb +257 -0
  49. data/lib/pdfrb/image_loader.rb +2 -0
  50. data/lib/pdfrb/layout/font_fallback.rb +203 -0
  51. data/lib/pdfrb/layout/hyphenation.rb +120 -0
  52. data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
  53. data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
  54. data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
  55. data/lib/pdfrb/layout/polygon_frame.rb +106 -0
  56. data/lib/pdfrb/layout/table_box.rb +154 -23
  57. data/lib/pdfrb/layout/text_shaper.rb +129 -0
  58. data/lib/pdfrb/layout.rb +7 -0
  59. data/lib/pdfrb/model/type/page_piece_info.rb +58 -0
  60. data/lib/pdfrb/model/type.rb +3 -0
  61. data/lib/pdfrb/source/linearization_reader.rb +76 -0
  62. data/lib/pdfrb/source/recovery.rb +65 -1
  63. data/lib/pdfrb/source/tokenizer.rb +21 -0
  64. data/lib/pdfrb/source.rb +1 -0
  65. data/lib/pdfrb/task/regenerate_appearances.rb +98 -0
  66. data/lib/pdfrb/task/thumbnail.rb +133 -0
  67. data/lib/pdfrb/task.rb +2 -0
  68. data/lib/pdfrb/version.rb +1 -1
  69. metadata +35 -3
@@ -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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Model
5
+ module Type
6
+ # Page-piece dictionary (s14.5). Per-page metadata recording
7
+ # application-specific private data. The dictionary hangs off
8
+ # a page's /PieceInfo key and is keyed by application name;
9
+ # each value carries /LastModified, /Private, and (optionally)
10
+ # an /ASFStream reference for a stream-encoded private payload.
11
+ #
12
+ # Conforming readers MUST NOT interpret application-private
13
+ # data; it survives round-trips and incremental updates intact.
14
+ class PagePieceInfo < Pdfrb::Model::Cos::Dictionary
15
+ # The application-name key (e.g. :Adobe, :Pdfrb). Multiple
16
+ # applications may each have their own entry in the same
17
+ # /PieceInfo dict.
18
+ def application_name
19
+ value.key?(:Application) ? value[:Application] : nil
20
+ end
21
+
22
+ # /LastModified — PDF date string, parsed to a Time.
23
+ def last_modified
24
+ v = value[:LastModified]
25
+ return nil unless v.is_a?(::String)
26
+
27
+ Pdfrb::Model::Date.parse(v)
28
+ end
29
+
30
+ # /Private — application-private dict. Untyped on purpose:
31
+ # the contents are app-defined.
32
+ def private_data
33
+ value[:Private]
34
+ end
35
+
36
+ # Set the application's /LastModified to +time+. +time+ is
37
+ # converted to a PDF date string.
38
+ def last_modified=(time)
39
+ value[:LastModified] = Pdfrb::Model::Date.format(time)
40
+ end
41
+
42
+ # Merge +hash+ into /Private. If /Private is absent, it is
43
+ # created from the hash.
44
+ def merge_private!(hash)
45
+ existing = value[:Private]
46
+ if existing.is_a?(Pdfrb::Model::Cos::Dictionary)
47
+ existing.value.merge!(hash)
48
+ elsif existing.is_a?(::Hash)
49
+ existing.merge!(hash)
50
+ else
51
+ value[:Private] = hash
52
+ end
53
+ self
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -353,6 +353,9 @@ module Pdfrb
353
353
  # Structure types (s14.7).
354
354
  autoload :StructureAttributes, "pdfrb/model/type/structure_attributes"
355
355
  autoload :StructureElementKid, "pdfrb/model/type/structure_element_kid"
356
+
357
+ # Page-piece dictionary (s14.5).
358
+ autoload :PagePieceInfo, "pdfrb/model/type/page_piece_info"
356
359
  end
357
360
  end
358
361
  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