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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +128 -23
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/document.rb +20 -4
  6. data/lib/stationery/elements/forms.rb +11 -6
  7. data/lib/stationery/elements/rich.rb +13 -6
  8. data/lib/stationery/elements.rb +11 -3
  9. data/lib/stationery/fonts/font.rb +28 -18
  10. data/lib/stationery/forms/acro_form.rb +95 -25
  11. data/lib/stationery/forms/appearance.rb +92 -30
  12. data/lib/stationery/forms/field.rb +20 -11
  13. data/lib/stationery/forms/metrics.rb +3 -17
  14. data/lib/stationery/forms/typeface.rb +113 -0
  15. data/lib/stationery/html/document.rb +8 -2
  16. data/lib/stationery/html/tree_builder.rb +34 -4
  17. data/lib/stationery/layout/image.rb +9 -1
  18. data/lib/stationery/layout/table/cell.rb +14 -4
  19. data/lib/stationery/layout/table.rb +72 -14
  20. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  21. data/lib/stationery/markdown/block_parser.rb +9 -3
  22. data/lib/stationery/markdown/document.rb +8 -2
  23. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  24. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  25. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  26. data/lib/stationery/markdown/inline_parser.rb +9 -3
  27. data/lib/stationery/minitest.rb +4 -0
  28. data/lib/stationery/pdf/assembler.rb +19 -9
  29. data/lib/stationery/pdf/conformance.rb +7 -5
  30. data/lib/stationery/pdf/serializer.rb +3 -1
  31. data/lib/stationery/pdf/signature/cms.rb +87 -0
  32. data/lib/stationery/pdf/signature.rb +167 -0
  33. data/lib/stationery/pdf/types.rb +4 -0
  34. data/lib/stationery/rich/nesting.rb +30 -0
  35. data/lib/stationery/rich/renderer/indents.rb +42 -0
  36. data/lib/stationery/rich/renderer.rb +22 -6
  37. data/lib/stationery/svg/document.rb +8 -4
  38. data/lib/stationery/svg/parser.rb +51 -11
  39. data/lib/stationery/svg/walker.rb +2 -0
  40. data/lib/stationery/tagging/element.rb +1 -1
  41. data/lib/stationery/testing/inspector.rb +55 -0
  42. data/lib/stationery/testing/matchers.rb +25 -0
  43. data/lib/stationery/text/wrapper.rb +12 -3
  44. data/lib/stationery/version.rb +1 -1
  45. data/lib/stationery/warnings.rb +4 -0
  46. data/lib/stationery.rb +3 -0
  47. 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
- if page.annotations.any?
73
- dictionary[:Annots] = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
74
- end
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[:widget], annotation[:rect], page) do |widget_ref|
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
- def page_entries(page) = pdf_ua? && page.annotations.any? ? { Tabs: :S } : {}
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
- # Form fields draw with the standard Helvetica and ZapfDingbats, which
114
- # are not embedded, and ask the viewer to regenerate appearances:
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
- names = pages.flat_map(&:annotations).filter_map { |annotation| annotation[:widget]&.name }.uniq
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
@@ -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) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
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 @component.box(role: :blockquote, **@styles[:blockquote]) { render(block.blocks) }
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]) { render(blocks) } }
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) { render(cell.blocks) } }
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
- root = Parser.parse(source)
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
- attr_reader :view_box, :unsupported
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
- def parse(source)
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
- root = Element.new("document", {}, [])
28
- stack = [root]
34
+ tree = Tree.new(max_depth)
29
35
  while (content = scanner.scan_until(/(?=<)/))
30
- keep(stack.last, content)
36
+ keep(tree.open, content)
31
37
  tag = scanner.scan(TAG) or (scanner.getch && next)
32
- handle(stack, *tag.match(TAG).captures)
38
+ tree.tag(*tag.match(TAG).captures)
33
39
  end
34
- root.children.first
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
- def handle(stack, closing, name, attributes, self_closing)
50
- return stack.pop if closing == "/" && stack.size > 1
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
- element = Element.new(name, attributes.scan(ATTRIBUTE).to_h { |k, *v| [k, v.compact.first] }, [])
53
- stack.last.children << element
54
- stack << element if self_closing.empty?
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")])