stationery 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +128 -23
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/document.rb +20 -4
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +13 -6
- data/lib/stationery/elements.rb +11 -3
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/document.rb +8 -2
- data/lib/stationery/html/tree_builder.rb +34 -4
- data/lib/stationery/layout/image.rb +9 -1
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +4 -0
- data/lib/stationery/pdf/assembler.rb +19 -9
- data/lib/stationery/pdf/conformance.rb +7 -5
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +87 -0
- data/lib/stationery/pdf/signature.rb +167 -0
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/renderer/indents.rb +42 -0
- data/lib/stationery/rich/renderer.rb +22 -6
- data/lib/stationery/svg/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +55 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +3 -0
- metadata +6 -1
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 +
|
|
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]) ||
|
|
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
|
-
|
|
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
|
|
184
|
+
return if row_spans? && !grid.boundaries.include?(row + 1)
|
|
140
185
|
|
|
141
186
|
widths = column_widths(width)
|
|
142
|
-
placements =
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
next index += 1 unless opener
|
|
32
|
+
next index += 1 unless closer?(closer)
|
|
31
33
|
|
|
32
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
82
|
+
before = previous_character
|
|
83
83
|
run = @scanner.scan(/\*+|_+|~+/)
|
|
84
|
-
|
|
85
|
-
|
|
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
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -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)
|