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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +251 -23
  4. data/lib/stationery/canvas.rb +22 -1
  5. data/lib/stationery/cli/render.rb +19 -4
  6. data/lib/stationery/document.rb +69 -9
  7. data/lib/stationery/elements/forms.rb +11 -6
  8. data/lib/stationery/elements/rich.rb +13 -6
  9. data/lib/stationery/elements.rb +11 -3
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/font.rb +28 -18
  12. data/lib/stationery/forms/acro_form.rb +95 -25
  13. data/lib/stationery/forms/appearance.rb +92 -30
  14. data/lib/stationery/forms/field.rb +20 -11
  15. data/lib/stationery/forms/metrics.rb +3 -17
  16. data/lib/stationery/forms/typeface.rb +113 -0
  17. data/lib/stationery/html/document.rb +8 -2
  18. data/lib/stationery/html/tree_builder.rb +34 -4
  19. data/lib/stationery/images/jpeg.rb +2 -1
  20. data/lib/stationery/layout/image.rb +9 -1
  21. data/lib/stationery/layout/table/cell.rb +14 -4
  22. data/lib/stationery/layout/table.rb +72 -14
  23. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  24. data/lib/stationery/markdown/block_parser.rb +9 -3
  25. data/lib/stationery/markdown/document.rb +8 -2
  26. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  27. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  28. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  29. data/lib/stationery/markdown/inline_parser.rb +9 -3
  30. data/lib/stationery/minitest.rb +16 -0
  31. data/lib/stationery/pdf/assembler.rb +51 -11
  32. data/lib/stationery/pdf/attachments.rb +76 -0
  33. data/lib/stationery/pdf/conformance.rb +141 -0
  34. data/lib/stationery/pdf/factur_x.rb +117 -0
  35. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  36. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  37. data/lib/stationery/pdf/serializer.rb +3 -1
  38. data/lib/stationery/pdf/signature/cms.rb +87 -0
  39. data/lib/stationery/pdf/signature.rb +167 -0
  40. data/lib/stationery/pdf/stream.rb +5 -3
  41. data/lib/stationery/pdf/types.rb +4 -0
  42. data/lib/stationery/pdf/xmp.rb +149 -0
  43. data/lib/stationery/resources.rb +3 -0
  44. data/lib/stationery/rich/nesting.rb +30 -0
  45. data/lib/stationery/rich/renderer/indents.rb +42 -0
  46. data/lib/stationery/rich/renderer.rb +22 -6
  47. data/lib/stationery/svg/clip_path.rb +69 -0
  48. data/lib/stationery/svg/document.rb +34 -26
  49. data/lib/stationery/svg/painter.rb +11 -0
  50. data/lib/stationery/svg/parser.rb +51 -11
  51. data/lib/stationery/svg/region.rb +25 -0
  52. data/lib/stationery/svg/shapes.rb +2 -0
  53. data/lib/stationery/svg/style.rb +25 -14
  54. data/lib/stationery/svg/viewport.rb +78 -0
  55. data/lib/stationery/svg/walker.rb +144 -0
  56. data/lib/stationery/tagging/element.rb +1 -1
  57. data/lib/stationery/testing/inspector.rb +123 -0
  58. data/lib/stationery/testing/matchers.rb +92 -0
  59. data/lib/stationery/text/wrapper.rb +12 -3
  60. data/lib/stationery/version.rb +1 -1
  61. data/lib/stationery/warnings.rb +8 -0
  62. data/lib/stationery.rb +11 -0
  63. 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
- unless dictionary.key?(:Filter)
18
+ if compress
17
19
  data = Zlib::Deflate.deflate(data)
18
20
  dictionary[:Filter] = :FlateDecode
19
21
  end
@@ -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