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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +28 -0
- data/README.md +145 -0
- data/lib/stationery/builder.rb +61 -0
- data/lib/stationery/canvas/path.rb +82 -0
- data/lib/stationery/canvas/text.rb +55 -0
- data/lib/stationery/canvas.rb +121 -0
- data/lib/stationery/color.rb +62 -0
- data/lib/stationery/component.rb +54 -0
- data/lib/stationery/document.rb +95 -0
- data/lib/stationery/elements.rb +90 -0
- data/lib/stationery/errors.rb +11 -0
- data/lib/stationery/fonts/cmap.rb +75 -0
- data/lib/stationery/fonts/family.rb +51 -0
- data/lib/stationery/fonts/font.rb +149 -0
- data/lib/stationery/fonts/font_book.rb +36 -0
- data/lib/stationery/fonts/name_table.rb +39 -0
- data/lib/stationery/fonts/registry.rb +38 -0
- data/lib/stationery/fonts/subset.rb +139 -0
- data/lib/stationery/fonts/true_type.rb +174 -0
- data/lib/stationery/geometry.rb +56 -0
- data/lib/stationery/images/cache.rb +32 -0
- data/lib/stationery/images/images.rb +45 -0
- data/lib/stationery/images/jpeg.rb +53 -0
- data/lib/stationery/images/png.rb +119 -0
- data/lib/stationery/images/scanlines.rb +56 -0
- data/lib/stationery/layout/box.rb +134 -0
- data/lib/stationery/layout/flow.rb +121 -0
- data/lib/stationery/layout/image.rb +53 -0
- data/lib/stationery/layout/leaves.rb +58 -0
- data/lib/stationery/layout/node.rb +46 -0
- data/lib/stationery/layout/paginator.rb +41 -0
- data/lib/stationery/layout/positioned.rb +24 -0
- data/lib/stationery/layout/row.rb +71 -0
- data/lib/stationery/layout/table/cell.rb +93 -0
- data/lib/stationery/layout/table/selection.rb +52 -0
- data/lib/stationery/layout/table/widths.rb +53 -0
- data/lib/stationery/layout/table.rb +118 -0
- data/lib/stationery/layout/text.rb +62 -0
- data/lib/stationery/page.rb +47 -0
- data/lib/stationery/page_templates.rb +36 -0
- data/lib/stationery/pdf/assembler.rb +53 -0
- data/lib/stationery/pdf/serializer.rb +56 -0
- data/lib/stationery/pdf/stream.rb +27 -0
- data/lib/stationery/pdf/types.rb +15 -0
- data/lib/stationery/pdf/writer.rb +59 -0
- data/lib/stationery/rails.rb +20 -0
- data/lib/stationery/resources.rb +34 -0
- data/lib/stationery/text/entities.rb +23 -0
- data/lib/stationery/text/line.rb +26 -0
- data/lib/stationery/text/markup.rb +103 -0
- data/lib/stationery/text/paragraph.rb +98 -0
- data/lib/stationery/text/runs_builder.rb +52 -0
- data/lib/stationery/text/style.rb +46 -0
- data/lib/stationery/text/wrapper.rb +131 -0
- data/lib/stationery/version.rb +5 -0
- data/lib/stationery.rb +58 -0
- 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
|