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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ module Pdfrb
5
+ module Conformance
6
+ # Tagged PDF validation per ISO 32000-1 §14.8 (and ISO 32000-2
7
+ # §14.8). Tagged PDF is the foundation of PDF/UA and PDF/A-2a/3a;
8
+ # this rule set checks the structural requirements that any
9
+ # tagged PDF must satisfy, regardless of higher-level profile.
10
+ #
11
+ # This is intentionally a subset of PDF/UA. Use PdfUA for the
12
+ # full accessibility profile; use TaggedPdf for a baseline
13
+ # structural pass that doesn't require accessibility annotations
14
+ # (alt text, reading order).
15
+ module TaggedPdf
16
+ STANDARD_STRUCTURE_TYPES = %i[
17
+ Document Part Div Art Sect BlockQuote Caption TOC TOCI
18
+ Index NonStruct Private H H1 H2 H3 H4 H5 H6 P L LI Lbl LBody
19
+ Table TR TH TD THead TBody TFoot Caption Span Quote Note
20
+ Reference BibEntry Code Figure Formula Form
21
+ ].freeze
22
+
23
+ module_function
24
+
25
+ RULESET = RuleSet.new("TaggedPDF").tap do |rs|
26
+ rs.register(Rule.new(
27
+ id: "tag-1",
28
+ description: "/MarkInfo /Marked must be true",
29
+ severity: :error,
30
+ spec_clause: "ISO 32000-2 14.8.1",
31
+ check: ->(doc) {
32
+ mark_info = doc.catalog[:MarkInfo]
33
+ mark_info = doc.object(mark_info) if mark_info.is_a?(Pdfrb::Model::Reference)
34
+ next nil if mark_info && mark_info[:Marked] == true
35
+
36
+ Violation.new(
37
+ rule_id: "tag-1",
38
+ message: "/MarkInfo /Marked true is required for tagged PDF",
39
+ object: "Catalog",
40
+ severity: :error,
41
+ spec_clause: "ISO 32000-2 14.8.1"
42
+ )
43
+ }
44
+ ))
45
+
46
+ rs.register(Rule.new(
47
+ id: "tag-2",
48
+ description: "/StructTreeRoot must be present",
49
+ severity: :error,
50
+ spec_clause: "ISO 32000-2 14.7.2",
51
+ check: ->(doc) {
52
+ next nil if doc.catalog[:StructTreeRoot]
53
+
54
+ Violation.new(
55
+ rule_id: "tag-2",
56
+ message: "/StructTreeRoot required for tagged PDF",
57
+ object: "Catalog",
58
+ severity: :error,
59
+ spec_clause: "ISO 32000-2 14.7.2"
60
+ )
61
+ }
62
+ ))
63
+
64
+ rs.register(Rule.new(
65
+ id: "tag-3",
66
+ description: "Structure elements must use standard types",
67
+ severity: :warning,
68
+ spec_clause: "ISO 32000-2 14.8.4",
69
+ check: ->(doc) {
70
+ violations = []
71
+ walk_structure(doc) do |elem|
72
+ s = elem[:S]
73
+ next if s.nil? || STANDARD_STRUCTURE_TYPES.include?(s.to_sym)
74
+
75
+ violations << Violation.new(
76
+ rule_id: "tag-3",
77
+ message: "Non-standard structure type '#{s}' on element",
78
+ object: "StructElem",
79
+ severity: :warning,
80
+ spec_clause: "ISO 32000-2 14.8.4"
81
+ )
82
+ end
83
+ violations
84
+ }
85
+ ))
86
+
87
+ rs.register(Rule.new(
88
+ id: "tag-4",
89
+ description: "Figure elements should have /Alt or /ActualText",
90
+ severity: :warning,
91
+ spec_clause: "ISO 32000-2 14.9.2",
92
+ check: ->(doc) {
93
+ violations = []
94
+ walk_structure(doc) do |elem|
95
+ next unless elem[:S] == :Figure
96
+ next if elem[:Alt] || elem[:ActualText]
97
+
98
+ violations << Violation.new(
99
+ rule_id: "tag-4",
100
+ message: "Figure without /Alt or /ActualText",
101
+ object: "StructElem/Figure",
102
+ severity: :warning,
103
+ spec_clause: "ISO 32000-2 14.9.2"
104
+ )
105
+ end
106
+ violations
107
+ }
108
+ ))
109
+
110
+ rs.register(Rule.new(
111
+ id: "tag-5",
112
+ description: "Table structure should include rows",
113
+ severity: :warning,
114
+ spec_clause: "ISO 32000-2 14.8.5",
115
+ check: ->(doc) {
116
+ violations = []
117
+ walk_structure(doc) do |elem|
118
+ next unless elem[:S] == :Table
119
+
120
+ kids = structure_kids(elem, doc)
121
+ has_tr = kids.any? { |k| k[:S] == :TR }
122
+ next if has_tr
123
+
124
+ violations << Violation.new(
125
+ rule_id: "tag-5",
126
+ message: "Table has no TR child rows",
127
+ object: "StructElem/Table",
128
+ severity: :warning,
129
+ spec_clause: "ISO 32000-2 14.8.5"
130
+ )
131
+ end
132
+ violations
133
+ }
134
+ ))
135
+ end
136
+
137
+ def validate(document)
138
+ RULESET.validate(document)
139
+ end
140
+
141
+ # Walk the structure tree starting at /StructTreeRoot, yielding
142
+ # each element dict. Silently skips cycles and missing refs.
143
+ def walk_structure(document)
144
+ seen = ::Set.new
145
+ root_ref = document.catalog[:StructTreeRoot]
146
+ return unless root_ref
147
+
148
+ root = root_ref.is_a?(Pdfrb::Model::Reference) ? document.object(root_ref) : root_ref
149
+ return unless root
150
+
151
+ first_kids = structure_kids(root, document)
152
+ queue = first_kids.dup
153
+ until queue.empty?
154
+ elem = queue.shift
155
+ next unless elem
156
+ next if seen.include?(elem.object_id)
157
+
158
+ seen << elem.object_id
159
+ yield elem
160
+ queue.concat(structure_kids(elem, document))
161
+ end
162
+ end
163
+
164
+ def structure_kids(element, document)
165
+ kids = element[:K]
166
+ return [] unless kids
167
+
168
+ kids = kids.value if kids.is_a?(Pdfrb::Model::PdfArray)
169
+ kids = [kids] unless kids.is_a?(::Array)
170
+ kids.filter_map do |k|
171
+ next nil unless k.is_a?(Pdfrb::Model::Reference)
172
+
173
+ obj = document.object(k)
174
+ next nil unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
175
+
176
+ obj
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ # rubocop:enable Metrics/BlockLength
@@ -7,8 +7,16 @@ module Pdfrb
7
7
  autoload :Violation, "pdfrb/conformance/rule"
8
8
  autoload :ValidationResult, "pdfrb/conformance/rule"
9
9
  autoload :PdfA, "pdfrb/conformance/pdf_a"
10
+ autoload :PdfA4Deep, "pdfrb/conformance/pdf_a4_deep"
10
11
  autoload :PdfUA, "pdfrb/conformance/pdf_ua"
12
+ autoload :PdfUA2Deep, "pdfrb/conformance/pdf_ua2_deep"
13
+ autoload :PdfUATaggingDeep, "pdfrb/conformance/pdf_ua_tagging_deep"
11
14
  autoload :PdfX, "pdfrb/conformance/pdf_x"
15
+ autoload :PdfVT, "pdfrb/conformance/pdf_vt"
16
+ autoload :Pdf2AF, "pdfrb/conformance/pdf_2_af"
17
+ autoload :Pades, "pdfrb/conformance/pades"
18
+ autoload :Ltv, "pdfrb/conformance/ltv"
19
+ autoload :TaggedPdf, "pdfrb/conformance/tagged_pdf"
12
20
  autoload :StructureElements, "pdfrb/conformance/structure_elements"
13
21
  autoload :VeraPdfBridge, "pdfrb/conformance/verapdf_bridge"
14
22
  end
@@ -68,6 +68,122 @@ module Pdfrb
68
68
  move_to(x1, y1).line_to(x2, y2)
69
69
  end
70
70
 
71
+ # Draw a connected polyline through +points+. Each point is
72
+ # [x, y]. If +close+ is true, append a close-path operator.
73
+ def polyline(points, close: false)
74
+ return self if points.empty?
75
+
76
+ first = points.first
77
+ move_to(first[0], first[1])
78
+ points.drop(1).each { |x, y| line_to(x, y) }
79
+ close_path if close
80
+ self
81
+ end
82
+
83
+ # Draw a closed polygon through +points+.
84
+ def polygon(points)
85
+ polyline(points, close: true)
86
+ end
87
+
88
+ # Approximate a circular arc from +start_angle+ to +end_angle+
89
+ # (radians) centered at (cx, cy) with +radius+. Uses 16-line
90
+ # segment approximation per full revolution.
91
+ def arc(cx, cy, radius, start_angle:, end_angle:, segments_per_rev: 16)
92
+ span = end_angle - start_angle
93
+ return self if span.abs < 1e-6
94
+
95
+ seg_count = [(segments_per_rev * span.abs / (2 * Math::PI)).ceil, 1].max
96
+ step = span / seg_count
97
+ seg_count.times do |i|
98
+ a0 = start_angle + (i * step)
99
+ a1 = a0 + step
100
+ x0 = cx + (radius * Math.cos(a0))
101
+ y0 = cy + (radius * Math.sin(a0))
102
+ x1 = cx + (radius * Math.cos(a1))
103
+ y1 = cy + (radius * Math.sin(a1))
104
+ i.zero? ? move_to(x0, y0) : line_to(x0, y0)
105
+ line_to(x1, y1)
106
+ end
107
+ self
108
+ end
109
+
110
+ # Full circle centered at (cx, cy) with +radius+.
111
+ def circle(cx, cy, radius)
112
+ arc(cx, cy, radius, start_angle: 0, end_angle: 2 * Math::PI)
113
+ close_path
114
+ end
115
+
116
+ # Ellipse centered at (cx, cy) with x/y radii (rx, ry).
117
+ # Built from a scaled circle.
118
+ def ellipse(cx, cy, rx, ry)
119
+ save_graphics_state do
120
+ translate(cx, cy)
121
+ concat(1, 0, 0, 1, 0, 0)
122
+ # Approximate ellipse by scaling a unit circle.
123
+ segments = 32
124
+ segments.times do |i|
125
+ a0 = (i / segments.to_f) * 2 * Math::PI
126
+ a1 = ((i + 1) / segments.to_f) * 2 * Math::PI
127
+ x0 = rx * Math.cos(a0)
128
+ y0 = ry * Math.sin(a0)
129
+ x1 = rx * Math.cos(a1)
130
+ y1 = ry * Math.sin(a1)
131
+ i.zero? ? move_to(x0, y0) : line_to(x0, y0)
132
+ line_to(x1, y1)
133
+ end
134
+ close_path
135
+ end
136
+ self
137
+ end
138
+
139
+ # Rectangle with rounded corners. +radius+ is the corner radius
140
+ # (a single value or 4-element [tl, tr, br, bl]).
141
+ def rounded_rectangle(x, y, width, height, radius)
142
+ radii = radius.is_a?(::Array) ? radius : [radius] * 4
143
+ tl, tr, br, bl = radii
144
+ # bottom edge
145
+ move_to(x + bl, y)
146
+ line_to(x + width - br, y)
147
+ corner(x + width - br, y, x + width, y + br)
148
+ line_to(x + width, y + height - tr)
149
+ corner(x + width, y + height - tr,
150
+ x + width - tr, y + height)
151
+ line_to(x + tl, y + height)
152
+ corner(x + tl, y + height, x, y + height - tl)
153
+ line_to(x, y + bl)
154
+ corner(x, y + bl, x + bl, y)
155
+ close_path
156
+ self
157
+ end
158
+
159
+ # Convenience alias matching HexaPDF's naming. Accepts either
160
+ # an Array (interpreted as the dash array, phase 0) or an
161
+ # [array, phase] pair.
162
+ def dash=(spec)
163
+ array, phase = case spec
164
+ when ::Array
165
+ if spec.first.is_a?(::Array)
166
+ spec
167
+ else
168
+ [spec, 0]
169
+ end
170
+ else
171
+ [[spec], 0]
172
+ end
173
+ emit_op(Pdfrb::Content::Operator::DashPattern, array, phase)
174
+ end
175
+
176
+ # Draw a quarter-circle corner from (x0, y0) to (x1, y1) by
177
+ # multi-segment linear interpolation. Adequate for typical
178
+ # radii (< 30 pts); for larger radii use curve_to directly.
179
+ def corner(x0, y0, x1, y1)
180
+ segments = 6
181
+ segments.times do |i|
182
+ t = (i + 1) / segments.to_f
183
+ line_to(x0 + ((x1 - x0) * t), y0 + ((y1 - y0) * t))
184
+ end
185
+ end
186
+
71
187
  def curve_to(c1x, c1y, c2x, c2y, x, y)
72
188
  emit_op(Pdfrb::Content::Operator::CurveTo, c1x, c1y, c2x, c2y, x, y)
73
189
  self
@@ -214,6 +330,27 @@ module Pdfrb
214
330
  self
215
331
  end
216
332
 
333
+ # Emit an inline image (BI ... ID ... EI) directly into the
334
+ # content stream. Avoids the /Resources /XObject registration
335
+ # overhead for one-shot images. The +dict+ keys are the image
336
+ # header keys (/W, /H, /CS, /BPC, /F, etc.); +data+ is the
337
+ # encoded image bytes (raw, Flate-compressed, or DCT).
338
+ #
339
+ # @param dict [Hash{Symbol=>Object}] inline image header entries
340
+ # (e.g. { W: 100, H: 100, CS: :RGB, BPC: 8, F: :DCTDecode }).
341
+ # @param data [String] raw image bytes (binary-encoded).
342
+ def inline_image(dict:, data:)
343
+ header = +"BI\n"
344
+ dict.each do |k, v|
345
+ header << @serializer.serialize(k) << " " << @serializer.serialize(v) << "\n"
346
+ end
347
+ header << "ID\n"
348
+ append(header)
349
+ append(data)
350
+ append("\nEI\n")
351
+ self
352
+ end
353
+
217
354
  def text(str, at:, font:, size:, char_spacing: nil, word_spacing: nil)
218
355
  @used_fonts[font] = size
219
356
  encoded = encode_for_font(str.to_s, font)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Content
5
+ module Operator
6
+ # Inline image operators (s8.9.7). Inline images embed raw image
7
+ # data directly in the content stream, framed by BI ... ID ...
8
+ # EI. They're an alternative to /XObject /Image for one-shot
9
+ # images: smaller overhead per image, no /Resources entry
10
+ # needed, but the bytes live in the content stream so they
11
+ # can't be reused.
12
+ #
13
+ # The serialise path emits the BI dict, ID marker, raw bytes,
14
+ # and EI marker. The invoke path is intentionally a no-op; the
15
+ # content Parser handles BI/ID/EI as a single bounded block
16
+ # rather than via the operator dispatch table.
17
+ class BeginInlineImage < Base
18
+ class << self
19
+ def name; "BI"; end
20
+
21
+ def serialize(serializer, **dict)
22
+ buf = +"BI\n"
23
+ dict.each do |k, v|
24
+ buf << serializer.serialize(k) << " " << serializer.serialize(v) << "\n"
25
+ end
26
+ buf << "ID\n"
27
+ buf
28
+ end
29
+
30
+ def invoke(_processor, *_operands); end
31
+ register
32
+ end
33
+ end
34
+
35
+ class EndInlineImage < Base
36
+ class << self
37
+ def name; "EI"; end
38
+
39
+ # The byte payload sits between ID and EI. The serializer
40
+ # for inline images writes the payload directly via
41
+ # Canvas#inline_image; this method is here so the registry
42
+ # knows about EI.
43
+ def serialize(_serializer, *_operands)
44
+ "EI\n"
45
+ end
46
+
47
+ def invoke(_processor, *_operands); end
48
+ register
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -16,6 +16,7 @@ module Pdfrb
16
16
  autoload :GraphicsStateParams, "pdfrb/content/operators/graphics_state_params"
17
17
  autoload :MarkedContent, "pdfrb/content/operators/marked_content"
18
18
  autoload :Clipping, "pdfrb/content/operators/clipping"
19
+ autoload :InlineImage, "pdfrb/content/operators/inline_image"
19
20
  end
20
21
  end
21
22
  end
@@ -23,11 +23,21 @@ module Pdfrb
23
23
 
24
24
  # Yields (operator_class, operands) pairs. Returns an Enumerator
25
25
  # if no block.
26
+ #
27
+ # Special-cases the BI/ID/EI inline image sequence: when BI is
28
+ # seen, the inline image dict + raw byte payload are read as a
29
+ # single InlineImage invocation rather than as discrete tokens.
26
30
  def each_invocation
27
31
  return enum_for(:each_invocation) unless block_given?
28
32
 
29
33
  operands = []
30
34
  while (tok = tokenizer.next_token)
35
+ if tok.type == :keyword && tok.value == "BI"
36
+ yield Pdfrb::Content::Operator::BeginInlineImage, [parse_inline_image]
37
+ operands = []
38
+ next
39
+ end
40
+
31
41
  case tok.type
32
42
  when :keyword
33
43
  op = Pdfrb::Content::Operator[tok.value]
@@ -48,6 +58,78 @@ module Pdfrb
48
58
  self
49
59
  end
50
60
 
61
+ # Parse a BI ... ID <bytes> EI inline image sequence. The BI
62
+ # keyword has already been consumed; the next tokens form the
63
+ # image header (key/value pairs), then ID introduces the raw
64
+ # byte payload terminated by EI.
65
+ #
66
+ # Returns a Hash with :header (the key-value pairs) and
67
+ # :data (the raw image bytes).
68
+ def parse_inline_image
69
+ header = {}
70
+ # Read header pairs until we hit the ID keyword.
71
+ while (tok = tokenizer.next_token)
72
+ break if tok.type == :keyword && tok.value == "ID"
73
+
74
+ if tok.type == :name
75
+ key = abbrev_for(tok.value) || tok.value.to_sym
76
+ val_tok = tokenizer.next_token
77
+ header[key] = token_value(val_tok)
78
+ end
79
+ end
80
+
81
+ # After ID, exactly one whitespace byte separates the
82
+ # keyword from the data. Read raw bytes until "\nEI" or
83
+ # " EI" terminator.
84
+ data = read_inline_image_data
85
+ { header: header, data: data }
86
+ end
87
+
88
+ # Map inline-image abbreviation keys to full PDF names per
89
+ # ISO 32000-2 §8.9.7 Table 89.
90
+ def abbrev_for(name)
91
+ ABBREV_TABLE[name.to_sym]
92
+ end
93
+
94
+ ABBREV_TABLE = {
95
+ BPC: :BitsPerComponent,
96
+ CS: :ColorSpace,
97
+ D: :Decode,
98
+ DP: :DecodeParms,
99
+ F: :Filter,
100
+ H: :Height,
101
+ IM: :ImageMask,
102
+ Intent: :Intent,
103
+ I: :Interpolate,
104
+ W: :Width,
105
+ }.freeze
106
+
107
+ def read_inline_image_data
108
+ # Skip exactly one whitespace byte after ID.
109
+ tokenizer.skip_whitespace
110
+ bytes = +"".b
111
+ # Read until we see the "EI" marker. The marker is usually
112
+ # preceded by whitespace and followed by whitespace or EOF.
113
+ until tokenizer.eof?
114
+ b = tokenizer.read_byte
115
+ break if b.nil?
116
+
117
+ bytes << b
118
+ if bytes.bytesize >= 3 &&
119
+ whitespace_byte?(bytes.getbyte(-3)) &&
120
+ bytes.byteslice(-2, 2) == "EI"
121
+ return bytes.byteslice(0, bytes.bytesize - 3).b
122
+ end
123
+ end
124
+ bytes.b
125
+ end
126
+
127
+ WHITESPACE_BYTE_VALUES = [0, 9, 10, 12, 13, 32].freeze
128
+
129
+ def whitespace_byte?(byte)
130
+ WHITESPACE_BYTE_VALUES.include?(byte)
131
+ end
132
+
51
133
  private
52
134
 
53
135
  def consume_array
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "net/https"
5
+ require "uri"
6
+
7
+ module Pdfrb
8
+ module DigitalSignature
9
+ # RFC 3161 trusted timestamp client. Submits a hash to a Time
10
+ # Stamp Authority (TSA) over HTTPS and returns the resulting
11
+ # timestamp token (TSTInfo) as DER bytes, suitable for inclusion
12
+ # in a PAdES B-T/LTA signature.
13
+ #
14
+ # Pure-Ruby: uses Net::HTTP with OpenSSL. The TSA URL is
15
+ # configurable; defaults to a free public TSA. Callers in
16
+ # production should configure a trusted TSA endpoint.
17
+ module TimestampClient
18
+ DEFAULT_TSA_URL = "https://freetsa.org/tsr"
19
+ DEFAULT_HASH_ALGORITHM = "sha256"
20
+
21
+ module_function
22
+
23
+ # Submit +data+ (or its digest) to the TSA at +url+ and return
24
+ # the DER-encoded TimeStampResp.
25
+ #
26
+ # @param data [String] the bytes to timestamp.
27
+ # @param url [String] TSA endpoint URL.
28
+ # @param hash_algorithm [String] OpenSSL digest name.
29
+ # @param cert [OpenSSL::X509::Certificate, nil] client cert.
30
+ # @param key [OpenSSL::PKey::RSA, nil] client key.
31
+ # @return [String] DER-encoded TimeStampResp bytes.
32
+ def request_timestamp(data:, url: DEFAULT_TSA_URL,
33
+ hash_algorithm: DEFAULT_HASH_ALGORITHM,
34
+ cert: nil, key: nil)
35
+ digest = OpenSSL::Digest.new(hash_algorithm)
36
+ hashed = digest.digest(data)
37
+ req = build_tsq_request(hashed, hash_algorithm)
38
+ https_post(url, req, cert: cert, key: key)
39
+ end
40
+
41
+ DIGEST_OIDS = {
42
+ "sha1" => "1.3.14.3.2.26",
43
+ "sha256" => "2.16.840.1.101.3.4.2.1",
44
+ "sha384" => "2.16.840.1.101.3.4.2.2",
45
+ "sha512" => "2.16.840.1.101.3.4.2.3",
46
+ }.freeze
47
+
48
+ # Build the TimeStampReq DER. Pure OpenSSL::ASN1 construction.
49
+ def build_tsq_request(hashed_bytes, hash_algorithm)
50
+ oid = DIGEST_OIDS[hash_algorithm.to_s] || DIGEST_OIDS["sha256"]
51
+ OpenSSL::ASN1::Sequence.new([
52
+ OpenSSL::ASN1::Integer.new(1), # version
53
+ OpenSSL::ASN1::Sequence.new([ # messageImprint
54
+ OpenSSL::ASN1::Sequence.new([
55
+ OpenSSL::ASN1::ObjectId.new(oid),
56
+ OpenSSL::ASN1::Null.new(nil),
57
+ ]),
58
+ OpenSSL::ASN1::OctetString.new(hashed_bytes),
59
+ ]),
60
+ OpenSSL::ASN1::Boolean.new(true), # reqCert
61
+ ]).to_der
62
+ end
63
+
64
+ def https_post(url, body, cert: nil, key: nil)
65
+ uri = URI(url)
66
+ http = Net::HTTP.new(uri.host, uri.port)
67
+ http.use_ssl = true if uri.is_a?(URI::HTTPS)
68
+ http.ssl_timeout = 30
69
+
70
+ if cert && key
71
+ http.cert = cert
72
+ http.key = key
73
+ end
74
+
75
+ req = Net::HTTP::Post.new(uri.request_uri)
76
+ req["Content-Type"] = "application/timestamp-query"
77
+ req.body = body
78
+
79
+ response = http.request(req)
80
+ raise "TSA error: #{response.code} #{response.message}" unless response.code == "200"
81
+
82
+ response.body
83
+ end
84
+ end
85
+ end
86
+ end
@@ -11,6 +11,7 @@ module Pdfrb
11
11
  autoload :VerificationResult, "pdfrb/digital_signature/verification_result"
12
12
  autoload :CmsHandler, "pdfrb/digital_signature/cms_handler"
13
13
  autoload :TimestampHandler, "pdfrb/digital_signature/timestamp_handler"
14
+ autoload :TimestampClient, "pdfrb/digital_signature/timestamp_client"
14
15
 
15
16
  HANDLERS = {
16
17
  "adbe.pkcs7.detached": CmsHandler,
@@ -43,6 +43,7 @@ module Pdfrb
43
43
  @object_reader = nil
44
44
  @version = "1.4"
45
45
  @empty_trailer = nil
46
+ @revisions = [] # array of [xref, trailer] tuples, latest first
46
47
 
47
48
  if io
48
49
  read_from_io(io)
@@ -259,6 +260,29 @@ module Pdfrb
259
260
  end
260
261
  end
261
262
 
263
+ # Walk every revision in the document's incremental-update chain,
264
+ # from the latest (most recent) revision backward via /Prev.
265
+ # Each revision yields a (revision_index, xref, trailer) tuple
266
+ # where revision_index 0 is the latest. Documents without
267
+ # incremental updates yield a single tuple.
268
+ #
269
+ # Useful for forensic inspection, version-aware rendering, and
270
+ # debugging "ghost" objects that were modified in a later
271
+ # revision.
272
+ def each_revision
273
+ return enum_for(:each_revision) unless block_given?
274
+
275
+ return (yield 0, @xref, trailer) if @revisions.empty? && @xref
276
+
277
+ @revisions.each_with_index { |(xref, tr), i| yield i, xref, tr }
278
+ end
279
+
280
+ # Number of revisions in the document. 1 for a freshly written
281
+ # PDF; >1 for incrementally-updated PDFs.
282
+ def revision_count
283
+ @revisions.empty? ? 1 : @revisions.length
284
+ end
285
+
262
286
  private
263
287
 
264
288
  def allocate_oid
@@ -285,6 +309,10 @@ module Pdfrb
285
309
  xref, trailer = load_single_xref(io, sxref)
286
310
  return [nil, nil] unless xref && trailer
287
311
 
312
+ # Track each revision's xref + trailer so callers can walk the
313
+ # history (Document#each_revision).
314
+ @revisions = [[xref, trailer]]
315
+
288
316
  # Follow /Prev chain for incremental updates. Earlier entries
289
317
  # fill gaps; later entries take precedence (already in xref).
290
318
  prev_offset = trailer[:Prev]
@@ -292,6 +320,7 @@ module Pdfrb
292
320
  prev_xref, prev_trailer = load_single_xref(io, prev_offset)
293
321
  break unless prev_xref
294
322
 
323
+ @revisions << [prev_xref, prev_trailer]
295
324
  xref.merge!(prev_xref)
296
325
  prev_offset = prev_trailer && prev_trailer[:Prev]
297
326
  end