stationery 0.8.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 +11 -0
- data/README.md +128 -23
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/document.rb +20 -4
- 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/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/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 +4 -0
- data/lib/stationery/pdf/assembler.rb +19 -9
- data/lib/stationery/pdf/conformance.rb +7 -5
- 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/types.rb +4 -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/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +55 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +3 -0
- metadata +6 -1
|
@@ -10,12 +10,14 @@ module Stationery
|
|
|
10
10
|
# PDF::Attachment files to embed; `xmp:` (true) writes the XMP packet,
|
|
11
11
|
# with `xmp_extensions:` as further schemas and `xmp_schemas:` describing
|
|
12
12
|
# them to PDF/A (see XMP); `conformance:` (a PDF::Conformance) adds what
|
|
13
|
-
# PDF/A and PDF/UA ask of the file
|
|
13
|
+
# PDF/A and PDF/UA ask of the file; `signature:` (a PDF::Signature)
|
|
14
|
+
# signs it.
|
|
14
15
|
def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
|
|
15
16
|
page_labels: nil, attachments: [], xmp: true, xmp_extensions: {}, xmp_schemas: [],
|
|
16
|
-
conformance: nil)
|
|
17
|
+
conformance: nil, signature: nil)
|
|
17
18
|
@xmp_schemas = xmp_schemas
|
|
18
19
|
@conformance = conformance
|
|
20
|
+
@signature = signature
|
|
19
21
|
@tagging = tagging
|
|
20
22
|
@lang = lang
|
|
21
23
|
@page_labels = page_labels
|
|
@@ -31,9 +33,10 @@ module Stationery
|
|
|
31
33
|
|
|
32
34
|
def render
|
|
33
35
|
writer = Writer.new(encryption: @encryption)
|
|
34
|
-
@form = Forms::AcroForm.new(writer)
|
|
35
36
|
tree = writer.reserve
|
|
36
37
|
refs = @resources.build(writer)
|
|
38
|
+
@form = Forms::AcroForm.new(writer, fonts: refs.fetch(:Font), signature: @signature,
|
|
39
|
+
need_appearances: @conformance.nil? && @signature.nil?)
|
|
37
40
|
kids = @kids = @pages.map { writer.reserve }
|
|
38
41
|
@structure = @tagging && Tagging::Writer.new(@tagging, pages: @pages, refs: kids)
|
|
39
42
|
@pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
|
|
@@ -44,7 +47,8 @@ module Stationery
|
|
|
44
47
|
entries = catalog(tree, outlines, @form.write)
|
|
45
48
|
.merge(accessibility(writer), metadata(writer, info, now), Attachments.write(writer, @attachments))
|
|
46
49
|
entries.merge!(@conformance.catalog_entries(writer)) if @conformance
|
|
47
|
-
writer.render(root: writer.add(entries), info: writer.add(info))
|
|
50
|
+
pdf = writer.render(root: writer.add(entries), info: writer.add(info))
|
|
51
|
+
@signature ? @signature.apply(pdf) : pdf
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
private
|
|
@@ -69,14 +73,20 @@ module Stationery
|
|
|
69
73
|
Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
|
|
70
74
|
Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
|
|
71
75
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
annotations = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
|
|
77
|
+
annotations << @form.sign(ref, taken: field_names) if @signature&.invisible? && ref == @kids.first
|
|
78
|
+
dictionary[:Annots] = annotations if annotations.any?
|
|
75
79
|
dictionary.merge!(@structure.page_entries(page)) if @structure
|
|
76
|
-
dictionary.merge!(@conformance.page_entries(page)) if @conformance
|
|
80
|
+
dictionary.merge!(@conformance.page_entries(page, annotated: annotations.any?)) if @conformance
|
|
77
81
|
writer.set(ref, dictionary)
|
|
78
82
|
end
|
|
79
83
|
|
|
84
|
+
# Every field name of the document and the group each one starts with.
|
|
85
|
+
def field_names
|
|
86
|
+
names = @pages.flat_map(&:annotations).filter_map { |annotation| annotation[:widget]&.name }
|
|
87
|
+
names.flat_map { |name| [name, name.split(".").first] }.uniq
|
|
88
|
+
end
|
|
89
|
+
|
|
80
90
|
def page_resources(page, refs)
|
|
81
91
|
page.resource_names.to_h do |category, names|
|
|
82
92
|
[category, names.to_h { |name| [name, refs.fetch(category).fetch(name)] }]
|
|
@@ -85,7 +95,7 @@ module Stationery
|
|
|
85
95
|
|
|
86
96
|
def annotation_ref(writer, annotation, page)
|
|
87
97
|
if annotation[:widget]
|
|
88
|
-
return @form.add(annotation
|
|
98
|
+
return @form.add(annotation, page) do |widget_ref|
|
|
89
99
|
@structure ? @structure.annotation(annotation, widget_ref) : {}
|
|
90
100
|
end
|
|
91
101
|
end
|
|
@@ -92,7 +92,8 @@ module Stationery
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# A page's entries: PDF/UA tabs through annotations in structure order.
|
|
95
|
-
|
|
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 } : {}
|
|
96
97
|
|
|
97
98
|
# A link annotation's entries: printable, and described for PDF/UA.
|
|
98
99
|
def annotation_entries(link)
|
|
@@ -110,11 +111,12 @@ module Stationery
|
|
|
110
111
|
link[:url] || "Page #{link[:dest].page + 1}"
|
|
111
112
|
end
|
|
112
113
|
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
# neither PDF/A nor PDF/UA accepts that.
|
|
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.
|
|
116
117
|
def fields(pages)
|
|
117
|
-
|
|
118
|
+
widgets = pages.flat_map(&:annotations).select { |annotation| annotation[:widget] }
|
|
119
|
+
names = widgets.reject { |annotation| annotation[:appearance].embedded? }.map { |a| a[:widget].name }.uniq
|
|
118
120
|
names.map { |name| %(form field "#{name}" draws with a font that is not embedded) }
|
|
119
121
|
end
|
|
120
122
|
|
|
@@ -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
|
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
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
# How deep one html or markdown source may nest, and how deep it went.
|
|
6
|
+
# Parsing, layout and painting all recurse over the nesting, so a parser
|
|
7
|
+
# flattens what lies deeper than `limit` into the deepest element it kept
|
|
8
|
+
# (text stays, structure goes) and records the depth it saw here.
|
|
9
|
+
class Nesting
|
|
10
|
+
DEFAULT = 64
|
|
11
|
+
|
|
12
|
+
attr_reader :limit, :deepest
|
|
13
|
+
|
|
14
|
+
def initialize(limit = DEFAULT)
|
|
15
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
16
|
+
raise ArgumentError, "max_depth must be a positive Integer (got #{limit.inspect})"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@limit = limit
|
|
20
|
+
@deepest = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(depth)
|
|
24
|
+
@deepest = depth if depth > @deepest
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exceeded? = @deepest > @limit
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
class Renderer
|
|
6
|
+
# Block quotes and lists each indent what they hold, so nested deep
|
|
7
|
+
# enough they leave their text no width and it is lost. Past LIMIT
|
|
8
|
+
# levels the content is drawn where the last indented level put it:
|
|
9
|
+
# a block quote without its bar, list items without their markers.
|
|
10
|
+
module Indents
|
|
11
|
+
LIMIT = 12
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def indented(block)
|
|
16
|
+
return flat(block) if @indents >= LIMIT
|
|
17
|
+
|
|
18
|
+
@indents += 1
|
|
19
|
+
begin
|
|
20
|
+
yield
|
|
21
|
+
ensure
|
|
22
|
+
@indents -= 1
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def flat(block) = group(block.is_a?(List) ? block.items.flatten(1) : block.blocks)
|
|
27
|
+
|
|
28
|
+
# How many block quotes and lists deep the blocks go.
|
|
29
|
+
def indents_of(blocks)
|
|
30
|
+
blocks.map do |block|
|
|
31
|
+
case block
|
|
32
|
+
when Blockquote then 1 + indents_of(block.blocks)
|
|
33
|
+
when List then 1 + (block.items.map { |item| indents_of(item) }.max || 0)
|
|
34
|
+
when Table then block.rows.flatten.map { |cell| indents_of(cell.blocks) }.max || 0
|
|
35
|
+
else 0
|
|
36
|
+
end
|
|
37
|
+
end.max || 0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "nodes"
|
|
4
4
|
require_relative "styles"
|
|
5
|
+
require_relative "renderer/indents"
|
|
5
6
|
require_relative "renderer/inlines"
|
|
6
7
|
require_relative "renderer/links"
|
|
7
8
|
|
|
@@ -9,8 +10,12 @@ module Stationery
|
|
|
9
10
|
module Rich
|
|
10
11
|
# Draws rich-text blocks with a component's element DSL. Images come from
|
|
11
12
|
# `images:` (a callable given the src, returning a path, an IO or nil) or
|
|
12
|
-
# from files under `base_path:`; remote URLs are never fetched.
|
|
13
|
+
# from files under `base_path:`; remote URLs are never fetched. Block
|
|
14
|
+
# quotes and lists nested deeper than Indents::LIMIT stop indenting, which
|
|
15
|
+
# is reported as a NestingLimit warning.
|
|
13
16
|
class Renderer
|
|
17
|
+
include Indents
|
|
18
|
+
|
|
14
19
|
REMOTE = /\A[a-z][a-z0-9+.-]*:/i
|
|
15
20
|
LINK_SCHEMES = %w[http https mailto tel].freeze
|
|
16
21
|
|
|
@@ -23,18 +28,25 @@ module Stationery
|
|
|
23
28
|
@base_path = base_path && File.expand_path(base_path.to_s)
|
|
24
29
|
@bookmarks = bookmarks
|
|
25
30
|
@links = Links.new(links || LINK_SCHEMES, builder.warnings)
|
|
31
|
+
@indents = 0
|
|
26
32
|
end
|
|
27
33
|
|
|
28
|
-
def render(blocks)
|
|
34
|
+
def render(blocks)
|
|
35
|
+
depth = indents_of(blocks)
|
|
36
|
+
@builder.warnings << Warnings::NestingLimit.new(depth:, limit: Indents::LIMIT) if depth > Indents::LIMIT
|
|
37
|
+
group(blocks)
|
|
38
|
+
end
|
|
29
39
|
|
|
30
40
|
private
|
|
31
41
|
|
|
42
|
+
def group(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
|
|
43
|
+
|
|
32
44
|
def block(block)
|
|
33
45
|
case block
|
|
34
46
|
when Paragraph then paragraph(block.inlines, **@styles[:p])
|
|
35
47
|
when Heading then heading(block)
|
|
36
|
-
when List then list(block)
|
|
37
|
-
when Blockquote then
|
|
48
|
+
when List then indented(block) { list(block) }
|
|
49
|
+
when Blockquote then indented(block) { blockquote(block) }
|
|
38
50
|
when CodeBlock then code(block)
|
|
39
51
|
when Rule then @component.rule(**@styles[:hr])
|
|
40
52
|
when Table then table(block)
|
|
@@ -59,11 +71,15 @@ module Stationery
|
|
|
59
71
|
{ title: heading.inlines.grep(Inline).map(&:text).join, level: heading.level }
|
|
60
72
|
end
|
|
61
73
|
|
|
74
|
+
def blockquote(quote)
|
|
75
|
+
@component.box(role: :blockquote, **@styles[:blockquote]) { group(quote.blocks) }
|
|
76
|
+
end
|
|
77
|
+
|
|
62
78
|
def list(list)
|
|
63
79
|
options = list.ordered ? { **@styles[:ol], start: list.start || 1 } : @styles[:ul]
|
|
64
80
|
@component.public_send(list.ordered ? :ol : :ul, **options) do
|
|
65
81
|
list.items.each do |blocks|
|
|
66
|
-
@component.li { @component.text_style(**@styles[:li]) {
|
|
82
|
+
@component.li { @component.text_style(**@styles[:li]) { group(blocks) } }
|
|
67
83
|
end
|
|
68
84
|
end
|
|
69
85
|
end
|
|
@@ -81,7 +97,7 @@ module Stationery
|
|
|
81
97
|
|
|
82
98
|
def cell_content(cell)
|
|
83
99
|
style = cell.header ? @styles[:table][:header] : {}
|
|
84
|
-
-> { @component.text_style(**style, align: cell.align) {
|
|
100
|
+
-> { @component.text_style(**style, align: cell.align) { group(cell.blocks) } }
|
|
85
101
|
end
|
|
86
102
|
|
|
87
103
|
def image(node)
|
|
@@ -16,16 +16,20 @@ module Stationery
|
|
|
16
16
|
tspan use symbol clipPath]).freeze
|
|
17
17
|
|
|
18
18
|
def self.parse(source)
|
|
19
|
-
|
|
19
|
+
nesting = nil
|
|
20
|
+
root = Parser.parse(source) { |depth| nesting = depth }
|
|
20
21
|
raise Error, "not an svg document" unless root&.name == "svg"
|
|
21
22
|
|
|
22
|
-
new(root)
|
|
23
|
+
new(root, nesting:)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
# `nesting` is how deep the source nested when that was deeper than
|
|
27
|
+
# Parser::MAX_DEPTH (and was flattened there), else nil.
|
|
28
|
+
attr_reader :view_box, :unsupported, :nesting
|
|
26
29
|
|
|
27
|
-
def initialize(root)
|
|
30
|
+
def initialize(root, nesting: nil)
|
|
28
31
|
@root = root
|
|
32
|
+
@nesting = nesting
|
|
29
33
|
@view_box = read_view_box
|
|
30
34
|
@sheet = Stylesheet.parse(style_text)
|
|
31
35
|
@gradients = Gradient.collect(root, @sheet)
|
|
@@ -8,10 +8,15 @@ module Stationery
|
|
|
8
8
|
# Comments, processing instructions and doctypes are skipped; text content
|
|
9
9
|
# (CDATA included) is kept only inside text, tspan and style, as String
|
|
10
10
|
# children with entities decoded and each whitespace run one space.
|
|
11
|
+
#
|
|
12
|
+
# An element nested deeper than MAX_DEPTH is not descended into: it takes
|
|
13
|
+
# its place in the deepest element kept and so does what it holds, so
|
|
14
|
+
# nothing that walks the tree recurses deeper than that.
|
|
11
15
|
module Parser
|
|
12
16
|
Element = Data.define(:name, :attributes, :children)
|
|
13
17
|
|
|
14
18
|
TEXT = %w[text tspan style].freeze
|
|
19
|
+
MAX_DEPTH = 128
|
|
15
20
|
|
|
16
21
|
IGNORED = /<!--.*?-->|<\?.*?\?>|<!DOCTYPE[^>]*>/m
|
|
17
22
|
CDATA = /<!\[CDATA\[(.*?)\]\]>/m
|
|
@@ -21,17 +26,19 @@ module Stationery
|
|
|
21
26
|
|
|
22
27
|
module_function
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
# The root element. The block is given how deep the source nested when
|
|
30
|
+
# that was deeper than `max_depth`.
|
|
31
|
+
def parse(source, max_depth: MAX_DEPTH)
|
|
25
32
|
source = source.to_s.gsub(IGNORED, "").gsub(CDATA) { Regexp.last_match(1).gsub(/[&<>]/, ESCAPES) }
|
|
26
33
|
scanner = StringScanner.new(source)
|
|
27
|
-
|
|
28
|
-
stack = [root]
|
|
34
|
+
tree = Tree.new(max_depth)
|
|
29
35
|
while (content = scanner.scan_until(/(?=<)/))
|
|
30
|
-
keep(
|
|
36
|
+
keep(tree.open, content)
|
|
31
37
|
tag = scanner.scan(TAG) or (scanner.getch && next)
|
|
32
|
-
|
|
38
|
+
tree.tag(*tag.match(TAG).captures)
|
|
33
39
|
end
|
|
34
|
-
|
|
40
|
+
yield tree.deepest if block_given? && tree.deepest > max_depth
|
|
41
|
+
tree.root
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
# Yields the element and every element below it, depth first.
|
|
@@ -46,12 +53,45 @@ module Stationery
|
|
|
46
53
|
element.children << Stationery::Text::Entities.decode(content).gsub(/\s+/, " ")
|
|
47
54
|
end
|
|
48
55
|
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
# The elements open while the source is read. `hoisted` counts the open
|
|
57
|
+
# ones too deep to descend into; their end tags are counted off again.
|
|
58
|
+
class Tree
|
|
59
|
+
attr_reader :deepest
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
def initialize(max_depth)
|
|
62
|
+
@document = Element.new("document", {}, [])
|
|
63
|
+
@stack = [@document]
|
|
64
|
+
@max_depth = max_depth
|
|
65
|
+
@hoisted = 0
|
|
66
|
+
@deepest = 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def root = @document.children.first
|
|
70
|
+
def open = @stack.last
|
|
71
|
+
|
|
72
|
+
def tag(closing, name, attributes, self_closing)
|
|
73
|
+
return close if closing == "/"
|
|
74
|
+
|
|
75
|
+
element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
|
|
76
|
+
open.children << element
|
|
77
|
+
descend(element) if self_closing.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def close
|
|
83
|
+
return @hoisted -= 1 if @hoisted.positive?
|
|
84
|
+
|
|
85
|
+
@stack.pop if @stack.size > 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def descend(element)
|
|
89
|
+
depth = @stack.size + @hoisted
|
|
90
|
+
@deepest = depth if depth > @deepest
|
|
91
|
+
return @stack << element if @stack.size <= @max_depth
|
|
92
|
+
|
|
93
|
+
@hoisted += 1
|
|
94
|
+
end
|
|
55
95
|
end
|
|
56
96
|
end
|
|
57
97
|
end
|
|
@@ -11,6 +11,7 @@ module Stationery
|
|
|
11
11
|
class Walker
|
|
12
12
|
RENDERED = (Shapes::NAMES + %w[text g use svg]).freeze
|
|
13
13
|
VIEWPORTS = %w[symbol svg].freeze
|
|
14
|
+
MAX_USES = 32 # a use of a use of a use …
|
|
14
15
|
|
|
15
16
|
attr_reader :issues
|
|
16
17
|
|
|
@@ -69,6 +70,7 @@ module Stationery
|
|
|
69
70
|
target, id = target_of(element)
|
|
70
71
|
return unless target
|
|
71
72
|
return issue("use: circular reference ##{id}") if chain.include?(id)
|
|
73
|
+
return issue("use: nested deeper than #{MAX_USES}") if chain.size >= MAX_USES
|
|
72
74
|
|
|
73
75
|
attributes = element.attributes
|
|
74
76
|
placed = style.transformed([1, 0, 0, 1, Shapes.f(attributes, "x"), Shapes.f(attributes, "y")])
|