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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +358 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +25 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +8 -5
  10. data/lib/stationery/canvas.rb +25 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +53 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +52 -88
  35. data/lib/stationery/fonts/font_book.rb +46 -8
  36. data/lib/stationery/fonts/glyph_run.rb +38 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +62 -0
  41. data/lib/stationery/fonts/installer.rb +121 -0
  42. data/lib/stationery/fonts/kern_table.rb +52 -0
  43. data/lib/stationery/fonts/kerning.rb +17 -0
  44. data/lib/stationery/fonts/packs.rb +45 -0
  45. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  46. data/lib/stationery/fonts/to_unicode.rb +36 -0
  47. data/lib/stationery/fonts/true_type.rb +32 -49
  48. data/lib/stationery/html/document.rb +11 -0
  49. data/lib/stationery/html/tokenizer.rb +74 -0
  50. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  51. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  52. data/lib/stationery/html/tree_builder.rb +72 -0
  53. data/lib/stationery/html/whitespace.rb +43 -0
  54. data/lib/stationery/layout/box.rb +85 -16
  55. data/lib/stationery/layout/flow.rb +48 -15
  56. data/lib/stationery/layout/image.rb +1 -0
  57. data/lib/stationery/layout/list_item.rb +89 -0
  58. data/lib/stationery/layout/mark.rb +58 -0
  59. data/lib/stationery/layout/node.rb +26 -2
  60. data/lib/stationery/layout/paginator.rb +35 -6
  61. data/lib/stationery/layout/positioned.rb +3 -1
  62. data/lib/stationery/layout/row.rb +34 -4
  63. data/lib/stationery/layout/svg.rb +45 -0
  64. data/lib/stationery/layout/table/cell.rb +33 -6
  65. data/lib/stationery/layout/table/grid.rb +93 -0
  66. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  67. data/lib/stationery/layout/table/selection.rb +3 -2
  68. data/lib/stationery/layout/table.rb +69 -23
  69. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  70. data/lib/stationery/layout/table_of_contents.rb +43 -0
  71. data/lib/stationery/layout/text.rb +5 -4
  72. data/lib/stationery/layout/wrap.rb +78 -0
  73. data/lib/stationery/list_markers.rb +47 -0
  74. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  75. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  76. data/lib/stationery/markdown/block_parser.rb +141 -0
  77. data/lib/stationery/markdown/document.rb +50 -0
  78. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  79. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  80. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  81. data/lib/stationery/markdown/inline_parser.rb +89 -0
  82. data/lib/stationery/minitest.rb +33 -0
  83. data/lib/stationery/outline.rb +37 -0
  84. data/lib/stationery/page.rb +18 -4
  85. data/lib/stationery/page_templates.rb +33 -10
  86. data/lib/stationery/pdf/assembler.rb +18 -7
  87. data/lib/stationery/pdf/outline_writer.rb +72 -0
  88. data/lib/stationery/pdf/serializer.rb +5 -1
  89. data/lib/stationery/pdf/writer.rb +4 -0
  90. data/lib/stationery/preview.rb +64 -0
  91. data/lib/stationery/rails/previews_controller.rb +61 -0
  92. data/lib/stationery/rails.rb +2 -0
  93. data/lib/stationery/railtie.rb +60 -0
  94. data/lib/stationery/regions.rb +67 -0
  95. data/lib/stationery/rich/nodes.rb +55 -0
  96. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  97. data/lib/stationery/rich/renderer.rb +121 -0
  98. data/lib/stationery/rich/styles.rb +34 -0
  99. data/lib/stationery/rspec.rb +6 -0
  100. data/lib/stationery/structure.rb +78 -0
  101. data/lib/stationery/svg/arc.rb +91 -0
  102. data/lib/stationery/svg/document.rb +73 -0
  103. data/lib/stationery/svg/parser.rb +38 -0
  104. data/lib/stationery/svg/path_data.rb +137 -0
  105. data/lib/stationery/svg/shapes.rb +54 -0
  106. data/lib/stationery/svg/style.rb +68 -0
  107. data/lib/stationery/svg/transform.rb +43 -0
  108. data/lib/stationery/testing/inspector.rb +98 -0
  109. data/lib/stationery/testing/matchers.rb +123 -0
  110. data/lib/stationery/text/entities/html4.rb +49 -0
  111. data/lib/stationery/text/entities.rb +6 -1
  112. data/lib/stationery/text/line.rb +8 -2
  113. data/lib/stationery/text/paragraph.rb +32 -11
  114. data/lib/stationery/text/runs_builder.rb +9 -1
  115. data/lib/stationery/text/style.rb +2 -2
  116. data/lib/stationery/text/wrapper.rb +3 -2
  117. data/lib/stationery/version.rb +1 -1
  118. data/lib/stationery/warnings.rb +65 -0
  119. data/lib/stationery.rb +51 -0
  120. 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
@@ -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 and its link annotations.
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 content_box
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
- # Runs a document's page templates on every finished page.
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
- @templates.each { |layer, block| draw(page, info, layer, block) }
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 draw(page, info, layer, block)
28
- builder = Builder.new(book: @book, text: @document.class.config[:text])
29
- @document.build_with(builder) { @document.instance_exec(info, &block) }
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
- box = page.content_box
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