stationery 0.1.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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +145 -0
  5. data/lib/stationery/builder.rb +61 -0
  6. data/lib/stationery/canvas/path.rb +82 -0
  7. data/lib/stationery/canvas/text.rb +55 -0
  8. data/lib/stationery/canvas.rb +121 -0
  9. data/lib/stationery/color.rb +62 -0
  10. data/lib/stationery/component.rb +54 -0
  11. data/lib/stationery/document.rb +95 -0
  12. data/lib/stationery/elements.rb +90 -0
  13. data/lib/stationery/errors.rb +11 -0
  14. data/lib/stationery/fonts/cmap.rb +75 -0
  15. data/lib/stationery/fonts/family.rb +51 -0
  16. data/lib/stationery/fonts/font.rb +149 -0
  17. data/lib/stationery/fonts/font_book.rb +36 -0
  18. data/lib/stationery/fonts/name_table.rb +39 -0
  19. data/lib/stationery/fonts/registry.rb +38 -0
  20. data/lib/stationery/fonts/subset.rb +139 -0
  21. data/lib/stationery/fonts/true_type.rb +174 -0
  22. data/lib/stationery/geometry.rb +56 -0
  23. data/lib/stationery/images/cache.rb +32 -0
  24. data/lib/stationery/images/images.rb +45 -0
  25. data/lib/stationery/images/jpeg.rb +53 -0
  26. data/lib/stationery/images/png.rb +119 -0
  27. data/lib/stationery/images/scanlines.rb +56 -0
  28. data/lib/stationery/layout/box.rb +134 -0
  29. data/lib/stationery/layout/flow.rb +121 -0
  30. data/lib/stationery/layout/image.rb +53 -0
  31. data/lib/stationery/layout/leaves.rb +58 -0
  32. data/lib/stationery/layout/node.rb +46 -0
  33. data/lib/stationery/layout/paginator.rb +41 -0
  34. data/lib/stationery/layout/positioned.rb +24 -0
  35. data/lib/stationery/layout/row.rb +71 -0
  36. data/lib/stationery/layout/table/cell.rb +93 -0
  37. data/lib/stationery/layout/table/selection.rb +52 -0
  38. data/lib/stationery/layout/table/widths.rb +53 -0
  39. data/lib/stationery/layout/table.rb +118 -0
  40. data/lib/stationery/layout/text.rb +62 -0
  41. data/lib/stationery/page.rb +47 -0
  42. data/lib/stationery/page_templates.rb +36 -0
  43. data/lib/stationery/pdf/assembler.rb +53 -0
  44. data/lib/stationery/pdf/serializer.rb +56 -0
  45. data/lib/stationery/pdf/stream.rb +27 -0
  46. data/lib/stationery/pdf/types.rb +15 -0
  47. data/lib/stationery/pdf/writer.rb +59 -0
  48. data/lib/stationery/rails.rb +20 -0
  49. data/lib/stationery/resources.rb +34 -0
  50. data/lib/stationery/text/entities.rb +23 -0
  51. data/lib/stationery/text/line.rb +26 -0
  52. data/lib/stationery/text/markup.rb +103 -0
  53. data/lib/stationery/text/paragraph.rb +98 -0
  54. data/lib/stationery/text/runs_builder.rb +52 -0
  55. data/lib/stationery/text/style.rb +46 -0
  56. data/lib/stationery/text/wrapper.rb +131 -0
  57. data/lib/stationery/version.rb +5 -0
  58. data/lib/stationery.rb +58 -0
  59. metadata +108 -0
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
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:`).
8
+ class Box < Node
9
+ BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
10
+
11
+ attr_reader :content, :width_spec
12
+
13
+ def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
14
+ height: nil, overflow: :visible, valign: :top, opacity: nil)
15
+ super()
16
+ @content = content
17
+ @padding = Geometry.box(padding)
18
+ @background = background
19
+ @border = border && BORDER.merge(border)
20
+ @radius = radius
21
+ @width_spec = width
22
+ @height = height
23
+ @overflow = overflow
24
+ @valign = valign
25
+ @opacity = opacity
26
+ end
27
+
28
+ def natural_width
29
+ @width_spec.is_a?(Numeric) && @width_spec > 1 ? @width_spec : @content.natural_width + horizontal
30
+ end
31
+
32
+ def min_width = @content.min_width + horizontal
33
+
34
+ def fixed_width(available)
35
+ case @width_spec
36
+ when :auto then [natural_width, available].min
37
+ when Float then @width_spec <= 1 ? available * @width_spec : @width_spec
38
+ when Numeric then @width_spec
39
+ end
40
+ end
41
+
42
+ def measure(width)
43
+ @height || (@content.measure(inner_width(width)) + vertical)
44
+ end
45
+
46
+ def paint(canvas, x, y, width, height = nil, valign: nil, **)
47
+ height ||= measure(width)
48
+ paint_background(canvas, x, y, width, height)
49
+ paint_border(canvas, x, y, width, height)
50
+ paint_content(canvas, x, y, width, height, valign || @valign)
51
+ end
52
+
53
+ private
54
+
55
+ def insets
56
+ 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)]
61
+ end
62
+
63
+ def horizontal = insets[1] + insets[3]
64
+ def vertical = insets[0] + insets[2]
65
+ def inner_width(width) = [width - horizontal, 0].max
66
+
67
+ def paint_background(canvas, x, y, width, height)
68
+ return unless @background
69
+
70
+ canvas.rounded_rect(x, y, width, height, radius: @radius, fill: @background, opacity: @opacity)
71
+ end
72
+
73
+ def paint_border(canvas, x, y, width, height)
74
+ return unless @border
75
+
76
+ stroke = @border[:width]
77
+ if @border[:sides].size == 4 && @radius.positive?
78
+ 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)
81
+ else
82
+ @border[:sides].each { |side| border_side(canvas, side, Rect.new(x, y, width, height), stroke) }
83
+ end
84
+ end
85
+
86
+ def border_side(canvas, side, rect, stroke)
87
+ half = stroke / 2.0
88
+ x1, y1, x2, y2 = case side
89
+ when :top then [rect.x, rect.y + half, rect.right, rect.y + half]
90
+ when :bottom then [rect.x, rect.bottom - half, rect.right, rect.bottom - half]
91
+ when :left then [rect.x + half, rect.y, rect.x + half, rect.bottom]
92
+ else [rect.right - half, rect.y, rect.right - half, rect.bottom]
93
+ end
94
+ canvas.line(x1, y1, x2, y2, color: @border[:color], width: stroke)
95
+ end
96
+
97
+ def paint_content(canvas, x, y, width, height, valign)
98
+ top, _right, _bottom, left = insets
99
+ inner = Rect.new(x, y, width, height).inset(*insets)
100
+ content = fitted(inner)
101
+ used = content.measure(inner.width)
102
+ offset = Geometry.align_offset(if valign == :middle
103
+ :center
104
+ else
105
+ (valign == :bottom ? :right : :left)
106
+ end,
107
+ inner.height, used)
108
+ paint_inner = -> { content.paint(canvas, x + left, y + top + [offset, 0].max, inner.width) }
109
+ if @overflow == :visible
110
+ paint_inner.call
111
+ else
112
+ canvas.clip(inner.x, inner.y, inner.width, inner.height) do
113
+ paint_inner.call
114
+ end
115
+ end
116
+ end
117
+
118
+ def fitted(inner)
119
+ return @content if @overflow == :visible || @height.nil? || @content.measure(inner.width) <= inner.height
120
+ return shrunk(inner) if @overflow == :shrink_to_fit && shrinkable?
121
+
122
+ @content.split(inner.width, inner.height).first || Flow.new
123
+ end
124
+
125
+ def shrinkable?
126
+ @content.is_a?(Flow) && @content.children.size == 1 && @content.children.first.respond_to?(:fit)
127
+ end
128
+
129
+ def shrunk(inner)
130
+ @content.with_children([@content.children.first.fit(inner.width, inner.height, overflow: :shrink_to_fit)])
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # Children stacked top to bottom: the document body and every box's
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.
9
+ class Flow < Node
10
+ attr_reader :children, :gap, :align
11
+
12
+ def initialize(children = [], gap: 0, align: :left)
13
+ super()
14
+ @children = children
15
+ @gap = gap
16
+ @align = align
17
+ end
18
+
19
+ def <<(child)
20
+ @children << child
21
+ self
22
+ end
23
+
24
+ def splittable? = true
25
+ def natural_width = @children.map(&:natural_width).max || 0
26
+ def min_width = @children.map(&:min_width).max || 0
27
+
28
+ def measure(width)
29
+ visible = @children.reject(&:page_break?)
30
+ visible.sum { |child| child.measure(width) } + (@gap * [visible.size - 1, 0].max)
31
+ end
32
+
33
+ def paint(canvas, x, y, width, _height = nil, **)
34
+ cursor = y
35
+ @children.reject(&:page_break?).each_with_index do |child, index|
36
+ 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
+ end
42
+ end
43
+
44
+ def split(width, height, fresh: false)
45
+ Splitter.new(self, width, height, fresh).call
46
+ end
47
+
48
+ def with_children(children)
49
+ self.class.new(children, gap: @gap, align: @align)
50
+ end
51
+ end
52
+
53
+ # One pass of Flow#split, kept apart so the rules read top to bottom.
54
+ class Flow
55
+ class Splitter
56
+ def initialize(flow, width, height, fresh)
57
+ @flow = flow
58
+ @width = width
59
+ @height = height
60
+ @fresh = fresh
61
+ @placed = []
62
+ @used = 0
63
+ end
64
+
65
+ def call
66
+ children = @flow.children
67
+ children.each_with_index do |child, index|
68
+ rest = children.drop(index + 1)
69
+ result = place(child, rest)
70
+ return result if result
71
+ end
72
+ [part(@placed), nil]
73
+ end
74
+
75
+ private
76
+
77
+ def place(child, rest)
78
+ return page_break(rest) if child.page_break?
79
+
80
+ gap = @placed.empty? ? 0 : @flow.gap
81
+ remaining = @height - @used - gap
82
+ height = child.measure(@width)
83
+ return fits(child, height + gap, remaining - height, rest) if height <= remaining + EPSILON
84
+ return [part(@placed), part(rest)] if child.is_a?(Spacer)
85
+
86
+ split_or_move(child, remaining, rest)
87
+ end
88
+
89
+ def page_break(rest)
90
+ return nil if @placed.empty?
91
+
92
+ [part(@placed), part(rest)]
93
+ end
94
+
95
+ 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
99
+
100
+ @placed << child
101
+ @used += consumed
102
+ nil
103
+ end
104
+
105
+ 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
109
+ end
110
+ return [part([child]), part(rest)] if @placed.empty? && @fresh
111
+
112
+ @placed.empty? ? [nil, part([child, *rest])] : [part(@placed), part([child, *rest])]
113
+ end
114
+
115
+ def part(children)
116
+ children.empty? ? nil : @flow.with_children(children)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # An image sized by width, height, both, or a box to fit into (aspect
6
+ # preserved), never wider than the space it is given. One pixel is one
7
+ # point when no size is given.
8
+ class Image < Node
9
+ def initialize(source, width: nil, height: nil, fit: nil, opacity: nil)
10
+ super()
11
+ @image = source.respond_to?(:build) ? source : Images.load(source)
12
+ @width = width
13
+ @height = height
14
+ @fit = fit
15
+ @opacity = opacity
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
+ def min_width = 0
29
+
30
+ def paint(canvas, x, y, width, _height = nil, **)
31
+ w, h = size(width)
32
+ canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
33
+ end
34
+
35
+ private
36
+
37
+ def requested
38
+ iw = @image.width.to_f
39
+ ih = @image.height.to_f
40
+ if @fit
41
+ scale = [@fit[0] / iw, @fit[1] / ih].min
42
+ [iw * scale, ih * scale].map { |v| tidy(v) }
43
+ elsif @width && @height then [@width, @height]
44
+ elsif @width then [@width, tidy(ih * @width / iw)]
45
+ elsif @height then [tidy(iw * @height / ih), @height]
46
+ else [@image.width, @image.height]
47
+ end
48
+ end
49
+
50
+ def tidy(value) = value.round(6)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # A filled horizontal bar: dividers and accent bands.
6
+ class Rule < Node
7
+ def initialize(height: 1, color: "#000000", width: nil, radius: 0)
8
+ super()
9
+ @height = height
10
+ @color = color
11
+ @width = width
12
+ @radius = radius
13
+ end
14
+
15
+ def measure(_width) = @height
16
+ def fixed_width(available) = @width && [@width, available].min
17
+
18
+ def paint(canvas, x, y, width, _height = nil, **)
19
+ canvas.rounded_rect(x, y, @width || width, @height, radius: @radius, fill: @color)
20
+ end
21
+ end
22
+
23
+ # Vertical space. Dropped when it lands on a page break.
24
+ class Spacer < Node
25
+ def initialize(height)
26
+ super()
27
+ @height = height
28
+ end
29
+
30
+ def measure(_width) = @height
31
+ def paint(*, **) = nil
32
+ end
33
+
34
+ # Forces the content after it onto a new page.
35
+ class PageBreak < Node
36
+ def measure(_width) = 0
37
+ def paint(*, **) = nil
38
+ def page_break? = true
39
+ end
40
+
41
+ # Reserves `height` and hands a block the canvas, clipped to the reserved
42
+ # rectangle, and that rectangle in page coordinates.
43
+ class CanvasNode < Node
44
+ def initialize(height:, &block)
45
+ super()
46
+ @height = height
47
+ @block = block
48
+ end
49
+
50
+ def measure(_width) = @height
51
+
52
+ def paint(canvas, x, y, width, _height = nil, **)
53
+ rect = Rect.new(x, y, width, @height)
54
+ canvas.clip(x, y, width, @height) { @block.call(canvas, rect) }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ EPSILON = 0.0001
6
+
7
+ # What text nodes need to measure: the document's fonts and the base style.
8
+ Context = Data.define(:book, :style)
9
+
10
+ # A node placed taller than the space a page had for it.
11
+ Overflow = Data.define(:page, :height, :available) do
12
+ def message
13
+ format("content %<height>.1fpt tall placed on page %<page>d with %<available>.1fpt available",
14
+ height:, page:, available:)
15
+ end
16
+ end
17
+
18
+ # The layout protocol. Every node can:
19
+ #
20
+ # - measure(width) → its height at that width
21
+ # - paint(canvas, x, y, width, height = nil, **)
22
+ # - split(width, height) → [part that fits, remainder] (nil for an empty side)
23
+ # - natural_width / min_width → its preferred and narrowest widths
24
+ # - fixed_width(available) → its own width, or nil when it fills the width
25
+ class Node
26
+ attr_accessor :keep_with_next, :break_inside
27
+
28
+ def measure(_width) = raise(NotImplementedError, "#{self.class} must implement measure")
29
+ def paint(_canvas, _x, _y, _width, _height = nil, **) = raise(NotImplementedError, "#{self.class}#paint")
30
+
31
+ def splittable? = false
32
+ def natural_width = 0
33
+ def min_width = 0
34
+ def fixed_width(_available) = nil
35
+ def page_break? = false
36
+
37
+ def split(width, height)
38
+ measure(width) <= height + EPSILON ? [self, nil] : [nil, self]
39
+ end
40
+
41
+ def avoid_break?
42
+ break_inside == :avoid || !splittable?
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # Lays a node tree out onto as many pages as it needs. Content placed
6
+ # taller than a page is kept (never raised on) and recorded in #warnings.
7
+ class Paginator
8
+ attr_reader :warnings
9
+
10
+ def initialize(resources:, page: {})
11
+ @resources = resources
12
+ @page_options = page
13
+ @warnings = []
14
+ end
15
+
16
+ def paginate(root)
17
+ root = Flow.new([root]) unless root.is_a?(Flow)
18
+ pages = []
19
+ remaining = root
20
+ 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)
24
+ pages << page
25
+ end
26
+ pages
27
+ end
28
+
29
+ private
30
+
31
+ def place(page, head, number)
32
+ return unless head
33
+
34
+ box = page.content_box
35
+ height = head.measure(box.width)
36
+ @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)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # A node painted at a fixed page position instead of in the flow; it takes
6
+ # up no flow height. Used for page-template headers, footers and
7
+ # backgrounds, and for content that floats over the page.
8
+ class Positioned < Node
9
+ def initialize(node, x:, y:, width: nil)
10
+ super()
11
+ @node = node
12
+ @x = x
13
+ @y = y
14
+ @width = width
15
+ end
16
+
17
+ def measure(_width) = 0
18
+
19
+ def paint(canvas, _x, _y, width, _height = nil, **)
20
+ @node.paint(canvas, @x, @y, @width || @node.fixed_width(width) || width)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # Columns side by side. A column's `width:` is points (a number above 1),
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.
9
+ class Row < Node
10
+ attr_reader :columns
11
+
12
+ def initialize(columns, gap: 0, align: :top)
13
+ super()
14
+ @columns = columns
15
+ @gap = gap
16
+ @align = align
17
+ end
18
+
19
+ def natural_width = @columns.sum(&:natural_width) + gaps
20
+ def min_width = @columns.sum(&:min_width) + gaps
21
+
22
+ def measure(width)
23
+ @columns.zip(column_widths(width)).map { |column, w| column.measure(w) }.max || 0
24
+ end
25
+
26
+ def paint(canvas, x, y, width, height = nil, **)
27
+ height ||= measure(width)
28
+ left = x
29
+ @columns.zip(column_widths(width)).each do |column, w|
30
+ column.paint(canvas, left, y, w, height, valign: @align)
31
+ left += w + @gap
32
+ end
33
+ end
34
+
35
+ def column_widths(width)
36
+ available = [width - gaps, 0].max
37
+ widths = @columns.map { |column| declared(column, available) }
38
+ shrink_auto(widths, available)
39
+ shared = @columns.each_index.select { |i| widths[i].nil? }
40
+ rest = [available - widths.compact.sum, 0].max
41
+ shared.each { |i| widths[i] = rest / shared.size }
42
+ widths
43
+ end
44
+
45
+ private
46
+
47
+ def gaps = @gap * [@columns.size - 1, 0].max
48
+
49
+ def declared(column, available)
50
+ spec = column.respond_to?(:width_spec) ? column.width_spec : nil
51
+ case spec
52
+ when nil then nil
53
+ when :auto then [column.natural_width, available].min
54
+ when Float then spec <= 1 ? available * spec : spec
55
+ else spec
56
+ end
57
+ end
58
+
59
+ def shrink_auto(widths, available)
60
+ excess = widths.compact.sum - available
61
+ autos = @columns.each_index.select do |i|
62
+ @columns[i].respond_to?(:width_spec) && @columns[i].width_spec == :auto
63
+ end
64
+ return unless excess.positive? && autos.any?
65
+
66
+ total = autos.sum { |i| widths[i] }
67
+ autos.each { |i| widths[i] -= excess * widths[i] / total }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Table < Node
6
+ # One table cell: content (a String, markup String or layout node) and its
7
+ # options, which selections change until the table is laid out.
8
+ class Cell
9
+ TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
10
+ letter_spacing: :letter_spacing }.freeze
11
+
12
+ attr_reader :content, :options
13
+
14
+ def initialize(content, options)
15
+ @content = content
16
+ @options = options.dup
17
+ end
18
+
19
+ def node(context)
20
+ @node ||= @content.is_a?(Node) ? @content : text_node(context)
21
+ end
22
+
23
+ def padding = Geometry.box(@options[:padding])
24
+ def horizontal = padding[1] + padding[3]
25
+ def vertical = padding[0] + padding[2]
26
+
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
30
+
31
+ # Backgrounds overlap the next cell by SEAM so viewers do not show
32
+ # hairline gaps between neighbouring fills.
33
+ SEAM = 0.5
34
+
35
+ def paint(canvas, context, rect, last_column: true, last_row: true)
36
+ if @options[:background]
37
+ canvas.fill_rect(rect.x, rect.y, rect.width + (last_column ? 0 : SEAM),
38
+ rect.height + (last_row ? 0 : SEAM), color: @options[:background])
39
+ end
40
+ paint_content(canvas, context, rect)
41
+ paint_borders(canvas, rect)
42
+ end
43
+
44
+ private
45
+
46
+ def text_node(context)
47
+ style = context.style.with(**TEXT_OPTIONS.filter_map do |key, attr|
48
+ [attr, @options[key]] if @options.key?(key)
49
+ end.to_h)
50
+ source = @content.to_s
51
+ runs = if @options[:markup]
52
+ ::Stationery::Text::Markup.parse(source, style)
53
+ else
54
+ [::Stationery::Text::Run.new(source, style)]
55
+ end
56
+ Text.new(runs, context: context.with(style:), align: @options[:align] || :left,
57
+ leading: @options[:leading] || 0)
58
+ end
59
+
60
+ def paint_content(canvas, context, rect)
61
+ inner = rect.inset(*padding)
62
+ content = node(context)
63
+ used = content.measure(inner.width)
64
+ valign = { middle: :center, bottom: :right }.fetch(@options[:valign], :left)
65
+ offset = [Geometry.align_offset(valign, inner.height, used), 0].max
66
+ own = content.fixed_width(inner.width)
67
+ left = own ? inner.x + Geometry.align_offset(@options[:align] || :left, inner.width, own) : inner.x
68
+ content.paint(canvas, left, inner.y + offset, own || inner.width)
69
+ end
70
+
71
+ def paint_borders(canvas, rect)
72
+ width = @options[:border_width]
73
+ return if width.nil? || width.zero?
74
+
75
+ color = @options[:border_color]
76
+ Array(@options[:borders]).each do |side|
77
+ x1, y1, x2, y2 = edge(side, rect)
78
+ canvas.line(x1, y1, x2, y2, color:, width:)
79
+ end
80
+ end
81
+
82
+ def edge(side, rect)
83
+ case side
84
+ when :top then [rect.x, rect.y, rect.right, rect.y]
85
+ when :bottom then [rect.x, rect.bottom, rect.right, rect.bottom]
86
+ when :left then [rect.x, rect.y, rect.x, rect.bottom]
87
+ else [rect.right, rect.y, rect.right, rect.bottom]
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class Table < Node
6
+ # Rows, columns or single cells picked out of a table to style together:
7
+ #
8
+ # t.row(0).background = "#111"
9
+ # t.columns(1..).align = :right
10
+ # t.row(-1).columns(2..).borders = [:top]
11
+ class Selection
12
+ ATTRIBUTES = %i[background color weight style size align valign borders border_color border_width
13
+ padding font markup leading letter_spacing].freeze
14
+
15
+ def initialize(table, rows, columns)
16
+ @table = table
17
+ @rows = rows
18
+ @columns = columns
19
+ end
20
+
21
+ def rows(spec) = Selection.new(@table, @rows & Selection.indexes(spec, @table.row_count), @columns)
22
+ alias row rows
23
+
24
+ def columns(spec) = Selection.new(@table, @rows, @columns & Selection.indexes(spec, @table.column_count))
25
+ alias column columns
26
+
27
+ def cells = @rows.flat_map { |r| @columns.filter_map { |c| @table.cell(r, c) } }
28
+
29
+ def set(**attributes)
30
+ attributes.each { |name, value| public_send(:"#{name}=", value) }
31
+ self
32
+ end
33
+
34
+ ATTRIBUTES.each do |name|
35
+ define_method(:"#{name}=") do |value|
36
+ cells.each { |cell| cell.options[name] = value }
37
+ end
38
+ end
39
+
40
+ def self.indexes(spec, count)
41
+ all = (0...count).to_a
42
+ case spec
43
+ when Integer then [all[spec]].compact
44
+ when Range then Array(all[spec])
45
+ when Array then spec.flat_map { |s| indexes(s, count) }
46
+ else raise ArgumentError, "select rows or columns with an Integer, Range or Array, not #{spec.inspect}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end