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 +4 -4
- data/Rakefile +30 -0
- data/lib/pdfrb/document/fonts.rb +24 -10
- data/lib/pdfrb/document/pdf_a.rb +60 -0
- data/lib/pdfrb/document.rb +3 -0
- data/lib/pdfrb/font/true_type/subsetter.rb +44 -47
- data/lib/pdfrb/font/true_type.rb +1 -0
- data/lib/pdfrb/task/vera_pdf_cross_check.rb +109 -0
- data/lib/pdfrb/task.rb +1 -0
- data/lib/pdfrb/version.rb +1 -1
- data/lib/pdfrb/writer.rb +37 -0
- data/lib/pdfrb/xmp/packet.rb +25 -1
- data/lib/pdfrb/xmp.rb +22 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 337b6b4b76c09f00902c8ef033eca2750dab1ca917693afd8eac09fe97e9c954
|
|
4
|
+
data.tar.gz: 9c6ff7acc3ee63cb5b680eba8b9f7469c7421cf19b514391f461de228b6eaec2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/pdfrb/document/fonts.rb
CHANGED
|
@@ -44,8 +44,15 @@ module Pdfrb
|
|
|
44
44
|
@pending_io_data = nil
|
|
45
45
|
end
|
|
46
46
|
@pending_subtype = nil
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
data/lib/pdfrb/document.rb
CHANGED
|
@@ -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
|
|
22
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
-
#
|
|
222
|
-
#
|
|
223
|
-
pairs =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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")
|
|
238
|
+
buf << [4, 0].pack("nn") # format=4, length placeholder
|
|
244
239
|
buf << [0].pack("n") # language
|
|
245
|
-
seg_count
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
buf << [range_shift].pack("
|
|
250
|
-
|
|
251
|
-
buf << [
|
|
252
|
-
buf <<
|
|
253
|
-
buf << [
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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("
|
|
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
|
data/lib/pdfrb/font/true_type.rb
CHANGED
|
@@ -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
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
|
data/lib/pdfrb/xmp/packet.rb
CHANGED
|
@@ -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.
|
|
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
|