stationery 0.7.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 +22 -0
- data/README.md +251 -23
- data/lib/stationery/canvas.rb +22 -1
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +69 -9
- 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/errors.rb +13 -0
- 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/images/jpeg.rb +2 -1
- 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 +16 -0
- data/lib/stationery/pdf/assembler.rb +51 -11
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +141 -0
- data/lib/stationery/pdf/factur_x.rb +117 -0
- data/lib/stationery/pdf/icc/LICENSE.md +14 -0
- data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
- 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/stream.rb +5 -3
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -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/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +34 -26
- data/lib/stationery/svg/painter.rb +11 -0
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/region.rb +25 -0
- data/lib/stationery/svg/shapes.rb +2 -0
- data/lib/stationery/svg/style.rb +25 -14
- data/lib/stationery/svg/viewport.rb +78 -0
- data/lib/stationery/svg/walker.rb +144 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +123 -0
- data/lib/stationery/testing/matchers.rb +92 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +11 -0
- metadata +16 -1
|
@@ -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
|
@@ -22,6 +22,22 @@ module Stationery
|
|
|
22
22
|
assert_pdf(Matchers::HavePageLabels.new(labels), subject, msg)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def assert_pdf_attachment(subject, name, msg = nil, **)
|
|
26
|
+
assert_pdf(Matchers::HaveAttachment.new(name, **), subject, msg)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def assert_pdf_conformance(subject, levels, msg = nil)
|
|
30
|
+
assert_pdf(Matchers::HaveConformance.new(*levels), subject, msg)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def assert_factur_x(subject, msg = nil, profile: nil)
|
|
34
|
+
assert_pdf(Matchers::HaveFacturX.new(profile:), subject, msg)
|
|
35
|
+
end
|
|
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
|
+
|
|
25
41
|
def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
|
|
26
42
|
def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
|
|
27
43
|
def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
|
|
@@ -6,12 +6,24 @@ module Stationery
|
|
|
6
6
|
class Assembler
|
|
7
7
|
# `tagging:` (a Tagging::Tree) writes the structure tree of a tagged PDF;
|
|
8
8
|
# `lang:` is the document's natural language; `page_labels:` is the
|
|
9
|
-
# /PageLabels number tree from PageLabels.entries
|
|
9
|
+
# /PageLabels number tree from PageLabels.entries; `attachments:` are
|
|
10
|
+
# PDF::Attachment files to embed; `xmp:` (true) writes the XMP packet,
|
|
11
|
+
# with `xmp_extensions:` as further schemas and `xmp_schemas:` describing
|
|
12
|
+
# them to PDF/A (see XMP); `conformance:` (a PDF::Conformance) adds what
|
|
13
|
+
# PDF/A and PDF/UA ask of the file; `signature:` (a PDF::Signature)
|
|
14
|
+
# signs it.
|
|
10
15
|
def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
|
|
11
|
-
page_labels: nil
|
|
16
|
+
page_labels: nil, attachments: [], xmp: true, xmp_extensions: {}, xmp_schemas: [],
|
|
17
|
+
conformance: nil, signature: nil)
|
|
18
|
+
@xmp_schemas = xmp_schemas
|
|
19
|
+
@conformance = conformance
|
|
20
|
+
@signature = signature
|
|
12
21
|
@tagging = tagging
|
|
13
22
|
@lang = lang
|
|
14
23
|
@page_labels = page_labels
|
|
24
|
+
@attachments = attachments
|
|
25
|
+
@xmp = xmp
|
|
26
|
+
@xmp_extensions = xmp_extensions
|
|
15
27
|
@pages = pages
|
|
16
28
|
@resources = resources
|
|
17
29
|
@info = info
|
|
@@ -21,16 +33,22 @@ module Stationery
|
|
|
21
33
|
|
|
22
34
|
def render
|
|
23
35
|
writer = Writer.new(encryption: @encryption)
|
|
24
|
-
@form = Forms::AcroForm.new(writer)
|
|
25
36
|
tree = writer.reserve
|
|
26
37
|
refs = @resources.build(writer)
|
|
38
|
+
@form = Forms::AcroForm.new(writer, fonts: refs.fetch(:Font), signature: @signature,
|
|
39
|
+
need_appearances: @conformance.nil? && @signature.nil?)
|
|
27
40
|
kids = @kids = @pages.map { writer.reserve }
|
|
28
41
|
@structure = @tagging && Tagging::Writer.new(@tagging, pages: @pages, refs: kids)
|
|
29
42
|
@pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
|
|
30
43
|
writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
|
|
31
44
|
outlines = OutlineWriter.new(writer, @outline, kids).write
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
now = Time.now
|
|
46
|
+
info = info_dictionary(now)
|
|
47
|
+
entries = catalog(tree, outlines, @form.write)
|
|
48
|
+
.merge(accessibility(writer), metadata(writer, info, now), Attachments.write(writer, @attachments))
|
|
49
|
+
entries.merge!(@conformance.catalog_entries(writer)) if @conformance
|
|
50
|
+
pdf = writer.render(root: writer.add(entries), info: writer.add(info))
|
|
51
|
+
@signature ? @signature.apply(pdf) : pdf
|
|
34
52
|
end
|
|
35
53
|
|
|
36
54
|
private
|
|
@@ -55,13 +73,20 @@ module Stationery
|
|
|
55
73
|
Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
|
|
56
74
|
Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
|
|
57
75
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
annotations = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
|
|
77
|
+
annotations << @form.sign(ref, taken: field_names) if @signature&.invisible? && ref == @kids.first
|
|
78
|
+
dictionary[:Annots] = annotations if annotations.any?
|
|
61
79
|
dictionary.merge!(@structure.page_entries(page)) if @structure
|
|
80
|
+
dictionary.merge!(@conformance.page_entries(page, annotated: annotations.any?)) if @conformance
|
|
62
81
|
writer.set(ref, dictionary)
|
|
63
82
|
end
|
|
64
83
|
|
|
84
|
+
# Every field name of the document and the group each one starts with.
|
|
85
|
+
def field_names
|
|
86
|
+
names = @pages.flat_map(&:annotations).filter_map { |annotation| annotation[:widget]&.name }
|
|
87
|
+
names.flat_map { |name| [name, name.split(".").first] }.uniq
|
|
88
|
+
end
|
|
89
|
+
|
|
65
90
|
def page_resources(page, refs)
|
|
66
91
|
page.resource_names.to_h do |category, names|
|
|
67
92
|
[category, names.to_h { |name| [name, refs.fetch(category).fetch(name)] }]
|
|
@@ -70,13 +95,14 @@ module Stationery
|
|
|
70
95
|
|
|
71
96
|
def annotation_ref(writer, annotation, page)
|
|
72
97
|
if annotation[:widget]
|
|
73
|
-
return @form.add(annotation
|
|
98
|
+
return @form.add(annotation, page) do |widget_ref|
|
|
74
99
|
@structure ? @structure.annotation(annotation, widget_ref) : {}
|
|
75
100
|
end
|
|
76
101
|
end
|
|
77
102
|
|
|
78
103
|
ref = writer.reserve
|
|
79
104
|
dictionary = annotation(annotation)
|
|
105
|
+
dictionary = dictionary.merge(@conformance.annotation_entries(annotation)) if @conformance
|
|
80
106
|
dictionary = dictionary.merge(@structure.annotation(annotation, ref)) if @structure
|
|
81
107
|
writer.set(ref, dictionary)
|
|
82
108
|
end
|
|
@@ -90,12 +116,26 @@ module Stationery
|
|
|
90
116
|
{ Type: :Annot, Subtype: :Link, Rect: link[:rect], Border: [0, 0, 0], **target }
|
|
91
117
|
end
|
|
92
118
|
|
|
93
|
-
def info_dictionary
|
|
119
|
+
def info_dictionary(now)
|
|
94
120
|
info = { Producer: "Stationery #{VERSION}" }.merge(@info.compact)
|
|
95
121
|
info = info.transform_values { |value| TextString.new(value.to_s) }
|
|
96
|
-
info[:CreationDate] = TextString.new(
|
|
122
|
+
info[:CreationDate] = TextString.new(now.utc.strftime("D:%Y%m%d%H%M%SZ"))
|
|
97
123
|
info
|
|
98
124
|
end
|
|
125
|
+
|
|
126
|
+
def extensions = (@conformance&.xmp_extensions || {}).merge(@xmp_extensions)
|
|
127
|
+
def schemas = (@conformance&.xmp_schemas || []) + @xmp_schemas
|
|
128
|
+
|
|
129
|
+
# The XMP packet as an uncompressed /Metadata stream, mirroring the Info
|
|
130
|
+
# dictionary at the same instant. An encrypted document encrypts it like
|
|
131
|
+
# every other stream (/EncryptMetadata defaults to true).
|
|
132
|
+
def metadata(writer, info, now)
|
|
133
|
+
return {} unless @xmp
|
|
134
|
+
|
|
135
|
+
values = info.except(:CreationDate).transform_values(&:value)
|
|
136
|
+
packet = XMP.packet(info: values, lang: @lang, time: now, extensions:, schemas:)
|
|
137
|
+
{ Metadata: writer.add(Stream.new(packet, { Type: :Metadata, Subtype: :XML }, compress: false)) }
|
|
138
|
+
end
|
|
99
139
|
end
|
|
100
140
|
end
|
|
101
141
|
end
|