stationery 0.8.0 → 0.10.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 +24 -0
- data/README.md +251 -37
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/{svg → css}/selector.rb +1 -1
- data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
- data/lib/stationery/css.rb +20 -0
- data/lib/stationery/document.rb +35 -7
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +21 -6
- data/lib/stationery/elements.rb +20 -7
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- 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/css.rb +276 -0
- data/lib/stationery/html/document.rb +10 -2
- data/lib/stationery/html/tokenizer.rb +15 -4
- data/lib/stationery/html/tree_builder/blocks.rb +90 -40
- data/lib/stationery/html/tree_builder/collector.rb +9 -3
- data/lib/stationery/html/tree_builder.rb +44 -4
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +21 -7
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/layout/text.rb +47 -10
- 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 +31 -11
- data/lib/stationery/pdf/conformance.rb +7 -5
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +95 -0
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +174 -0
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +43 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +49 -16
- data/lib/stationery/svg/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/breaks.rb +123 -0
- data/lib/stationery/text/paragraph.rb +8 -0
- data/lib/stationery/text/runs_builder.rb +4 -0
- data/lib/stationery/text/wrapper.rb +69 -11
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +13 -0
- data/lib/stationery.rb +6 -2
- metadata +13 -3
|
@@ -0,0 +1,174 @@
|
|
|
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. `timestamp:` adds a time-stamping authority's token to
|
|
12
|
+
# the signature (PAdES baseline B-T, see Timestamp). Long-term validation
|
|
13
|
+
# data and a second signature need incremental updates, which Stationery
|
|
14
|
+
# does not write.
|
|
15
|
+
#
|
|
16
|
+
# OpenSSL is loaded when the first signature is made, never before.
|
|
17
|
+
class Signature
|
|
18
|
+
SUBFILTER = :"ETSI.CAdES.detached"
|
|
19
|
+
CONTENTS_SIZE = 8192
|
|
20
|
+
# A timestamp token brings the TSA's certificate chain along.
|
|
21
|
+
TIMESTAMPED_CONTENTS_SIZE = 16_384
|
|
22
|
+
BYTE_RANGE = "[0 0000000000 0000000000 0000000000]"
|
|
23
|
+
OPTIONS = %i[certificate key chain passphrase reason location contact name field at contents_size
|
|
24
|
+
timestamp].freeze
|
|
25
|
+
# Options a Symbol names a document method for; elsewhere it is a value.
|
|
26
|
+
SECRETS = %i[certificate key chain passphrase].freeze
|
|
27
|
+
# Print and Locked: the widget of a signature that fills no field.
|
|
28
|
+
INVISIBLE = 132
|
|
29
|
+
|
|
30
|
+
attr_reader :certificate, :key, :chain, :field, :at, :contents_size, :timestamp
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# The signature for `spec` (the options of `sign` and `to_pdf(sign:)`,
|
|
34
|
+
# or a block answering them) resolved for `document`; nil without one.
|
|
35
|
+
def for(spec, document)
|
|
36
|
+
return unless spec
|
|
37
|
+
|
|
38
|
+
spec = resolve(spec, document) if spec.respond_to?(:call)
|
|
39
|
+
spec = check(spec).to_h { |name, value| [name, resolve(value, document, method: SECRETS.include?(name))] }
|
|
40
|
+
new(**spec)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The options with Symbol keys; an unknown one raises.
|
|
44
|
+
def check(spec)
|
|
45
|
+
spec = spec.to_h.transform_keys(&:to_sym)
|
|
46
|
+
unknown = spec.keys - OPTIONS
|
|
47
|
+
return spec if unknown.empty?
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "unknown sign option: #{unknown.join(", ")} (use #{OPTIONS.join(", ")})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether any of the options is only known when a document renders.
|
|
53
|
+
def deferred?(spec)
|
|
54
|
+
spec.any? { |name, value| value.respond_to?(:call) || (SECRETS.include?(name) && value.is_a?(Symbol)) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The first DER object in `bytes`: a signature's /Contents without the
|
|
58
|
+
# zeros that pad it.
|
|
59
|
+
def der(bytes)
|
|
60
|
+
length = bytes.getbyte(1).to_i
|
|
61
|
+
return bytes.byteslice(0, 2 + length) if length < 0x80
|
|
62
|
+
|
|
63
|
+
size = length & 0x7F
|
|
64
|
+
content = bytes.byteslice(2, size).unpack("C*").inject(0) { |sum, byte| (sum << 8) | byte }
|
|
65
|
+
bytes.byteslice(0, 2 + size + content)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def resolve(value, document, method: false)
|
|
71
|
+
case value
|
|
72
|
+
when Symbol then method ? document.send(value) : value
|
|
73
|
+
when Proc then value.arity.zero? ? document.instance_exec(&value) : value.call(document)
|
|
74
|
+
else value.respond_to?(:call) ? value.call(document) : value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def initialize(certificate: nil, key: nil, chain: [], passphrase: nil, field: nil, at: Time.now,
|
|
80
|
+
contents_size: nil, timestamp: nil, **details)
|
|
81
|
+
require "openssl"
|
|
82
|
+
@certificate = certificate_for(certificate, "certificate:")
|
|
83
|
+
@key = key_for(key, passphrase)
|
|
84
|
+
@chain = Array(chain).map { |link| certificate_for(link, "chain:") }
|
|
85
|
+
@field = field&.to_s
|
|
86
|
+
@at = at
|
|
87
|
+
@timestamp = Timestamp.for(timestamp)
|
|
88
|
+
@contents_size = contents_size || (@timestamp ? TIMESTAMPED_CONTENTS_SIZE : CONTENTS_SIZE)
|
|
89
|
+
@details = self.class.check(details)
|
|
90
|
+
match!
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Whether it adds its own field, a widget nobody sees, to the first page.
|
|
94
|
+
def invisible? = @field.nil?
|
|
95
|
+
|
|
96
|
+
# The signer's name: `name:`, else the certificate's common name.
|
|
97
|
+
def name
|
|
98
|
+
@details[:name] || @certificate.subject.to_utf8[/(?:\A|,)CN=((?:\\.|[^,])*)/, 1]&.gsub(/\\(.)/, '\1')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The signature dictionary, with placeholders #apply fills in. Its
|
|
102
|
+
# /Contents and /ByteRange are written as they are, never encrypted.
|
|
103
|
+
def dictionary
|
|
104
|
+
entries = { Type: :Sig, Filter: :"Adobe.PPKLite", SubFilter: SUBFILTER,
|
|
105
|
+
ByteRange: Verbatim.new(BYTE_RANGE), Contents: Verbatim.new(placeholder),
|
|
106
|
+
M: TextString.new(@at.utc.strftime("D:%Y%m%d%H%M%SZ")) }
|
|
107
|
+
text = { Name: name, Reason: @details[:reason], Location: @details[:location],
|
|
108
|
+
ContactInfo: @details[:contact] }.compact
|
|
109
|
+
entries.merge(text.transform_values { |value| TextString.new(value.to_s) })
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Signs `pdf` (the file as written with #dictionary in it): fills in the
|
|
113
|
+
# byte range and the signature over it, and answers the same String.
|
|
114
|
+
def apply(pdf)
|
|
115
|
+
range, first, last = gap(pdf)
|
|
116
|
+
pdf.bytesplice(range, BYTE_RANGE.bytesize,
|
|
117
|
+
"[0 #{first} #{last} #{pdf.bytesize - last}]".ljust(BYTE_RANGE.bytesize))
|
|
118
|
+
signed = pdf.byteslice(0, first) + pdf.byteslice(last..)
|
|
119
|
+
signature = CMS.new(@certificate, @key, @chain).sign(signed, timestamp: @timestamp&.method(:token))
|
|
120
|
+
raise ArgumentError, too_large(signature) if signature.bytesize > @contents_size
|
|
121
|
+
|
|
122
|
+
pdf.bytesplice(first + 1, @contents_size * 2, signature.unpack1("H*").upcase.ljust(@contents_size * 2, "0"))
|
|
123
|
+
pdf
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def placeholder = "<#{"0" * (@contents_size * 2)}>"
|
|
129
|
+
|
|
130
|
+
# Where the byte range is written, and where the /Contents string
|
|
131
|
+
# starts and ends: the gap the signature does not cover.
|
|
132
|
+
def gap(pdf)
|
|
133
|
+
range = pdf.index("/ByteRange #{BYTE_RANGE}")
|
|
134
|
+
contents = range && pdf.index("/Contents #{placeholder}", range)
|
|
135
|
+
raise Error, "the signature dictionary is not in the file" unless contents
|
|
136
|
+
|
|
137
|
+
first = contents + "/Contents ".bytesize
|
|
138
|
+
[range + "/ByteRange ".bytesize, first, first + placeholder.bytesize]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def too_large(signature)
|
|
142
|
+
what = @timestamp ? "the signature and its timestamp take" : "the signature takes"
|
|
143
|
+
"#{what} #{signature.bytesize} bytes, more than contents_size: #{@contents_size} leaves for it; " \
|
|
144
|
+
"pass a larger contents_size:"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def certificate_for(value, option)
|
|
148
|
+
return value if value.is_a?(OpenSSL::X509::Certificate)
|
|
149
|
+
|
|
150
|
+
OpenSSL::X509::Certificate.new(value.to_s)
|
|
151
|
+
rescue OpenSSL::X509::CertificateError
|
|
152
|
+
raise ArgumentError, "sign #{option} needs an OpenSSL::X509::Certificate or its PEM, #{got(value)}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def key_for(value, passphrase)
|
|
156
|
+
key = value.is_a?(OpenSSL::PKey::PKey) ? value : OpenSSL::PKey.read(value.to_s, passphrase.to_s)
|
|
157
|
+
return key if key.is_a?(OpenSSL::PKey::RSA) || key.is_a?(OpenSSL::PKey::EC)
|
|
158
|
+
|
|
159
|
+
raise ArgumentError, "sign key: needs an RSA or EC key, got #{key.class}"
|
|
160
|
+
rescue OpenSSL::PKey::PKeyError
|
|
161
|
+
raise ArgumentError, "sign key: needs an OpenSSL::PKey or its PEM (with passphrase: when encrypted), " \
|
|
162
|
+
"#{got(value)}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def got(value) = value.nil? ? "got nil" : "got a #{value.class}"
|
|
166
|
+
|
|
167
|
+
def match!
|
|
168
|
+
return if @key.private? && @certificate.check_private_key(@key)
|
|
169
|
+
|
|
170
|
+
raise ArgumentError, "sign key: is not the private key of certificate: (#{@certificate.subject.to_utf8})"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
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
|
|
@@ -5,15 +5,26 @@ require "digest/md5"
|
|
|
5
5
|
module Stationery
|
|
6
6
|
module PDF
|
|
7
7
|
# Collects numbered objects and writes them as a PDF file with a classic
|
|
8
|
-
# cross-reference table.
|
|
8
|
+
# cross-reference table. `render` returns the file as a String with the
|
|
9
|
+
# objects in numbered order. With a `sink:` (anything answering
|
|
10
|
+
# `call(bytes)`) the file goes there in pieces instead: `flush` writes every
|
|
11
|
+
# object set so far and drops it, so objects leave in the order they were
|
|
12
|
+
# flushed and the first bytes leave before the last page is assembled.
|
|
9
13
|
class Writer
|
|
10
14
|
HEADER = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
|
|
15
|
+
WRITTEN = Object.new.freeze
|
|
11
16
|
|
|
12
17
|
# `encryption` (an Encryption::StandardSecurity) encrypts every string
|
|
13
18
|
# and stream except those of its own /Encrypt dictionary.
|
|
14
|
-
def initialize(encryption: nil)
|
|
19
|
+
def initialize(encryption: nil, sink: nil)
|
|
15
20
|
@objects = []
|
|
21
|
+
@offsets = []
|
|
16
22
|
@encryption = encryption
|
|
23
|
+
@sink = sink
|
|
24
|
+
@buffer = HEADER.dup unless sink
|
|
25
|
+
@size = HEADER.bytesize
|
|
26
|
+
@digest = Digest::MD5.new if sink && !encryption
|
|
27
|
+
announce(HEADER) if sink
|
|
17
28
|
end
|
|
18
29
|
|
|
19
30
|
# Hands out a reference now and lets the object be set later, so a page
|
|
@@ -24,6 +35,8 @@ module Stationery
|
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
def set(ref, value)
|
|
38
|
+
raise Error, "object #{ref.id} is already written" if @objects[ref.id - 1].equal?(WRITTEN)
|
|
39
|
+
|
|
27
40
|
@objects[ref.id - 1] = value
|
|
28
41
|
ref
|
|
29
42
|
end
|
|
@@ -32,43 +45,84 @@ module Stationery
|
|
|
32
45
|
set(reserve, value)
|
|
33
46
|
end
|
|
34
47
|
|
|
48
|
+
# With a sink: writes every object set and not yet written, lowest number
|
|
49
|
+
# first, and drops it. References to objects still unset stay valid, as
|
|
50
|
+
# the cross-reference table is written last. Without a sink it does
|
|
51
|
+
# nothing: the String keeps its objects in numbered order.
|
|
52
|
+
def flush
|
|
53
|
+
return unless @sink
|
|
54
|
+
|
|
55
|
+
@encrypt ||= reserve if @encryption
|
|
56
|
+
write_pending
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Finishes the file: the remaining objects, the cross-reference table
|
|
60
|
+
# and the trailer. Returns the whole file as a String when there is no
|
|
61
|
+
# sink, else the number of bytes handed to it.
|
|
35
62
|
def render(root:, info:)
|
|
63
|
+
set(@encrypt ||= reserve, @encryption.dictionary) if @encryption
|
|
36
64
|
if (missing = @objects.index(nil))
|
|
37
65
|
raise Error, "object #{missing + 1} reserved but never set"
|
|
38
66
|
end
|
|
39
67
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
xref = out.bytesize
|
|
44
|
-
id = HexString.new(@encryption ? @encryption.file_id : Digest::MD5.digest(out))
|
|
68
|
+
write_pending
|
|
69
|
+
xref = @size
|
|
70
|
+
id = HexString.new(file_id)
|
|
45
71
|
|
|
46
|
-
|
|
47
|
-
offsets.each { |offset|
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
emit("xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n")
|
|
73
|
+
@offsets.each { |offset| emit(format("%010d 00000 n \n", offset)) }
|
|
74
|
+
emit("trailer\n#{Serializer.dump(trailer(root, info, id))}")
|
|
75
|
+
emit("\nstartxref\n#{xref}\n%%EOF\n")
|
|
76
|
+
@buffer || @size
|
|
50
77
|
end
|
|
51
78
|
|
|
52
79
|
private
|
|
53
80
|
|
|
81
|
+
def write_pending
|
|
82
|
+
@objects.each_with_index do |object, index|
|
|
83
|
+
next if object.nil? || object.equal?(WRITTEN)
|
|
84
|
+
|
|
85
|
+
write_object(index + 1, object)
|
|
86
|
+
@objects[index] = WRITTEN
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
54
90
|
def trailer(root, info, id)
|
|
55
91
|
trailer = { Size: @objects.size + 1, Root: root, Info: info, ID: [id, id] }
|
|
56
92
|
@encrypt ? trailer.merge(Encrypt: @encrypt) : trailer
|
|
57
93
|
end
|
|
58
94
|
|
|
59
|
-
def write_object(
|
|
60
|
-
|
|
95
|
+
def write_object(number, object)
|
|
96
|
+
@offsets[number - 1] = @size
|
|
61
97
|
crypt = crypt_for(number)
|
|
62
|
-
|
|
98
|
+
emit("#{number} 0 obj\n")
|
|
63
99
|
if object.is_a?(Stream)
|
|
64
100
|
data = crypt ? crypt.call(object.data) : object.data
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
emit(Serializer.dump(object.dictionary.merge(Length: data.bytesize), crypt))
|
|
102
|
+
emit("\nstream\n")
|
|
103
|
+
emit(data)
|
|
104
|
+
emit("\nendstream")
|
|
67
105
|
else
|
|
68
|
-
|
|
106
|
+
emit(Serializer.dump(object, crypt))
|
|
69
107
|
end
|
|
70
|
-
|
|
71
|
-
|
|
108
|
+
emit("\nendobj\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def emit(bytes)
|
|
112
|
+
@size += bytes.bytesize
|
|
113
|
+
@sink ? announce(bytes) : @buffer << bytes
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def announce(bytes)
|
|
117
|
+
@digest&.update(bytes)
|
|
118
|
+
@sink.call(bytes)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The encryption's own identifier, else a digest of the file so far.
|
|
122
|
+
def file_id
|
|
123
|
+
return @encryption.file_id if @encryption
|
|
124
|
+
|
|
125
|
+
@digest ? @digest.digest : Digest::MD5.digest(@buffer)
|
|
72
126
|
end
|
|
73
127
|
|
|
74
128
|
# The Serializer hook: encrypts a string or stream with this object's key.
|
|
@@ -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
|
|
@@ -4,7 +4,9 @@ module Stationery
|
|
|
4
4
|
# The block model rich-text parsers (HTML, Markdown) produce and elements render.
|
|
5
5
|
module Rich
|
|
6
6
|
# A run of text with its marks: any of bold:, italic:, underline:, strike:, code:, link: (href),
|
|
7
|
-
# script: (:sub/:sup)
|
|
7
|
+
# script: (:sub/:sup), color: (a colour), size: (points), scale: (a factor of the text size)
|
|
8
|
+
# and break: true, a hard line break whose text is "". A false bold:, italic:, underline: or
|
|
9
|
+
# strike: switches the mark off inside an element that has it.
|
|
8
10
|
Inline = Data.define(:text, :marks) do
|
|
9
11
|
def self.break = new(text: "", marks: { break: true })
|
|
10
12
|
|
|
@@ -12,15 +14,39 @@ module Stationery
|
|
|
12
14
|
def break? = marks[:break] == true
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
# Every block carries a `style` Hash from the source's CSS (see HTML::Css):
|
|
18
|
+
# align:, background:, padding:, margin:/margin_top:/margin_bottom:,
|
|
19
|
+
# border:, width:, break_before:, break_after:, keep_together:. Empty
|
|
20
|
+
# for Markdown and for HTML without styles.
|
|
21
|
+
Paragraph = Data.define(:inlines, :style) do
|
|
22
|
+
def initialize(inlines:, style: {}) = super
|
|
23
|
+
end
|
|
24
|
+
Heading = Data.define(:level, :inlines, :style) do
|
|
25
|
+
def initialize(level:, inlines:, style: {}) = super
|
|
26
|
+
end
|
|
27
|
+
List = Data.define(:ordered, :start, :items, :style) do
|
|
28
|
+
def initialize(ordered:, start:, items:, style: {}) = super
|
|
29
|
+
end
|
|
30
|
+
Blockquote = Data.define(:blocks, :style) do
|
|
31
|
+
def initialize(blocks:, style: {}) = super
|
|
32
|
+
end
|
|
33
|
+
CodeBlock = Data.define(:text, :language, :style) do
|
|
34
|
+
def initialize(text:, language:, style: {}) = super
|
|
35
|
+
end
|
|
20
36
|
Rule = Data.define
|
|
21
|
-
Table = Data.define(:rows)
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
Table = Data.define(:rows, :style) do
|
|
38
|
+
def initialize(rows:, style: {}) = super
|
|
39
|
+
end
|
|
40
|
+
Cell = Data.define(:header, :align, :blocks, :style) do
|
|
41
|
+
def initialize(header:, align:, blocks:, style: {}) = super
|
|
42
|
+
end
|
|
43
|
+
Image = Data.define(:src, :alt, :width, :height, :style) do
|
|
44
|
+
def initialize(src:, alt:, width:, height:, style: {}) = super
|
|
45
|
+
end
|
|
46
|
+
# A styled container (a `div` with a background, padding, margins or a
|
|
47
|
+
# page-break rule) holding blocks; an unstyled one flattens into its parent.
|
|
48
|
+
Container = Data.define(:blocks, :style)
|
|
49
|
+
PageBreak = Data.define
|
|
24
50
|
|
|
25
51
|
# Operations on inline sequences shared by the parsers.
|
|
26
52
|
module Inlines
|
|
@@ -42,12 +68,12 @@ module Stationery
|
|
|
42
68
|
end
|
|
43
69
|
|
|
44
70
|
# Groups inlines into paragraphs, with each image standing as a block between them.
|
|
45
|
-
def paragraphs(items)
|
|
71
|
+
def paragraphs(items, style: {})
|
|
46
72
|
items.chunk_while { |a, b| !a.is_a?(Image) && !b.is_a?(Image) }.filter_map do |run|
|
|
47
73
|
next run.first if run.first.is_a?(Image)
|
|
48
74
|
|
|
49
75
|
inlines = merge(block_given? ? yield(run) : run)
|
|
50
|
-
Paragraph.new(inlines:) unless inlines.empty?
|
|
76
|
+
Paragraph.new(inlines:, style:) unless inlines.empty?
|
|
51
77
|
end
|
|
52
78
|
end
|
|
53
79
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
class Renderer
|
|
6
|
+
# What a block's CSS style becomes around the block: margins as spacers,
|
|
7
|
+
# a background and padding as a box, page-break rules as page breaks and
|
|
8
|
+
# kept-together groups, borders and widths as table options.
|
|
9
|
+
module Boxes
|
|
10
|
+
PADDING = { padding_top: 0, padding_right: 1, padding_bottom: 2, padding_left: 3 }.freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Draws the block inside its margins and its box. A block styled with
|
|
15
|
+
# nothing but an alignment draws as it is.
|
|
16
|
+
def styled(style, &)
|
|
17
|
+
return yield if style.empty?
|
|
18
|
+
|
|
19
|
+
spaced(style) { boxed(style, &) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Page breaks and margins around the block, and the rule that keeps it
|
|
23
|
+
# on one page.
|
|
24
|
+
def spaced(style)
|
|
25
|
+
return yield unless spacing?(style)
|
|
26
|
+
|
|
27
|
+
top, bottom = margins(style)
|
|
28
|
+
@component.page_break if style[:break_before]
|
|
29
|
+
@component.group(keep_together: style[:keep_together] == true) do
|
|
30
|
+
@component.spacer(top) if top.positive?
|
|
31
|
+
yield
|
|
32
|
+
@component.spacer(bottom) if bottom.positive?
|
|
33
|
+
end
|
|
34
|
+
@component.page_break if style[:break_after]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def spacing?(style)
|
|
38
|
+
style[:break_before] || style[:break_after] || style[:keep_together] || margins(style).any?(&:positive?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def margins(style)
|
|
42
|
+
all = style[:margin]
|
|
43
|
+
[style[:margin_top] || all&.[](0) || 0, style[:margin_bottom] || all&.[](2) || 0]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def boxed(style, &)
|
|
47
|
+
options = box_options(style)
|
|
48
|
+
options.empty? ? yield : @component.box(**options, &)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The background and padding a style gives a box, to merge over a
|
|
52
|
+
# block's own (`styles[:blockquote]`, `styles[:pre]`).
|
|
53
|
+
def box_options(style)
|
|
54
|
+
options = {}
|
|
55
|
+
options[:background] = style[:background] if style[:background]
|
|
56
|
+
padding = padding_of(style)
|
|
57
|
+
options[:padding] = padding if padding
|
|
58
|
+
options
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def padding_of(style)
|
|
62
|
+
sides = PADDING.select { |key, _| style[key] }
|
|
63
|
+
return style[:padding] if sides.empty?
|
|
64
|
+
|
|
65
|
+
padding = (style[:padding] || [0, 0, 0, 0]).dup
|
|
66
|
+
sides.each { |key, index| padding[index] = style[key] }
|
|
67
|
+
padding
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def aligned(style) = style[:align] ? { align: style[:align] } : {}
|
|
71
|
+
|
|
72
|
+
def border_options(border)
|
|
73
|
+
return {} unless border
|
|
74
|
+
return { borders: [] } if border[:width].zero?
|
|
75
|
+
|
|
76
|
+
{ border_width: border[:width], border_color: border[:color] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cell_options(style)
|
|
80
|
+
options = border_options(style[:border]).merge(box_options(style))
|
|
81
|
+
options[:borders] = %i[top right bottom left] if options[:border_width]
|
|
82
|
+
options
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Column widths from the first row, when every cell of it has one. A
|
|
86
|
+
# percentage is a share of the table, which then takes the full width
|
|
87
|
+
# unless it has a width of its own.
|
|
88
|
+
def column_widths(table)
|
|
89
|
+
widths = table.rows.first.map { |cell| cell.style[:width] }
|
|
90
|
+
return {} unless widths.all?
|
|
91
|
+
|
|
92
|
+
widths.any? { |width| fraction?(width) } && !table.style[:width] ? { widths:, width: :full } : { widths: }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A table's own width: points, the full width for 100%, or a share of
|
|
96
|
+
# the space through a box of that width.
|
|
97
|
+
def sized(width)
|
|
98
|
+
return yield({}) unless width
|
|
99
|
+
return yield({ width: }) unless fraction?(width)
|
|
100
|
+
return yield({ width: :full }) if width >= 1
|
|
101
|
+
|
|
102
|
+
@component.box(width:) { yield({ width: :full }) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fraction?(value) = value.is_a?(Float) && value <= 1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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 Container then indents_of(block.blocks)
|
|
34
|
+
when List then 1 + (block.items.map { |item| indents_of(item) }.max || 0)
|
|
35
|
+
when Table then block.rows.flatten.map { |cell| indents_of(cell.blocks) }.max || 0
|
|
36
|
+
else 0
|
|
37
|
+
end
|
|
38
|
+
end.max || 0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -15,8 +15,12 @@ module Stationery
|
|
|
15
15
|
font: ->(value) { [:font, value] if value }
|
|
16
16
|
}.freeze
|
|
17
17
|
|
|
18
|
+
# A true mark switches the style on, a false one (from CSS) switches it off again.
|
|
18
19
|
MARK_STEPS = {
|
|
19
|
-
bold: [[:b]],
|
|
20
|
+
bold: { true => [[:b]], false => [[:with, { weight: :regular }]] },
|
|
21
|
+
italic: { true => [[:i]], false => [[:with, { style: :normal }]] },
|
|
22
|
+
underline: { true => [[:u]], false => [[:with, { underline: false }]] },
|
|
23
|
+
strike: { true => [[:strikethrough]], false => [[:with, { strikethrough: false }]] }
|
|
20
24
|
}.freeze
|
|
21
25
|
|
|
22
26
|
def initialize(runs, styles, links = Links.new(:all, nil))
|
|
@@ -50,7 +54,10 @@ module Stationery
|
|
|
50
54
|
when :link then [[:link, value], *style_steps(@styles[:a])]
|
|
51
55
|
when :code then style_steps(@styles[:code])
|
|
52
56
|
when :script then [[value]]
|
|
53
|
-
|
|
57
|
+
when :color then [[:color, value]]
|
|
58
|
+
when :size then [[:size, value]]
|
|
59
|
+
when :scale then [[:scale, value]]
|
|
60
|
+
else MARK_STEPS.dig(mark, value) || []
|
|
54
61
|
end
|
|
55
62
|
end
|
|
56
63
|
|