pdfrb 0.7.1 → 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 (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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Pdfrb
6
+ module Color
7
+ # Default sRGB ICC profile accessor. Builds a usable sRGB v4
8
+ # ICC profile (header + tag table + tagged elements) that real
9
+ # PDF readers can consume for ICCBased color spaces. The profile
10
+ # includes:
11
+ #
12
+ # * desc — profile description (sRGB by pdfrb)
13
+ # * wtpt — D50 media white point
14
+ # * rXYZ, gXYZ, bXYZ — sRGB colorant matrix (D50-adapted)
15
+ # * rTRC, gTRC, bTRC — gamma 2.2 tone reproduction curves
16
+ #
17
+ # Tagged-element types follow ICC.1:2022 §10 (multiLocalizedUnicodeType
18
+ # for desc, XYZType for colorants, curveType for TRCs).
19
+ module DefaultProfile
20
+ SRGB_SIGNATURE = "acsp".b
21
+ ICC_VERSION_V4 = [0x04, 0x40, 0x00, 0x00].freeze
22
+
23
+ module_function
24
+
25
+ # Returns the sRGB ICC profile bytes (~ 600 bytes). Includes
26
+ # the structural header + tag table + tagged elements needed
27
+ # for a real ICCBased color space.
28
+ def srgb_bytes
29
+ tags = build_srgb_tags
30
+ tag_table = build_tag_table(tags)
31
+ header = build_srgb_header(tag_table.bytesize + tags_total_size(tags))
32
+ profile = header + tag_table
33
+ tags.each_value { |tag_bytes| profile << tag_bytes }
34
+ # Compute MD5 ProfileID over the on-disk profile (sans the
35
+ # existing ID slot, which we'll patch in). ICC.1:2022 §7.1.
36
+ profile = patch_profile_id(profile)
37
+ profile.force_encoding(Encoding::BINARY)
38
+ end
39
+
40
+ # Build an ICCBased color space array referencing a new stream
41
+ # containing the sRGB profile bytes. Returns
42
+ # [:ICCBased, ref] suitable for /ColorSpace resources.
43
+ def srgb_color_space(document)
44
+ bytes = srgb_bytes
45
+ stream = document.add(
46
+ { N: 3, Length: bytes.bytesize },
47
+ type: Pdfrb::Model::Cos::Stream
48
+ )
49
+ stream.stream = bytes
50
+ [:ICCBased, Pdfrb::Model::Reference.new(stream.oid, stream.gen)]
51
+ end
52
+
53
+ # ---- Header construction ----
54
+
55
+ def build_srgb_header(tag_section_size)
56
+ header = +""
57
+ header << ([0] * 4).pack("C*") # Profile size placeholder
58
+ header << ([0] * 4).pack("C*") # Preferred CMM
59
+ header << ICC_VERSION_V4.pack("C*") # Version 4.4
60
+ header << "mntr" # Device class
61
+ header << "RGB " # Color space
62
+ header << "XYZ " # PCS
63
+ header << date_bytes # Date
64
+ header << SRGB_SIGNATURE # 'acsp'
65
+ header << "APPL" # Primary platform
66
+ header << ([0] * 4).pack("C*") # Profile flags
67
+ header << ([0] * 4).pack("C*") # Device manufacturer
68
+ header << ([0] * 4).pack("C*") # Device model
69
+ header << ([0] * 8).pack("C*") # Device attributes
70
+ header << ([0] * 4).pack("C*") # Rendering intent (perceptual)
71
+ header << d50_illuminant_bytes # PCS illuminant (D50)
72
+ header << ([0] * 4).pack("C*") # Profile creator
73
+ header << ([0] * 16).pack("C*") # Profile ID (MD5, patched later)
74
+ header << ([0] * 28).pack("C*") # Reserved
75
+
76
+ total_size = header.bytesize + tag_section_size
77
+ header[0, 4] = [total_size].pack("N")
78
+ header
79
+ end
80
+
81
+ def date_bytes
82
+ [0x07, 0xE8, 0x00, 0x01, 0x00, 0x01,
83
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00].pack("C*")
84
+ end
85
+
86
+ def d50_illuminant_bytes
87
+ # D50 X=0.9642, Y=1.0, Z=0.8249 in s15Fixed16.
88
+ [0x00, 0x00, 0xF6, 0xD6, 0x00, 0x01, 0x00, 0x00,
89
+ 0x00, 0x00, 0xD3, 0x2D].pack("C*")
90
+ end
91
+
92
+ # ---- Tag table + tagged elements ----
93
+
94
+ # Returns a Hash of { tag_signature => tag_bytes } in the
95
+ # order they should appear after the tag table.
96
+ def build_srgb_tags
97
+ {
98
+ "desc" => desc_tag_bytes("sRGB by pdfrb"),
99
+ "wtpt" => xyz_tag_bytes(d50_xyz),
100
+ "rXYZ" => xyz_tag_bytes(srgb_r_xyz),
101
+ "gXYZ" => xyz_tag_bytes(srgb_g_xyz),
102
+ "bXYZ" => xyz_tag_bytes(srgb_b_xyz),
103
+ "rTRC" => curve_tag_bytes,
104
+ "gTRC" => curve_tag_bytes,
105
+ "bTRC" => curve_tag_bytes,
106
+ }
107
+ end
108
+
109
+ def build_tag_table(tags)
110
+ out = +""
111
+ count = tags.length
112
+ out << [count].pack("N")
113
+
114
+ # Compute offsets: tag table is 4 + count*12 bytes; each tag
115
+ # element is appended after the table in order.
116
+ offset = 4 + (count * 12)
117
+ tags.each do |sig, bytes|
118
+ out << sig
119
+ out << [offset].pack("N")
120
+ out << [bytes.bytesize].pack("N")
121
+ offset += bytes.bytesize
122
+ end
123
+ out
124
+ end
125
+
126
+ def tags_total_size(tags)
127
+ tags.values.sum(&:bytesize)
128
+ end
129
+
130
+ # ---- Tag element builders ----
131
+
132
+ # multiLocalizedUnicodeType (mluc) for desc, per ICC.10.13.
133
+ def desc_tag_bytes(text)
134
+ utf16 = text.encode("UTF-16BE").bytes
135
+ body = +""
136
+ body << "mluc" # type signature
137
+ body << ([0] * 4).pack("C*") # reserved
138
+ body << [1].pack("N") # number of records
139
+ body << [12].pack("N") # record size in bytes
140
+ body << "enUS" # language + country
141
+ body << [utf16.length].pack("N") # string length in bytes
142
+ body << [28].pack("N") # string offset (from tag start)
143
+ body << utf16.pack("C*")
144
+ body
145
+ end
146
+
147
+ # XYZType for colorants and white points, per ICC.10.22.
148
+ def xyz_tag_bytes(xyz_triplet)
149
+ body = +""
150
+ body << "XYZ " # type signature
151
+ body << ([0] * 4).pack("C*") # reserved
152
+ xyz_triplet.each { |v| body << s15fixed16(v) }
153
+ body
154
+ end
155
+
156
+ # curveType with a single gamma value, per ICC.10.16.
157
+ # 2.2 is the canonical sRGB approximation (real sRGB uses a
158
+ # piecewise curve, but a single 2.2 gamma is acceptable for
159
+ # most rendering).
160
+ def curve_tag_bytes
161
+ body = +""
162
+ body << "curv" # type signature
163
+ body << ([0] * 4).pack("C*") # reserved
164
+ body << [0].pack("N") # count = 0 → gamma follows in u8Fixed8
165
+ body << u8fixed8(2.2)
166
+ body
167
+ end
168
+
169
+ # Encode a Float as ICC s15Fixed16 (signed 16.16).
170
+ def s15fixed16(value)
171
+ v = (value * 65536.0).round.to_i
172
+ [v].pack("N")
173
+ end
174
+
175
+ # Encode a Float as ICC u8Fixed8 (unsigned 8.8).
176
+ def u8fixed8(value)
177
+ v = (value * 256.0).round.to_i
178
+ [v].pack("n")
179
+ end
180
+
181
+ # ---- sRGB colorants (D50-adapted, per ICC.1:2022 Annex A) ----
182
+
183
+ def d50_xyz
184
+ [0.9642, 1.0000, 0.8249]
185
+ end
186
+
187
+ def srgb_r_xyz
188
+ [0.4360, 0.2225, 0.0139]
189
+ end
190
+
191
+ def srgb_g_xyz
192
+ [0.3851, 0.7169, 0.0971]
193
+ end
194
+
195
+ def srgb_b_xyz
196
+ [0.1431, 0.0606, 0.7141]
197
+ end
198
+
199
+ # ---- Profile ID (MD5) ----
200
+
201
+ # Patch the Profile ID (offset 84..99) with the MD5 of the
202
+ # profile bytes computed with the ID slot zeroed (per ICC.1
203
+ # §7.1.1).
204
+ def patch_profile_id(profile)
205
+ copy = profile.b
206
+ copy[84, 16] = ([0] * 16).pack("C*")
207
+ md5 = Digest::MD5.digest(copy)
208
+ copy[84, 16] = md5
209
+ copy
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ module Color
5
+ # Validates an ICC profile binary against the structural rules in
6
+ # ICC.1:2022 §7. The validator checks:
7
+ # * Profile size >= 128 (header) + 4 (tag count)
8
+ # * 'acsp' signature at offset 36
9
+ # * Profile version is 2.x, 4.x, or 5.x
10
+ # * Device class is one of the known classes
11
+ # * Color space is one of the known spaces
12
+ # * Tag table offsets don't exceed the file size
13
+ #
14
+ # Returns an Array of Violation strings (empty if valid).
15
+ module ICCValidator
16
+ VALID_VERSIONS = [(2.0..2.99), (4.0..4.99), (5.0..5.99)].freeze
17
+ VALID_DEVICE_CLASSES = %w[
18
+ scnr mntr prtr link spac abst nmcl nkpr
19
+ ].freeze
20
+ VALID_COLOR_SPACES = %w[
21
+ XYZ Lab Luv YCbCr Yxy RGB GRAY HSV HLS CMYK CMY 2CLR 3CLR
22
+ 4CLR 5CLR 6CLR 7CLR 8CLR 9CLR ACLR BCLR CCLR DCLR ECLR FCLR
23
+ ].freeze
24
+
25
+ module_function
26
+
27
+ def validate(profile_bytes)
28
+ violations = []
29
+ unless profile_bytes.is_a?(::String) && profile_bytes.bytesize >= 132
30
+ violations << "ICC profile too small (need >= 132 bytes, got #{profile_bytes&.bytesize})"
31
+ return violations
32
+ end
33
+
34
+ unless profile_bytes.byteslice(36, 4) == "acsp"
35
+ violations << "ICC profile signature at offset 36 is not 'acsp'"
36
+ end
37
+
38
+ version_hi = profile_bytes.getbyte(8)
39
+ version_lo = profile_bytes.getbyte(9)
40
+ version = (version_hi * 1.0) + (version_lo / 16.0 / 10.0)
41
+ unless VALID_VERSIONS.any? { |range| range.cover?(version) }
42
+ violations << "ICC profile version #{version} not in 2.x/4.x/5.x"
43
+ end
44
+
45
+ device_class = profile_bytes.byteslice(12, 4).to_s.strip
46
+ unless VALID_DEVICE_CLASSES.include?(device_class)
47
+ violations << "ICC device class '#{device_class}' is not recognised"
48
+ end
49
+
50
+ color_space = profile_bytes.byteslice(16, 4).to_s.strip
51
+ unless VALID_COLOR_SPACES.include?(color_space)
52
+ violations << "ICC color space '#{color_space}' is not recognised"
53
+ end
54
+
55
+ # Tag table sanity check
56
+ tag_count = profile_bytes.unpack1("N", offset: 128)
57
+ if tag_count > 256
58
+ violations << "ICC tag count #{tag_count} looks bogus (max 256)"
59
+ end
60
+
61
+ violations
62
+ end
63
+
64
+ def valid?(profile_bytes)
65
+ validate(profile_bytes).empty?
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/pdfrb/color.rb CHANGED
@@ -8,5 +8,7 @@ module Pdfrb
8
8
  module Color
9
9
  autoload :ICCProfile, "pdfrb/color/icc_profile"
10
10
  autoload :ColorSpace, "pdfrb/color/color_space"
11
+ autoload :DefaultProfile, "pdfrb/color/default_profile"
12
+ autoload :ICCValidator, "pdfrb/color/icc_validator"
11
13
  end
12
14
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ module Pdfrb
5
+ module Conformance
6
+ # Long-Term Validation (LTV) per ETSI EN 319 102-1 Annex B. LTV
7
+ # preserves the validity of a signature beyond the validity
8
+ # period of the signer's certificate by archiving revocation
9
+ # data and timestamping the archive over time.
10
+ #
11
+ # LTV requires:
12
+ # * At least one signature with /ByteRange and /Contents.
13
+ # * /DSS dictionary on Catalog with /Certs, /CRLs, /OCSPs as
14
+ # applicable.
15
+ # * For archival (LTA): a /DocumentTimeStamp on the catalog.
16
+ module Ltv
17
+ module_function
18
+
19
+ RULESET = RuleSet.new("LTV").tap do |rs|
20
+ rs.register(Rule.new(
21
+ id: "ltv-1",
22
+ description: "/DSS dictionary required for LTV",
23
+ severity: :error,
24
+ spec_clause: "ETSI EN 319 102-1 B.5",
25
+ check: ->(doc) {
26
+ next nil if doc.catalog[:DSS]
27
+
28
+ Violation.new(
29
+ rule_id: "ltv-1",
30
+ message: "LTV requires /DSS dictionary on Catalog",
31
+ object: "Catalog/DSS",
32
+ severity: :error,
33
+ spec_clause: "ETSI EN 319 102-1 B.5"
34
+ )
35
+ }
36
+ ))
37
+
38
+ rs.register(Rule.new(
39
+ id: "ltv-2",
40
+ description: "/DSS must contain validation material",
41
+ severity: :warning,
42
+ spec_clause: "ETSI EN 319 102-1 B.5",
43
+ check: ->(doc) {
44
+ dss = doc.catalog[:DSS]
45
+ dss = doc.object(dss) if dss.is_a?(Pdfrb::Model::Reference)
46
+ next nil unless dss
47
+
48
+ has_material = dss[:Certs] || dss[:CRLs] || dss[:OCSPs] ||
49
+ dss[:VRI]
50
+ next nil if has_material
51
+
52
+ Violation.new(
53
+ rule_id: "ltv-2",
54
+ message: "/DSS is empty (no /Certs, /CRLs, /OCSPs, or /VRI)",
55
+ object: "Catalog/DSS",
56
+ severity: :warning,
57
+ spec_clause: "ETSI EN 319 102-1 B.5"
58
+ )
59
+ }
60
+ ))
61
+
62
+ rs.register(Rule.new(
63
+ id: "ltv-3",
64
+ description: "At least one signature should be present",
65
+ severity: :warning,
66
+ spec_clause: "ETSI EN 319 102-1 B.4",
67
+ check: ->(doc) {
68
+ fields = Pdfrb::Conformance::Pades.signature_fields(doc)
69
+ next nil if fields.any?
70
+
71
+ Violation.new(
72
+ rule_id: "ltv-3",
73
+ message: "LTV document has no signatures to validate",
74
+ object: "AcroForm/Fields",
75
+ severity: :warning,
76
+ spec_clause: "ETSI EN 319 102-1 B.4"
77
+ )
78
+ }
79
+ ))
80
+
81
+ rs.register(Rule.new(
82
+ id: "ltv-4",
83
+ description: "Document Time Stamp for archival (LTA)",
84
+ severity: :warning,
85
+ spec_clause: "ETSI EN 319 102-1 B.6",
86
+ check: ->(doc) {
87
+ fields = Pdfrb::Conformance::Pades.signature_fields(doc)
88
+ has_archival = fields.any? do |field|
89
+ v = field[:V]
90
+ v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
91
+ v_obj && v_obj[:Type]&.to_sym == :DocTimeStamp
92
+ end
93
+ next nil if has_archival
94
+
95
+ Violation.new(
96
+ rule_id: "ltv-4",
97
+ message: "Long-Term Archival (LTA) recommends a Document Time Stamp",
98
+ object: "AcroForm/Fields",
99
+ severity: :warning,
100
+ spec_clause: "ETSI EN 319 102-1 B.6"
101
+ )
102
+ }
103
+ ))
104
+ end
105
+
106
+ def validate(document)
107
+ RULESET.validate(document)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ module Pdfrb
5
+ module Conformance
6
+ # PAdES (ETSI EN 319 142-1) signature profile validation. Four
7
+ # baseline levels build on each other:
8
+ #
9
+ # B-B: Basic signatures — just the document hash + signer cert.
10
+ # B-T: B-B + trusted timestamp (RFC 3161) on the signature.
11
+ # B-LT: B-T + long-term validation material (DSS dictionary
12
+ # with revocation data, signer certs, TSA cert chain).
13
+ # B-LTA: B-LT + archival timestamp protecting the DSS itself.
14
+ #
15
+ # Each rule set checks signature dictionaries on the document's
16
+ # /AcroForm /Fields for the required components per level.
17
+ module Pades
18
+ ALLOWED_SIGNATURE_TYPES = %i[Sig DocTimeStamp].freeze
19
+
20
+ module_function
21
+
22
+ SHARED = RuleSet.new("PAdES-shared").tap do |rs|
23
+ rs.register(Rule.new(
24
+ id: "pades-1",
25
+ description: "At least one signature field must be present",
26
+ severity: :error,
27
+ spec_clause: "ETSI EN 319 142-1 5.2",
28
+ check: ->(doc) {
29
+ next nil if signature_fields(doc).any?
30
+
31
+ Violation.new(
32
+ rule_id: "pades-1",
33
+ message: "PAdES requires at least one signature field",
34
+ object: "AcroForm/Fields",
35
+ severity: :error,
36
+ spec_clause: "ETSI EN 319 142-1 5.2"
37
+ )
38
+ }
39
+ ))
40
+ end
41
+
42
+ BB = RuleSet.new("PAdES-B-B").tap do |rs|
43
+ SHARED.rules.each { |r| rs.register(r) }
44
+ rs.register(Rule.new(
45
+ id: "bb-1",
46
+ description: "B-B: each signature must have /Type /Sig or /DocTimeStamp",
47
+ severity: :error,
48
+ spec_clause: "ETSI EN 319 142-1 5.3",
49
+ check: ->(doc) {
50
+ vs = []
51
+ signature_fields(doc).each do |field|
52
+ v = field[:V]
53
+ next unless v
54
+
55
+ v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
56
+ next if v_obj && ALLOWED_SIGNATURE_TYPES.include?(v_obj[:Type]&.to_sym)
57
+
58
+ vs << Violation.new(
59
+ rule_id: "bb-1",
60
+ message: "Signature V must be /Type /Sig or /DocTimeStamp",
61
+ object: "Field/V",
62
+ severity: :error,
63
+ spec_clause: "ETSI EN 319 142-1 5.3"
64
+ )
65
+ end
66
+ vs.empty? ? nil : vs
67
+ }
68
+ ))
69
+ end
70
+
71
+ BT = RuleSet.new("PAdES-B-T").tap do |rs|
72
+ BB.rules.each { |r| rs.register(r) }
73
+ rs.register(Rule.new(
74
+ id: "bt-1",
75
+ description: "B-T: signature must include a trusted timestamp",
76
+ severity: :error,
77
+ spec_clause: "ETSI EN 319 142-1 5.4",
78
+ check: ->(doc) {
79
+ vs = []
80
+ signature_fields(doc).each do |field|
81
+ v = field[:V]
82
+ next unless v
83
+
84
+ v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
85
+ has_timestamp = v_obj && (
86
+ v_obj[:Type]&.to_sym == :DocTimeStamp ||
87
+ (v_obj[:ByteRange] && timestamp_in_signature?(v_obj))
88
+ )
89
+ next if has_timestamp
90
+
91
+ vs << Violation.new(
92
+ rule_id: "bt-1",
93
+ message: "B-T requires a trusted timestamp (RFC 3161)",
94
+ object: "Field/V",
95
+ severity: :error,
96
+ spec_clause: "ETSI EN 319 142-1 5.4"
97
+ )
98
+ end
99
+ vs.empty? ? nil : vs
100
+ }
101
+ ))
102
+ end
103
+
104
+ BLT = RuleSet.new("PAdES-B-LT").tap do |rs|
105
+ BT.rules.each { |r| rs.register(r) }
106
+ rs.register(Rule.new(
107
+ id: "blt-1",
108
+ description: "B-LT: DSS dictionary required for LTV material",
109
+ severity: :error,
110
+ spec_clause: "ETSI EN 319 142-1 5.5",
111
+ check: ->(doc) {
112
+ next nil if doc.catalog[:DSS]
113
+
114
+ Violation.new(
115
+ rule_id: "blt-1",
116
+ message: "B-LT requires /DSS dictionary with validation material",
117
+ object: "Catalog/DSS",
118
+ severity: :error,
119
+ spec_clause: "ETSI EN 319 142-1 5.5"
120
+ )
121
+ }
122
+ ))
123
+ end
124
+
125
+ BLTA = RuleSet.new("PAdES-B-LTA").tap do |rs|
126
+ BLT.rules.each { |r| rs.register(r) }
127
+ rs.register(Rule.new(
128
+ id: "blta-1",
129
+ description: "B-LTA: archival Document Time Stamp required",
130
+ severity: :error,
131
+ spec_clause: "ETSI EN 319 142-1 5.6",
132
+ check: ->(doc) {
133
+ # B-LTA needs a DocTimeStamp as the last signature,
134
+ # protecting the DSS.
135
+ has_archival = signature_fields(doc).any? do |field|
136
+ v = field[:V]
137
+ v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
138
+ v_obj && v_obj[:Type]&.to_sym == :DocTimeStamp
139
+ end
140
+ next nil if has_archival
141
+
142
+ Violation.new(
143
+ rule_id: "blta-1",
144
+ message: "B-LTA requires an archival Document Time Stamp",
145
+ object: "AcroForm/Fields",
146
+ severity: :error,
147
+ spec_clause: "ETSI EN 319 142-1 5.6"
148
+ )
149
+ }
150
+ ))
151
+ end
152
+
153
+ LEVEL_RULESETS = {
154
+ "B-B": BB, "B-T": BT, "B-LT": BLT, "B-LTA": BLTA
155
+ }.freeze
156
+
157
+ def profiles
158
+ LEVEL_RULESETS.dup
159
+ end
160
+
161
+ def validate(document, level: :"B-B")
162
+ rs = LEVEL_RULESETS[level] || SHARED
163
+ rs.validate(document)
164
+ end
165
+
166
+ def signature_fields(document)
167
+ acroform = document.catalog[:AcroForm]
168
+ acroform = document.object(acroform) if acroform.is_a?(Pdfrb::Model::Reference)
169
+ return [] unless acroform
170
+
171
+ fields = acroform[:Fields]
172
+ fields = fields.value if fields.is_a?(Pdfrb::Model::PdfArray)
173
+ return [] unless fields.is_a?(::Array)
174
+
175
+ fields.filter_map do |ref|
176
+ obj = ref.is_a?(Pdfrb::Model::Reference) ? document.object(ref) : ref
177
+ next nil unless obj
178
+
179
+ obj[:FT]&.to_sym == :Sig ? obj : nil
180
+ end
181
+ end
182
+
183
+ # Heuristic: look for the standard ASN.1 timestamp OID
184
+ # (1.2.840.113549.1.7.2 signedData, with a TSTInfo content
185
+ # type). Real validation requires parsing the PKCS#7 — out of
186
+ # scope for this rule set, which only checks the structural
187
+ # presence of a ByteRange + Contents long enough to hold a
188
+ # timestamp token.
189
+ def timestamp_in_signature?(signature)
190
+ contents = signature[:Contents]
191
+ return false unless contents.is_a?(::String)
192
+
193
+ contents.bytesize > 1024
194
+ end
195
+ end
196
+ end
197
+ end
198
+ # rubocop:enable Metrics/BlockLength