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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -1
- data/README.md +358 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +25 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +8 -5
- data/lib/stationery/canvas.rb +25 -8
- data/lib/stationery/cli/fonts.rb +85 -0
- data/lib/stationery/cli/render.rb +137 -0
- data/lib/stationery/cli.rb +74 -0
- data/lib/stationery/document.rb +53 -9
- data/lib/stationery/elements/lists.rb +58 -0
- data/lib/stationery/elements/rich.rb +27 -0
- data/lib/stationery/elements.rb +88 -12
- data/lib/stationery/errors.rb +13 -1
- data/lib/stationery/fonts/bundled.rb +25 -0
- data/lib/stationery/fonts/catalog.rb +101 -0
- data/lib/stationery/fonts/cff.rb +140 -0
- data/lib/stationery/fonts/cff_subset.rb +145 -0
- data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
- data/lib/stationery/fonts/data/OFL.txt +92 -0
- data/lib/stationery/fonts/data/README.md +14 -0
- data/lib/stationery/fonts/embedding/base.rb +56 -0
- data/lib/stationery/fonts/embedding/cff.rb +35 -0
- data/lib/stationery/fonts/embedding/true_type.rb +35 -0
- data/lib/stationery/fonts/fallback.rb +58 -0
- data/lib/stationery/fonts/family.rb +11 -0
- data/lib/stationery/fonts/font.rb +52 -88
- data/lib/stationery/fonts/font_book.rb +46 -8
- data/lib/stationery/fonts/glyph_run.rb +38 -0
- data/lib/stationery/fonts/gpos/class_def.rb +25 -0
- data/lib/stationery/fonts/gpos/coverage.rb +20 -0
- data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
- data/lib/stationery/fonts/gpos.rb +62 -0
- data/lib/stationery/fonts/installer.rb +121 -0
- data/lib/stationery/fonts/kern_table.rb +52 -0
- data/lib/stationery/fonts/kerning.rb +17 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
- data/lib/stationery/fonts/to_unicode.rb +36 -0
- data/lib/stationery/fonts/true_type.rb +32 -49
- data/lib/stationery/html/document.rb +11 -0
- data/lib/stationery/html/tokenizer.rb +74 -0
- data/lib/stationery/html/tree_builder/blocks.rb +137 -0
- data/lib/stationery/html/tree_builder/collector.rb +50 -0
- data/lib/stationery/html/tree_builder.rb +72 -0
- data/lib/stationery/html/whitespace.rb +43 -0
- data/lib/stationery/layout/box.rb +85 -16
- data/lib/stationery/layout/flow.rb +48 -15
- data/lib/stationery/layout/image.rb +1 -0
- data/lib/stationery/layout/list_item.rb +89 -0
- data/lib/stationery/layout/mark.rb +58 -0
- data/lib/stationery/layout/node.rb +26 -2
- data/lib/stationery/layout/paginator.rb +35 -6
- data/lib/stationery/layout/positioned.rb +3 -1
- data/lib/stationery/layout/row.rb +34 -4
- data/lib/stationery/layout/svg.rb +45 -0
- data/lib/stationery/layout/table/cell.rb +33 -6
- data/lib/stationery/layout/table/grid.rb +93 -0
- data/lib/stationery/layout/table/row_splitter.rb +36 -0
- data/lib/stationery/layout/table/selection.rb +3 -2
- data/lib/stationery/layout/table.rb +69 -23
- data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
- data/lib/stationery/layout/table_of_contents.rb +43 -0
- data/lib/stationery/layout/text.rb +5 -4
- data/lib/stationery/layout/wrap.rb +78 -0
- data/lib/stationery/list_markers.rb +47 -0
- data/lib/stationery/markdown/block_parser/containers.rb +94 -0
- data/lib/stationery/markdown/block_parser/tables.rb +54 -0
- data/lib/stationery/markdown/block_parser.rb +141 -0
- data/lib/stationery/markdown/document.rb +50 -0
- data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
- data/lib/stationery/markdown/inline_parser/links.rb +70 -0
- data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
- data/lib/stationery/markdown/inline_parser.rb +89 -0
- data/lib/stationery/minitest.rb +33 -0
- data/lib/stationery/outline.rb +37 -0
- data/lib/stationery/page.rb +18 -4
- data/lib/stationery/page_templates.rb +33 -10
- data/lib/stationery/pdf/assembler.rb +18 -7
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +5 -1
- data/lib/stationery/pdf/writer.rb +4 -0
- data/lib/stationery/preview.rb +64 -0
- data/lib/stationery/rails/previews_controller.rb +61 -0
- data/lib/stationery/rails.rb +2 -0
- data/lib/stationery/railtie.rb +60 -0
- data/lib/stationery/regions.rb +67 -0
- data/lib/stationery/rich/nodes.rb +55 -0
- data/lib/stationery/rich/renderer/inlines.rb +53 -0
- data/lib/stationery/rich/renderer.rb +121 -0
- data/lib/stationery/rich/styles.rb +34 -0
- data/lib/stationery/rspec.rb +6 -0
- data/lib/stationery/structure.rb +78 -0
- data/lib/stationery/svg/arc.rb +91 -0
- data/lib/stationery/svg/document.rb +73 -0
- data/lib/stationery/svg/parser.rb +38 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +68 -0
- data/lib/stationery/svg/transform.rb +43 -0
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +123 -0
- data/lib/stationery/text/entities/html4.rb +49 -0
- data/lib/stationery/text/entities.rb +6 -1
- data/lib/stationery/text/line.rb +8 -2
- data/lib/stationery/text/paragraph.rb +32 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +2 -2
- data/lib/stationery/text/wrapper.rb +3 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +51 -0
- metadata +85 -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
|
|
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
|
-
@
|
|
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
|
-
|
|
22
|
-
head, remaining = remaining
|
|
23
|
-
place(page, head,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
# every column to that height
|
|
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.with_content(Flow.new).with_open(:top) }
|
|
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,45 @@
|
|
|
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
|
+
def initialize(document, width: nil, height: nil, color: "#000000")
|
|
9
|
+
super()
|
|
10
|
+
@document = document
|
|
11
|
+
@width = width
|
|
12
|
+
@height = height
|
|
13
|
+
@color = color
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def size(available)
|
|
17
|
+
width, height = requested
|
|
18
|
+
return [width, height] if width <= available
|
|
19
|
+
|
|
20
|
+
[available, height * available / width]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def measure(width) = size(width)[1]
|
|
24
|
+
def fixed_width(available) = size(available)[0]
|
|
25
|
+
def natural_width = requested[0]
|
|
26
|
+
|
|
27
|
+
def paint(canvas, x, y, width, _height = nil, **)
|
|
28
|
+
w, h = size(width)
|
|
29
|
+
@document.draw(canvas, x:, y:, width: w, height: h, color: @color)
|
|
30
|
+
canvas.debug_rect(x, y, w, h, :image)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def requested
|
|
36
|
+
aspect = @document.aspect
|
|
37
|
+
if @width && @height then [@width, @height]
|
|
38
|
+
elsif @width then [@width, @width / aspect]
|
|
39
|
+
elsif @height then [@height * aspect, @height]
|
|
40
|
+
else @document.view_box.last(2)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
def
|
|
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
|
|
36
|
+
cells.each { |cell| cell[name] = value }
|
|
37
|
+
@table.invalidate!
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
|