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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ module Pdfrb
5
+ module Conformance
6
+ # PDF/VT conformance per ISO 16612-2. PDF/VT is the variable
7
+ # data printing standard for transactional and direct mail
8
+ # workflows. Two conformance levels:
9
+ #
10
+ # PDF/VT-1: rooted in PDF/X-4 (production-ready print environment)
11
+ # PDF/VT-2: rooted in PDF/X-4 but allows external references
12
+ #
13
+ # Both levels require:
14
+ # * /MarkInfo /Marked true (tagged)
15
+ # * /OutputIntents present
16
+ # * /DPartRoot dict on the Catalog (Document Part registry)
17
+ # * All fonts embedded
18
+ # * No encryption
19
+ module PdfVT
20
+ module_function
21
+
22
+ SHARED = RuleSet.new("PDF/VT-shared").tap do |rs|
23
+ rs.register(Rule.new(
24
+ id: "vt-1",
25
+ description: "/MarkInfo /Marked true required",
26
+ severity: :error,
27
+ spec_clause: "ISO 16612-2 6.2.1",
28
+ check: ->(doc) {
29
+ mark = doc.catalog[:MarkInfo]
30
+ mark = doc.object(mark) if mark.is_a?(Pdfrb::Model::Reference)
31
+ next nil if mark && mark[:Marked] == true
32
+
33
+ Violation.new(
34
+ rule_id: "vt-1",
35
+ message: "PDF/VT requires /MarkInfo /Marked true",
36
+ object: "Catalog/MarkInfo",
37
+ severity: :error,
38
+ spec_clause: "ISO 16612-2 6.2.1"
39
+ )
40
+ }
41
+ ))
42
+
43
+ rs.register(Rule.new(
44
+ id: "vt-2",
45
+ description: "OutputIntents required",
46
+ severity: :error,
47
+ spec_clause: "ISO 16612-2 6.2.2",
48
+ check: ->(doc) {
49
+ next nil if doc.catalog[:OutputIntents]
50
+
51
+ Violation.new(
52
+ rule_id: "vt-2",
53
+ message: "PDF/VT requires /OutputIntents",
54
+ object: "Catalog/OutputIntents",
55
+ severity: :error,
56
+ spec_clause: "ISO 16612-2 6.2.2"
57
+ )
58
+ }
59
+ ))
60
+
61
+ rs.register(Rule.new(
62
+ id: "vt-3",
63
+ description: "/DPartRoot required for variable data partitioning",
64
+ severity: :error,
65
+ spec_clause: "ISO 16612-2 6.3",
66
+ check: ->(doc) {
67
+ next nil if doc.catalog[:DPartRoot]
68
+
69
+ Violation.new(
70
+ rule_id: "vt-3",
71
+ message: "PDF/VT requires /DPartRoot on Catalog",
72
+ object: "Catalog/DPartRoot",
73
+ severity: :error,
74
+ spec_clause: "ISO 16612-2 6.3"
75
+ )
76
+ }
77
+ ))
78
+
79
+ rs.register(Rule.new(
80
+ id: "vt-4",
81
+ description: "Encryption is forbidden",
82
+ severity: :error,
83
+ spec_clause: "ISO 16612-2 6.2.4",
84
+ check: ->(doc) {
85
+ next nil unless doc.trailer && doc.trailer[:Encrypt]
86
+
87
+ Violation.new(
88
+ rule_id: "vt-4",
89
+ message: "PDF/VT prohibits encryption",
90
+ object: "Trailer/Encrypt",
91
+ severity: :error,
92
+ spec_clause: "ISO 16612-2 6.2.4"
93
+ )
94
+ }
95
+ ))
96
+ end
97
+
98
+ VT1 = RuleSet.new("PDF/VT-1").tap do |rs|
99
+ SHARED.rules.each { |r| rs.register(r) }
100
+ end
101
+
102
+ VT2 = RuleSet.new("PDF/VT-2").tap do |rs|
103
+ SHARED.rules.each { |r| rs.register(r) }
104
+ rs.register(Rule.new(
105
+ id: "vt2-1",
106
+ description: "PDF/VT-2 permits external OutputIntents references",
107
+ severity: :warning,
108
+ spec_clause: "ISO 16612-2 6.4.2",
109
+ check: ->(_doc) {}
110
+ ))
111
+ end
112
+
113
+ LEVEL_RULESETS = {
114
+ vt1: VT1, vt2: VT2
115
+ }.freeze
116
+
117
+ def profiles
118
+ LEVEL_RULESETS.dup
119
+ end
120
+
121
+ def validate(document, level: :vt1)
122
+ rs = LEVEL_RULESETS[level] || SHARED
123
+ rs.validate(document)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ # rubocop:enable Metrics/BlockLength
@@ -21,7 +21,7 @@ module Pdfrb
21
21
  module_function
22
22
 
23
23
  def profiles
24
- { x1a: X1A, x3: X3, x4: X4 }
24
+ { x1a: X1A, x3: X3, x4: X4, x6: X6 }
25
25
  end
26
26
 
27
27
  def validate(document, level: :x4)
@@ -32,6 +32,7 @@ module Pdfrb
32
32
  case level.to_s
33
33
  when /\Ax1/i then X1A
34
34
  when /\Ax3/i then X3
35
+ when /\Ax6/i then X6
35
36
  else X4
36
37
  end
37
38
  end
@@ -221,6 +222,70 @@ module Pdfrb
221
222
  SHARED.rules.each { |r| rs.register(r) }
222
223
  end
223
224
 
225
+ # X-6 specific rules. Defined as lambdas (not module methods)
226
+ # so they are in scope when X6 is built.
227
+ X6_VERSION_RULE = Rule.new(
228
+ id: "x6-1",
229
+ description: "PDF/X-6 requires PDF 2.0",
230
+ severity: :error,
231
+ spec_clause: "ISO 15930-11 6.1",
232
+ check: ->(doc) {
233
+ v = doc.version.to_s
234
+ next nil if compare_versions(v, "2.0") >= 0
235
+
236
+ Violation.new(
237
+ rule_id: "x6-1",
238
+ message: "PDF/X-6 requires PDF version 2.0 (was #{v})",
239
+ object: "Header",
240
+ severity: :error,
241
+ spec_clause: "ISO 15930-11 6.1"
242
+ )
243
+ }
244
+ )
245
+
246
+ X6_OUTPUT_INTENT_RULE = Rule.new(
247
+ id: "x6-2",
248
+ description: "PDF/X-6 OutputIntents should use /S /GTS_PDFX",
249
+ severity: :warning,
250
+ spec_clause: "ISO 15930-11 6.2.4",
251
+ check: ->(doc) {
252
+ intents = doc.catalog[:OutputIntents]
253
+ intents_array = case intents
254
+ when ::Array then intents
255
+ when Pdfrb::Model::PdfArray then intents.value
256
+ when nil then []
257
+ else [intents]
258
+ end
259
+ has_pdfx = intents_array.any? do |i|
260
+ obj = i.is_a?(Pdfrb::Model::Reference) ? doc.object(i) : i
261
+ obj && obj[:S] == :GTS_PDFX
262
+ end
263
+ next nil if has_pdfx
264
+
265
+ Violation.new(
266
+ rule_id: "x6-2",
267
+ message: "PDF/X-6 OutputIntents should include an entry with /S /GTS_PDFX",
268
+ object: "Catalog/OutputIntents",
269
+ severity: :warning,
270
+ spec_clause: "ISO 15930-11 6.2.4"
271
+ )
272
+ }
273
+ )
274
+
275
+ # X-6: ISO 15930-11 — PDF 2.0 based. Adds the PDF 2.0 base
276
+ # version requirement and PDF/X-4's relaxed rules.
277
+ X6 = RuleSet.new("PDF/X-6").tap do |rs|
278
+ SHARED.rules.each { |r| rs.register(r) }
279
+ rs.register(X6_VERSION_RULE)
280
+ rs.register(X6_OUTPUT_INTENT_RULE)
281
+ end
282
+
283
+ def compare_versions(a, b)
284
+ aa = a.to_s.split(".").map(&:to_i)
285
+ bb = b.to_s.split(".").map(&:to_i)
286
+ (aa <=> bb) || 0
287
+ end
288
+
224
289
  def scan_color_spaces(doc, &block)
225
290
  doc.each_indirect_object do |obj|
226
291
  next unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
@@ -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
@@ -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