stationery 0.1.0 → 0.3.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 +114 -1
- data/README.md +395 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +26 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +10 -6
- data/lib/stationery/canvas.rb +38 -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 +60 -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 +68 -88
- data/lib/stationery/fonts/font_book.rb +49 -8
- data/lib/stationery/fonts/glyph_run.rb +39 -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 +64 -0
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -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/ligatures.rb +16 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- 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 +64 -57
- data/lib/stationery/fonts/woff.rb +52 -0
- 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 +102 -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 +48 -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 +6 -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 +20 -8
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +22 -10
- data/lib/stationery/pdf/writer.rb +28 -5
- 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/resources.rb +8 -2
- 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/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +103 -0
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +58 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +85 -0
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +54 -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 +33 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +4 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +67 -0
- metadata +101 -4
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require_relative "../text/entities"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
module HTML
|
|
8
|
+
# Splits HTML into [:start, name, attributes, self_closing], [:end, name] and [:text, string] tokens.
|
|
9
|
+
# Names are lowercased, text and attribute values entity-decoded. Comments, declarations and the
|
|
10
|
+
# content of script, style, template and title elements are skipped; void elements never end.
|
|
11
|
+
class Tokenizer
|
|
12
|
+
VOID = %w[area base br col embed hr img input keygen link meta param source track wbr].freeze
|
|
13
|
+
SKIPPED = %w[script style template title].freeze
|
|
14
|
+
COMMENT = /<!--.*?(?:-->|\z)/m
|
|
15
|
+
CDATA = /<!\[CDATA\[(.*?)(?:\]\]>|\z)/m
|
|
16
|
+
DECLARATION = /<[!?][^>]*>?/
|
|
17
|
+
START = /<([a-zA-Z][a-zA-Z0-9:-]*)/
|
|
18
|
+
FINISH = %r{</([a-zA-Z][a-zA-Z0-9:-]*)[^>]*>?}
|
|
19
|
+
ATTRIBUTE = %r{[\s/]*([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?}
|
|
20
|
+
TAG_END = %r{\s*/?\s*>}
|
|
21
|
+
|
|
22
|
+
def self.tokenize(source) = new(source).tokens
|
|
23
|
+
|
|
24
|
+
def initialize(source)
|
|
25
|
+
@scanner = StringScanner.new(source.to_s)
|
|
26
|
+
@tokens = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def tokens
|
|
30
|
+
step until @scanner.eos?
|
|
31
|
+
@tokens
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def step
|
|
37
|
+
if (text = @scanner.scan(/[^<]+/)) then text(Text::Entities.decode(text))
|
|
38
|
+
elsif @scanner.scan(CDATA) then text(@scanner[1])
|
|
39
|
+
elsif @scanner.skip(COMMENT) || @scanner.skip(DECLARATION) then nil
|
|
40
|
+
elsif @scanner.scan(FINISH) then finish(@scanner[1].downcase)
|
|
41
|
+
elsif @scanner.scan(START) then start(@scanner[1].downcase)
|
|
42
|
+
else text(@scanner.getch)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def text(string)
|
|
47
|
+
if @tokens.last&.first == :text
|
|
48
|
+
@tokens.last[1] += string
|
|
49
|
+
else
|
|
50
|
+
@tokens << [:text, string]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start(name)
|
|
55
|
+
attributes = {}
|
|
56
|
+
while @scanner.scan(ATTRIBUTE)
|
|
57
|
+
attributes[@scanner[1].downcase] ||= Text::Entities.decode(@scanner[2] || @scanner[3] || @scanner[4] || "")
|
|
58
|
+
end
|
|
59
|
+
ending = @scanner.scan(TAG_END) || @scanner.scan_until(/>/) || (@scanner.terminate && "")
|
|
60
|
+
return skip_raw(name) if SKIPPED.include?(name)
|
|
61
|
+
|
|
62
|
+
@tokens << [:start, name, attributes, ending.include?("/") || VOID.include?(name)]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def finish(name)
|
|
66
|
+
@tokens << [:end, name] unless VOID.include?(name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def skip_raw(name)
|
|
70
|
+
@scanner.skip_until(%r{</#{name}\s*>}i) || @scanner.terminate
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rich/nodes"
|
|
4
|
+
require_relative "collector"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
module HTML
|
|
8
|
+
class TreeBuilder
|
|
9
|
+
# Converts an element tree into rich-text blocks. Containers flatten into their children, inline
|
|
10
|
+
# elements add marks, and unknown elements keep their content.
|
|
11
|
+
class Blocks
|
|
12
|
+
CONTAINERS = %w[
|
|
13
|
+
#root html body div p section article header footer main figure figcaption aside nav address
|
|
14
|
+
dl dt dd center details summary form fieldset
|
|
15
|
+
].freeze
|
|
16
|
+
HEADINGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
17
|
+
MARKS = {
|
|
18
|
+
"strong" => { bold: true }, "b" => { bold: true },
|
|
19
|
+
"em" => { italic: true }, "i" => { italic: true },
|
|
20
|
+
"u" => { underline: true }, "ins" => { underline: true },
|
|
21
|
+
"s" => { strike: true }, "del" => { strike: true }, "strike" => { strike: true },
|
|
22
|
+
"code" => { code: true }, "kbd" => { code: true }, "samp" => { code: true }, "tt" => { code: true },
|
|
23
|
+
"sub" => { script: :sub }, "sup" => { script: :sup }
|
|
24
|
+
}.freeze
|
|
25
|
+
ALIGN = /\A\s*(left|center|right|justify)\s*\z/i
|
|
26
|
+
TEXT_ALIGN = /text-align\s*:\s*(left|center|right|justify)/i
|
|
27
|
+
|
|
28
|
+
def self.convert(root) = new.blocks_of(root, {})
|
|
29
|
+
|
|
30
|
+
def blocks_of(element, marks)
|
|
31
|
+
collector = Collector.new
|
|
32
|
+
children(element, marks, collector)
|
|
33
|
+
collector.blocks
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def children(element, marks, out)
|
|
39
|
+
element.children.each { |child| visit(child, marks, out) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def visit(node, marks, out)
|
|
43
|
+
return out.text(node, marks) if node.is_a?(String)
|
|
44
|
+
|
|
45
|
+
case node.name
|
|
46
|
+
when *CONTAINERS then out.boundary { children(node, marks, out) }
|
|
47
|
+
when *HEADINGS then out.block(heading(node, marks))
|
|
48
|
+
when "ul", "ol" then out.block(list(node, marks))
|
|
49
|
+
when "blockquote" then out.block(Rich::Blockquote.new(blocks: blocks_of(node, marks)))
|
|
50
|
+
when "pre" then out.block(code(node))
|
|
51
|
+
when "hr" then out.block(Rich::Rule.new)
|
|
52
|
+
when "br" then out.inline(Rich::Inline.break)
|
|
53
|
+
when "img" then out.inline(image(node.attributes))
|
|
54
|
+
when "table" then out.block(table(node, marks))
|
|
55
|
+
else children(node, marks.merge(marks_for(node)), out)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def heading(element, marks)
|
|
60
|
+
inlines = blocks_of(element, marks).grep(Rich::Paragraph).flat_map(&:inlines)
|
|
61
|
+
Rich::Heading.new(level: element.name[1].to_i, inlines:) unless inlines.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def list(element, marks)
|
|
65
|
+
items = elements(element).map do |item|
|
|
66
|
+
blocks_of(item.name == "li" ? item : Element.new("li", {}, [item]), marks)
|
|
67
|
+
end
|
|
68
|
+
return if items.empty?
|
|
69
|
+
|
|
70
|
+
ordered = element.name == "ol"
|
|
71
|
+
start = Integer(element.attributes["start"], exception: false) || 1 if ordered
|
|
72
|
+
Rich::List.new(ordered:, start:, items:)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def code(element)
|
|
76
|
+
language = elements(element).find { |child| child.name == "code" }&.attributes&.[]("class")
|
|
77
|
+
Rich::CodeBlock.new(text: raw_text(element).delete_prefix("\n").chomp,
|
|
78
|
+
language: language&.[](/lang(?:uage)?-(\S+)/, 1))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def raw_text(element)
|
|
82
|
+
element.children.sum("") do |child|
|
|
83
|
+
next child if child.is_a?(String)
|
|
84
|
+
|
|
85
|
+
child.name == "br" ? "\n" : raw_text(child)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def image(attributes)
|
|
90
|
+
return unless attributes["src"]
|
|
91
|
+
|
|
92
|
+
Rich::Image.new(src: attributes["src"], alt: attributes["alt"],
|
|
93
|
+
width: dimension(attributes["width"]), height: dimension(attributes["height"]))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def dimension(value) = value&.[](/\A\s*(\d+)/, 1)&.to_i
|
|
97
|
+
|
|
98
|
+
def table(element, marks)
|
|
99
|
+
rows = rows_of(element).filter_map do |row|
|
|
100
|
+
cells = elements(row).select { |cell| %w[td th].include?(cell.name) }
|
|
101
|
+
cells.map { |cell| cell(cell, marks) } unless cells.empty?
|
|
102
|
+
end
|
|
103
|
+
Rich::Table.new(rows:) unless rows.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def rows_of(element)
|
|
107
|
+
elements(element).flat_map do |child|
|
|
108
|
+
next [child] if child.name == "tr"
|
|
109
|
+
|
|
110
|
+
TreeBuilder::SECTIONS.include?(child.name) ? rows_of(child) : []
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cell(element, marks)
|
|
115
|
+
attributes = element.attributes
|
|
116
|
+
align = attributes["align"]&.[](ALIGN, 1) || attributes["style"]&.[](TEXT_ALIGN, 1)
|
|
117
|
+
Rich::Cell.new(header: element.name == "th", align: align&.downcase&.to_sym,
|
|
118
|
+
blocks: blocks_of(element, marks))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def marks_for(element)
|
|
122
|
+
return MARKS[element.name] if MARKS.key?(element.name)
|
|
123
|
+
return { link: element.attributes["href"] } if element.name == "a" && element.attributes["href"]
|
|
124
|
+
return {} unless element.name == "span"
|
|
125
|
+
|
|
126
|
+
style = element.attributes["style"].to_s
|
|
127
|
+
marks = {}
|
|
128
|
+
marks[:bold] = true if style.match?(/font-weight\s*:\s*(bold|[6-9]00)/i)
|
|
129
|
+
marks[:italic] = true if style.match?(/font-style\s*:\s*italic/i)
|
|
130
|
+
marks
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def elements(element) = element.children.grep(TreeBuilder::Element)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../rich/nodes"
|
|
4
|
+
require_relative "../whitespace"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
module HTML
|
|
8
|
+
class TreeBuilder
|
|
9
|
+
# Gathers the blocks of one container: loose inline content between blocks becomes a paragraph,
|
|
10
|
+
# with its whitespace collapsed the way a browser would.
|
|
11
|
+
class Collector
|
|
12
|
+
def initialize
|
|
13
|
+
@blocks = []
|
|
14
|
+
@pending = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def text(string, marks) = @pending << Rich::Inline.new(text: string, marks:)
|
|
18
|
+
|
|
19
|
+
def inline(item)
|
|
20
|
+
@pending << item if item
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def block(block)
|
|
24
|
+
return unless block
|
|
25
|
+
|
|
26
|
+
flush
|
|
27
|
+
@blocks << block
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def boundary
|
|
31
|
+
flush
|
|
32
|
+
yield
|
|
33
|
+
flush
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def blocks
|
|
37
|
+
flush
|
|
38
|
+
@blocks
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def flush
|
|
44
|
+
@blocks.concat(Rich::Inlines.paragraphs(@pending) { |run| Whitespace.collapse(run) })
|
|
45
|
+
@pending = []
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tree_builder/blocks"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module HTML
|
|
7
|
+
# Builds a lenient element tree from tokens, closing what HTML5 closes implicitly (a p before a block,
|
|
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.
|
|
10
|
+
class TreeBuilder
|
|
11
|
+
Element = Data.define(:name, :attributes, :children)
|
|
12
|
+
|
|
13
|
+
CLOSES_P = %w[
|
|
14
|
+
address article aside blockquote dd details div dl dt fieldset figcaption figure footer form
|
|
15
|
+
h1 h2 h3 h4 h5 h6 header hr li main nav ol p pre section table ul
|
|
16
|
+
].freeze
|
|
17
|
+
HEADINGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
18
|
+
SECTIONS = %w[thead tbody tfoot].freeze
|
|
19
|
+
IMPLIED = {
|
|
20
|
+
"li" => [%w[li], %w[ul ol]],
|
|
21
|
+
"dt" => [%w[dt dd], %w[dl]], "dd" => [%w[dt dd], %w[dl]],
|
|
22
|
+
"td" => [%w[td th], %w[tr table]], "th" => [%w[td th], %w[tr table]],
|
|
23
|
+
"tr" => [%w[tr], %w[table]],
|
|
24
|
+
"thead" => [SECTIONS, %w[table]], "tbody" => [SECTIONS, %w[table]], "tfoot" => [SECTIONS, %w[table]]
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def self.parse(tokens) = Blocks.convert(new.build(tokens))
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@root = Element.new("#root", {}, [])
|
|
31
|
+
@stack = [@root]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build(tokens)
|
|
35
|
+
tokens.each do |type, *args|
|
|
36
|
+
case type
|
|
37
|
+
when :start then start(*args)
|
|
38
|
+
when :end then finish(*args)
|
|
39
|
+
else @stack.last.children << args.first
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
@root
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def start(name, attributes, self_closing)
|
|
48
|
+
close_nearest(%w[p], %w[table td th caption]) if CLOSES_P.include?(name)
|
|
49
|
+
close_nearest(HEADINGS, []) if HEADINGS.include?(name) && HEADINGS.include?(@stack.last.name)
|
|
50
|
+
close_nearest(*IMPLIED[name]) if IMPLIED.key?(name)
|
|
51
|
+
push("tr", {}, false) if %w[td th].include?(name) && %w[table thead tbody tfoot].include?(@stack.last.name)
|
|
52
|
+
push(name, attributes, self_closing)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def push(name, attributes, self_closing)
|
|
56
|
+
element = Element.new(name, attributes, [])
|
|
57
|
+
@stack.last.children << element
|
|
58
|
+
@stack << element unless self_closing
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def finish(name)
|
|
62
|
+
index = @stack.rindex { |element| element.name == name }
|
|
63
|
+
@stack.slice!(index..) if index&.positive?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def close_nearest(names, boundary)
|
|
67
|
+
index = @stack.rindex { |element| names.include?(element.name) || boundary.include?(element.name) }
|
|
68
|
+
@stack.slice!(index..) if index&.positive? && names.include?(@stack[index].name)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module HTML
|
|
5
|
+
# Collapses the whitespace of one block's inlines as CSS `white-space: normal` does: runs become one
|
|
6
|
+
# space, the block's (and each line's) leading and trailing space goes, and U+00A0 is left alone.
|
|
7
|
+
# A trailing line break after content draws no empty line, so it is dropped too.
|
|
8
|
+
module Whitespace
|
|
9
|
+
RUN = /[ \t\n\r\f]+/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def collapse(inlines)
|
|
14
|
+
collapsed = trim_trailing(trim_leading(inlines))
|
|
15
|
+
collapsed.pop if collapsed.last&.break? && collapsed[..-2].any? { |i| i.break? || !i.text.empty? }
|
|
16
|
+
collapsed
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def trim_leading(inlines)
|
|
20
|
+
space = true
|
|
21
|
+
inlines.map do |inline|
|
|
22
|
+
next inline.tap { space = true } if inline.break?
|
|
23
|
+
|
|
24
|
+
text = inline.text.gsub(RUN, " ")
|
|
25
|
+
text = text.delete_prefix(" ") if space
|
|
26
|
+
space = text.end_with?(" ") unless text.empty?
|
|
27
|
+
inline.with(text:)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def trim_trailing(inlines)
|
|
32
|
+
space = true
|
|
33
|
+
inlines.reverse_each.map do |inline|
|
|
34
|
+
next inline.tap { space = true } if inline.break?
|
|
35
|
+
|
|
36
|
+
text = space ? inline.text.delete_suffix(" ") : inline.text
|
|
37
|
+
space = false unless text.empty?
|
|
38
|
+
inline.with(text:)
|
|
39
|
+
end.reverse
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -2,17 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Layout
|
|
5
|
-
# A container with padding, background, border and corner radius.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# A container with padding, background, border and corner radius. A box
|
|
6
|
+
# moves to the next page whole when it fits there and continues across
|
|
7
|
+
# pages when it does not (`break_inside: :auto` splits at any break,
|
|
8
|
+
# `:avoid` never). A fragment's cut sides are `open`: `decoration: :slice`
|
|
9
|
+
# drops their padding and border, `:clone` keeps the padding. A
|
|
10
|
+
# fixed-height box can truncate or shrink text that does not fit
|
|
11
|
+
# (`overflow:`) and never splits. `min_height:` is a floor that does split:
|
|
12
|
+
# the first fragment keeps as much of it as the page holds and the next
|
|
13
|
+
# one carries only what is left of it.
|
|
8
14
|
class Box < Node
|
|
9
15
|
BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
|
|
10
16
|
|
|
11
17
|
attr_reader :content, :width_spec
|
|
12
18
|
|
|
13
19
|
def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
|
|
14
|
-
height: nil, overflow: :visible, valign: :top, opacity: nil
|
|
20
|
+
height: nil, min_height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
|
|
21
|
+
open: [], decoration: :slice)
|
|
22
|
+
raise ArgumentError, "pass height: or min_height:, not both" if height && min_height
|
|
23
|
+
|
|
15
24
|
super()
|
|
25
|
+
@min_height = min_height
|
|
26
|
+
@open = open
|
|
27
|
+
@decoration = decoration
|
|
28
|
+
@link = link
|
|
29
|
+
@outset = Geometry.box(outset)
|
|
16
30
|
@content = content
|
|
17
31
|
@padding = Geometry.box(padding)
|
|
18
32
|
@background = background
|
|
@@ -39,47 +53,111 @@ module Stationery
|
|
|
39
53
|
end
|
|
40
54
|
end
|
|
41
55
|
|
|
56
|
+
def splittable? = @height.nil? && @overflow == :visible && @content.splittable?
|
|
57
|
+
def prefer_whole? = break_inside.nil? && splittable?
|
|
58
|
+
|
|
59
|
+
def split(width, height, fresh: false)
|
|
60
|
+
return [self, nil] if measure(width) <= height + EPSILON
|
|
61
|
+
|
|
62
|
+
cut = @open | [:bottom]
|
|
63
|
+
available = height - vertical(cut)
|
|
64
|
+
head, tail = @content.split(inner_width(width), available, fresh:)
|
|
65
|
+
return [nil, self] unless head
|
|
66
|
+
return [with_content(head), nil] unless tail || @min_height.to_f > height + EPSILON
|
|
67
|
+
|
|
68
|
+
fragments(width, height, head, tail || Flow.new, cut)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# An empty continuation: open at the top, without a floor.
|
|
72
|
+
def continued = fragment(Flow.new, @open | [:top], nil)
|
|
73
|
+
|
|
74
|
+
# A copy holding other content and open sides, every option kept.
|
|
75
|
+
def with_content(content, open = @open) = dup.reopen(content, open)
|
|
76
|
+
def with_open(*sides) = with_content(@content, @open | sides)
|
|
77
|
+
|
|
42
78
|
def measure(width)
|
|
43
|
-
@height || (@content.measure(inner_width(width)) + vertical
|
|
79
|
+
@height || memoize_by_width(width) { [@content.measure(inner_width(width)) + vertical, @min_height || 0].max }
|
|
44
80
|
end
|
|
45
81
|
|
|
46
|
-
def paint(canvas, x, y, width, height = nil, valign: nil, **)
|
|
82
|
+
def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
|
|
47
83
|
height ||= measure(width)
|
|
48
84
|
paint_background(canvas, x, y, width, height)
|
|
49
85
|
paint_border(canvas, x, y, width, height)
|
|
50
86
|
paint_content(canvas, x, y, width, height, valign || @valign)
|
|
87
|
+
canvas.link(x, y, width, height, @link) if @link
|
|
88
|
+
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
protected
|
|
92
|
+
|
|
93
|
+
def reopen(content, open, min_height = @min_height)
|
|
94
|
+
@content = content
|
|
95
|
+
@open = open
|
|
96
|
+
@min_height = min_height
|
|
97
|
+
self
|
|
51
98
|
end
|
|
52
99
|
|
|
53
100
|
private
|
|
54
101
|
|
|
55
|
-
def
|
|
102
|
+
def fragment(content, open, min_height) = dup.reopen(content, open, min_height)
|
|
103
|
+
|
|
104
|
+
def fragments(width, height, head, tail, cut)
|
|
105
|
+
first = fragment(head, cut, @min_height && [@min_height, height].min).tap { |part| part.keep_with_next = nil }
|
|
106
|
+
rest = @min_height.to_f - first.measure(width)
|
|
107
|
+
[first, fragment(tail, @open | [:top], rest.positive? ? rest : nil)]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def insets(open = @open)
|
|
56
111
|
border = @border ? @border[:width] : 0
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
112
|
+
sides = @border ? @border[:sides] - open : []
|
|
113
|
+
Geometry::SIDES.keys.zip(@padding).map do |side, padding|
|
|
114
|
+
(open.include?(side) && @decoration == :slice ? 0 : padding) + (sides.include?(side) ? border : 0)
|
|
115
|
+
end
|
|
61
116
|
end
|
|
62
117
|
|
|
63
118
|
def horizontal = insets[1] + insets[3]
|
|
64
|
-
def vertical = insets
|
|
119
|
+
def vertical(open = @open) = insets(open).values_at(0, 2).sum
|
|
65
120
|
def inner_width(width) = [width - horizontal, 0].max
|
|
66
121
|
|
|
122
|
+
# The outset paints the background past the box's own edges (a band that
|
|
123
|
+
# bleeds into the page margins) without moving the content.
|
|
67
124
|
def paint_background(canvas, x, y, width, height)
|
|
68
125
|
return unless @background
|
|
69
126
|
|
|
70
|
-
|
|
127
|
+
top, right, bottom, left = @outset.each_with_index.map { |v, i| open?(i) ? 0 : v }
|
|
128
|
+
area = Rect.new(x - left, y - top, width + left + right, height + top + bottom)
|
|
129
|
+
cut(canvas, area, area) do |shape|
|
|
130
|
+
canvas.rounded_rect(*shape.to_h.values, radius: @radius, fill: @background, opacity: @opacity)
|
|
131
|
+
end
|
|
71
132
|
end
|
|
72
133
|
|
|
73
134
|
def paint_border(canvas, x, y, width, height)
|
|
74
135
|
return unless @border
|
|
75
136
|
|
|
76
137
|
stroke = @border[:width]
|
|
138
|
+
area = Rect.new(x, y, width, height)
|
|
77
139
|
if @border[:sides].size == 4 && @radius.positive?
|
|
78
140
|
half = stroke / 2.0
|
|
79
|
-
canvas.
|
|
80
|
-
|
|
141
|
+
cut(canvas, area, area.inset(half, half, half, half)) do |shape|
|
|
142
|
+
canvas.rounded_rect(*shape.to_h.values, radius: @radius, stroke: @border[:color], line_width: stroke)
|
|
143
|
+
end
|
|
81
144
|
else
|
|
82
|
-
@border[:sides].each { |side| border_side(canvas, side,
|
|
145
|
+
(@border[:sides] - @open).each { |side| border_side(canvas, side, area, stroke) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def open?(index) = @open.include?(Geometry::SIDES.key(index))
|
|
150
|
+
|
|
151
|
+
# Cut edges are square: the rounded shape runs past them by its radius
|
|
152
|
+
# and stroke, clipped to the fragment.
|
|
153
|
+
def cut(canvas, area, shape)
|
|
154
|
+
return yield(shape) if @open.empty? || !@radius.positive?
|
|
155
|
+
|
|
156
|
+
reach = @radius + (@border ? @border[:width] : 0)
|
|
157
|
+
top = @open.include?(:top) ? reach : 0
|
|
158
|
+
bottom = @open.include?(:bottom) ? reach : 0
|
|
159
|
+
canvas.clip(area.x, area.y, area.width, area.height) do
|
|
160
|
+
yield Rect.new(shape.x, shape.y - top, shape.width, shape.height + top + bottom)
|
|
83
161
|
end
|
|
84
162
|
end
|
|
85
163
|
|
|
@@ -115,6 +193,14 @@ module Stationery
|
|
|
115
193
|
end
|
|
116
194
|
end
|
|
117
195
|
|
|
196
|
+
def paint_debug(canvas, rect, kind)
|
|
197
|
+
canvas.debug_rect(rect.x, rect.y, rect.width, rect.height, kind)
|
|
198
|
+
return if insets.all?(&:zero?)
|
|
199
|
+
|
|
200
|
+
inner = rect.inset(*insets)
|
|
201
|
+
canvas.debug_rect(inner.x, inner.y, inner.width, inner.height, :padding)
|
|
202
|
+
end
|
|
203
|
+
|
|
118
204
|
def fitted(inner)
|
|
119
205
|
return @content if @overflow == :visible || @height.nil? || @content.measure(inner.width) <= inner.height
|
|
120
206
|
return shrunk(inner) if @overflow == :shrink_to_fit && shrinkable?
|
|
@@ -4,8 +4,9 @@ module Stationery
|
|
|
4
4
|
module Layout
|
|
5
5
|
# Children stacked top to bottom: the document body and every box's
|
|
6
6
|
# content. Splits across pages: a splittable child continues on the next
|
|
7
|
-
# page
|
|
8
|
-
#
|
|
7
|
+
# page (one that prefers staying whole only from the top of a fresh page),
|
|
8
|
+
# anything else moves there whole, a spacer at the break is dropped and a
|
|
9
|
+
# child marked keep_with_next moves with its successor.
|
|
9
10
|
class Flow < Node
|
|
10
11
|
attr_reader :children, :gap, :align
|
|
11
12
|
|
|
@@ -18,6 +19,7 @@ module Stationery
|
|
|
18
19
|
|
|
19
20
|
def <<(child)
|
|
20
21
|
@children << child
|
|
22
|
+
forget_measures
|
|
21
23
|
self
|
|
22
24
|
end
|
|
23
25
|
|
|
@@ -26,18 +28,22 @@ module Stationery
|
|
|
26
28
|
def min_width = @children.map(&:min_width).max || 0
|
|
27
29
|
|
|
28
30
|
def measure(width)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
memoize_by_width(width) do
|
|
32
|
+
visible = @children.reject(&:page_break?)
|
|
33
|
+
visible.sum { |child| child.measure(child.width_in(width)) } + (@gap * [visible.size - 1, 0].max)
|
|
34
|
+
end
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
34
38
|
cursor = y
|
|
35
39
|
@children.reject(&:page_break?).each_with_index do |child, index|
|
|
36
40
|
cursor += @gap unless index.zero?
|
|
37
|
-
own = child.
|
|
38
|
-
left =
|
|
39
|
-
child.paint(canvas, left, cursor, own
|
|
40
|
-
|
|
41
|
+
own = child.width_in(width)
|
|
42
|
+
left = child.fixed_width(width) ? x + Geometry.align_offset(@align, width, own) : x
|
|
43
|
+
child.paint(canvas, left, cursor, own)
|
|
44
|
+
height = child.measure(own)
|
|
45
|
+
canvas.debug_rect(left, cursor, own, height, :flow)
|
|
46
|
+
cursor += height
|
|
41
47
|
end
|
|
42
48
|
end
|
|
43
49
|
|
|
@@ -79,7 +85,7 @@ module Stationery
|
|
|
79
85
|
|
|
80
86
|
gap = @placed.empty? ? 0 : @flow.gap
|
|
81
87
|
remaining = @height - @used - gap
|
|
82
|
-
height = child.measure(@width)
|
|
88
|
+
height = child.measure(child.width_in(@width))
|
|
83
89
|
return fits(child, height + gap, remaining - height, rest) if height <= remaining + EPSILON
|
|
84
90
|
return [part(@placed), part(rest)] if child.is_a?(Spacer)
|
|
85
91
|
|
|
@@ -93,25 +99,52 @@ module Stationery
|
|
|
93
99
|
end
|
|
94
100
|
|
|
95
101
|
def fits(child, consumed, left_after, rest)
|
|
96
|
-
|
|
97
|
-
return [part(@placed), part([child, *rest])]
|
|
98
|
-
end
|
|
102
|
+
return [part(@placed), part([child, *rest])] if strand?(child, left_after, rest)
|
|
99
103
|
|
|
100
104
|
@placed << child
|
|
101
105
|
@used += consumed
|
|
102
106
|
nil
|
|
103
107
|
end
|
|
104
108
|
|
|
109
|
+
# keep_with_next: true needs the start of the next child on this page; a
|
|
110
|
+
# number needs that many points of what follows (or all of it, if less).
|
|
111
|
+
def strand?(child, left_after, rest)
|
|
112
|
+
want = child.keep_with_next
|
|
113
|
+
return false unless want && rest.any? && !@placed.empty?
|
|
114
|
+
return !starts?(rest.first, left_after) unless want.is_a?(Numeric)
|
|
115
|
+
|
|
116
|
+
following = 0
|
|
117
|
+
rest.each do |node|
|
|
118
|
+
following += node.measure(node.width_in(@width))
|
|
119
|
+
break if following >= want
|
|
120
|
+
end
|
|
121
|
+
left_after + EPSILON < [want, following].min
|
|
122
|
+
end
|
|
123
|
+
|
|
105
124
|
def split_or_move(child, remaining, rest)
|
|
106
|
-
|
|
107
|
-
head, tail = child.split(@width, remaining)
|
|
108
|
-
|
|
125
|
+
if may_split?(child)
|
|
126
|
+
head, tail = child.split(child.width_in(@width), remaining, fresh: @fresh && @placed.empty?)
|
|
127
|
+
# A nested flow can finish on this page (its trailing spacer
|
|
128
|
+
# dropped at the break) and hand back no remainder.
|
|
129
|
+
return [part(@placed + [head]), part([tail, *rest].compact)] if head
|
|
109
130
|
end
|
|
110
131
|
return [part([child]), part(rest)] if @placed.empty? && @fresh
|
|
111
132
|
|
|
112
133
|
@placed.empty? ? [nil, part([child, *rest])] : [part(@placed), part([child, *rest])]
|
|
113
134
|
end
|
|
114
135
|
|
|
136
|
+
def may_split?(child, top: @placed.empty? && @fresh)
|
|
137
|
+
!child.avoid_break? && (!child.prefer_whole? || top)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Whether any of `node` would be placed in `height` below other content.
|
|
141
|
+
def starts?(node, height)
|
|
142
|
+
width = node.width_in(@width)
|
|
143
|
+
return !node.split(width, height).first.nil? if may_split?(node, top: false)
|
|
144
|
+
|
|
145
|
+
node.measure(width) <= height + EPSILON
|
|
146
|
+
end
|
|
147
|
+
|
|
115
148
|
def part(children)
|
|
116
149
|
children.empty? ? nil : @flow.with_children(children)
|
|
117
150
|
end
|