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,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,26 @@
2
2
 
3
3
  module Stationery
4
4
  module Layout
5
- # A container with padding, background, border and corner radius. Boxes
6
- # move to the next page whole. A fixed-height box can truncate or shrink
7
- # text that does not fit (`overflow:`).
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.
8
12
  class Box < Node
9
13
  BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
10
14
 
11
15
  attr_reader :content, :width_spec
12
16
 
13
17
  def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
14
- height: nil, overflow: :visible, valign: :top, opacity: nil)
18
+ height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
19
+ open: [], decoration: :slice)
15
20
  super()
21
+ @open = open
22
+ @decoration = decoration
23
+ @link = link
24
+ @outset = Geometry.box(outset)
16
25
  @content = content
17
26
  @padding = Geometry.box(padding)
18
27
  @background = background
@@ -39,47 +48,99 @@ module Stationery
39
48
  end
40
49
  end
41
50
 
51
+ def splittable? = @height.nil? && @overflow == :visible && @content.splittable?
52
+ def prefer_whole? = break_inside.nil? && splittable?
53
+
54
+ def split(width, height, fresh: false)
55
+ return [self, nil] if measure(width) <= height + EPSILON
56
+
57
+ cut = @open | [:bottom]
58
+ available = height - vertical(cut)
59
+ head, tail = @content.split(inner_width(width), available, fresh:)
60
+ return [nil, self] unless head
61
+ return [with_content(head), nil] unless tail
62
+
63
+ [with_content(head, cut).tap { |part| part.keep_with_next = nil }, with_content(tail, @open | [:top])]
64
+ end
65
+
66
+ # A copy holding other content and open sides, every option kept.
67
+ def with_content(content, open = @open) = dup.reopen(content, open)
68
+ def with_open(*sides) = with_content(@content, @open | sides)
69
+
42
70
  def measure(width)
43
- @height || (@content.measure(inner_width(width)) + vertical)
71
+ @height || memoize_by_width(width) { @content.measure(inner_width(width)) + vertical }
44
72
  end
45
73
 
46
- def paint(canvas, x, y, width, height = nil, valign: nil, **)
74
+ def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
47
75
  height ||= measure(width)
48
76
  paint_background(canvas, x, y, width, height)
49
77
  paint_border(canvas, x, y, width, height)
50
78
  paint_content(canvas, x, y, width, height, valign || @valign)
79
+ canvas.link(x, y, width, height, @link) if @link
80
+ paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
81
+ end
82
+
83
+ protected
84
+
85
+ def reopen(content, open)
86
+ @content = content
87
+ @open = open
88
+ self
51
89
  end
52
90
 
53
91
  private
54
92
 
55
- def insets
93
+ def insets(open = @open)
56
94
  border = @border ? @border[:width] : 0
57
- top, right, bottom, left = @padding
58
- sides = @border ? @border[:sides] : []
59
- [top + (sides.include?(:top) ? border : 0), right + (sides.include?(:right) ? border : 0),
60
- bottom + (sides.include?(:bottom) ? border : 0), left + (sides.include?(:left) ? border : 0)]
95
+ sides = @border ? @border[:sides] - open : []
96
+ Geometry::SIDES.keys.zip(@padding).map do |side, padding|
97
+ (open.include?(side) && @decoration == :slice ? 0 : padding) + (sides.include?(side) ? border : 0)
98
+ end
61
99
  end
62
100
 
63
101
  def horizontal = insets[1] + insets[3]
64
- def vertical = insets[0] + insets[2]
102
+ def vertical(open = @open) = insets(open).values_at(0, 2).sum
65
103
  def inner_width(width) = [width - horizontal, 0].max
66
104
 
105
+ # The outset paints the background past the box's own edges (a band that
106
+ # bleeds into the page margins) without moving the content.
67
107
  def paint_background(canvas, x, y, width, height)
68
108
  return unless @background
69
109
 
70
- canvas.rounded_rect(x, y, width, height, radius: @radius, fill: @background, opacity: @opacity)
110
+ top, right, bottom, left = @outset.each_with_index.map { |v, i| open?(i) ? 0 : v }
111
+ area = Rect.new(x - left, y - top, width + left + right, height + top + bottom)
112
+ cut(canvas, area, area) do |shape|
113
+ canvas.rounded_rect(*shape.to_h.values, radius: @radius, fill: @background, opacity: @opacity)
114
+ end
71
115
  end
72
116
 
73
117
  def paint_border(canvas, x, y, width, height)
74
118
  return unless @border
75
119
 
76
120
  stroke = @border[:width]
121
+ area = Rect.new(x, y, width, height)
77
122
  if @border[:sides].size == 4 && @radius.positive?
78
123
  half = stroke / 2.0
79
- canvas.rounded_rect(x + half, y + half, width - stroke, height - stroke,
80
- radius: @radius, stroke: @border[:color], line_width: stroke)
124
+ cut(canvas, area, area.inset(half, half, half, half)) do |shape|
125
+ canvas.rounded_rect(*shape.to_h.values, radius: @radius, stroke: @border[:color], line_width: stroke)
126
+ end
81
127
  else
82
- @border[:sides].each { |side| border_side(canvas, side, Rect.new(x, y, width, height), stroke) }
128
+ (@border[:sides] - @open).each { |side| border_side(canvas, side, area, stroke) }
129
+ end
130
+ end
131
+
132
+ def open?(index) = @open.include?(Geometry::SIDES.key(index))
133
+
134
+ # Cut edges are square: the rounded shape runs past them by its radius
135
+ # and stroke, clipped to the fragment.
136
+ def cut(canvas, area, shape)
137
+ return yield(shape) if @open.empty? || !@radius.positive?
138
+
139
+ reach = @radius + (@border ? @border[:width] : 0)
140
+ top = @open.include?(:top) ? reach : 0
141
+ bottom = @open.include?(:bottom) ? reach : 0
142
+ canvas.clip(area.x, area.y, area.width, area.height) do
143
+ yield Rect.new(shape.x, shape.y - top, shape.width, shape.height + top + bottom)
83
144
  end
84
145
  end
85
146
 
@@ -115,6 +176,14 @@ module Stationery
115
176
  end
116
177
  end
117
178
 
179
+ def paint_debug(canvas, rect, kind)
180
+ canvas.debug_rect(rect.x, rect.y, rect.width, rect.height, kind)
181
+ return if insets.all?(&:zero?)
182
+
183
+ inner = rect.inset(*insets)
184
+ canvas.debug_rect(inner.x, inner.y, inner.width, inner.height, :padding)
185
+ end
186
+
118
187
  def fitted(inner)
119
188
  return @content if @overflow == :visible || @height.nil? || @content.measure(inner.width) <= inner.height
120
189
  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, anything else moves there whole, a spacer at the break is dropped
8
- # and a child marked keep_with_next moves with its successor.
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
- visible = @children.reject(&:page_break?)
30
- visible.sum { |child| child.measure(width) } + (@gap * [visible.size - 1, 0].max)
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.fixed_width(width)
38
- left = own ? x + Geometry.align_offset(@align, width, own) : x
39
- child.paint(canvas, left, cursor, own || width)
40
- cursor += child.measure(width)
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
- if child.keep_with_next && rest.any? && !@placed.empty? && rest.first.split(@width, left_after).first.nil?
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
- unless child.avoid_break?
107
- head, tail = child.split(@width, remaining)
108
- return [part(@placed + [head]), part([tail, *rest])] if head
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
@@ -30,6 +30,7 @@ module Stationery
30
30
  def paint(canvas, x, y, width, _height = nil, **)
31
31
  w, h = size(width)
32
32
  canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
33
+ canvas.debug_rect(x, y, w, h, :image)
33
34
  end
34
35
 
35
36
  private