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
@@ -6,7 +6,9 @@ module Stationery
6
6
  module HTML
7
7
  # Builds a lenient element tree from tokens, closing what HTML5 closes implicitly (a p before a block,
8
8
  # an li before the next li, cells and rows before the next), ignoring stray end tags, then converts
9
- # the tree into rich-text blocks.
9
+ # the tree into rich-text blocks. An element nested deeper than the Rich::Nesting limit is left out
10
+ # of the tree: what it holds goes to the deepest element kept, so everything that walks the tree
11
+ # (and the layout it becomes) recurses no deeper than the limit.
10
12
  class TreeBuilder
11
13
  Element = Data.define(:name, :attributes, :children)
12
14
 
@@ -16,6 +18,7 @@ module Stationery
16
18
  ].freeze
17
19
  HEADINGS = %w[h1 h2 h3 h4 h5 h6].freeze
18
20
  SECTIONS = %w[thead tbody tfoot].freeze
21
+ BLOCKS = (CLOSES_P + SECTIONS + %w[tr td th caption]).freeze
19
22
  IMPLIED = {
20
23
  "li" => [%w[li], %w[ul ol]],
21
24
  "dt" => [%w[dt dd], %w[dl]], "dd" => [%w[dt dd], %w[dl]],
@@ -24,11 +27,13 @@ module Stationery
24
27
  "thead" => [SECTIONS, %w[table]], "tbody" => [SECTIONS, %w[table]], "tfoot" => [SECTIONS, %w[table]]
25
28
  }.freeze
26
29
 
27
- def self.parse(tokens) = Blocks.convert(new.build(tokens))
30
+ def self.parse(tokens, nesting = Rich::Nesting.new) = Blocks.convert(new(nesting).build(tokens))
28
31
 
29
- def initialize
32
+ def initialize(nesting = Rich::Nesting.new)
30
33
  @root = Element.new("#root", {}, [])
31
34
  @stack = [@root]
35
+ @nesting = nesting
36
+ @flattened = [] # the names of the open elements left out of the tree
32
37
  end
33
38
 
34
39
  def build(tokens)
@@ -45,9 +50,13 @@ module Stationery
45
50
  private
46
51
 
47
52
  def start(name, attributes, self_closing)
53
+ return flatten(name, attributes, self_closing) unless @flattened.empty?
54
+
48
55
  close_nearest(%w[p], %w[table td th caption]) if CLOSES_P.include?(name)
49
56
  close_nearest(HEADINGS, []) if HEADINGS.include?(name) && HEADINGS.include?(@stack.last.name)
50
57
  close_nearest(*IMPLIED[name]) if IMPLIED.key?(name)
58
+ return flatten(name, attributes, self_closing) if @stack.size > @nesting.limit
59
+
51
60
  push("tr", {}, false) if %w[td th].include?(name) && %w[table thead tbody tfoot].include?(@stack.last.name)
52
61
  push(name, attributes, self_closing)
53
62
  end
@@ -58,9 +67,30 @@ module Stationery
58
67
  @stack << element unless self_closing
59
68
  end
60
69
 
70
+ # Too deep for the tree: a void element (br, img, hr) still takes its place in the deepest
71
+ # element kept, anything else is only remembered until its end tag, its content going there too.
72
+ def flatten(name, attributes, self_closing)
73
+ return push(name, attributes, true) if self_closing
74
+
75
+ @flattened << name
76
+ @nesting.record(@stack.size - 1 + @flattened.size)
77
+ separate(name)
78
+ end
79
+
61
80
  def finish(name)
81
+ flattened = @flattened.rindex(name)
82
+ return @flattened.slice!(flattened..).each { |left_out| separate(left_out) } if flattened
83
+
62
84
  index = @stack.rindex { |element| element.name == name }
63
- @stack.slice!(index..) if index&.positive?
85
+ return unless index&.positive?
86
+
87
+ @stack.slice!(index..)
88
+ @flattened.clear
89
+ end
90
+
91
+ # A flattened block no longer parts its text from its neighbours'; a space does.
92
+ def separate(name)
93
+ @stack.last.children << " " if BLOCKS.include?(name)
64
94
  end
65
95
 
66
96
  def close_nearest(names, boundary)
@@ -25,7 +25,7 @@ module Stationery
25
25
  super()
26
26
  @tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
27
27
  @image = source.respond_to?(:build) ? source : Images.load(source)
28
- @name = source.is_a?(String) || source.is_a?(Pathname) ? File.basename(source.to_s) : "inline image"
28
+ @name = name_of(source)
29
29
  @max_ppi = max_ppi
30
30
  @downscale = downscale
31
31
  @width = width
@@ -58,6 +58,14 @@ module Stationery
58
58
 
59
59
  private
60
60
 
61
+ # What a warning calls the image: the file's name for a path, a
62
+ # Pathname or an open File (anything with a path), else "inline image".
63
+ # Asked by duck type: Pathname is not loaded in plain Ruby.
64
+ def name_of(source)
65
+ path = source.is_a?(String) ? source : (source.to_path if source.respond_to?(:to_path))
66
+ path ? File.basename(path) : "inline image"
67
+ end
68
+
61
69
  def paint_clipped(canvas, x, y, w, h)
62
70
  return paint_image(canvas, x, y, w, h) unless @fit == :cover || @radius.positive?
63
71
 
@@ -6,6 +6,7 @@ module Stationery
6
6
  # One table cell: content (a String, markup String or layout node) and its
7
7
  # options, which selections change until the table is laid out.
8
8
  class Cell
9
+ SIDES = %i[top right bottom left].freeze
9
10
  TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
10
11
  letter_spacing: :letter_spacing }.freeze
11
12
 
@@ -103,14 +104,23 @@ module Stationery
103
104
  canvas.debug_rect(inner.x, inner.y, inner.width, inner.height, :cell_padding)
104
105
  end
105
106
 
107
+ # One path for the cell's border, centred on its edges: the rectangle
108
+ # when all four sides are drawn, else a line per side.
106
109
  def paint_borders(canvas, rect)
107
110
  width = @options[:border_width]
108
111
  return if width.nil? || width.zero?
109
112
 
110
- color = @options[:border_color]
111
- Array(@options[:borders]).each do |side|
112
- x1, y1, x2, y2 = edge(side, rect)
113
- canvas.line(x1, y1, x2, y2, color:, width:)
113
+ sides = Array(@options[:borders]).uniq
114
+ return if sides.empty?
115
+
116
+ canvas.path(stroke: @options[:border_color], line_width: width) do |path|
117
+ next path.rect(rect.x, rect.y, rect.width, rect.height) if (SIDES - sides).empty?
118
+
119
+ sides.each do |side|
120
+ x1, y1, x2, y2 = edge(side, rect)
121
+ path.move_to(x1, y1)
122
+ path.line_to(x2, y2)
123
+ end
114
124
  end
115
125
  end
116
126
 
@@ -6,6 +6,13 @@ module Stationery
6
6
  # header rows repeated after a page break, and splitting between rows. A
7
7
  # row taller than a fresh page, or any row with split_rows: true, is cut
8
8
  # through its cells and continues below the repeated header.
9
+ #
10
+ # The fragments a page break cuts a table into keep the whole table's
11
+ # column metrics, so every page resolves the same column widths, and the
12
+ # widths and row heights measured for the cut, so a page break measures
13
+ # nothing again. Laid out at another width they resolve and measure anew.
14
+ # Where no cell spans rows a cut can fall before any row, so the rows
15
+ # still to come are not placed on a grid until a page paints them.
9
16
  class Table < Node
10
17
  DEFAULT_CELL = { padding: 5, borders: %i[top right bottom left], border_width: 0.5,
11
18
  border_color: "#000000" }.freeze
@@ -30,7 +37,7 @@ module Stationery
30
37
  end
31
38
 
32
39
  def row_count = @cells.size
33
- def column_count = grid.column_count
40
+ def column_count = @column_count ||= grid.column_count
34
41
  def cell(row, column) = grid.at(row, column)
35
42
 
36
43
  def rows(spec) = Selection.new(self, Selection.indexes(spec, row_count), (0...column_count).to_a)
@@ -53,6 +60,7 @@ module Stationery
53
60
  @column_widths = nil
54
61
  @column_metrics = nil
55
62
  @row_heights = nil
63
+ @column_count = nil
56
64
  @grid = nil
57
65
  end
58
66
 
@@ -80,10 +88,11 @@ module Stationery
80
88
  def paint(canvas, x, y, width, _height = nil, **)
81
89
  widths = column_widths(width)
82
90
  heights = row_heights(width)
91
+ lefts = offsets(widths)
92
+ tops = offsets(heights)
83
93
  canvas.structure(@tag) do
84
94
  grid.placements.each do |p|
85
- rect = Rect.new(x + widths[0...p.column].sum, y + heights[0...p.row].sum,
86
- widths[p.columns].sum, heights[p.rows].sum)
95
+ rect = Rect.new(x + lefts[p.column], y + tops[p.row], widths[p.columns].sum, heights[p.rows].sum)
87
96
  paint_cell(canvas, p, rect)
88
97
  end
89
98
  end
@@ -96,14 +105,45 @@ module Stationery
96
105
  count += 1 while count < row_count && used + heights[count] <= height + EPSILON && (used += heights[count])
97
106
  return [self, nil] if count == row_count
98
107
 
99
- count = grid.boundaries.grep(@header..count).max
100
- split_row(count, width, height - heights.first(count).sum, fresh: options[:fresh]) || split_before(count)
108
+ count = grid.boundaries.grep(@header..count).max if row_spans?
109
+ split_row(count, width, height - heights.first(count).sum, fresh: options[:fresh]) ||
110
+ split_before(count, width)
111
+ end
112
+
113
+ protected
114
+
115
+ # Becomes a fragment holding `rows`, with the column widths and row
116
+ # heights its table measured at `width`.
117
+ def carry(rows, continued, widths, heights)
118
+ @cells = rows
119
+ @continued = continued
120
+ @grid = nil
121
+ @column_widths = widths
122
+ @row_heights = heights
123
+ self
101
124
  end
102
125
 
103
126
  private
104
127
 
105
128
  def grid = @grid ||= Grid.new(@cells)
106
129
 
130
+ # Whether any cell spans rows, of the table a fragment was cut from too.
131
+ def row_spans?
132
+ return @row_spans if defined?(@row_spans)
133
+
134
+ @row_spans = @cells.any? { |row| row.any? { |cell| cell.rowspan > 1 } }
135
+ end
136
+
137
+ # The placements of one row, which no cell from above reaches into.
138
+ def placements_in(row)
139
+ return grid.placements.select { |placement| placement.row == row } if row_spans?
140
+
141
+ Grid.new([@cells[row]]).placements
142
+ end
143
+
144
+ # Where each column or row starts: the sizes before it, summed.
145
+ def offsets(sizes) = Array.new(sizes.size) { |index| sizes[0...index].sum }
146
+
107
147
  def paint_cell(canvas, placement, rect)
108
148
  cell = placement.cell
109
149
  paint = lambda do
@@ -123,28 +163,42 @@ module Stationery
123
163
  end
124
164
 
125
165
  def cell_tag(cell, header)
166
+ return Tagging::Element.new(:TD) unless header || cell.colspan > 1 || cell.rowspan > 1
167
+
126
168
  attributes = { Scope: (:Column if header), ColSpan: (cell.colspan if cell.colspan > 1),
127
169
  RowSpan: (cell.rowspan if cell.rowspan > 1) }.compact
128
170
  Tagging::Element.new(header ? :TH : :TD, attributes: attributes.empty? ? {} : { Table: attributes })
129
171
  end
130
172
 
131
- def split_before(count)
173
+ def split_before(count, width)
132
174
  return [nil, self] if count == @header
133
175
 
134
- [with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count), continued: true)]
176
+ heights = row_heights(width)
177
+ [fragment(@cells.first(count), width, heights.first(count)),
178
+ fragment(@cells.first(@header) + @cells.drop(count), width,
179
+ heights.first(@header) + heights.drop(count), continued: true)]
135
180
  end
136
181
 
137
182
  def split_row(row, width, space, fresh:)
138
183
  return unless @split_rows || (fresh && row == @header)
139
- return unless grid.boundaries.include?(row + 1)
184
+ return if row_spans? && !grid.boundaries.include?(row + 1)
140
185
 
141
186
  widths = column_widths(width)
142
- placements = grid.placements.select { |p| p.row == row }
187
+ placements = placements_in(row)
143
188
  heads, tails = RowSplitter.new(placements, widths:, context: @context).call(space)
144
189
  return unless heads
145
190
 
146
- [with_rows(@cells.first(row) + [heads], widths:),
147
- with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:, continued: true)]
191
+ heights = row_heights(width)
192
+ above, below = [heads, tails].map { |cells| [row_height(placements, cells, widths)] }
193
+ [fragment(@cells.first(row) + [heads], width, heights.first(row) + above),
194
+ fragment(@cells.first(@header) + [tails] + @cells.drop(row + 1), width,
195
+ heights.first(@header) + below + heights.drop(row + 1), continued: true)]
196
+ end
197
+
198
+ # The height of a cut row's part. No cell of a row that can be cut
199
+ # spans rows, so it is its tallest cell.
200
+ def row_height(placements, cells, widths)
201
+ placements.zip(cells).map { |placement, cell| cell.measure(@context, widths[placement.columns].sum) }.max
148
202
  end
149
203
 
150
204
  def build_cell(content, defaults)
@@ -187,9 +241,13 @@ module Stationery
187
241
  @row_heights[width] ||= grid.row_heights(column_widths(width)) { |p, span| p.cell.measure(@context, span) }
188
242
  end
189
243
 
190
- def with_rows(rows, widths: @widths, continued: @continued)
191
- self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows,
192
- tag: @tag, continued:)
244
+ # A copy holding other rows of this table. Its cells are built and
245
+ # tagged already and its column metrics are this table's.
246
+ def fragment(rows, width, heights, continued: @continued)
247
+ %i[natural_width min_width].each { |metric| column_metric(metric) }
248
+ column_count
249
+ row_spans?
250
+ dup.carry(rows, continued, { width => column_widths(width) }, { width => heights })
193
251
  end
194
252
  end
195
253
  end
@@ -9,9 +9,30 @@ module Stationery
9
9
  module Containers
10
10
  QUOTE = /\A {0,3}> ?/
11
11
  LIST = /\A(?<indent> {0,3})(?<marker>[-+*]|(?<number>\d{1,9})[.)])(?<space> *)(?<rest>.*)\z/
12
+ MARKER = /\G[ \t]*(?:>|(?:[-+*]|\d{1,9}[.)])(?=[ \t]|\z))/
12
13
 
13
14
  private
14
15
 
16
+ def container?(line) = QUOTE.match?(line) || !list_marker(line).nil?
17
+
18
+ # Block quotes and lists nested past the limit: their lines, up to a blank one, as one
19
+ # paragraph with the markers dropped.
20
+ def flattened
21
+ lines = []
22
+ while (line = @lines[@index]) && !blank?(line)
23
+ lines << unmarked(line)
24
+ @index += 1
25
+ end
26
+ lines.reject!(&:empty?)
27
+ Rich::Paragraph.new(inlines: text(lines)) if lines.any?
28
+ end
29
+
30
+ def unmarked(line)
31
+ markers = line.scan(MARKER).size
32
+ @nesting.record(@depth + markers)
33
+ line.sub(/\A(?:#{MARKER.source.delete_prefix("\\G")})*[ \t]*/, "")
34
+ end
35
+
15
36
  def blockquote
16
37
  lines = []
17
38
  while (line = @lines[@index])
@@ -10,6 +10,8 @@ module Stationery
10
10
  # Splits CommonMark source into blocks, line by line. Paragraph, heading and table cell text is left
11
11
  # raw (a String in place of inlines) for Markdown.parse to run through the inline parser once every
12
12
  # link reference definition, which this parser collects into `refs`, is known. Raw HTML stays text.
13
+ # Every block quote and list is parsed by a parser of its own, one level deeper; at the
14
+ # Rich::Nesting limit a further one becomes a paragraph of its text instead.
13
15
  class BlockParser
14
16
  include Containers
15
17
  include Tables
@@ -24,7 +26,7 @@ module Stationery
24
26
  (?:[ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?[ \t]*\z
25
27
  /x
26
28
 
27
- def self.parse(source, refs) = new(lines(source), refs).blocks
29
+ def self.parse(source, refs, nesting = Rich::Nesting.new) = new(lines(source), refs, nesting).blocks
28
30
 
29
31
  def self.lines(source) = source.to_s.split(/\r\n?|\n/).map { |line| expand_tabs(line) }
30
32
 
@@ -39,9 +41,12 @@ module Stationery
39
41
  expanded + line[prefix.length..]
40
42
  end
41
43
 
42
- def initialize(lines, refs)
44
+ # `depth` counts the block quotes and lists this parser is inside.
45
+ def initialize(lines, refs, nesting = Rich::Nesting.new, depth = 0)
43
46
  @lines = lines
44
47
  @refs = refs
48
+ @nesting = nesting
49
+ @depth = depth
45
50
  @index = 0
46
51
  end
47
52
 
@@ -62,6 +67,7 @@ module Stationery
62
67
  elsif (match = FENCE.match(line)) then fenced_code(match)
63
68
  elsif (match = ATX.match(line)) then atx(match)
64
69
  elsif THEMATIC.match?(line) then skip(Rich::Rule.new)
70
+ elsif container?(line) && @depth >= @nesting.limit then flattened
65
71
  elsif QUOTE.match?(line) then blockquote
66
72
  elsif (match = list_marker(line)) then list(match)
67
73
  elsif table_start? then table
@@ -135,7 +141,7 @@ module Stationery
135
141
 
136
142
  def blank?(line) = line.strip.empty?
137
143
  def indent(line) = line[/\A */].length
138
- def nested(lines) = BlockParser.new(lines, @refs).blocks
144
+ def nested(lines) = BlockParser.new(lines, @refs, @nesting, @depth + 1).blocks
139
145
  end
140
146
  end
141
147
  end
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rich/nesting"
3
4
  require_relative "block_parser"
4
5
  require_relative "inline_parser"
5
6
 
6
7
  module Stationery
7
8
  # A CommonMark subset (plus GFM tables and strikethrough) to rich-text blocks (see Stationery::Rich).
8
9
  module Markdown
9
- def self.parse(source)
10
+ # Block quotes and lists nested deeper than `max_depth` are flattened into
11
+ # a paragraph; the block is then given how deep the source went.
12
+ def self.parse(source, max_depth: Rich::Nesting::DEFAULT)
13
+ nesting = Rich::Nesting.new(max_depth)
10
14
  Stationery.instrument("parse.stationery", format: :markdown, bytes: source.bytesize) do
11
15
  refs = {}
12
- Resolver.new(refs).blocks(BlockParser.parse(source, refs))
16
+ Resolver.new(refs).blocks(BlockParser.parse(source, refs, nesting)).tap do
17
+ yield nesting.deepest if nesting.exceeded? && block_given?
18
+ end
13
19
  end
14
20
  end
15
21
 
@@ -21,23 +21,30 @@ module Stationery
21
21
  end
22
22
  end
23
23
 
24
- # Wraps matched delimiter pairs (and what lies between them) in place.
24
+ # Wraps matched delimiter pairs (and what lies between them) in place. `bottoms` remembers, per
25
+ # kind of closer, the index below which no opener is left for it, so a run of closers that
26
+ # nothing opens is not searched for again and again (CommonMark's openers_bottom).
25
27
  def process(nodes)
28
+ bottoms = Hash.new(0)
26
29
  index = 0
27
30
  while index < nodes.size
28
31
  closer = nodes[index]
29
- opener = closer?(closer) && opener_for(nodes, index)
30
- next index += 1 unless opener
32
+ next index += 1 unless closer?(closer)
31
33
 
32
- index = wrap(nodes, opener, index)
34
+ opening = opener_for(nodes, index, bottoms[kind(closer)])
35
+ next index = wrap(nodes, opening, index, bottoms) if opening
36
+
37
+ bottoms[kind(closer)] = index
38
+ index += 1
33
39
  end
34
40
  end
35
41
 
36
- def wrap(nodes, opening, index)
42
+ def wrap(nodes, opening, index, bottoms)
37
43
  opener = nodes[opening]
38
44
  closer = nodes[index]
39
45
  used = used(opener, closer)
40
46
  nodes[(opening + 1)...index] = [Nodes::Wrap.new(marks(closer.char, used), nodes[(opening + 1)...index])]
47
+ bottoms.each { |kind, bottom| bottoms[kind] = opening + 1 if bottom > opening + 1 }
41
48
  opener.remaining -= used
42
49
  closer.remaining -= used
43
50
  index = opening + 2
@@ -49,9 +56,9 @@ module Stationery
49
56
  index
50
57
  end
51
58
 
52
- def opener_for(nodes, index)
59
+ def opener_for(nodes, index, bottom)
53
60
  closer = nodes[index]
54
- (index - 1).downto(0).find do |i|
61
+ (index - 1).downto(bottom).find do |i|
55
62
  node = nodes[i]
56
63
  node.is_a?(Nodes::Delimiter) && node.opener && node.char == closer.char && node.remaining.positive? &&
57
64
  (closer.char != "~" || node.remaining == closer.remaining)
@@ -60,6 +67,9 @@ module Stationery
60
67
 
61
68
  def closer?(node) = node.is_a?(Nodes::Delimiter) && node.closer && node.remaining.positive?
62
69
 
70
+ # What decides which openers suit a closer: its character, and for `~` its length too.
71
+ def kind(closer) = closer.char == "~" ? [closer.char, closer.remaining] : closer.char
72
+
63
73
  def used(opener, closer)
64
74
  return closer.remaining if closer.char == "~"
65
75
 
@@ -3,9 +3,14 @@
3
3
  module Stationery
4
4
  module Markdown
5
5
  class InlineParser
6
- # Brackets, inline and reference links, images and autolinks.
6
+ # Brackets, inline and reference links, images and autolinks. At most OPEN brackets wait for
7
+ # their `]` at once (the oldest gives way), and a link label is at most LABEL characters as
8
+ # CommonMark has it, so neither nesting nor the work per `]` grows with the input. A bracket's
9
+ # `position` is in bytes, which the scanner knows without counting characters.
7
10
  module Links
8
11
  Bracket = Struct.new(:index, :image, :active, :position)
12
+ OPEN = 64
13
+ LABEL = 999
9
14
 
10
15
  INLINE = /
11
16
  \(\s*
@@ -23,7 +28,8 @@ module Stationery
23
28
  private
24
29
 
25
30
  def open_bracket(image: false)
26
- @brackets << Bracket.new(@nodes.size, image, true, @scanner.charpos)
31
+ @brackets.shift if @brackets.size >= OPEN
32
+ @brackets << Bracket.new(@nodes.size, image, true, @scanner.pos)
27
33
  add(image ? "![" : "[")
28
34
  end
29
35
 
@@ -31,7 +37,7 @@ module Stationery
31
37
  bracket = @brackets.pop
32
38
  return add("]") unless bracket&.active
33
39
 
34
- href = inline_destination || reference(@source[bracket.position...(@scanner.charpos - 1)])
40
+ href = inline_destination || reference(bracket.position...(@scanner.pos - 1))
35
41
  return add("]") unless href
36
42
 
37
43
  children = @nodes.slice!(bracket.index..).drop(1)
@@ -48,18 +54,17 @@ module Stationery
48
54
  InlineParser.destination(@scanner[1] || @scanner[2])
49
55
  end
50
56
 
51
- def reference(label)
57
+ # The target of `[text][label]`, `[text][]` or `[text]`, `span` being where the text lies.
58
+ def reference(span)
52
59
  position = @scanner.pos
53
- if @scanner.scan(REFERENCE)
54
- label = @scanner[1] unless @scanner[1].empty?
55
- href = @refs[InlineParser.label(label)]
56
- @scanner.pos = position unless href
57
- href
58
- else
59
- @refs[InlineParser.label(label)]
60
- end
60
+ label = @scanner[1] if @scanner.scan(REFERENCE) && !@scanner[1].empty?
61
+ href = defined_as(label || (@source.byteslice(span) if span.size <= LABEL * 4))
62
+ @scanner.pos = position unless href
63
+ href
61
64
  end
62
65
 
66
+ def defined_as(label) = label && label.length <= LABEL && @refs[InlineParser.label(label)]
67
+
63
68
  def autolink
64
69
  url = @scanner[1] || "mailto:#{@scanner[2]}"
65
70
  @nodes << Nodes::Wrap.new({ link: url }, [Nodes::Text.new(@scanner[1] || @scanner[2])])
@@ -14,20 +14,34 @@ module Stationery
14
14
 
15
15
  module_function
16
16
 
17
- def flatten(nodes, marks = {}, out = [])
18
- nodes.each do |node|
19
- case node
20
- when Text then out << Rich::Inline.new(text: node.text, marks:)
21
- when Delimiter then out << Rich::Inline.new(text: node.char * node.remaining, marks:)
22
- when Code then out << Rich::Inline.new(text: node.text, marks: marks.merge(code: true))
23
- when Break then out << Rich::Inline.break
24
- when Wrap then flatten(node.children, marks.merge(node.marks), out)
25
- when Picture then out << picture(node)
26
- end
17
+ # The tree as a flat list of inlines, each with the marks of the wraps around it. Walked with
18
+ # a stack of its own, not by recursion: emphasis nests as deep as its author types asterisks.
19
+ def flatten(nodes, marks = {})
20
+ out = []
21
+ pending = [[nodes, 0, marks]]
22
+ until pending.empty?
23
+ level = pending.last
24
+ node = level[0][level[1]]
25
+ next pending.pop unless node
26
+
27
+ level[1] += 1
28
+ next pending << [node.children, 0, level[2].merge(node.marks)] if node.is_a?(Wrap)
29
+
30
+ out << inline(node, level[2])
27
31
  end
28
32
  out
29
33
  end
30
34
 
35
+ def inline(node, marks)
36
+ case node
37
+ when Text then Rich::Inline.new(text: node.text, marks:)
38
+ when Delimiter then Rich::Inline.new(text: node.char * node.remaining, marks:)
39
+ when Code then Rich::Inline.new(text: node.text, marks: marks.merge(code: true))
40
+ when Break then Rich::Inline.break
41
+ when Picture then picture(node)
42
+ end
43
+ end
44
+
31
45
  def picture(node) = Rich::Image.new(src: node.src, alt: plain(node.children), width: nil, height: nil)
32
46
 
33
47
  def plain(nodes)
@@ -79,10 +79,16 @@ module Stationery
79
79
  end
80
80
 
81
81
  def delimiter
82
- position = @scanner.charpos
82
+ before = previous_character
83
83
  run = @scanner.scan(/\*+|_+|~+/)
84
- before = position.zero? ? " " : @source[position - 1]
85
- @nodes << Emphasis.delimiter(run, before, @source[@scanner.charpos] || " ")
84
+ @nodes << Emphasis.delimiter(run, before, @scanner.check(/./m) || " ")
85
+ end
86
+
87
+ # The character before the scanner, read from the few bytes before it: a character position
88
+ # is counted from the start of the source, which a long paragraph would pay for at every run.
89
+ def previous_character
90
+ position = @scanner.pos
91
+ @source.byteslice([position - 4, 0].max...position).scrub("")[-1] || " "
86
92
  end
87
93
  end
88
94
  end
@@ -34,6 +34,10 @@ module Stationery
34
34
  assert_pdf(Matchers::HaveFacturX.new(profile:), subject, msg)
35
35
  end
36
36
 
37
+ def assert_pdf_signature(subject, msg = nil, name: nil, valid: true)
38
+ assert_pdf(Matchers::HaveSignature.new(name:, valid:), subject, msg)
39
+ end
40
+
37
41
  def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
38
42
  def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
39
43
  def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)