stationery 0.7.0 → 0.9.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/CHANGELOG.md +22 -0
- data/README.md +251 -23
- data/lib/stationery/canvas.rb +22 -1
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +69 -9
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +13 -6
- data/lib/stationery/elements.rb +11 -3
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/document.rb +8 -2
- data/lib/stationery/html/tree_builder.rb +34 -4
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/layout/image.rb +9 -1
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +16 -0
- data/lib/stationery/pdf/assembler.rb +51 -11
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +141 -0
- data/lib/stationery/pdf/factur_x.rb +117 -0
- data/lib/stationery/pdf/icc/LICENSE.md +14 -0
- data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +87 -0
- data/lib/stationery/pdf/signature.rb +167 -0
- data/lib/stationery/pdf/stream.rb +5 -3
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/renderer/indents.rb +42 -0
- data/lib/stationery/rich/renderer.rb +22 -6
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +34 -26
- data/lib/stationery/svg/painter.rb +11 -0
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/region.rb +25 -0
- data/lib/stationery/svg/shapes.rb +2 -0
- data/lib/stationery/svg/style.rb +25 -14
- data/lib/stationery/svg/viewport.rb +78 -0
- data/lib/stationery/svg/walker.rb +144 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +123 -0
- data/lib/stationery/testing/matchers.rb +92 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +11 -0
- metadata +16 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module PDF
|
|
7
|
+
# A file embedded in the document: its name, bytes, MIME type, an
|
|
8
|
+
# optional description and how it relates to the document (the
|
|
9
|
+
# /AFRelationship a Factur-X invoice or a PDF/A-3 attachment declares).
|
|
10
|
+
Attachment = Data.define(:name, :data, :mime, :description, :relationship, :modified_at)
|
|
11
|
+
|
|
12
|
+
# Builds and writes embedded files: each becomes a /Filespec with an
|
|
13
|
+
# /EmbeddedFile stream, listed in the catalog's /Names /EmbeddedFiles
|
|
14
|
+
# tree and its /AF array.
|
|
15
|
+
module Attachments
|
|
16
|
+
RELATIONSHIPS = { alternative: :Alternative, source: :Source, data: :Data, supplement: :Supplement,
|
|
17
|
+
unspecified: :Unspecified }.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def build(name, data, mime: "application/octet-stream", description: nil, relationship: :unspecified,
|
|
22
|
+
modified_at: nil)
|
|
23
|
+
name = name.to_s
|
|
24
|
+
raise ArgumentError, "an attachment needs a name" if name.empty?
|
|
25
|
+
raise ArgumentError, "attachment #{name.inspect} needs its data as a String" unless data.is_a?(String)
|
|
26
|
+
unless RELATIONSHIPS.key?(relationship)
|
|
27
|
+
raise ArgumentError, "unknown attachment relationship #{relationship.inspect}; use one of " \
|
|
28
|
+
"#{RELATIONSHIPS.keys.map(&:inspect).join(", ")}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Attachment.new(name:, data: data.b, mime: mime.to_s, description: description&.to_s, relationship:,
|
|
32
|
+
modified_at:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# One list from class-level attachments and per-render hashes
|
|
36
|
+
# (`{ name:, data:, mime:, … }`); the same name twice raises.
|
|
37
|
+
def merge(*lists)
|
|
38
|
+
lists.flatten.compact.map { |item| item.is_a?(Attachment) ? item : from_hash(item) }.tap do |all|
|
|
39
|
+
duplicate = all.map(&:name).tally.find { |_, count| count > 1 }&.first
|
|
40
|
+
raise ArgumentError, "attachment #{duplicate.inspect} is given twice" if duplicate
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def from_hash(hash)
|
|
45
|
+
hash = hash.to_h.transform_keys(&:to_sym)
|
|
46
|
+
build(hash[:name], hash[:data], **hash.except(:name, :data))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The catalog entries for the files, written through `writer`; empty without any.
|
|
50
|
+
def write(writer, attachments)
|
|
51
|
+
return {} if attachments.nil? || attachments.empty?
|
|
52
|
+
|
|
53
|
+
refs = attachments.sort_by(&:name).map do |attachment|
|
|
54
|
+
[attachment.name, writer.add(filespec(writer, attachment))]
|
|
55
|
+
end
|
|
56
|
+
{ Names: { EmbeddedFiles: { Names: refs.flatten } }, AF: refs.map(&:last) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def filespec(writer, attachment)
|
|
60
|
+
spec = { Type: :Filespec, F: attachment.name.b, UF: TextString.new(attachment.name),
|
|
61
|
+
AFRelationship: RELATIONSHIPS.fetch(attachment.relationship),
|
|
62
|
+
EF: { F: writer.add(stream(attachment)) } }
|
|
63
|
+
spec[:Desc] = TextString.new(attachment.description) if attachment.description
|
|
64
|
+
spec
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stream(attachment)
|
|
68
|
+
params = { Size: attachment.data.bytesize, CheckSum: HexString.new(Digest::MD5.digest(attachment.data)) }
|
|
69
|
+
if attachment.modified_at
|
|
70
|
+
params[:ModDate] = TextString.new(attachment.modified_at.utc.strftime("D:%Y%m%d%H%M%SZ"))
|
|
71
|
+
end
|
|
72
|
+
Stream.new(attachment.data, { Type: :EmbeddedFile, Subtype: attachment.mime.to_sym, Params: params })
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
# The archival and accessibility standards a render claims, and what each
|
|
6
|
+
# asks of the file: PDF/A-2b and PDF/A-3b (ISO 19005-2/3, level B) and
|
|
7
|
+
# PDF/UA-1 (ISO 14289-1). Levels combine: `Conformance.new(:pdf_a3b, :pdf_ua1)`.
|
|
8
|
+
#
|
|
9
|
+
# A claim is only written when the document keeps it: what cannot be made
|
|
10
|
+
# conformant raises (ArgumentError for options that contradict the level,
|
|
11
|
+
# ConformanceError for content), so a file is never mislabelled.
|
|
12
|
+
class Conformance
|
|
13
|
+
LEVELS = {
|
|
14
|
+
pdf_a2b: { standard: :pdf_a, part: 2, conformance: "B", label: "PDF/A-2b" },
|
|
15
|
+
pdf_a3b: { standard: :pdf_a, part: 3, conformance: "B", label: "PDF/A-3b" },
|
|
16
|
+
pdf_ua1: { standard: :pdf_ua, part: 1, label: "PDF/UA-1" }
|
|
17
|
+
}.freeze
|
|
18
|
+
PDFA_ID = "http://www.aiim.org/pdfa/ns/id/"
|
|
19
|
+
PDFUA_ID = "http://www.aiim.org/pdfua/ns/id/"
|
|
20
|
+
# PDF/A knows its own schema; PDF/UA's has to be described to it.
|
|
21
|
+
PDFUA_SCHEMA = {
|
|
22
|
+
name: "PDF/UA identification schema", uri: PDFUA_ID, prefix: "pdfuaid",
|
|
23
|
+
properties: [{ name: "part", type: "Integer", category: "internal",
|
|
24
|
+
description: "The part of ISO 14289 the document conforms to" }]
|
|
25
|
+
}.freeze
|
|
26
|
+
ICC_PROFILE = File.expand_path("icc/sRGB2014.icc", __dir__)
|
|
27
|
+
OUTPUT_CONDITION = "sRGB IEC61966-2.1"
|
|
28
|
+
PRINT = 4
|
|
29
|
+
CMYK_OPERATOR = /^(?:-?[\d.]+ ){4}[kK]$/
|
|
30
|
+
|
|
31
|
+
attr_reader :levels
|
|
32
|
+
|
|
33
|
+
def self.label(level) = LEVELS.dig(level.to_sym, :label) || level.to_s
|
|
34
|
+
|
|
35
|
+
# The Conformance for `levels` (a Symbol, an Array or nil); nil without any.
|
|
36
|
+
def self.for(levels)
|
|
37
|
+
levels = Array(levels).flatten.compact
|
|
38
|
+
levels.empty? ? nil : new(*levels)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(*levels)
|
|
42
|
+
@levels = levels.flatten.map(&:to_sym).uniq
|
|
43
|
+
unknown = @levels - LEVELS.keys
|
|
44
|
+
raise ArgumentError, "unknown conformance #{unknown.map(&:inspect).join(", ")} (use #{names})" if unknown.any?
|
|
45
|
+
raise ArgumentError, "conformance takes one PDF/A level, got #{archival.join(" and ")}" if archival.size > 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def pdf_a? = archival.any?
|
|
49
|
+
def pdf_ua? = @levels.include?(:pdf_ua1)
|
|
50
|
+
# The PDF/A part (2 or 3), nil without one.
|
|
51
|
+
def part = archival.first && LEVELS.dig(archival.first, :part)
|
|
52
|
+
|
|
53
|
+
# What can be refused before anything is drawn: options and metadata.
|
|
54
|
+
def validate!(encrypt:, metadata:, attachments:)
|
|
55
|
+
raise ArgumentError, "PDF/A forbids encryption: drop encrypt: or the conformance level" if pdf_a? && encrypt
|
|
56
|
+
if part == 2 && attachments.any?
|
|
57
|
+
raise ArgumentError, "PDF/A-2b only embeds PDF/A files: use conformance :pdf_a3b to attach " \
|
|
58
|
+
"#{attachments.map(&:name).join(", ")}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
missing = pdf_ua? ? %i[title lang].select { |key| metadata[key].to_s.strip.empty? } : []
|
|
62
|
+
raise ConformanceError.new(@levels, missing.map { |key| "metadata #{key}: is missing" }) if missing.any?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# What only the finished pages show. Colour that the sRGB output intent
|
|
66
|
+
# does not cover is reported; what breaks the claim outright raises.
|
|
67
|
+
def audit!(pages, resources:, warnings:)
|
|
68
|
+
cmyk(pages, resources).each { |subject| warnings << Warnings::ConformanceIssue.new(level: archival.first, subject:) }
|
|
69
|
+
issues = fields(pages) + alternatives(warnings)
|
|
70
|
+
raise ConformanceError.new(@levels, issues) if issues.any?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The identification schemas for the XMP packet (see XMP.packet).
|
|
74
|
+
def xmp_extensions
|
|
75
|
+
extensions = {}
|
|
76
|
+
extensions[PDFA_ID] = { prefix: "pdfaid", "part" => part, "conformance" => "B" } if pdf_a?
|
|
77
|
+
extensions[PDFUA_ID] = { prefix: "pdfuaid", "part" => 1 } if pdf_ua?
|
|
78
|
+
extensions
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Schemas PDF/A has to be told about: PDF/UA's, when both are claimed.
|
|
82
|
+
def xmp_schemas = pdf_a? && pdf_ua? ? [PDFUA_SCHEMA] : []
|
|
83
|
+
|
|
84
|
+
# The catalog's entries: the sRGB output intent of a PDF/A file.
|
|
85
|
+
def catalog_entries(writer)
|
|
86
|
+
return {} unless pdf_a?
|
|
87
|
+
|
|
88
|
+
profile = writer.add(Stream.new(File.binread(ICC_PROFILE), { N: 3 }))
|
|
89
|
+
{ OutputIntents: [{ Type: :OutputIntent, S: :GTS_PDFA1, DestOutputProfile: profile,
|
|
90
|
+
OutputConditionIdentifier: TextString.new(OUTPUT_CONDITION),
|
|
91
|
+
Info: TextString.new(OUTPUT_CONDITION) }] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A page's entries: PDF/UA tabs through annotations in structure order.
|
|
95
|
+
# `annotated` is whether the page has any, its own or a signature's.
|
|
96
|
+
def page_entries(page, annotated: page.annotations.any?) = pdf_ua? && annotated ? { Tabs: :S } : {}
|
|
97
|
+
|
|
98
|
+
# A link annotation's entries: printable, and described for PDF/UA.
|
|
99
|
+
def annotation_entries(link)
|
|
100
|
+
entries = { F: PRINT }
|
|
101
|
+
entries[:Contents] = TextString.new(description(link)) if pdf_ua?
|
|
102
|
+
entries
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def archival = @levels.select { |level| LEVELS.dig(level, :standard) == :pdf_a }
|
|
108
|
+
def names = LEVELS.keys.map(&:inspect).join(", ")
|
|
109
|
+
|
|
110
|
+
def description(link)
|
|
111
|
+
link[:url] || "Page #{link[:dest].page + 1}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A form field draws with the document's embedded fonts; one made
|
|
115
|
+
# without a font book draws with the standard Helvetica, which is not
|
|
116
|
+
# embedded: neither PDF/A nor PDF/UA accepts that.
|
|
117
|
+
def fields(pages)
|
|
118
|
+
widgets = pages.flat_map(&:annotations).select { |annotation| annotation[:widget] }
|
|
119
|
+
names = widgets.reject { |annotation| annotation[:appearance].embedded? }.map { |a| a[:widget].name }.uniq
|
|
120
|
+
names.map { |name| %(form field "#{name}" draws with a font that is not embedded) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def alternatives(warnings)
|
|
124
|
+
return [] unless pdf_ua?
|
|
125
|
+
|
|
126
|
+
warnings.grep(Warnings::MissingAlt).map(&:message)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def cmyk(pages, resources)
|
|
130
|
+
return [] unless pdf_a?
|
|
131
|
+
|
|
132
|
+
images = resources.images.any? { |image| image.respond_to?(:color_space) && image.color_space == :DeviceCMYK }
|
|
133
|
+
subjects = images ? ["a CMYK image"] : []
|
|
134
|
+
pages.each_with_index do |page, index|
|
|
135
|
+
subjects << "CMYK colour on page #{index + 1}" if page.content.match?(CMYK_OPERATOR)
|
|
136
|
+
end
|
|
137
|
+
subjects
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
# A Factur-X / ZUGFeRD e-invoice: the invoice's Cross Industry Invoice XML
|
|
6
|
+
# embedded in a PDF/A-3b file and identified in XMP, so the one document
|
|
7
|
+
# is read by people and booked by machines.
|
|
8
|
+
#
|
|
9
|
+
# Stationery carries the XML, it does not write or validate it: `xml` is
|
|
10
|
+
# the finished document as a String, or a method name, block or callable
|
|
11
|
+
# that answers it for the document being rendered.
|
|
12
|
+
class FacturX
|
|
13
|
+
NAMESPACE = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
|
|
14
|
+
PROFILES = { minimum: "MINIMUM", basic_wl: "BASIC WL", basic: "BASIC", en16931: "EN 16931",
|
|
15
|
+
extended: "EXTENDED", xrechnung: "XRECHNUNG" }.freeze
|
|
16
|
+
# Profiles too small to stand for the invoice: their XML is data beside
|
|
17
|
+
# the page, not an alternative to it.
|
|
18
|
+
DATA_ONLY = %i[minimum basic_wl].freeze
|
|
19
|
+
FILENAMES = { xrechnung: "xrechnung.xml" }.freeze
|
|
20
|
+
DEFAULT_FILENAME = "factur-x.xml"
|
|
21
|
+
DESCRIPTION = "Factur-X Invoice"
|
|
22
|
+
ROOTS = ["<?xml", "<rsm:CrossIndustryInvoice"].freeze
|
|
23
|
+
SCHEMA = {
|
|
24
|
+
name: "Factur-X PDFA Extension Schema", uri: NAMESPACE, prefix: "fx",
|
|
25
|
+
properties: [
|
|
26
|
+
{ name: "DocumentFileName", type: "Text", category: "external",
|
|
27
|
+
description: "The name of the embedded XML document" },
|
|
28
|
+
{ name: "DocumentType", type: "Text", category: "external",
|
|
29
|
+
description: "The type of the hybrid document in capital letters, e.g. INVOICE or ORDER" },
|
|
30
|
+
{ name: "Version", type: "Text", category: "external",
|
|
31
|
+
description: "The actual version of the standard applying to the embedded XML document" },
|
|
32
|
+
{ name: "ConformanceLevel", type: "Text", category: "external",
|
|
33
|
+
description: "The conformance level of the embedded XML document" }
|
|
34
|
+
].freeze
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :xml, :profile, :filename, :version
|
|
38
|
+
|
|
39
|
+
# The invoice for `spec` (`{ xml:, profile:, filename:, version:,
|
|
40
|
+
# relationship: }`, as `factur_x` and `to_pdf(factur_x:)` take it)
|
|
41
|
+
# resolved for `document`; nil without a spec.
|
|
42
|
+
def self.for(spec, document)
|
|
43
|
+
return unless spec
|
|
44
|
+
|
|
45
|
+
spec = spec.to_h.transform_keys(&:to_sym)
|
|
46
|
+
new(resolve(spec[:xml], document), **spec.except(:xml))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.resolve(xml, document)
|
|
50
|
+
case xml
|
|
51
|
+
when Symbol then document.send(xml)
|
|
52
|
+
when Proc then xml.arity.zero? ? document.instance_exec(&xml) : xml.call(document)
|
|
53
|
+
else xml.respond_to?(:call) ? xml.call(document) : xml
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.profile(name)
|
|
58
|
+
name = name.to_s.downcase.to_sym
|
|
59
|
+
return name if PROFILES.key?(name)
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, "unknown Factur-X profile #{name.inspect} (use #{PROFILES.keys.map(&:inspect).join(", ")})"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.invoice?(xml) = xml.is_a?(String) && xml.delete_prefix("").lstrip.start_with?(*ROOTS)
|
|
65
|
+
|
|
66
|
+
def initialize(xml, profile: :en16931, filename: nil, version: "1.0", relationship: nil)
|
|
67
|
+
@profile = self.class.profile(profile)
|
|
68
|
+
unless self.class.invoice?(xml)
|
|
69
|
+
raise ArgumentError, "factur_x needs the invoice XML (a String starting with #{ROOTS.join(" or ")}), " \
|
|
70
|
+
"#{got(xml)}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@xml = xml
|
|
74
|
+
@filename = (filename || FILENAMES.fetch(@profile, DEFAULT_FILENAME)).to_s
|
|
75
|
+
@version = version.to_s
|
|
76
|
+
@relationship = relationship || (DATA_ONLY.include?(@profile) ? :data : :alternative)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The fx:ConformanceLevel of the profile ("EN 16931").
|
|
80
|
+
def level = PROFILES.fetch(@profile)
|
|
81
|
+
|
|
82
|
+
# The declared conformance levels with the PDF/A-3b the invoice needs.
|
|
83
|
+
def conformance(levels)
|
|
84
|
+
levels = Array(levels).flatten.compact.map(&:to_sym)
|
|
85
|
+
if levels.include?(:pdf_a2b)
|
|
86
|
+
raise ArgumentError, "Factur-X embeds its XML, which needs PDF/A-3b: drop conformance :pdf_a2b"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
levels.include?(:pdf_a3b) ? levels : levels + [:pdf_a3b]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The XML as the file to embed, stamped `at` (its /ModDate).
|
|
93
|
+
def attachment(at: Time.now)
|
|
94
|
+
Attachments.build(@filename, @xml, mime: "text/xml", description: DESCRIPTION, relationship: @relationship,
|
|
95
|
+
modified_at: at)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The invoice's identification for the XMP packet (see XMP.packet).
|
|
99
|
+
def xmp_extensions
|
|
100
|
+
{ NAMESPACE => { prefix: "fx", "DocumentType" => "INVOICE", "DocumentFileName" => @filename,
|
|
101
|
+
"Version" => @version, "ConformanceLevel" => level } }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The description of the `fx` schema PDF/A asks for.
|
|
105
|
+
def xmp_schema = SCHEMA
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def got(xml)
|
|
110
|
+
return "got #{xml.nil? ? "nil" : "a #{xml.class}"}" unless xml.is_a?(String)
|
|
111
|
+
return "got an empty String" if xml.strip.empty?
|
|
112
|
+
|
|
113
|
+
"got #{xml[0, 40].inspect}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# sRGB2014.icc
|
|
2
|
+
|
|
3
|
+
`sRGB2014.icc` is the ICC's sRGB v2 profile ("sRGB2014", copyright International Color
|
|
4
|
+
Consortium, 2015), downloaded unmodified from
|
|
5
|
+
<https://registry.color.org/rgb-registry/profiles/sRGB2014.icc>
|
|
6
|
+
(SHA-256 `384b832de3412066743b52a75ee906b6fb9fb8d9e09e936fc2c43223815c6e0a`).
|
|
7
|
+
Stationery embeds it as the output intent of PDF/A documents.
|
|
8
|
+
|
|
9
|
+
The ICC's terms for profiles it owns (<https://www.color.org/profiles2.xalter#license>):
|
|
10
|
+
|
|
11
|
+
> This profile is made available by the International Color Consortium, and may be copied,
|
|
12
|
+
> distributed, embedded, made, used, and sold without restriction. Altered versions of this
|
|
13
|
+
> profile shall have the original identification and copyright information removed and shall
|
|
14
|
+
> not be misrepresented as the original profile.
|
|
Binary file
|
|
@@ -9,7 +9,8 @@ module Stationery
|
|
|
9
9
|
# TextString for human-readable text that may need UTF-16.
|
|
10
10
|
#
|
|
11
11
|
# `crypt`, when given, receives every string's bytes (the writer binds it
|
|
12
|
-
# to the object being written) and the result is written as hex.
|
|
12
|
+
# to the object being written) and the result is written as hex. A
|
|
13
|
+
# Verbatim is written as it is.
|
|
13
14
|
module Serializer
|
|
14
15
|
NAME_ESCAPE = %r{[^\x21-\x7E]|[#%()/<>\[\]{}]}n
|
|
15
16
|
LITERAL_ESCAPE = /[\\()\r]/n
|
|
@@ -25,6 +26,7 @@ module Stationery
|
|
|
25
26
|
when Array then "[#{value.map { |v| dump(v, crypt) }.join(" ")}]"
|
|
26
27
|
when HexString then hex(value.bytes, crypt)
|
|
27
28
|
when TextString then text(value.value, crypt)
|
|
29
|
+
when Verbatim then value.source
|
|
28
30
|
when String then crypt ? hex(value, crypt) : literal(value)
|
|
29
31
|
when Integer, true, false then value.to_s
|
|
30
32
|
when Float then number(value)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
class Signature
|
|
6
|
+
# The signature value: a detached CMS SignedData (RFC 5652) over the
|
|
7
|
+
# signed bytes, as CAdES and PAdES baseline ask for it. The signed
|
|
8
|
+
# attributes are the content type, the SHA-256 message digest and the
|
|
9
|
+
# ESS signing-certificate-v2 (RFC 5035) that binds the signature to the
|
|
10
|
+
# signer's certificate; there is no signing-time attribute, a PDF keeps
|
|
11
|
+
# that in the signature dictionary's /M.
|
|
12
|
+
#
|
|
13
|
+
# Ruby's OpenSSL::PKCS7 cannot add signed attributes, so the structure
|
|
14
|
+
# is built with OpenSSL::ASN1. RSA keys sign with PKCS #1 v1.5, EC keys
|
|
15
|
+
# with ECDSA, both over SHA-256.
|
|
16
|
+
class CMS
|
|
17
|
+
OIDS = {
|
|
18
|
+
data: "1.2.840.113549.1.7.1", signed_data: "1.2.840.113549.1.7.2",
|
|
19
|
+
content_type: "1.2.840.113549.1.9.3", message_digest: "1.2.840.113549.1.9.4",
|
|
20
|
+
signing_certificate_v2: "1.2.840.113549.1.9.16.2.47", sha256: "2.16.840.1.101.3.4.2.1",
|
|
21
|
+
rsa: "1.2.840.113549.1.1.1", ecdsa_sha256: "1.2.840.10045.4.3.2"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# `chain` are the certificates between the signer's and its root.
|
|
25
|
+
def initialize(certificate, key, chain = [])
|
|
26
|
+
@certificate = certificate
|
|
27
|
+
@key = key
|
|
28
|
+
@chain = chain
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The DER of the SignedData signing `data`.
|
|
32
|
+
def sign(data)
|
|
33
|
+
attributes = attributes(OpenSSL::Digest::SHA256.digest(data))
|
|
34
|
+
signature = @key.sign("SHA256", asn1::Set.new(attributes).to_der)
|
|
35
|
+
content = asn1::Sequence.new([
|
|
36
|
+
asn1::Integer.new(1),
|
|
37
|
+
asn1::Set.new([algorithm(:sha256)]),
|
|
38
|
+
asn1::Sequence.new([oid(:data)]),
|
|
39
|
+
implicit(certificates),
|
|
40
|
+
asn1::Set.new([signer(attributes, signature)])
|
|
41
|
+
])
|
|
42
|
+
asn1::Sequence.new([oid(:signed_data), asn1::ASN1Data.new([content], 0, :CONTEXT_SPECIFIC)]).to_der
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def asn1 = OpenSSL::ASN1
|
|
48
|
+
def oid(name) = asn1::ObjectId.new(OIDS.fetch(name))
|
|
49
|
+
def implicit(items) = asn1::Set.new(items, 0, :IMPLICIT, :CONTEXT_SPECIFIC)
|
|
50
|
+
def certificates = [@certificate, *@chain].map { |certificate| asn1.decode(certificate.to_der) }
|
|
51
|
+
|
|
52
|
+
# RSA and the digests carry an explicit NULL parameter, ECDSA none.
|
|
53
|
+
def algorithm(name, null: true) = asn1::Sequence.new(null ? [oid(name), asn1::Null.new(nil)] : [oid(name)])
|
|
54
|
+
|
|
55
|
+
def attribute(name, value) = asn1::Sequence.new([oid(name), asn1::Set.new([value])])
|
|
56
|
+
|
|
57
|
+
# DER orders the members of a SET OF by their encoding.
|
|
58
|
+
def attributes(digest)
|
|
59
|
+
[
|
|
60
|
+
attribute(:content_type, oid(:data)),
|
|
61
|
+
attribute(:message_digest, asn1::OctetString.new(digest)),
|
|
62
|
+
attribute(:signing_certificate_v2, signing_certificate)
|
|
63
|
+
].sort_by(&:to_der)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# SigningCertificateV2 with one ESSCertIDv2: the certificate's SHA-256
|
|
67
|
+
# (the default hash algorithm, so it is left out).
|
|
68
|
+
def signing_certificate
|
|
69
|
+
hash = asn1::OctetString.new(OpenSSL::Digest::SHA256.digest(@certificate.to_der))
|
|
70
|
+
asn1::Sequence.new([asn1::Sequence.new([asn1::Sequence.new([hash])])])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def signer(attributes, signature)
|
|
74
|
+
issuer = asn1.decode(@certificate.issuer.to_der)
|
|
75
|
+
asn1::Sequence.new([
|
|
76
|
+
asn1::Integer.new(1),
|
|
77
|
+
asn1::Sequence.new([issuer, asn1::Integer.new(@certificate.serial)]),
|
|
78
|
+
algorithm(:sha256),
|
|
79
|
+
implicit(attributes),
|
|
80
|
+
@key.is_a?(OpenSSL::PKey::EC) ? algorithm(:ecdsa_sha256, null: false) : algorithm(:rsa),
|
|
81
|
+
asn1::OctetString.new(signature)
|
|
82
|
+
])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
# A digital signature over the whole file, as PAdES baseline B-B asks for
|
|
6
|
+
# it: a signature dictionary with `/SubFilter /ETSI.CAdES.detached` whose
|
|
7
|
+
# /Contents is a detached CMS over every byte but itself (see CMS).
|
|
8
|
+
#
|
|
9
|
+
# The file is written once with room for the signature (`contents_size:`
|
|
10
|
+
# bytes) and a placeholder /ByteRange; #apply then fills both in place, so
|
|
11
|
+
# no offset moves. Timestamps (PAdES-T), long-term validation data and a
|
|
12
|
+
# second signature need incremental updates, which Stationery does not
|
|
13
|
+
# write.
|
|
14
|
+
#
|
|
15
|
+
# OpenSSL is loaded when the first signature is made, never before.
|
|
16
|
+
class Signature
|
|
17
|
+
SUBFILTER = :"ETSI.CAdES.detached"
|
|
18
|
+
CONTENTS_SIZE = 8192
|
|
19
|
+
BYTE_RANGE = "[0 0000000000 0000000000 0000000000]"
|
|
20
|
+
OPTIONS = %i[certificate key chain passphrase reason location contact name field at contents_size].freeze
|
|
21
|
+
# Options a Symbol names a document method for; elsewhere it is a value.
|
|
22
|
+
SECRETS = %i[certificate key chain passphrase].freeze
|
|
23
|
+
# Print and Locked: the widget of a signature that fills no field.
|
|
24
|
+
INVISIBLE = 132
|
|
25
|
+
|
|
26
|
+
attr_reader :certificate, :key, :chain, :field, :at, :contents_size
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# The signature for `spec` (the options of `sign` and `to_pdf(sign:)`,
|
|
30
|
+
# or a block answering them) resolved for `document`; nil without one.
|
|
31
|
+
def for(spec, document)
|
|
32
|
+
return unless spec
|
|
33
|
+
|
|
34
|
+
spec = resolve(spec, document) if spec.respond_to?(:call)
|
|
35
|
+
spec = check(spec).to_h { |name, value| [name, resolve(value, document, method: SECRETS.include?(name))] }
|
|
36
|
+
new(**spec)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The options with Symbol keys; an unknown one raises.
|
|
40
|
+
def check(spec)
|
|
41
|
+
spec = spec.to_h.transform_keys(&:to_sym)
|
|
42
|
+
unknown = spec.keys - OPTIONS
|
|
43
|
+
return spec if unknown.empty?
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "unknown sign option: #{unknown.join(", ")} (use #{OPTIONS.join(", ")})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether any of the options is only known when a document renders.
|
|
49
|
+
def deferred?(spec)
|
|
50
|
+
spec.any? { |name, value| value.respond_to?(:call) || (SECRETS.include?(name) && value.is_a?(Symbol)) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The first DER object in `bytes`: a signature's /Contents without the
|
|
54
|
+
# zeros that pad it.
|
|
55
|
+
def der(bytes)
|
|
56
|
+
length = bytes.getbyte(1).to_i
|
|
57
|
+
return bytes.byteslice(0, 2 + length) if length < 0x80
|
|
58
|
+
|
|
59
|
+
size = length & 0x7F
|
|
60
|
+
content = bytes.byteslice(2, size).unpack("C*").inject(0) { |sum, byte| (sum << 8) | byte }
|
|
61
|
+
bytes.byteslice(0, 2 + size + content)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def resolve(value, document, method: false)
|
|
67
|
+
case value
|
|
68
|
+
when Symbol then method ? document.send(value) : value
|
|
69
|
+
when Proc then value.arity.zero? ? document.instance_exec(&value) : value.call(document)
|
|
70
|
+
else value.respond_to?(:call) ? value.call(document) : value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def initialize(certificate: nil, key: nil, chain: [], passphrase: nil, field: nil, at: Time.now,
|
|
76
|
+
contents_size: CONTENTS_SIZE, **details)
|
|
77
|
+
require "openssl"
|
|
78
|
+
@certificate = certificate_for(certificate, "certificate:")
|
|
79
|
+
@key = key_for(key, passphrase)
|
|
80
|
+
@chain = Array(chain).map { |link| certificate_for(link, "chain:") }
|
|
81
|
+
@field = field&.to_s
|
|
82
|
+
@at = at
|
|
83
|
+
@contents_size = contents_size
|
|
84
|
+
@details = self.class.check(details)
|
|
85
|
+
match!
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether it adds its own field, a widget nobody sees, to the first page.
|
|
89
|
+
def invisible? = @field.nil?
|
|
90
|
+
|
|
91
|
+
# The signer's name: `name:`, else the certificate's common name.
|
|
92
|
+
def name
|
|
93
|
+
@details[:name] || @certificate.subject.to_utf8[/(?:\A|,)CN=((?:\\.|[^,])*)/, 1]&.gsub(/\\(.)/, '\1')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The signature dictionary, with placeholders #apply fills in. Its
|
|
97
|
+
# /Contents and /ByteRange are written as they are, never encrypted.
|
|
98
|
+
def dictionary
|
|
99
|
+
entries = { Type: :Sig, Filter: :"Adobe.PPKLite", SubFilter: SUBFILTER,
|
|
100
|
+
ByteRange: Verbatim.new(BYTE_RANGE), Contents: Verbatim.new(placeholder),
|
|
101
|
+
M: TextString.new(@at.utc.strftime("D:%Y%m%d%H%M%SZ")) }
|
|
102
|
+
text = { Name: name, Reason: @details[:reason], Location: @details[:location],
|
|
103
|
+
ContactInfo: @details[:contact] }.compact
|
|
104
|
+
entries.merge(text.transform_values { |value| TextString.new(value.to_s) })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Signs `pdf` (the file as written with #dictionary in it): fills in the
|
|
108
|
+
# byte range and the signature over it, and answers the same String.
|
|
109
|
+
def apply(pdf)
|
|
110
|
+
range, first, last = gap(pdf)
|
|
111
|
+
pdf.bytesplice(range, BYTE_RANGE.bytesize,
|
|
112
|
+
"[0 #{first} #{last} #{pdf.bytesize - last}]".ljust(BYTE_RANGE.bytesize))
|
|
113
|
+
signature = CMS.new(@certificate, @key, @chain).sign(pdf.byteslice(0, first) + pdf.byteslice(last..))
|
|
114
|
+
raise ArgumentError, too_large(signature) if signature.bytesize > @contents_size
|
|
115
|
+
|
|
116
|
+
pdf.bytesplice(first + 1, @contents_size * 2, signature.unpack1("H*").upcase.ljust(@contents_size * 2, "0"))
|
|
117
|
+
pdf
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def placeholder = "<#{"0" * (@contents_size * 2)}>"
|
|
123
|
+
|
|
124
|
+
# Where the byte range is written, and where the /Contents string
|
|
125
|
+
# starts and ends: the gap the signature does not cover.
|
|
126
|
+
def gap(pdf)
|
|
127
|
+
range = pdf.index("/ByteRange #{BYTE_RANGE}")
|
|
128
|
+
contents = range && pdf.index("/Contents #{placeholder}", range)
|
|
129
|
+
raise Error, "the signature dictionary is not in the file" unless contents
|
|
130
|
+
|
|
131
|
+
first = contents + "/Contents ".bytesize
|
|
132
|
+
[range + "/ByteRange ".bytesize, first, first + placeholder.bytesize]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def too_large(signature)
|
|
136
|
+
"the signature takes #{signature.bytesize} bytes, more than contents_size: #{@contents_size} leaves for it; " \
|
|
137
|
+
"pass a larger contents_size:"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def certificate_for(value, option)
|
|
141
|
+
return value if value.is_a?(OpenSSL::X509::Certificate)
|
|
142
|
+
|
|
143
|
+
OpenSSL::X509::Certificate.new(value.to_s)
|
|
144
|
+
rescue OpenSSL::X509::CertificateError
|
|
145
|
+
raise ArgumentError, "sign #{option} needs an OpenSSL::X509::Certificate or its PEM, #{got(value)}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def key_for(value, passphrase)
|
|
149
|
+
key = value.is_a?(OpenSSL::PKey::PKey) ? value : OpenSSL::PKey.read(value.to_s, passphrase.to_s)
|
|
150
|
+
return key if key.is_a?(OpenSSL::PKey::RSA) || key.is_a?(OpenSSL::PKey::EC)
|
|
151
|
+
|
|
152
|
+
raise ArgumentError, "sign key: needs an RSA or EC key, got #{key.class}"
|
|
153
|
+
rescue OpenSSL::PKey::PKeyError
|
|
154
|
+
raise ArgumentError, "sign key: needs an OpenSSL::PKey or its PEM (with passphrase: when encrypted), " \
|
|
155
|
+
"#{got(value)}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def got(value) = value.nil? ? "got nil" : "got a #{value.class}"
|
|
159
|
+
|
|
160
|
+
def match!
|
|
161
|
+
return if @key.private? && @certificate.check_private_key(@key)
|
|
162
|
+
|
|
163
|
+
raise ArgumentError, "sign key: is not the private key of certificate: (#{@certificate.subject.to_utf8})"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -5,15 +5,17 @@ require "zlib"
|
|
|
5
5
|
module Stationery
|
|
6
6
|
module PDF
|
|
7
7
|
# A stream object. Data is Flate-compressed unless the dictionary already
|
|
8
|
-
# names a filter (JPEG's DCTDecode, or PNG data that is already zlib)
|
|
8
|
+
# names a filter (JPEG's DCTDecode, or PNG data that is already zlib) or
|
|
9
|
+
# `compress: false` asks for it plain (XMP metadata, which PDF/A readers
|
|
10
|
+
# scan for without decoding).
|
|
9
11
|
class Stream
|
|
10
12
|
attr_reader :dictionary, :data
|
|
11
13
|
|
|
12
|
-
def initialize(data, dictionary = {})
|
|
14
|
+
def initialize(data, dictionary = {}, compress: !dictionary.key?(:Filter))
|
|
13
15
|
data = data.b
|
|
14
16
|
dictionary = dictionary.dup
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
if compress
|
|
17
19
|
data = Zlib::Deflate.deflate(data)
|
|
18
20
|
dictionary[:Filter] = :FlateDecode
|
|
19
21
|
end
|
data/lib/stationery/pdf/types.rb
CHANGED
|
@@ -11,5 +11,9 @@ module Stationery
|
|
|
11
11
|
# A human-readable string (document info, outline titles): written as a
|
|
12
12
|
# literal when ASCII, as UTF-16BE with a byte order mark otherwise.
|
|
13
13
|
TextString = Data.define(:value)
|
|
14
|
+
|
|
15
|
+
# PDF syntax written as it is and never encrypted: a signature's /Contents
|
|
16
|
+
# and /ByteRange, which are filled in once the file is written.
|
|
17
|
+
Verbatim = Data.define(:source)
|
|
14
18
|
end
|
|
15
19
|
end
|