stationery 0.1.0 → 0.2.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 +94 -1
- data/README.md +358 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +25 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +8 -5
- data/lib/stationery/canvas.rb +25 -8
- data/lib/stationery/cli/fonts.rb +85 -0
- data/lib/stationery/cli/render.rb +137 -0
- data/lib/stationery/cli.rb +74 -0
- data/lib/stationery/document.rb +53 -9
- data/lib/stationery/elements/lists.rb +58 -0
- data/lib/stationery/elements/rich.rb +27 -0
- data/lib/stationery/elements.rb +88 -12
- data/lib/stationery/errors.rb +13 -1
- data/lib/stationery/fonts/bundled.rb +25 -0
- data/lib/stationery/fonts/catalog.rb +101 -0
- data/lib/stationery/fonts/cff.rb +140 -0
- data/lib/stationery/fonts/cff_subset.rb +145 -0
- data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
- data/lib/stationery/fonts/data/OFL.txt +92 -0
- data/lib/stationery/fonts/data/README.md +14 -0
- data/lib/stationery/fonts/embedding/base.rb +56 -0
- data/lib/stationery/fonts/embedding/cff.rb +35 -0
- data/lib/stationery/fonts/embedding/true_type.rb +35 -0
- data/lib/stationery/fonts/fallback.rb +58 -0
- data/lib/stationery/fonts/family.rb +11 -0
- data/lib/stationery/fonts/font.rb +52 -88
- data/lib/stationery/fonts/font_book.rb +46 -8
- data/lib/stationery/fonts/glyph_run.rb +38 -0
- data/lib/stationery/fonts/gpos/class_def.rb +25 -0
- data/lib/stationery/fonts/gpos/coverage.rb +20 -0
- data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
- data/lib/stationery/fonts/gpos.rb +62 -0
- data/lib/stationery/fonts/installer.rb +121 -0
- data/lib/stationery/fonts/kern_table.rb +52 -0
- data/lib/stationery/fonts/kerning.rb +17 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
- data/lib/stationery/fonts/to_unicode.rb +36 -0
- data/lib/stationery/fonts/true_type.rb +32 -49
- data/lib/stationery/html/document.rb +11 -0
- data/lib/stationery/html/tokenizer.rb +74 -0
- data/lib/stationery/html/tree_builder/blocks.rb +137 -0
- data/lib/stationery/html/tree_builder/collector.rb +50 -0
- data/lib/stationery/html/tree_builder.rb +72 -0
- data/lib/stationery/html/whitespace.rb +43 -0
- data/lib/stationery/layout/box.rb +85 -16
- data/lib/stationery/layout/flow.rb +48 -15
- data/lib/stationery/layout/image.rb +1 -0
- data/lib/stationery/layout/list_item.rb +89 -0
- data/lib/stationery/layout/mark.rb +58 -0
- data/lib/stationery/layout/node.rb +26 -2
- data/lib/stationery/layout/paginator.rb +35 -6
- data/lib/stationery/layout/positioned.rb +3 -1
- data/lib/stationery/layout/row.rb +34 -4
- data/lib/stationery/layout/svg.rb +45 -0
- data/lib/stationery/layout/table/cell.rb +33 -6
- data/lib/stationery/layout/table/grid.rb +93 -0
- data/lib/stationery/layout/table/row_splitter.rb +36 -0
- data/lib/stationery/layout/table/selection.rb +3 -2
- data/lib/stationery/layout/table.rb +69 -23
- data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
- data/lib/stationery/layout/table_of_contents.rb +43 -0
- data/lib/stationery/layout/text.rb +5 -4
- data/lib/stationery/layout/wrap.rb +78 -0
- data/lib/stationery/list_markers.rb +47 -0
- data/lib/stationery/markdown/block_parser/containers.rb +94 -0
- data/lib/stationery/markdown/block_parser/tables.rb +54 -0
- data/lib/stationery/markdown/block_parser.rb +141 -0
- data/lib/stationery/markdown/document.rb +50 -0
- data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
- data/lib/stationery/markdown/inline_parser/links.rb +70 -0
- data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
- data/lib/stationery/markdown/inline_parser.rb +89 -0
- data/lib/stationery/minitest.rb +33 -0
- data/lib/stationery/outline.rb +37 -0
- data/lib/stationery/page.rb +18 -4
- data/lib/stationery/page_templates.rb +33 -10
- data/lib/stationery/pdf/assembler.rb +18 -7
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +5 -1
- data/lib/stationery/pdf/writer.rb +4 -0
- data/lib/stationery/preview.rb +64 -0
- data/lib/stationery/rails/previews_controller.rb +61 -0
- data/lib/stationery/rails.rb +2 -0
- data/lib/stationery/railtie.rb +60 -0
- data/lib/stationery/regions.rb +67 -0
- data/lib/stationery/rich/nodes.rb +55 -0
- data/lib/stationery/rich/renderer/inlines.rb +53 -0
- data/lib/stationery/rich/renderer.rb +121 -0
- data/lib/stationery/rich/styles.rb +34 -0
- data/lib/stationery/rspec.rb +6 -0
- data/lib/stationery/structure.rb +78 -0
- data/lib/stationery/svg/arc.rb +91 -0
- data/lib/stationery/svg/document.rb +73 -0
- data/lib/stationery/svg/parser.rb +38 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +68 -0
- data/lib/stationery/svg/transform.rb +43 -0
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +123 -0
- data/lib/stationery/text/entities/html4.rb +49 -0
- data/lib/stationery/text/entities.rb +6 -1
- data/lib/stationery/text/line.rb +8 -2
- data/lib/stationery/text/paragraph.rb +32 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +2 -2
- data/lib/stationery/text/wrapper.rb +3 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +51 -0
- metadata +85 -4
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rich/nodes"
|
|
4
|
+
require_relative "inline_parser"
|
|
5
|
+
require_relative "block_parser/containers"
|
|
6
|
+
require_relative "block_parser/tables"
|
|
7
|
+
|
|
8
|
+
module Stationery
|
|
9
|
+
module Markdown
|
|
10
|
+
# Splits CommonMark source into blocks, line by line. Paragraph, heading and table cell text is left
|
|
11
|
+
# raw (a String in place of inlines) for Markdown.parse to run through the inline parser once every
|
|
12
|
+
# link reference definition, which this parser collects into `refs`, is known. Raw HTML stays text.
|
|
13
|
+
class BlockParser
|
|
14
|
+
include Containers
|
|
15
|
+
include Tables
|
|
16
|
+
|
|
17
|
+
ATX = /\A {0,3}(\#{1,6})(?:[ \t]+(.*?))?[ \t]*\z/
|
|
18
|
+
SETEXT = /\A {0,3}(=+|-+)[ \t]*\z/
|
|
19
|
+
THEMATIC = /\A {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})\z/
|
|
20
|
+
FENCE = /\A( {0,3})(`{3,}|~{3,})[ \t]*([^`]*?)[ \t]*\z/
|
|
21
|
+
DEFINITION = /
|
|
22
|
+
\A\ {0,3}\[((?:[^\]\\]|\\.)+)\]:[ \t]*
|
|
23
|
+
(?:<([^<>]*)>|(\S+))
|
|
24
|
+
(?:[ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?[ \t]*\z
|
|
25
|
+
/x
|
|
26
|
+
|
|
27
|
+
def self.parse(source, refs) = new(lines(source), refs).blocks
|
|
28
|
+
|
|
29
|
+
def self.lines(source) = source.to_s.split(/\r\n?|\n/).map { |line| expand_tabs(line) }
|
|
30
|
+
|
|
31
|
+
# Tabs in a line's indentation and container markers advance to the next multiple of four columns.
|
|
32
|
+
def self.expand_tabs(line)
|
|
33
|
+
prefix = line[/\A[ \t>*+\-\d.)]*/]
|
|
34
|
+
return line unless prefix.include?("\t")
|
|
35
|
+
|
|
36
|
+
expanded = prefix.each_char.with_object(+"") do |char, out|
|
|
37
|
+
out << (char == "\t" ? " " * (4 - (out.length % 4)) : char)
|
|
38
|
+
end
|
|
39
|
+
expanded + line[prefix.length..]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(lines, refs)
|
|
43
|
+
@lines = lines
|
|
44
|
+
@refs = refs
|
|
45
|
+
@index = 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def blocks
|
|
49
|
+
blocks = []
|
|
50
|
+
while (line = @lines[@index])
|
|
51
|
+
block = block(line)
|
|
52
|
+
blocks << block if block
|
|
53
|
+
end
|
|
54
|
+
blocks
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def block(line)
|
|
60
|
+
if blank?(line) then skip
|
|
61
|
+
elsif indent(line) >= 4 then indented_code
|
|
62
|
+
elsif (match = FENCE.match(line)) then fenced_code(match)
|
|
63
|
+
elsif (match = ATX.match(line)) then atx(match)
|
|
64
|
+
elsif THEMATIC.match?(line) then skip(Rich::Rule.new)
|
|
65
|
+
elsif QUOTE.match?(line) then blockquote
|
|
66
|
+
elsif (match = list_marker(line)) then list(match)
|
|
67
|
+
elsif table_start? then table
|
|
68
|
+
else paragraph
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def skip(block = nil)
|
|
73
|
+
@index += 1
|
|
74
|
+
block
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def indented_code
|
|
78
|
+
lines = []
|
|
79
|
+
while (line = @lines[@index]) && (blank?(line) || indent(line) >= 4)
|
|
80
|
+
lines << line.sub(/\A {1,4}/, "")
|
|
81
|
+
@index += 1
|
|
82
|
+
end
|
|
83
|
+
lines.pop while lines.last.strip.empty?
|
|
84
|
+
Rich::CodeBlock.new(text: lines.join("\n"), language: nil)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fenced_code(match)
|
|
88
|
+
indent, fence, info = match.captures
|
|
89
|
+
closing = /\A {0,3}#{Regexp.escape(fence[0])}{#{fence.length},}[ \t]*\z/
|
|
90
|
+
lines = []
|
|
91
|
+
while (line = @lines[@index += 1])
|
|
92
|
+
break @index += 1 if closing.match?(line)
|
|
93
|
+
|
|
94
|
+
lines << line.sub(/\A {0,#{indent.length}}/, "")
|
|
95
|
+
end
|
|
96
|
+
Rich::CodeBlock.new(text: lines.join("\n"), language: info.split.first)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def atx(match)
|
|
100
|
+
@index += 1
|
|
101
|
+
Rich::Heading.new(level: match[1].length, inlines: match[2].to_s.sub(/(?:\A|[ \t]+)#+\z/, ""))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def paragraph
|
|
105
|
+
lines = [@lines[@index].lstrip]
|
|
106
|
+
while (line = @lines[@index += 1]) && !blank?(line)
|
|
107
|
+
if (underline = SETEXT.match(line)) && definitions(lines).any?
|
|
108
|
+
@index += 1
|
|
109
|
+
return Rich::Heading.new(level: underline[1].start_with?("=") ? 1 : 2, inlines: text(lines))
|
|
110
|
+
end
|
|
111
|
+
break if interrupts?(line)
|
|
112
|
+
|
|
113
|
+
lines << line.lstrip
|
|
114
|
+
end
|
|
115
|
+
lines = definitions(lines)
|
|
116
|
+
Rich::Paragraph.new(inlines: text(lines)) if lines.any?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Strips the link reference definitions leading a paragraph into @refs.
|
|
120
|
+
def definitions(lines)
|
|
121
|
+
lines = lines.dup
|
|
122
|
+
while (match = DEFINITION.match(lines.first.to_s))
|
|
123
|
+
@refs[InlineParser.label(match[1])] ||= InlineParser.destination(match[2] || match[3])
|
|
124
|
+
lines.shift
|
|
125
|
+
end
|
|
126
|
+
lines
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def text(lines) = lines.join("\n").rstrip
|
|
130
|
+
|
|
131
|
+
def interrupts?(line)
|
|
132
|
+
FENCE.match?(line) || ATX.match?(line) || THEMATIC.match?(line) || QUOTE.match?(line) ||
|
|
133
|
+
list_interrupts?(line)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def blank?(line) = line.strip.empty?
|
|
137
|
+
def indent(line) = line[/\A */].length
|
|
138
|
+
def nested(lines) = BlockParser.new(lines, @refs).blocks
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "block_parser"
|
|
4
|
+
require_relative "inline_parser"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
# A CommonMark subset (plus GFM tables and strikethrough) to rich-text blocks (see Stationery::Rich).
|
|
8
|
+
module Markdown
|
|
9
|
+
def self.parse(source)
|
|
10
|
+
refs = {}
|
|
11
|
+
Resolver.new(refs).blocks(BlockParser.parse(source, refs))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Runs the raw text the block parser left in paragraphs, headings and cells through the inline parser.
|
|
15
|
+
class Resolver
|
|
16
|
+
def initialize(refs)
|
|
17
|
+
@refs = refs
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def blocks(blocks) = blocks.flat_map { |block| block(block) }
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def block(block)
|
|
25
|
+
case block
|
|
26
|
+
when Rich::Paragraph then Rich::Inlines.paragraphs(inlines(block.inlines)) { |run| trim(run) }
|
|
27
|
+
when Rich::Heading then [block.with(inlines: Rich::Inlines.merge(inlines(block.inlines).map { |i| alt(i) }))]
|
|
28
|
+
when Rich::List then [block.with(items: block.items.map { |item| blocks(item) })]
|
|
29
|
+
when Rich::Blockquote then [block.with(blocks: blocks(block.blocks))]
|
|
30
|
+
when Rich::Table then [block.with(rows: block.rows.map { |row| cells(row) })]
|
|
31
|
+
else [block]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cells(row) = row.map { |cell| cell.with(blocks: blocks(cell.blocks)) }
|
|
36
|
+
|
|
37
|
+
def inlines(text) = InlineParser.parse(text, @refs)
|
|
38
|
+
|
|
39
|
+
def alt(item) = item.is_a?(Rich::Image) ? Rich::Inline.new(text: item.alt) : item
|
|
40
|
+
|
|
41
|
+
# Text around an image loses the space that separated it from the image.
|
|
42
|
+
def trim(run)
|
|
43
|
+
run = run.dup
|
|
44
|
+
run[0] = run[0].with(text: run[0].text.lstrip) unless run[0].break?
|
|
45
|
+
run[-1] = run[-1].with(text: run[-1].text.rstrip) unless run[-1].break?
|
|
46
|
+
run
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Markdown
|
|
5
|
+
class InlineParser
|
|
6
|
+
# CommonMark's delimiter-run emphasis, simplified: flanking rules and the `_` intraword restriction,
|
|
7
|
+
# without the rule of three. `~` runs of equal length strike through (GFM).
|
|
8
|
+
module Emphasis
|
|
9
|
+
WHITESPACE = /[[:space:]]/
|
|
10
|
+
PUNCTUATION = /[[:punct:]]|\p{S}/
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def delimiter(run, before, after)
|
|
15
|
+
left = !space?(after) && (!punct?(after) || space?(before) || punct?(before))
|
|
16
|
+
right = !space?(before) && (!punct?(before) || space?(after) || punct?(after))
|
|
17
|
+
if run.start_with?("_")
|
|
18
|
+
Nodes::Delimiter.new("_", run.length, left && (!right || punct?(before)), right && (!left || punct?(after)))
|
|
19
|
+
else
|
|
20
|
+
Nodes::Delimiter.new(run[0], run.length, left, right)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Wraps matched delimiter pairs (and what lies between them) in place.
|
|
25
|
+
def process(nodes)
|
|
26
|
+
index = 0
|
|
27
|
+
while index < nodes.size
|
|
28
|
+
closer = nodes[index]
|
|
29
|
+
opener = closer?(closer) && opener_for(nodes, index)
|
|
30
|
+
next index += 1 unless opener
|
|
31
|
+
|
|
32
|
+
index = wrap(nodes, opener, index)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def wrap(nodes, opening, index)
|
|
37
|
+
opener = nodes[opening]
|
|
38
|
+
closer = nodes[index]
|
|
39
|
+
used = used(opener, closer)
|
|
40
|
+
nodes[(opening + 1)...index] = [Nodes::Wrap.new(marks(closer.char, used), nodes[(opening + 1)...index])]
|
|
41
|
+
opener.remaining -= used
|
|
42
|
+
closer.remaining -= used
|
|
43
|
+
index = opening + 2
|
|
44
|
+
if opener.remaining.zero?
|
|
45
|
+
nodes.delete_at(opening)
|
|
46
|
+
index -= 1
|
|
47
|
+
end
|
|
48
|
+
nodes.delete_at(index) if closer.remaining.zero?
|
|
49
|
+
index
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def opener_for(nodes, index)
|
|
53
|
+
closer = nodes[index]
|
|
54
|
+
(index - 1).downto(0).find do |i|
|
|
55
|
+
node = nodes[i]
|
|
56
|
+
node.is_a?(Nodes::Delimiter) && node.opener && node.char == closer.char && node.remaining.positive? &&
|
|
57
|
+
(closer.char != "~" || node.remaining == closer.remaining)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def closer?(node) = node.is_a?(Nodes::Delimiter) && node.closer && node.remaining.positive?
|
|
62
|
+
|
|
63
|
+
def used(opener, closer)
|
|
64
|
+
return closer.remaining if closer.char == "~"
|
|
65
|
+
|
|
66
|
+
opener.remaining >= 2 && closer.remaining >= 2 ? 2 : 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def marks(char, used)
|
|
70
|
+
return { strike: true } if char == "~"
|
|
71
|
+
|
|
72
|
+
used == 2 ? { bold: true } : { italic: true }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def space?(char) = WHITESPACE.match?(char)
|
|
76
|
+
def punct?(char) = PUNCTUATION.match?(char)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Markdown
|
|
5
|
+
class InlineParser
|
|
6
|
+
# Brackets, inline and reference links, images and autolinks.
|
|
7
|
+
module Links
|
|
8
|
+
Bracket = Struct.new(:index, :image, :active, :position)
|
|
9
|
+
|
|
10
|
+
INLINE = /
|
|
11
|
+
\(\s*
|
|
12
|
+
(?:<([^<>\n]*)>|((?:[^\s()\\]|\\.|\((?:[^\s()\\]|\\.)*\))*))
|
|
13
|
+
(?:\s+(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^()\\]|\\.)*\)))?
|
|
14
|
+
\s*\)
|
|
15
|
+
/x
|
|
16
|
+
REFERENCE = /\[((?:[^\[\]\\]|\\.){0,999})\]/
|
|
17
|
+
AUTOLINK = %r{
|
|
18
|
+
<((?:https?|mailto):[^\s<>]*)>
|
|
19
|
+
|<([a-zA-Z0-9.!\#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?
|
|
20
|
+
(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*)>
|
|
21
|
+
}x
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def open_bracket(image: false)
|
|
26
|
+
@brackets << Bracket.new(@nodes.size, image, true, @scanner.charpos)
|
|
27
|
+
add(image ? "![" : "[")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def close_bracket
|
|
31
|
+
bracket = @brackets.pop
|
|
32
|
+
return add("]") unless bracket&.active
|
|
33
|
+
|
|
34
|
+
href = inline_destination || reference(@source[bracket.position...(@scanner.charpos - 1)])
|
|
35
|
+
return add("]") unless href
|
|
36
|
+
|
|
37
|
+
children = @nodes.slice!(bracket.index..).drop(1)
|
|
38
|
+
Emphasis.process(children)
|
|
39
|
+
return @nodes << Nodes::Picture.new(href, children) if bracket.image
|
|
40
|
+
|
|
41
|
+
@nodes << Nodes::Wrap.new({ link: href }, children)
|
|
42
|
+
@brackets.each { |open| open.active = false unless open.image }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inline_destination
|
|
46
|
+
return unless @scanner.scan(INLINE)
|
|
47
|
+
|
|
48
|
+
InlineParser.destination(@scanner[1] || @scanner[2])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reference(label)
|
|
52
|
+
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
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def autolink
|
|
64
|
+
url = @scanner[1] || "mailto:#{@scanner[2]}"
|
|
65
|
+
@nodes << Nodes::Wrap.new({ link: url }, [Nodes::Text.new(@scanner[1] || @scanner[2])])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Markdown
|
|
5
|
+
class InlineParser
|
|
6
|
+
# The intermediate inline tree: text, delimiter runs, code, breaks, and mark or image wrappers.
|
|
7
|
+
module Nodes
|
|
8
|
+
Text = Struct.new(:text)
|
|
9
|
+
Delimiter = Struct.new(:char, :remaining, :opener, :closer)
|
|
10
|
+
Code = Data.define(:text)
|
|
11
|
+
Break = Data.define
|
|
12
|
+
Wrap = Data.define(:marks, :children)
|
|
13
|
+
Picture = Data.define(:src, :children)
|
|
14
|
+
|
|
15
|
+
module_function
|
|
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
|
|
27
|
+
end
|
|
28
|
+
out
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def picture(node) = Rich::Image.new(src: node.src, alt: plain(node.children), width: nil, height: nil)
|
|
32
|
+
|
|
33
|
+
def plain(nodes)
|
|
34
|
+
flatten(nodes).sum("") { |item| item.is_a?(Rich::Image) ? item.alt.to_s : item.text }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require_relative "../rich/nodes"
|
|
5
|
+
require_relative "../text/entities"
|
|
6
|
+
require_relative "inline_parser/nodes"
|
|
7
|
+
require_relative "inline_parser/emphasis"
|
|
8
|
+
require_relative "inline_parser/links"
|
|
9
|
+
|
|
10
|
+
module Stationery
|
|
11
|
+
module Markdown
|
|
12
|
+
# Parses CommonMark inline content into Rich::Inline runs and Rich::Image items: backslash escapes,
|
|
13
|
+
# code spans, `*`/`_` emphasis, GFM `~` strikethrough, inline, reference and auto links, images,
|
|
14
|
+
# hard and soft breaks and entities. Raw HTML stays text.
|
|
15
|
+
class InlineParser
|
|
16
|
+
include Links
|
|
17
|
+
|
|
18
|
+
PUNCTUATION = /[!-\/:-@\[-`{-~]/
|
|
19
|
+
PLAIN = /[^\\`*_~\[\]!<&\n]+/
|
|
20
|
+
ENTITY = /&(?:#[xX]\h{1,6}|#\d{1,7}|[a-zA-Z][a-zA-Z0-9]{1,31});/
|
|
21
|
+
|
|
22
|
+
def self.parse(text, refs = {}) = new(text, refs).items
|
|
23
|
+
def self.label(text) = text.strip.gsub(/\s+/, " ").downcase
|
|
24
|
+
def self.destination(text) = Text::Entities.decode(text.gsub(/\\(#{PUNCTUATION})/o, '\1'))
|
|
25
|
+
|
|
26
|
+
def initialize(text, refs)
|
|
27
|
+
@source = text.to_s
|
|
28
|
+
@scanner = StringScanner.new(@source)
|
|
29
|
+
@refs = refs
|
|
30
|
+
@nodes = []
|
|
31
|
+
@brackets = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def items
|
|
35
|
+
step until @scanner.eos?
|
|
36
|
+
Emphasis.process(@nodes)
|
|
37
|
+
Rich::Inlines.merge(Nodes.flatten(@nodes))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def step
|
|
43
|
+
if (text = @scanner.scan(PLAIN)) then add(text)
|
|
44
|
+
elsif @scanner.skip(/\\\n/) then newline(hard: true)
|
|
45
|
+
elsif @scanner.scan(/\\(#{PUNCTUATION})/o) then add(@scanner[1])
|
|
46
|
+
elsif @scanner.skip("\n") then newline
|
|
47
|
+
elsif @scanner.check("`") then code_span
|
|
48
|
+
elsif @scanner.check(/[*_~]/) then delimiter
|
|
49
|
+
elsif @scanner.skip("![") then open_bracket(image: true)
|
|
50
|
+
elsif @scanner.skip("[") then open_bracket
|
|
51
|
+
elsif @scanner.skip("]") then close_bracket
|
|
52
|
+
elsif @scanner.scan(AUTOLINK) then autolink
|
|
53
|
+
elsif @scanner.scan(ENTITY) then add(Text::Entities.decode(@scanner.matched))
|
|
54
|
+
else add(@scanner.getch)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def add(text) = @nodes << Nodes::Text.new(text)
|
|
59
|
+
|
|
60
|
+
def newline(hard: false)
|
|
61
|
+
last = @nodes.last
|
|
62
|
+
if last.is_a?(Nodes::Text)
|
|
63
|
+
stripped = last.text.sub(/ +\z/, "")
|
|
64
|
+
hard ||= last.text.length - stripped.length >= 2
|
|
65
|
+
last.text = stripped
|
|
66
|
+
end
|
|
67
|
+
@scanner.skip(/ +/)
|
|
68
|
+
@nodes << (hard ? Nodes::Break.new : Nodes::Text.new(" "))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def code_span
|
|
72
|
+
run = @scanner.scan(/`+/)
|
|
73
|
+
content = @scanner.scan_until(/(?<!`)#{run}(?!`)/)
|
|
74
|
+
return add(run) unless content
|
|
75
|
+
|
|
76
|
+
text = content[0...-run.length].tr("\n", " ")
|
|
77
|
+
text = text[1...-1] if text.start_with?(" ") && text.end_with?(" ") && !text.strip.empty?
|
|
78
|
+
@nodes << Nodes::Code.new(text)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delimiter
|
|
82
|
+
position = @scanner.charpos
|
|
83
|
+
run = @scanner.scan(/\*+|_+|~+/)
|
|
84
|
+
before = position.zero? ? " " : @source[position - 1]
|
|
85
|
+
@nodes << Emphasis.delimiter(run, before, @source[@scanner.charpos] || " ")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stationery"
|
|
4
|
+
require_relative "testing/matchers"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
module Testing
|
|
8
|
+
# Minitest assertions over the same matchers the RSpec integration uses:
|
|
9
|
+
#
|
|
10
|
+
# class InvoiceTest < Minitest::Test
|
|
11
|
+
# include Stationery::Testing::Assertions
|
|
12
|
+
# end
|
|
13
|
+
module Assertions
|
|
14
|
+
def assert_pdf_text(subject, expected, msg = nil) = assert_pdf(Matchers::HaveText.new(expected), subject, msg)
|
|
15
|
+
def assert_page_count(subject, count, msg = nil) = assert_pdf(Matchers::HavePageCount.new(count), subject, msg)
|
|
16
|
+
def assert_pdf_link(subject, url, msg = nil) = assert_pdf(Matchers::HaveLink.new(url), subject, msg)
|
|
17
|
+
def assert_image_count(subject, count, msg = nil) = assert_pdf(Matchers::HaveImageCount.new(count), subject, msg)
|
|
18
|
+
def assert_bookmark(subject, title, msg = nil) = assert_pdf(Matchers::HaveBookmark.new(title), subject, msg)
|
|
19
|
+
def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
|
|
20
|
+
|
|
21
|
+
def refute_pdf_text(subject, expected, msg = nil)
|
|
22
|
+
matcher = Matchers::HaveText.new(expected)
|
|
23
|
+
assert(!matcher.matches?(subject), msg || matcher.failure_message_when_negated)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def assert_pdf(matcher, subject, msg)
|
|
29
|
+
assert(matcher.matches?(subject), msg || matcher.failure_message)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# The bookmarks a document declares, in build order. Each entry names an
|
|
5
|
+
# automatic anchor, unique to this outline so a page template's bookmarks
|
|
6
|
+
# never stand in for the body's; once pages are painted, the entries whose
|
|
7
|
+
# anchors landed somewhere become outline items.
|
|
8
|
+
class Outline
|
|
9
|
+
# A declared bookmark: its title, depth (1 = top level), anchor and
|
|
10
|
+
# whether it starts expanded.
|
|
11
|
+
Entry = Data.define(:title, :level, :anchor, :open)
|
|
12
|
+
|
|
13
|
+
# A bookmark resolved to the page and top its anchor was painted at.
|
|
14
|
+
Item = Data.define(:title, :level, :dest, :open)
|
|
15
|
+
|
|
16
|
+
attr_reader :entries
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@entries = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add(title, level: 1, open: false)
|
|
23
|
+
level = Integer(level)
|
|
24
|
+
raise ArgumentError, "bookmark level must be 1 or more, got #{level}" if level < 1
|
|
25
|
+
|
|
26
|
+
anchor = "__bookmark-#{object_id}-#{@entries.size + 1}"
|
|
27
|
+
Entry.new(title.to_s, level, anchor, open ? true : false).tap { @entries << it }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def resolve(destinations)
|
|
31
|
+
@entries.filter_map do |entry|
|
|
32
|
+
dest = destinations[entry.anchor]
|
|
33
|
+
dest && Item.new(entry.title, entry.level, dest, entry.open)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/stationery/page.rb
CHANGED
|
@@ -2,32 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
# One page: its size and margins, the drawing operators written to it, the
|
|
5
|
-
# resources those operators reference
|
|
5
|
+
# resources those operators reference, its link annotations and the named
|
|
6
|
+
# anchors painted on it (PDF-space tops) and the page-number slots waiting
|
|
7
|
+
# for their anchors' pages.
|
|
6
8
|
class Page
|
|
9
|
+
# Room for the page number of `anchor`, right-aligned in `width` from `x`
|
|
10
|
+
# on `baseline` (top-left coordinates), drawn in `style`. `link` is an
|
|
11
|
+
# [x, y, w, h] area linked to the anchor only once it resolves.
|
|
12
|
+
Slot = Data.define(:anchor, :x, :baseline, :width, :style, :link)
|
|
13
|
+
|
|
7
14
|
SIZES = {
|
|
8
15
|
a3: [841.89, 1190.55], a4: [595.28, 841.89], a5: [419.53, 595.28],
|
|
9
16
|
letter: [612, 792], legal: [612, 1008], tabloid: [792, 1224]
|
|
10
17
|
}.freeze
|
|
11
18
|
|
|
12
|
-
attr_reader :size, :margin, :content, :annotations, :resource_names
|
|
19
|
+
attr_reader :size, :margin, :reserve, :content, :annotations, :anchors, :template_anchors, :slots, :resource_names
|
|
13
20
|
|
|
14
|
-
def initialize(size: :letter, layout: :portrait, margin: 0)
|
|
21
|
+
def initialize(size: :letter, layout: :portrait, margin: 0, reserve: [0, 0])
|
|
15
22
|
@size = dimensions(size)
|
|
16
23
|
@size = @size.reverse if layout.to_sym == :landscape
|
|
17
24
|
@margin = Geometry.box(margin)
|
|
25
|
+
@reserve = reserve
|
|
18
26
|
@content = String.new(encoding: Encoding::BINARY)
|
|
19
27
|
@annotations = []
|
|
28
|
+
@anchors = []
|
|
29
|
+
@template_anchors = []
|
|
30
|
+
@slots = []
|
|
20
31
|
@resource_names = Hash.new { |hash, key| hash[key] = [] }
|
|
21
32
|
end
|
|
22
33
|
|
|
23
34
|
def width = @size[0]
|
|
24
35
|
def height = @size[1]
|
|
25
36
|
|
|
26
|
-
def
|
|
37
|
+
def margin_box
|
|
27
38
|
top, right, bottom, left = @margin
|
|
28
39
|
Rect.new(left, top, width - left - right, height - top - bottom)
|
|
29
40
|
end
|
|
30
41
|
|
|
42
|
+
# The margin box less the space reserved for the header and footer.
|
|
43
|
+
def content_box = margin_box.inset(@reserve[0], 0, @reserve[1], 0)
|
|
44
|
+
|
|
31
45
|
def use(category, name)
|
|
32
46
|
names = @resource_names[category]
|
|
33
47
|
names << name unless names.include?(name)
|
|
@@ -4,32 +4,55 @@ module Stationery
|
|
|
4
4
|
# What a page template knows about the page it draws on.
|
|
5
5
|
PageInfo = Data.define(:number, :count, :width, :height, :margin, :content_box)
|
|
6
6
|
|
|
7
|
-
#
|
|
7
|
+
# Paints a document's headers, footers and page templates on every
|
|
8
|
+
# finished page.
|
|
8
9
|
class PageTemplates
|
|
9
|
-
def initialize(document, book:, resources:)
|
|
10
|
+
def initialize(document, book:, resources:, debug: false, regions: nil, warnings: [])
|
|
10
11
|
@document = document
|
|
12
|
+
@debug = debug
|
|
11
13
|
@book = book
|
|
12
14
|
@resources = resources
|
|
15
|
+
@regions = regions
|
|
16
|
+
@warnings = warnings
|
|
13
17
|
@templates = document.class.config[:templates]
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
def apply(pages)
|
|
17
|
-
return if @templates.empty?
|
|
18
|
-
|
|
19
21
|
pages.each_with_index do |page, index|
|
|
20
22
|
info = PageInfo.new(index + 1, pages.size, page.width, page.height, page.margin, page.content_box)
|
|
21
|
-
|
|
23
|
+
paint_regions(page, info) if @regions
|
|
24
|
+
@templates.each { |layer, block| paint(page, root(info, block), page.content_box, layer) }
|
|
22
25
|
end
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
private
|
|
26
29
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
def paint_regions(page, info)
|
|
31
|
+
box = page.margin_box
|
|
32
|
+
Regions::SLOTS.each do |slot|
|
|
33
|
+
region = @regions.entry_for(slot, info.number, last: info.number == info.count)
|
|
34
|
+
next unless region&.block
|
|
35
|
+
|
|
36
|
+
root = root(info, region.block)
|
|
37
|
+
height = root.measure(box.width)
|
|
38
|
+
reserved = @regions.height_of(region, info.number)
|
|
39
|
+
overflow(info.number, height, reserved)
|
|
40
|
+
y = slot == :header ? box.y : box.bottom - height
|
|
41
|
+
paint(page, root, Rect.new(box.x, y, box.width, height))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def overflow(number, height, reserved)
|
|
46
|
+
return if height <= reserved + Layout::EPSILON
|
|
47
|
+
|
|
48
|
+
@warnings << Layout::Overflow.new(page: number, height:, available: reserved)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def root(info, block) = @document.template_root(info, book: @book, &block)
|
|
52
|
+
|
|
53
|
+
def paint(page, root, rect, layer = :foreground)
|
|
30
54
|
mark = page.content.bytesize
|
|
31
|
-
|
|
32
|
-
builder.root.paint(Canvas.new(page, @resources), box.x, box.y, box.width)
|
|
55
|
+
root.paint(Canvas.new(page, @resources, template: true, debug: @debug), rect.x, rect.y, rect.width)
|
|
33
56
|
page.content.prepend(page.content.slice!(mark..)) if layer == :background
|
|
34
57
|
end
|
|
35
58
|
end
|