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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +251 -37
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/{svg → css}/selector.rb +1 -1
  6. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  7. data/lib/stationery/css.rb +20 -0
  8. data/lib/stationery/document.rb +35 -7
  9. data/lib/stationery/elements/forms.rb +11 -6
  10. data/lib/stationery/elements/rich.rb +21 -6
  11. data/lib/stationery/elements.rb +20 -7
  12. data/lib/stationery/errors.rb +4 -0
  13. data/lib/stationery/fonts/fallback.rb +1 -1
  14. data/lib/stationery/fonts/font.rb +28 -18
  15. data/lib/stationery/forms/acro_form.rb +95 -25
  16. data/lib/stationery/forms/appearance.rb +92 -30
  17. data/lib/stationery/forms/field.rb +20 -11
  18. data/lib/stationery/forms/metrics.rb +3 -17
  19. data/lib/stationery/forms/typeface.rb +113 -0
  20. data/lib/stationery/html/css.rb +276 -0
  21. data/lib/stationery/html/document.rb +10 -2
  22. data/lib/stationery/html/tokenizer.rb +15 -4
  23. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  24. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  25. data/lib/stationery/html/tree_builder.rb +44 -4
  26. data/lib/stationery/layout/flow.rb +29 -0
  27. data/lib/stationery/layout/image.rb +21 -7
  28. data/lib/stationery/layout/mark.rb +2 -0
  29. data/lib/stationery/layout/node.rb +4 -0
  30. data/lib/stationery/layout/table/cell.rb +14 -4
  31. data/lib/stationery/layout/table.rb +72 -14
  32. data/lib/stationery/layout/text.rb +47 -10
  33. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  34. data/lib/stationery/markdown/block_parser.rb +9 -3
  35. data/lib/stationery/markdown/document.rb +8 -2
  36. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  37. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  38. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  39. data/lib/stationery/markdown/inline_parser.rb +9 -3
  40. data/lib/stationery/minitest.rb +4 -0
  41. data/lib/stationery/pdf/assembler.rb +31 -11
  42. data/lib/stationery/pdf/conformance.rb +7 -5
  43. data/lib/stationery/pdf/serializer.rb +3 -1
  44. data/lib/stationery/pdf/signature/cms.rb +95 -0
  45. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  46. data/lib/stationery/pdf/signature.rb +174 -0
  47. data/lib/stationery/pdf/types.rb +4 -0
  48. data/lib/stationery/pdf/writer.rb +73 -19
  49. data/lib/stationery/rich/nesting.rb +30 -0
  50. data/lib/stationery/rich/nodes.rb +37 -11
  51. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  52. data/lib/stationery/rich/renderer/indents.rb +43 -0
  53. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  54. data/lib/stationery/rich/renderer.rb +49 -16
  55. data/lib/stationery/svg/document.rb +8 -4
  56. data/lib/stationery/svg/parser.rb +51 -11
  57. data/lib/stationery/svg/style.rb +4 -6
  58. data/lib/stationery/svg/walker.rb +2 -0
  59. data/lib/stationery/tagging/element.rb +1 -1
  60. data/lib/stationery/testing/inspector.rb +98 -0
  61. data/lib/stationery/testing/matchers.rb +25 -0
  62. data/lib/stationery/text/breaks.rb +123 -0
  63. data/lib/stationery/text/paragraph.rb +8 -0
  64. data/lib/stationery/text/runs_builder.rb +4 -0
  65. data/lib/stationery/text/wrapper.rb +69 -11
  66. data/lib/stationery/version.rb +1 -1
  67. data/lib/stationery/warnings.rb +13 -0
  68. data/lib/stationery.rb +6 -2
  69. 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
@@ -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
- @encrypt = @encryption && add(@encryption.dictionary)
41
- out = HEADER.dup
42
- offsets = @objects.each_with_index.map { |object, index| write_object(out, index + 1, object) }
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
- out << "xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n"
47
- offsets.each { |offset| out << format("%010d 00000 n \n", offset) }
48
- out << "trailer\n" << Serializer.dump(trailer(root, info, id))
49
- out << "\nstartxref\n#{xref}\n%%EOF\n"
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(out, number, object)
60
- offset = out.bytesize
95
+ def write_object(number, object)
96
+ @offsets[number - 1] = @size
61
97
  crypt = crypt_for(number)
62
- out << "#{number} 0 obj\n"
98
+ emit("#{number} 0 obj\n")
63
99
  if object.is_a?(Stream)
64
100
  data = crypt ? crypt.call(object.data) : object.data
65
- out << Serializer.dump(object.dictionary.merge(Length: data.bytesize), crypt)
66
- out << "\nstream\n" << data << "\nendstream"
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
- out << Serializer.dump(object, crypt)
106
+ emit(Serializer.dump(object, crypt))
69
107
  end
70
- out << "\nendobj\n"
71
- offset
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) and break: true, a hard line break whose text is "".
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
- Paragraph = Data.define(:inlines)
16
- Heading = Data.define(:level, :inlines)
17
- List = Data.define(:ordered, :start, :items)
18
- Blockquote = Data.define(:blocks)
19
- CodeBlock = Data.define(:text, :language)
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
- Cell = Data.define(:header, :align, :blocks)
23
- Image = Data.define(:src, :alt, :width, :height)
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]], italic: [[:i]], underline: [[:u]], strike: [[:strikethrough]]
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
- else MARK_STEPS.fetch(mark, [])
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