pdfrb 0.7.49 → 0.8.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94341614760eda37ab21d465f8363342d2e3129690b17264fbed0b8aef514058
4
- data.tar.gz: 35d8e757ffa1098d5c1fa1122aa7e5cba573f9c708d5c56c73642c5e5719f55f
3
+ metadata.gz: 337b6b4b76c09f00902c8ef033eca2750dab1ca917693afd8eac09fe97e9c954
4
+ data.tar.gz: 9c6ff7acc3ee63cb5b680eba8b9f7469c7421cf19b514391f461de228b6eaec2
5
5
  SHA512:
6
- metadata.gz: 155b88f7e6c82de7e4cf00d16027a073d7fd6a504ceac0e88c5ea07675524b403806bfa9bc4c54de070724e30ffd4585954c4bcf464e02551dc820d717a0122f
7
- data.tar.gz: 0cef9fb85098dcccf6213ecfde992946888729fd82ea758b668ae744bfc42638f004b7cdc615099f66cdbb752f9bf41b89bffebc1128544efa16209b6fc31902
6
+ metadata.gz: b8038696cccb6196ede83f12111f0c32af4171d6200047b92e0f7fea97a9ebf86c73ee428ad6943b7512cab3306fc23802115c3f19201a2d73f68de6f52c76d0
7
+ data.tar.gz: de8f2bd6aff02c082a1b4c204d317d5544defcc1c50f4f6cbfe604e5f4f6a5d2e854b299d7b921cb3f13a599a1df52491ba4d8e6b8a379dabe6d8d40cc7192de
data/Rakefile CHANGED
@@ -9,6 +9,36 @@ RuboCop::RakeTask.new
9
9
 
10
10
  task default: %i[spec rubocop]
11
11
 
12
+ desc "Cross-check PDF/A output against veraPDF (needs verapdf on PATH)"
13
+ task :verapdf do
14
+ require "pdfrb"
15
+ require "stringio"
16
+ check = Pdfrb::Task::VeraPdfCrossCheck
17
+ abort "verapdf not found on PATH (brew install verapdf)" unless check.available?
18
+
19
+ font = [
20
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
21
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
22
+ ].find { |path| File.file?(path) }
23
+ abort "no system TTF found" unless font
24
+
25
+ doc = Pdfrb::Document.new
26
+ doc.enable_pdf_a!(part: 2, conformance: "B")
27
+ font_obj = doc.fonts.add(font)
28
+ doc.pages.add.canvas.text("veraPDF cross-check", at: [72, 720],
29
+ font: font_obj, size: 24)
30
+ io = StringIO.new
31
+ doc.write(io: io)
32
+
33
+ result = check.call(io.string, flavour: :a2b)
34
+ puts "profile: #{result.profile}"
35
+ puts "checks: #{result.passed_checks} passed, #{result.failed_checks} failed"
36
+ puts "size: #{io.string.bytesize} bytes"
37
+ result.failures.each { |f| puts " #{f.clause}: #{f.message}" }
38
+ abort "NOT PDF/A-2b COMPLIANT" unless result.compliant?
39
+ puts "PDF/A-2b COMPLIANT"
40
+ end
41
+
12
42
  namespace :arlington do
13
43
  desc "Re-vendor the Arlington TSVs from ~/src/pdfa/arlington-pdf-model/tsv/latest"
14
44
  task :refresh do
@@ -44,8 +44,15 @@ module Pdfrb
44
44
  @pending_io_data = nil
45
45
  end
46
46
  @pending_subtype = nil
47
- if @afm_metrics[resource] && font_dict&.value&.[](:Widths)
48
- font_dict.value[:Widths] = @afm_metrics[resource][:widths]
47
+ metrics = @afm_metrics[resource]
48
+ if metrics && font_dict&.value&.[](:Widths)
49
+ # /Widths live in the 1000-unit glyph space (s9.2.4); TTF
50
+ # hmtx advances are in unitsPerEm units, so scale. AFM
51
+ # metrics already carry 1000-unit widths (upem defaults to
52
+ # 1000 there, making this a no-op for standard fonts).
53
+ scale = 1000.0 / (metrics[:units_per_em] || 1000)
54
+ font_dict.value[:Widths] =
55
+ metrics[:widths].map { |w| (w * scale).round }
49
56
  end
50
57
  @registry[name] = resource
51
58
  resource
@@ -490,8 +497,8 @@ module Pdfrb
490
497
  [Pdfrb::Font::CFF::Subsetter.subset_otf(data, gids), :FontFile3]
491
498
  else
492
499
  ttf = Pdfrb::Font::TrueType::File.new(data)
493
- subsetter = Pdfrb::Font::TrueType::Subsetter.new(ttf)
494
- [subsetter.subset(codepoints.to_a), :FontFile2]
500
+ subsetter = Pdfrb::Font::TrueType::Subsetter.new(ttf, codepoints.to_a)
501
+ [subsetter.subset, :FontFile2]
495
502
  end
496
503
  dict = @font_dicts[resource]
497
504
  return unless dict
@@ -502,13 +509,20 @@ module Pdfrb
502
509
  desc = desc_ref.is_a?(Pdfrb::Model::Reference) ? document.object(desc_ref) : desc_ref
503
510
  return unless desc
504
511
 
505
- fd_stream = document.add({ Length: subset.bytesize }, type: Pdfrb::Model::Cos::Stream)
506
- fd_stream.stream = subset
507
- if font_file_key == :FontFile3
508
- fd_stream.value[:Subtype] = :OpenType
509
- fd_stream.value[:Length1] = subset.bytesize
512
+ # Reuse the add-time stream object in place rather than
513
+ # allocating a replacement — a second stream would leave the
514
+ # full original orphaned in the file (doubling output size).
515
+ fd_stream = desc.value[font_file_key]
516
+ fd_stream = document.object(fd_stream) if fd_stream.is_a?(Pdfrb::Model::Reference)
517
+ if fd_stream.is_a?(Pdfrb::Model::Cos::Stream)
518
+ fd_stream.stream = subset
519
+ fd_stream.value[:Length] = subset.bytesize
520
+ fd_stream.value.delete(:Filter)
521
+ if font_file_key == :FontFile3
522
+ fd_stream.value[:Subtype] = :OpenType
523
+ fd_stream.value[:Length1] = subset.bytesize
524
+ end
510
525
  end
511
- desc.value[font_file_key] = Pdfrb::Model::Reference.new(fd_stream.oid, fd_stream.gen)
512
526
  end
513
527
 
514
528
  # Fonts attach to the page-tree ROOT's /Resources so every page
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pdfrb
4
+ class Document
5
+ # PDF/A production helper (ISO 19005-2). Installs the three
6
+ # things a conforming file must carry that a generic document
7
+ # does not:
8
+ #
9
+ # * a GTS_PDFA1 OutputIntent with an embedded sRGB ICC
10
+ # profile (DeviceGray/RGB rendering legitimacy),
11
+ # * an XMP metadata stream carrying the pdfaid identification
12
+ # (part + conformance) on the Catalog,
13
+ # * a document Title so the XMP synchronises on write.
14
+ #
15
+ # Fonts must be EMBEDDED separately (fonts.add with a file path);
16
+ # the standard-14 names alone do not satisfy 6.2.11.4.1.
17
+ module PdfA
18
+ SRGB_IDENTIFIER = "sRGB IEC61966-2.1"
19
+ SRGB_REGISTRY = "http://www.color.org"
20
+
21
+ # @param part [Integer] 1, 2, 3, or 4.
22
+ # @param conformance [String] "B" (basic), "A", or "U".
23
+ def enable_pdf_a!(part: 2, conformance: "B")
24
+ self.version = part == 4 ? "2.0" : "1.7"
25
+
26
+ srgb_output_intent
27
+ metadata[:Title] ||= "Untitled"
28
+ packet = xmp || Pdfrb::XMP::Packet.new
29
+ packet.pdfa_id = { part: part, conformance: conformance }
30
+ @xmp = packet
31
+ @pdfa_part = part
32
+ @pdfa_conformance = conformance
33
+ self
34
+ end
35
+
36
+ def pdfa_part; @pdfa_part; end
37
+ def pdfa_conformance; @pdfa_conformance; end
38
+
39
+ private
40
+
41
+ def srgb_output_intent
42
+ catalog = self.catalog
43
+ intents = catalog.value[:OutputIntents]
44
+ return if intents
45
+
46
+ bytes = Pdfrb::Color::DefaultProfile.srgb_bytes
47
+ icc = add({ N: 3, Length: bytes.bytesize },
48
+ type: Pdfrb::Model::Cos::Stream)
49
+ icc.stream = bytes
50
+ output_intents.add(
51
+ Pdfrb::Model::Reference.new(icc.oid, icc.gen),
52
+ identifier: SRGB_IDENTIFIER,
53
+ condition: "sRGB IEC61966-2.1",
54
+ registry: SRGB_REGISTRY,
55
+ subtype: :GTS_PDFA1
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
@@ -21,6 +21,9 @@ module Pdfrb
21
21
  autoload :AssociatedFiles, "pdfrb/document/associated_files"
22
22
  autoload :Colors, "pdfrb/document/colors"
23
23
  autoload :Display, "pdfrb/document/display"
24
+ autoload :PdfA, "pdfrb/document/pdf_a"
25
+ include PdfA
26
+
24
27
  autoload :Form, "pdfrb/document/form"
25
28
  autoload :GraphicsState, "pdfrb/document/graphics_state"
26
29
  autoload :Layers, "pdfrb/document/layers"
@@ -18,10 +18,13 @@ module Pdfrb
18
18
  attr_reader :ttf, :glyph_ids
19
19
 
20
20
  # @param ttf [Pdfrb::Font::TrueType::File] parsed TTF source.
21
- # @param glyph_ids [Array<Integer>] used glyph IDs.
22
- def initialize(ttf, glyph_ids)
21
+ # @param codepoints [Array<Integer>] used Unicode codepoints;
22
+ # glyph IDs are resolved via the font's cmap.
23
+ def initialize(ttf, codepoints)
23
24
  @ttf = ttf
24
- @glyph_ids = ([NOTDEF_GID] + glyph_ids).sort.uniq
25
+ @codepoint_gids = codepoints.map { |cp| [cp, ttf.cmap.glyph_id_for(cp)] }
26
+ .reject { |_cp, gid| gid.nil? || gid.zero? }
27
+ @glyph_ids = ([NOTDEF_GID] + @codepoint_gids.map(&:last)).sort.uniq
25
28
  end
26
29
 
27
30
  # Returns the subset font bytes.
@@ -104,7 +107,8 @@ module Pdfrb
104
107
  end
105
108
 
106
109
  def loca_format
107
- head_table.long_loca? ? :long : :short
110
+ # head_table is the raw bytes; parse for indexToLocFormat.
111
+ @ttf.head.long_loca? ? :long : :short
108
112
  end
109
113
 
110
114
  # Get byte range [start, end) for a glyph in the glyf table.
@@ -167,7 +171,7 @@ module Pdfrb
167
171
  pad_to_even(data)
168
172
  end
169
173
  @glyf_end = data.bytesize
170
- data.force_encoding(Encoding::BINARY)
174
+ data.force_encoding(::Encoding::BINARY)
171
175
  end
172
176
 
173
177
  def composite?(glyph_data)
@@ -214,55 +218,48 @@ module Pdfrb
214
218
  else
215
219
  offsets.each { |o| data << [o / 2].pack("n") }
216
220
  end
217
- data.force_encoding(Encoding::BINARY)
221
+ data.force_encoding(::Encoding::BINARY)
218
222
  end
219
223
 
220
224
  def build_cmap
221
- # Build a simple format 4 cmap with used codepoints.
222
- # Map Unicode new glyph IDs.
223
- pairs = {}
224
- @resolved.each do |old_gid|
225
- glyph_map[old_gid]
226
- # We don't have a reverse cmap (gid → unicode); skip for now.
227
- # A real impl would use the original cmap to build this.
225
+ # One segment per kept codepoint (idDelta maps code to the
226
+ # remapped gid), plus the mandatory 0xFFFF terminator.
227
+ pairs = @codepoint_gids.map do |cp, old_gid|
228
+ [cp, glyph_map[old_gid]]
229
+ end.sort
230
+
231
+ segments = pairs.map do |cp, new_gid|
232
+ [cp, cp, ((new_gid - cp) & 0xFFFF)]
228
233
  end
234
+ segments << [0xFFFF, 0xFFFF, 1]
229
235
 
230
- # Emit a minimal format 4 cmap with just .notdef.
231
- build_format4_cmap(pairs)
232
- end
233
-
234
- def build_format4_cmap(_mapping)
235
- 1
236
- search_range = 2
237
- entry_selector = 0
238
- range_shift = 0
239
-
240
- buf = +""
241
- buf << [0].pack("n") # format 0 placeholder; we'll emit format 4
236
+ seg_count = segments.length
242
237
  buf = +""
243
- buf << [4, 0].pack("nn") # format=4, length placeholder
238
+ buf << [4, 0].pack("nn") # format=4, length placeholder
244
239
  buf << [0].pack("n") # language
245
- seg_count = 1
246
- buf << [seg_count * 2].pack("n") # segCountX2
247
- buf << [search_range].pack("n")
248
- buf << [entry_selector].pack("n")
249
- buf << [range_shift].pack("n")
250
- buf << [0xFFFF].pack("n") # endCode
251
- buf << [0].pack("n") # reservedPad
252
- buf << [0xFFFF].pack("n") # startCode
253
- buf << [0].pack("n") # idDelta
254
- buf << [0].pack("n") # idRangeOffset
255
-
256
- length = buf.bytesize
257
- buf[2, 2] = [length].pack("n")
258
-
259
- # Wrap in cmap table structure
240
+ buf << [seg_count * 2].pack("n")
241
+ search_range = (2**Math.log2(seg_count).floor) * 2
242
+ entry_selector = Math.log2(search_range / 2).to_i
243
+ range_shift = (seg_count * 2) - search_range
244
+ buf << [search_range, entry_selector, range_shift].pack("nnn")
245
+ zero = [0].pack("n")
246
+ segments.each { |end_c, _, _| buf << [end_c].pack("n") }
247
+ buf << zero # reservedPad
248
+ segments.each { |_, start_c, _| buf << [start_c].pack("n") }
249
+ # idDelta first pass then idRangeOffset zeros: format 4
250
+ # requires the four arrays in sequence, so delta and offset
251
+ # loops cannot merge with the startCode loop.
252
+ # rubocop:disable Style/CombinableLoops
253
+ segments.each { |_, _, delta| buf << [delta].pack("n") }
254
+ segments.each { buf << zero } # idRangeOffset: delta only
255
+ # rubocop:enable Style/CombinableLoops
256
+
257
+ buf[2, 2] = [buf.bytesize].pack("n")
258
+
260
259
  cmap = +""
261
- cmap << [0, 1, 1].pack("nnn") # version, numTables, platform=1
262
- cmap << [0].pack("n") # encoding=0
263
- cmap << [12].pack("N") # offset to subtable
260
+ cmap << [0, 1, 3, 1, 12].pack("nnnnN") # version, 1 table, (3,1) Unicode BMP
264
261
  cmap << buf
265
- cmap.force_encoding(Encoding::BINARY)
262
+ cmap.force_encoding(::Encoding::BINARY)
266
263
  end
267
264
 
268
265
  def build_hmtx
@@ -272,7 +269,7 @@ module Pdfrb
272
269
  lsb = @ttf.hmtx.lsb(old_gid)
273
270
  buf << [advance, lsb].pack("nn")
274
271
  end
275
- buf.force_encoding(Encoding::BINARY)
272
+ buf.force_encoding(::Encoding::BINARY)
276
273
  end
277
274
 
278
275
  def build_maxp
@@ -364,7 +361,7 @@ module Pdfrb
364
361
  end
365
362
  end
366
363
 
367
- out.force_encoding(Encoding::BINARY)
364
+ out.force_encoding(::Encoding::BINARY)
368
365
  end
369
366
 
370
367
  # rubocop:disable Naming/MethodName
@@ -16,6 +16,7 @@ module Pdfrb
16
16
  autoload :Glyf, "pdfrb/font/true_type/glyf"
17
17
  autoload :Kern, "pdfrb/font/true_type/kern"
18
18
  autoload :Wrapper, "pdfrb/font/true_type/wrapper"
19
+ autoload :Subsetter, "pdfrb/font/true_type/subsetter"
19
20
  end
20
21
  end
21
22
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "stringio"
6
+
7
+ module Pdfrb
8
+ module Task
9
+ # Cross-check pdfrb's PDF/A output against the veraPDF validator
10
+ # (the reference implementation for ISO 19005 conformance).
11
+ #
12
+ # Runs the +verapdf+ binary over a PDF file and parses the XML
13
+ # report. The binary path is configurable via VERAPDF_BIN or the
14
+ # +binary:+ argument for CI environments that download a release.
15
+ module VeraPdfCrossCheck
16
+ Result = Struct.new(
17
+ :compliant, :passed_rules, :failed_rules,
18
+ :passed_checks, :failed_checks, :failures, :raw, :profile
19
+ ) do
20
+ def compliant?
21
+ compliant == true
22
+ end
23
+
24
+ def failure_messages
25
+ failures.map(&:message)
26
+ end
27
+ end
28
+
29
+ Failure = Struct.new(:clause, :description, :message, :context)
30
+
31
+ FLAVOURS = {
32
+ a1b: "1b", a1a: "1a", a2b: "2b", a2a: "2a", a2u: "2u",
33
+ a3b: "3b", a3a: "3a", a3u: "3u", a4b: "4b", a4f: "4f",
34
+ a4e: "4e", u1a: "ua1", u2a: "ua2", u3a: "ua3"
35
+ }.freeze
36
+
37
+ module_function
38
+
39
+ # @param pdf [String, #read] path to a PDF file, or IO/bytes.
40
+ # @param flavour [Symbol, String] veraPDF flavour key (:a2b) or
41
+ # raw code ("2b").
42
+ # @param binary [String] verapdf executable path.
43
+ # @return [Result]
44
+ def call(pdf, flavour: :a2b, binary: ENV.fetch("VERAPDF_BIN", "verapdf"))
45
+ flavour_code = FLAVOURS.fetch(flavour.to_sym, flavour.to_s)
46
+ path, tempdir = materialise(pdf)
47
+ out, err, status = Open3.capture3(binary, "--flavour", flavour_code, path)
48
+
49
+ report = parse_report(out) if status.success? || out.include?("<report")
50
+ report || raise(Pdfrb::Error,
51
+ "verapdf failed (#{status.exitstatus}): #{err[0, 200]}")
52
+ ensure
53
+ FileUtils.remove_entry(tempdir) if tempdir
54
+ end
55
+
56
+ # True when the verapdf binary is available.
57
+ def available?(binary: ENV.fetch("VERAPDF_BIN", "verapdf"))
58
+ _out, _err, status = Open3.capture3(binary, "--version")
59
+ status.success?
60
+ rescue Errno::ENOENT
61
+ false
62
+ end
63
+
64
+ def parse_report(xml)
65
+ require "rexml/document"
66
+ doc = REXML::Document.new(xml)
67
+
68
+ validation = REXML::XPath.first(doc, "//validationReport")
69
+ details = REXML::XPath.first(doc, "//validationReport/details")
70
+ result = Result.new(
71
+ validation&.[]("isCompliant") == "true",
72
+ details&.[]("passedRules").to_i,
73
+ details&.[]("failedRules").to_i,
74
+ details&.[]("passedChecks").to_i,
75
+ details&.[]("failedChecks").to_i,
76
+ [], xml, validation&.[]("profileName").to_s
77
+ )
78
+
79
+ REXML::XPath.each(doc, "//rule[@status='failed']") do |rule|
80
+ description = rule.elements["description"]&.text.to_s.strip
81
+ clause = "#{rule.attributes['specification']} " \
82
+ "cl.#{rule.attributes['clause']}.#{rule.attributes['testNumber']}"
83
+ REXML::XPath.each(rule, ".//check[@status='failed']") do |check|
84
+ result.failures << Failure.new(
85
+ clause, description,
86
+ check.elements["errorMessage"]&.text.to_s.strip,
87
+ check.elements["context"]&.text.to_s.strip
88
+ )
89
+ end
90
+ end
91
+ result
92
+ end
93
+
94
+ # Returns [path, tempdir-or-nil]; the caller removes tempdir.
95
+ def materialise(pdf)
96
+ if pdf.is_a?(String) && !pdf.include?("\0") && File.file?(pdf)
97
+ return [pdf, nil]
98
+ end
99
+
100
+ bytes = pdf.is_a?(String) ? pdf : pdf.read
101
+ require "tmpdir"
102
+ tempdir = Dir.mktmpdir("verapdf")
103
+ path = File.join(tempdir, "check.pdf")
104
+ File.binwrite(path, bytes)
105
+ [path, tempdir]
106
+ end
107
+ end
108
+ end
109
+ end
data/lib/pdfrb/task.rb CHANGED
@@ -11,5 +11,6 @@ module Pdfrb
11
11
  autoload :MemoryProfile, "pdfrb/task/memory_profile"
12
12
  autoload :RegenerateAppearances, "pdfrb/task/regenerate_appearances"
13
13
  autoload :Thumbnail, "pdfrb/task/thumbnail"
14
+ autoload :VeraPdfCrossCheck, "pdfrb/task/vera_pdf_cross_check"
14
15
  end
15
16
  end
data/lib/pdfrb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pdfrb
4
- VERSION = "0.7.49"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/pdfrb/writer.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "zlib"
4
5
 
5
6
  module Pdfrb
@@ -317,6 +318,42 @@ module Pdfrb
317
318
  def dispatch_before_write
318
319
  document.dispatch_message(:before_write)
319
320
  compress_content_streams if document.config["writer.compress_streams"]
321
+ normalize_page_resources
322
+ seed_file_identifier
323
+ end
324
+
325
+ # PDF/A (ISO 19005-2, 6.2.2) requires every page whose content
326
+ # references resources to carry an EXPLICIT /Resources entry;
327
+ # inherited-only resources fail validation. Copy the page-tree
328
+ # root's resources onto pages that lack their own.
329
+ def normalize_page_resources
330
+ root = document.catalog && document.object(document.catalog.value[:Pages])
331
+ return unless root.is_a?(Pdfrb::Model::Cos::Dictionary)
332
+
333
+ inherited = root.value[:Resources]
334
+ return if inherited.nil?
335
+
336
+ Array(root.value[:Kids]).each do |kid_ref|
337
+ page = kid_ref.is_a?(Pdfrb::Model::Reference) ? document.object(kid_ref) : kid_ref
338
+ next unless page.is_a?(Pdfrb::Model::Cos::Dictionary)
339
+ next if page.value.key?(:Resources)
340
+
341
+ page.value[:Resources] = inherited
342
+ end
343
+ end
344
+
345
+ # ISO 32000-1 s14.4: the trailer shall carry a /ID file
346
+ # identifier. Derived deterministically from the catalog
347
+ # serialization + object census so identical content yields
348
+ # identical identifiers.
349
+ def seed_file_identifier
350
+ existing = document.trailer || {}
351
+ return if existing[:ID]
352
+
353
+ fingerprint = +@serializer.serialize(document.catalog.value)
354
+ each_indirect_object { |obj| fingerprint << obj.oid.to_s << "," }
355
+ digest = ::Digest::MD5.digest(fingerprint)
356
+ existing[:ID] = [digest, digest]
320
357
  end
321
358
 
322
359
  def version
@@ -21,6 +21,7 @@ module Pdfrb
21
21
  @pdf = Schemas::PDF.new
22
22
  @xmp = Schemas::XMPBasic.new
23
23
  @rights = Schemas::XMPRights.new
24
+ @pdfaid = nil
24
25
  end
25
26
 
26
27
  def title=(value); @dc.title = Array(value); end
@@ -43,6 +44,15 @@ module Pdfrb
43
44
  def creator_tool=(value); @xmp.creator_tool = value; end
44
45
  def creator_tool; @xmp.creator_tool; end
45
46
 
47
+ # PDF/A identification (ISO 19005-1 s6.7.11 / 19005-2 s6.6.4):
48
+ # part (e.g. 2) and conformance level ("A", "B", "U").
49
+ def pdfa_id=(value)
50
+ @pdfaid = { part: Integer(value[:part]), conformance: value[:conformance].to_s }
51
+ end
52
+
53
+ def pdfa_part; @pdfaid && @pdfaid[:part]; end
54
+ def pdfa_conformance; @pdfaid && @pdfaid[:conformance]; end
55
+
46
56
  def to_xmp
47
57
  XMP_BEGIN + rdf_body + XMP_END
48
58
  end
@@ -56,8 +66,10 @@ module Pdfrb
56
66
  '<rdf:Description rdf:about=""',
57
67
  ' xmlns:dc="http://purl.org/dc/elements/1.1/"',
58
68
  ' xmlns:pdf="http://ns.adobe.com/pdf/1.3/"',
59
- ' xmlns:xmp="http://ns.adobe.com/xap/1.0/">',
69
+ ' xmlns:xmp="http://ns.adobe.com/xap/1.0/"',
60
70
  ]
71
+ lines << ' xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/"' if has_pdfa?
72
+ lines << ">"
61
73
  lines += schema_lines
62
74
  lines << "</rdf:Description>"
63
75
  lines << "</rdf:RDF>"
@@ -70,9 +82,21 @@ module Pdfrb
70
82
  parts += dc_lines if has_dc?
71
83
  parts += pdf_lines if has_pdf?
72
84
  parts += xmp_lines if has_xmp?
85
+ parts += pdfa_lines if has_pdfa?
73
86
  parts
74
87
  end
75
88
 
89
+ def has_pdfa?
90
+ !@pdfaid.nil?
91
+ end
92
+
93
+ def pdfa_lines
94
+ [
95
+ " <pdfaid:part>#{@pdfaid[:part]}</pdfaid:part>",
96
+ " <pdfaid:conformance>#{@pdfaid[:conformance]}</pdfaid:conformance>",
97
+ ]
98
+ end
99
+
76
100
  def has_dc?
77
101
  (@dc.title && !@dc.title.empty?) ||
78
102
  (@dc.creator && !@dc.creator.empty?) ||
data/lib/pdfrb/xmp.rb CHANGED
@@ -16,6 +16,10 @@ Pdfrb::Document.prepend(Module.new do
16
16
  private
17
17
 
18
18
  def sync_xmp_metadata!
19
+ # PDF/A files must carry an XMP metadata stream even without a
20
+ # document title; sync whenever a PDF/A identification is set.
21
+ return sync_pdfa_metadata! if pdfa_part
22
+
19
23
  info = trailer&.[](:Info)
20
24
  return unless info
21
25
 
@@ -41,4 +45,22 @@ Pdfrb::Document.prepend(Module.new do
41
45
  catalog.value[:Metadata] =
42
46
  Pdfrb::Model::Reference.new(stream.oid, stream.gen)
43
47
  end
48
+
49
+ def sync_pdfa_metadata!
50
+ packet = begin
51
+ xmp
52
+ rescue StandardError
53
+ nil
54
+ end
55
+ return unless packet
56
+
57
+ xmp_data = packet.to_xmp
58
+ stream = add(
59
+ { Type: :Metadata, Subtype: :XML, Length: xmp_data.bytesize },
60
+ type: Pdfrb::Model::Cos::Stream
61
+ )
62
+ stream.stream = xmp_data
63
+ catalog.value[:Metadata] =
64
+ Pdfrb::Model::Reference.new(stream.oid, stream.gen)
65
+ end
44
66
  end)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pdfrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.49
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '1.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rexml
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.3'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: thor
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -862,6 +876,7 @@ files:
862
876
  - lib/pdfrb/document/output_intents.rb
863
877
  - lib/pdfrb/document/page_labels.rb
864
878
  - lib/pdfrb/document/pages.rb
879
+ - lib/pdfrb/document/pdf_a.rb
865
880
  - lib/pdfrb/document/portfolio.rb
866
881
  - lib/pdfrb/document/shadings.rb
867
882
  - lib/pdfrb/document/stamps.rb
@@ -1269,6 +1284,7 @@ files:
1269
1284
  - lib/pdfrb/task/optimize.rb
1270
1285
  - lib/pdfrb/task/regenerate_appearances.rb
1271
1286
  - lib/pdfrb/task/thumbnail.rb
1287
+ - lib/pdfrb/task/vera_pdf_cross_check.rb
1272
1288
  - lib/pdfrb/test_utils.rb
1273
1289
  - lib/pdfrb/validator.rb
1274
1290
  - lib/pdfrb/version.rb