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.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -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 +10 -6
  10. data/lib/stationery/canvas.rb +38 -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 +60 -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 +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -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 +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. metadata +101 -4
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # One list entry: a marker right-aligned in the column before `indent`
6
+ # (`marker_gap` short of it) and a body flow beside it. Splits inside the
7
+ # body; the marker stays with the first fragment and never leaves its
8
+ # first line behind.
9
+ class ListItem < Node
10
+ attr_reader :marker, :body, :indent, :marker_gap
11
+
12
+ def initialize(marker, body, indent:, marker_gap:)
13
+ super()
14
+ @marker = marker
15
+ @body = body
16
+ @indent = indent
17
+ @marker_gap = marker_gap
18
+ end
19
+
20
+ def splittable? = @body.splittable?
21
+ def natural_width = @indent + @body.natural_width
22
+ def min_width = @indent + @body.min_width
23
+ def break_inside = @body.break_inside
24
+
25
+ def break_inside=(value)
26
+ @body.break_inside = value
27
+ end
28
+
29
+ def measure(width)
30
+ memoize_by_width(width) { [@marker ? @marker.measure(column) : 0, @body.measure(body_width(width))].max }
31
+ end
32
+
33
+ def paint(canvas, x, y, width, _height = nil, **)
34
+ if @marker
35
+ own = @marker.width_in(column)
36
+ @marker.paint(canvas, x + column - own, y, own)
37
+ end
38
+ @body.paint(canvas, x + @indent, y, body_width(width))
39
+ end
40
+
41
+ def split(width, height, fresh: false)
42
+ return [self, nil] if measure(width) <= height + EPSILON
43
+
44
+ head, tail = @body.split(body_width(width), height, fresh:)
45
+ return [nil, self] unless head
46
+
47
+ [with(@marker, head), tail && with(nil, tail).tap { |rest| rest.keep_with_next = keep_with_next }]
48
+ end
49
+
50
+ private
51
+
52
+ def column = [@indent - @marker_gap, 0].max
53
+ def body_width(width) = [width - @indent, 0].max
54
+ def with(marker, body) = self.class.new(marker, body, indent: @indent, marker_gap: @marker_gap)
55
+ end
56
+
57
+ # A drawn bullet (:disc, :circle or :square) sized to the text style and
58
+ # centred on its x-height.
59
+ class Bullet < Node
60
+ SIZE = 0.36
61
+ STROKE = 0.06
62
+
63
+ def initialize(shape, style:, context:)
64
+ super()
65
+ @shape = shape
66
+ @style = style
67
+ @font = context.book.resolve(style).first
68
+ end
69
+
70
+ def measure(_width) = @font.line_height(@style.size)
71
+ def fixed_width(_available) = diameter
72
+ def natural_width = diameter
73
+
74
+ def paint(canvas, x, y, _width, _height = nil, **)
75
+ radius = diameter / 2.0
76
+ cy = y + @font.ascender(@style.size) - (@font.x_height(@style.size) / 2.0)
77
+ case @shape
78
+ when :circle then canvas.circle(x + radius, cy, radius, stroke: @style.color, line_width: STROKE * @style.size)
79
+ when :square then canvas.fill_rect(x, cy - radius, diameter, diameter, color: @style.color)
80
+ else canvas.circle(x + radius, cy, radius, fill: @style.color)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def diameter = SIZE * @style.size
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # Takes up no space and paints nothing. Unlike a Spacer it survives a page
6
+ # break, so a standalone anchor is never dropped.
7
+ class Empty < Node
8
+ def measure(_width) = 0
9
+ def paint(*, **) = nil
10
+ end
11
+
12
+ # Names a node as a link target: its top is recorded as an anchor for each
13
+ # name where it is painted. When the node splits, the names stay with the
14
+ # first fragment.
15
+ class Mark < Node
16
+ attr_reader :child, :names
17
+
18
+ def self.standalone(name)
19
+ new(Empty.new, [name.to_s]).tap { |mark| mark.keep_with_next = true }
20
+ end
21
+
22
+ def initialize(child, names)
23
+ super()
24
+ @child = child
25
+ @names = names
26
+ end
27
+
28
+ def measure(width) = @child.measure(width)
29
+ def natural_width = @child.natural_width
30
+ def min_width = @child.min_width
31
+ def fixed_width(available) = @child.fixed_width(available)
32
+ def splittable? = @child.splittable?
33
+ def page_break? = @child.page_break?
34
+ def avoid_break? = @child.avoid_break?
35
+ def keep_with_next = @child.keep_with_next
36
+
37
+ def keep_with_next=(value)
38
+ @child.keep_with_next = value
39
+ end
40
+
41
+ def break_inside = @child.break_inside
42
+
43
+ def break_inside=(value)
44
+ @child.break_inside = value
45
+ end
46
+
47
+ def paint(canvas, x, y, width, height = nil, **)
48
+ @names.each { |name| canvas.anchor(name, y) }
49
+ @child.paint(canvas, x, y, width, height, **)
50
+ end
51
+
52
+ def split(width, height, **)
53
+ head, tail = @child.split(width, height, **)
54
+ [head && Mark.new(head, @names), tail]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -19,7 +19,9 @@ module Stationery
19
19
  #
20
20
  # - measure(width) → its height at that width
21
21
  # - paint(canvas, x, y, width, height = nil, **)
22
- # - split(width, height) → [part that fits, remainder] (nil for an empty side)
22
+ # - split(width, height, fresh: false)
23
+ # → [part that fits, remainder] (nil for an empty side);
24
+ # fresh: true when nothing is above it on a new page
23
25
  # - natural_width / min_width → its preferred and narrowest widths
24
26
  # - fixed_width(available) → its own width, or nil when it fills the width
25
27
  class Node
@@ -29,18 +31,40 @@ module Stationery
29
31
  def paint(_canvas, _x, _y, _width, _height = nil, **) = raise(NotImplementedError, "#{self.class}#paint")
30
32
 
31
33
  def splittable? = false
34
+ # Moves whole to a fresh page before splitting; splits only when it does
35
+ # not fit there either.
36
+ def prefer_whole? = false
32
37
  def natural_width = 0
33
38
  def min_width = 0
34
39
  def fixed_width(_available) = nil
40
+ # The width this node is laid out at inside a parent of `available`.
41
+ def width_in(available) = fixed_width(available) || available
35
42
  def page_break? = false
36
43
 
37
- def split(width, height)
44
+ def split(width, height, **)
38
45
  measure(width) <= height + EPSILON ? [self, nil] : [nil, self]
39
46
  end
40
47
 
41
48
  def avoid_break?
42
49
  break_inside == :avoid || !splittable?
43
50
  end
51
+
52
+ private
53
+
54
+ # Remembers a height per width: a page measures the same subtree for the
55
+ # flow splitter, keep_with_next and paint. Nodes are not changed after
56
+ # layout starts (Flow#<< forgets), and copies start with no measurements.
57
+ def memoize_by_width(width)
58
+ @measures ||= {}
59
+ @measures.fetch(width) { @measures[width] = yield }
60
+ end
61
+
62
+ def forget_measures = @measures = nil
63
+
64
+ def initialize_copy(source)
65
+ super
66
+ forget_measures
67
+ end
44
68
  end
45
69
  end
46
70
  end
@@ -7,10 +7,12 @@ module Stationery
7
7
  class Paginator
8
8
  attr_reader :warnings
9
9
 
10
- def initialize(resources:, page: {})
10
+ def initialize(resources:, page: {}, warnings: Warnings.new, debug: false, regions: nil)
11
11
  @resources = resources
12
+ @debug = debug
12
13
  @page_options = page
13
- @warnings = []
14
+ @regions = regions
15
+ @warnings = warnings
14
16
  end
15
17
 
16
18
  def paginate(root)
@@ -18,9 +20,9 @@ module Stationery
18
20
  pages = []
19
21
  remaining = root
20
22
  while remaining
21
- page = Page.new(**@page_options)
22
- head, remaining = remaining.split(page.content_box.width, page.content_box.height, fresh: true)
23
- place(page, head, pages.size + 1)
23
+ number = pages.size + 1
24
+ page, head, remaining = next_page(remaining, number)
25
+ place(page, head, number)
24
26
  pages << page
25
27
  end
26
28
  pages
@@ -28,13 +30,40 @@ module Stationery
28
30
 
29
31
  private
30
32
 
33
+ # A page whose regions differ when it is the last one tries the
34
+ # last-page box first: if everything left fits, it is the last page.
35
+ # Content that fits a normal page but not the last one moves partly on,
36
+ # so the last page follows.
37
+ def next_page(remaining, number)
38
+ page = new_page(number)
39
+ return [page, *fill(page, remaining)] unless @regions&.last_sensitive?(number)
40
+
41
+ last = new_page(number, last: true)
42
+ head, rest = fill(last, remaining)
43
+ return [last, head, nil] unless rest
44
+
45
+ normal_head, normal_rest = fill(page, remaining)
46
+ normal_rest ? [page, normal_head, normal_rest] : [page, head, rest]
47
+ end
48
+
49
+ def fill(page, remaining) = remaining.split(page.content_box.width, page.content_box.height, fresh: true)
50
+
51
+ def new_page(number, last: false)
52
+ page = Page.new(**@page_options, reserve: @regions ? @regions.reserve(number, last:) : [0, 0])
53
+ return page if page.reserve.sum < page.margin_box.height
54
+
55
+ raise ArgumentError, "header and footer leave no room for content on page #{number}"
56
+ end
57
+
31
58
  def place(page, head, number)
32
59
  return unless head
33
60
 
34
61
  box = page.content_box
35
62
  height = head.measure(box.width)
36
63
  @warnings << Overflow.new(page: number, height:, available: box.height) if height > box.height + EPSILON
37
- head.paint(Canvas.new(page, @resources), box.x, box.y, box.width)
64
+ canvas = Canvas.new(page, @resources, debug: @debug)
65
+ head.paint(canvas, box.x, box.y, box.width)
66
+ canvas.debug_rect(box.x, box.y, box.width, box.height, :page)
38
67
  end
39
68
  end
40
69
  end
@@ -17,7 +17,9 @@ module Stationery
17
17
  def measure(_width) = 0
18
18
 
19
19
  def paint(canvas, _x, _y, width, _height = nil, **)
20
- @node.paint(canvas, @x, @y, @width || @node.fixed_width(width) || width)
20
+ width = @width || @node.fixed_width(width) || width
21
+ @node.paint(canvas, @x, @y, width)
22
+ canvas.debug_rect(@x, @y, width, @node.measure(width), :positioned) if canvas.debug?
21
23
  end
22
24
  end
23
25
  end
@@ -4,8 +4,11 @@ module Stationery
4
4
  module Layout
5
5
  # Columns side by side. A column's `width:` is points (a number above 1),
6
6
  # a fraction of the row (0.5), :auto (its natural width) or nil (an equal
7
- # share of what is left). The row is as tall as its tallest column, stretches
8
- # every column to that height and never splits.
7
+ # share of what is left). The row is as tall as its tallest column and
8
+ # stretches every column to that height. Like a box it moves to the next
9
+ # page whole when it fits there and otherwise splits every column at the
10
+ # same line; a column that ends early continues as an empty fragment so
11
+ # the backgrounds stay aligned.
9
12
  class Row < Node
10
13
  attr_reader :columns
11
14
 
@@ -19,15 +22,35 @@ module Stationery
19
22
  def natural_width = @columns.sum(&:natural_width) + gaps
20
23
  def min_width = @columns.sum(&:min_width) + gaps
21
24
 
25
+ def splittable? = @columns.all?(&:splittable?)
26
+ def prefer_whole? = break_inside.nil? && splittable?
27
+
28
+ def split(width, height, fresh: false)
29
+ return [self, nil] if measure(width) <= height + EPSILON
30
+
31
+ parts = @columns.zip(column_widths(width)).map do |column, w|
32
+ next [column, nil] if column.measure(w) <= height + EPSILON
33
+
34
+ column.avoid_break? ? [nil, column] : column.split(w, height, fresh:)
35
+ end
36
+ return [nil, self] if parts.any? { |head, _| head.nil? }
37
+
38
+ heads = parts.map { |head, _| head.with_open(:bottom) }
39
+ tails = parts.zip(@columns).map { |(_, tail), column| tail || column.continued }
40
+ [with_columns(heads).tap { |row| row.keep_with_next = nil }, with_columns(tails)]
41
+ end
42
+
43
+ def with_columns(columns) = dup.replace_columns(columns)
44
+
22
45
  def measure(width)
23
- @columns.zip(column_widths(width)).map { |column, w| column.measure(w) }.max || 0
46
+ memoize_by_width(width) { @columns.zip(column_widths(width)).map { |column, w| column.measure(w) }.max || 0 }
24
47
  end
25
48
 
26
49
  def paint(canvas, x, y, width, height = nil, **)
27
50
  height ||= measure(width)
28
51
  left = x
29
52
  @columns.zip(column_widths(width)).each do |column, w|
30
- column.paint(canvas, left, y, w, height, valign: @align)
53
+ column.paint(canvas, left, y, w, height, valign: @align, debug_kind: :column)
31
54
  left += w + @gap
32
55
  end
33
56
  end
@@ -42,6 +65,13 @@ module Stationery
42
65
  widths
43
66
  end
44
67
 
68
+ protected
69
+
70
+ def replace_columns(columns)
71
+ @columns = columns
72
+ self
73
+ end
74
+
45
75
  private
46
76
 
47
77
  def gaps = @gap * [@columns.size - 1, 0].max
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # A vector drawing at a fixed size: `width:` and/or `height:` (the other
6
+ # follows the viewBox's aspect ratio), never wider than the space given.
7
+ class Svg < Node
8
+ # `context` gives SVG text its font book and default family.
9
+ def initialize(document, width: nil, height: nil, color: "#000000", context: nil)
10
+ super()
11
+ @document = document
12
+ @context = context
13
+ @width = width
14
+ @height = height
15
+ @color = color
16
+ end
17
+
18
+ def size(available)
19
+ width, height = requested
20
+ return [width, height] if width <= available
21
+
22
+ [available, height * available / width]
23
+ end
24
+
25
+ def measure(width) = size(width)[1]
26
+ def fixed_width(available) = size(available)[0]
27
+ def natural_width = requested[0]
28
+
29
+ def paint(canvas, x, y, width, _height = nil, **)
30
+ w, h = size(width)
31
+ @document.draw(canvas, x:, y:, width: w, height: h, color: @color, book: @context&.book,
32
+ family: @context&.style&.family)
33
+ canvas.debug_rect(x, y, w, h, :image)
34
+ end
35
+
36
+ private
37
+
38
+ def requested
39
+ aspect = @document.aspect
40
+ if @width && @height then [@width, @height]
41
+ elsif @width then [@width, @width / aspect]
42
+ elsif @height then [@height * aspect, @height]
43
+ else @document.view_box.last(2)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -9,24 +9,39 @@ module Stationery
9
9
  TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
10
10
  letter_spacing: :letter_spacing }.freeze
11
11
 
12
- attr_reader :content, :options
12
+ attr_reader :content, :options, :colspan, :rowspan
13
13
 
14
- def initialize(content, options)
14
+ def initialize(content, options, colspan: 1, rowspan: 1)
15
15
  @content = content
16
16
  @options = options.dup
17
+ @colspan = colspan
18
+ @rowspan = rowspan
17
19
  end
18
20
 
19
21
  def node(context)
20
22
  @node ||= @content.is_a?(Node) ? @content : text_node(context)
21
23
  end
22
24
 
23
- def padding = Geometry.box(@options[:padding])
25
+ # The same cell holding another node, as a row split into two parts needs.
26
+ def with_content(node) = Cell.new(node, @options, colspan:, rowspan:)
27
+
28
+ # Changes an option and forgets the node built from the old ones.
29
+ def []=(name, value)
30
+ @options[name] = value
31
+ @node = nil
32
+ @padding = nil
33
+ @metrics = nil
34
+ end
35
+
36
+ def padding = @padding ||= Geometry.box(@options[:padding])
24
37
  def horizontal = padding[1] + padding[3]
25
38
  def vertical = padding[0] + padding[2]
26
39
 
27
- def measure(context, width) = node(context).measure([width - horizontal, 0].max) + vertical
28
- def natural_width(context) = node(context).natural_width + horizontal
29
- def min_width(context) = node(context).min_width + horizontal
40
+ # Heights per width and the natural and minimum widths are remembered:
41
+ # every fragment of a table split across pages measures the same cells.
42
+ def measure(context, width) = metric(width) { node(context).measure([width - horizontal, 0].max) + vertical }
43
+ def natural_width(context) = metric(:natural) { node(context).natural_width + horizontal }
44
+ def min_width(context) = metric(:min) { node(context).min_width + horizontal }
30
45
 
31
46
  # Backgrounds overlap the next cell by SEAM so viewers do not show
32
47
  # hairline gaps between neighbouring fills.
@@ -39,10 +54,16 @@ module Stationery
39
54
  end
40
55
  paint_content(canvas, context, rect)
41
56
  paint_borders(canvas, rect)
57
+ paint_debug(canvas, rect) if canvas.debug?
42
58
  end
43
59
 
44
60
  private
45
61
 
62
+ def metric(key)
63
+ @metrics ||= {}
64
+ @metrics.fetch(key) { @metrics[key] = yield }
65
+ end
66
+
46
67
  def text_node(context)
47
68
  style = context.style.with(**TEXT_OPTIONS.filter_map do |key, attr|
48
69
  [attr, @options[key]] if @options.key?(key)
@@ -68,6 +89,12 @@ module Stationery
68
89
  content.paint(canvas, left, inner.y + offset, own || inner.width)
69
90
  end
70
91
 
92
+ def paint_debug(canvas, rect)
93
+ canvas.debug_rect(rect.x, rect.y, rect.width, rect.height, :cell)
94
+ inner = rect.inset(*padding)
95
+ canvas.debug_rect(inner.x, inner.y, inner.width, inner.height, :cell_padding)
96
+ end
97
+
71
98
  def paint_borders(canvas, rect)
72
99
  width = @options[:border_width]
73
100
  return if width.nil? || width.zero?
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Table < Node
6
+ # Places cells that span columns and rows the way HTML does: each row
7
+ # lists only the cells it starts, and a cell skips slots a rowspan from
8
+ # above already covers. Rowspans running past the last row are clamped.
9
+ class Grid
10
+ Placement = Data.define(:cell, :row, :column, :colspan, :rowspan) do
11
+ def columns = column...(column + colspan)
12
+ def rows = row...(row + rowspan)
13
+ end
14
+
15
+ attr_reader :placements, :row_count, :column_count
16
+
17
+ def initialize(rows)
18
+ @row_count = rows.size
19
+ @slots = Array.new(@row_count) { [] }
20
+ @placements = rows.each_with_index.flat_map { |row, r| place(row, r) }
21
+ @column_count = @placements.map { |p| p.columns.end }.max || 0
22
+ end
23
+
24
+ def at(row, column) = row.negative? || column.negative? ? nil : @slots.dig(row, column)&.cell
25
+
26
+ # Row indexes a horizontal cut can fall before without splitting a rowspan.
27
+ def boundaries
28
+ @boundaries ||= begin
29
+ inside = Array.new(row_count + 1, false)
30
+ @placements.each do |p|
31
+ ((p.row + 1)...p.rows.end).each { |r| inside[r] = true } if p.rowspan > 1
32
+ end
33
+ (0..row_count).reject { |r| inside[r] }.freeze
34
+ end
35
+ end
36
+
37
+ # A metric per column: the largest single-column value, then the excess
38
+ # of a spanning cell over its columns spread evenly across them.
39
+ def column_metric
40
+ values = Array.new(column_count, 0)
41
+ single, spanning = @placements.partition { |p| p.colspan == 1 }
42
+ single.each do |p|
43
+ value = yield(p)
44
+ values[p.column] = value if value > values[p.column]
45
+ end
46
+ spanning.each do |p|
47
+ excess = yield(p) - values[p.columns].sum
48
+ p.columns.each { |c| values[c] += excess.fdiv(p.colspan) } if excess.positive?
49
+ end
50
+ values
51
+ end
52
+
53
+ # Each row's height from its cells measured at their spanned width; a
54
+ # rowspan taller than its rows adds the excess to its last row.
55
+ def row_heights(widths)
56
+ heights = Array.new(row_count, 0)
57
+ single, spanning = @placements.partition { |p| p.rowspan == 1 }
58
+ single.each do |p|
59
+ height = yield(p, p.colspan == 1 ? widths[p.column] : widths[p.columns].sum)
60
+ heights[p.row] = height if height > heights[p.row]
61
+ end
62
+ spanning.each do |p|
63
+ excess = yield(p, widths[p.columns].sum) - heights[p.rows].sum
64
+ heights[p.rows.end - 1] += excess if excess.positive?
65
+ end
66
+ heights
67
+ end
68
+
69
+ private
70
+
71
+ def place(row, r)
72
+ column = 0
73
+ slots = @slots[r]
74
+ row.map do |cell|
75
+ column += 1 while slots[column]
76
+ placement = Placement.new(cell, r, column, cell.colspan, cell.rowspan.clamp(1, row_count - r))
77
+ occupy(placement)
78
+ column += cell.colspan
79
+ placement
80
+ end
81
+ end
82
+
83
+ def occupy(placement)
84
+ if placement.colspan == 1 && placement.rowspan == 1
85
+ @slots[placement.row][placement.column] = placement
86
+ else
87
+ placement.rows.each { |pr| placement.columns.each { |pc| @slots[pr][pc] = placement } }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Table < Node
6
+ # Cuts one row too tall for the space left: each cell's content splits at
7
+ # that height, what fits stays above the cut and the rest starts the row
8
+ # again below it. A cell with nothing on one side becomes an empty cell
9
+ # there, so backgrounds and borders run the full height of both parts.
10
+ class RowSplitter
11
+ def initialize(placements, widths:, context:)
12
+ @placements = placements
13
+ @widths = widths
14
+ @context = context
15
+ end
16
+
17
+ # [head cells, tail cells], or nil when no cell fits anything above the cut.
18
+ def call(height)
19
+ parts = @placements.map { |placement| [placement.cell, *cut(placement, height)] }
20
+ return if parts.none? { |_, head| head }
21
+
22
+ [parts.map { |cell, head| cell.with_content(head || Flow.new) },
23
+ parts.map { |cell, _, tail| cell.with_content(tail || Flow.new) }]
24
+ end
25
+
26
+ private
27
+
28
+ def cut(placement, height)
29
+ cell = placement.cell
30
+ width = [@widths[placement.columns].sum - cell.horizontal, 0].max
31
+ cell.node(@context).split(width, height - cell.vertical)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -24,7 +24,7 @@ module Stationery
24
24
  def columns(spec) = Selection.new(@table, @rows, @columns & Selection.indexes(spec, @table.column_count))
25
25
  alias column columns
26
26
 
27
- def cells = @rows.flat_map { |r| @columns.filter_map { |c| @table.cell(r, c) } }
27
+ def cells = @rows.flat_map { |r| @columns.filter_map { |c| @table.cell(r, c) } }.uniq
28
28
 
29
29
  def set(**attributes)
30
30
  attributes.each { |name, value| public_send(:"#{name}=", value) }
@@ -33,7 +33,8 @@ module Stationery
33
33
 
34
34
  ATTRIBUTES.each do |name|
35
35
  define_method(:"#{name}=") do |value|
36
- cells.each { |cell| cell.options[name] = value }
36
+ cells.each { |cell| cell[name] = value }
37
+ @table.invalidate!
37
38
  end
38
39
  end
39
40