stationery 0.8.0 → 0.10.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 +24 -0
- data/README.md +251 -37
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/{svg → css}/selector.rb +1 -1
- data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
- data/lib/stationery/css.rb +20 -0
- data/lib/stationery/document.rb +35 -7
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +21 -6
- data/lib/stationery/elements.rb +20 -7
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +10 -2
- data/lib/stationery/html/tokenizer.rb +15 -4
- data/lib/stationery/html/tree_builder/blocks.rb +90 -40
- data/lib/stationery/html/tree_builder/collector.rb +9 -3
- data/lib/stationery/html/tree_builder.rb +44 -4
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +21 -7
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +4 -0
- data/lib/stationery/pdf/assembler.rb +31 -11
- data/lib/stationery/pdf/conformance.rb +7 -5
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +95 -0
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +174 -0
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +43 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +49 -16
- data/lib/stationery/svg/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/breaks.rb +123 -0
- data/lib/stationery/text/paragraph.rb +8 -0
- data/lib/stationery/text/runs_builder.rb +4 -0
- data/lib/stationery/text/wrapper.rb +69 -11
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +13 -0
- data/lib/stationery.rb +6 -2
- metadata +13 -3
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "css"
|
|
3
4
|
require_relative "tree_builder/blocks"
|
|
4
5
|
|
|
5
6
|
module Stationery
|
|
6
7
|
module HTML
|
|
7
8
|
# Builds a lenient element tree from tokens, closing what HTML5 closes implicitly (a p before a block,
|
|
8
9
|
# an li before the next li, cells and rows before the next), ignoring stray end tags, then converts
|
|
9
|
-
# the tree into rich-text blocks.
|
|
10
|
+
# the tree into rich-text blocks. An element nested deeper than the Rich::Nesting limit is left out
|
|
11
|
+
# of the tree: what it holds goes to the deepest element kept, so everything that walks the tree
|
|
12
|
+
# (and the layout it becomes) recurses no deeper than the limit.
|
|
10
13
|
class TreeBuilder
|
|
11
14
|
Element = Data.define(:name, :attributes, :children)
|
|
12
15
|
|
|
@@ -16,6 +19,7 @@ module Stationery
|
|
|
16
19
|
].freeze
|
|
17
20
|
HEADINGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
18
21
|
SECTIONS = %w[thead tbody tfoot].freeze
|
|
22
|
+
BLOCKS = (CLOSES_P + SECTIONS + %w[tr td th caption]).freeze
|
|
19
23
|
IMPLIED = {
|
|
20
24
|
"li" => [%w[li], %w[ul ol]],
|
|
21
25
|
"dt" => [%w[dt dd], %w[dl]], "dd" => [%w[dt dd], %w[dl]],
|
|
@@ -24,11 +28,21 @@ module Stationery
|
|
|
24
28
|
"thead" => [SECTIONS, %w[table]], "tbody" => [SECTIONS, %w[table]], "tfoot" => [SECTIONS, %w[table]]
|
|
25
29
|
}.freeze
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
# `css` is the Css::Report unsupported styles are told to.
|
|
32
|
+
def self.parse(tokens, nesting = Rich::Nesting.new, css: nil)
|
|
33
|
+
builder = new(nesting)
|
|
34
|
+
root = builder.build(tokens)
|
|
35
|
+
Blocks.convert(root, Css.parse(builder.sheets, css || Css::Report.new))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :sheets
|
|
28
39
|
|
|
29
|
-
def initialize
|
|
40
|
+
def initialize(nesting = Rich::Nesting.new)
|
|
30
41
|
@root = Element.new("#root", {}, [])
|
|
31
42
|
@stack = [@root]
|
|
43
|
+
@nesting = nesting
|
|
44
|
+
@flattened = [] # the names of the open elements left out of the tree
|
|
45
|
+
@sheets = []
|
|
32
46
|
end
|
|
33
47
|
|
|
34
48
|
def build(tokens)
|
|
@@ -36,6 +50,7 @@ module Stationery
|
|
|
36
50
|
case type
|
|
37
51
|
when :start then start(*args)
|
|
38
52
|
when :end then finish(*args)
|
|
53
|
+
when :style then @sheets << args.first
|
|
39
54
|
else @stack.last.children << args.first
|
|
40
55
|
end
|
|
41
56
|
end
|
|
@@ -45,9 +60,13 @@ module Stationery
|
|
|
45
60
|
private
|
|
46
61
|
|
|
47
62
|
def start(name, attributes, self_closing)
|
|
63
|
+
return flatten(name, attributes, self_closing) unless @flattened.empty?
|
|
64
|
+
|
|
48
65
|
close_nearest(%w[p], %w[table td th caption]) if CLOSES_P.include?(name)
|
|
49
66
|
close_nearest(HEADINGS, []) if HEADINGS.include?(name) && HEADINGS.include?(@stack.last.name)
|
|
50
67
|
close_nearest(*IMPLIED[name]) if IMPLIED.key?(name)
|
|
68
|
+
return flatten(name, attributes, self_closing) if @stack.size > @nesting.limit
|
|
69
|
+
|
|
51
70
|
push("tr", {}, false) if %w[td th].include?(name) && %w[table thead tbody tfoot].include?(@stack.last.name)
|
|
52
71
|
push(name, attributes, self_closing)
|
|
53
72
|
end
|
|
@@ -58,9 +77,30 @@ module Stationery
|
|
|
58
77
|
@stack << element unless self_closing
|
|
59
78
|
end
|
|
60
79
|
|
|
80
|
+
# Too deep for the tree: a void element (br, img, hr) still takes its place in the deepest
|
|
81
|
+
# element kept, anything else is only remembered until its end tag, its content going there too.
|
|
82
|
+
def flatten(name, attributes, self_closing)
|
|
83
|
+
return push(name, attributes, true) if self_closing
|
|
84
|
+
|
|
85
|
+
@flattened << name
|
|
86
|
+
@nesting.record(@stack.size - 1 + @flattened.size)
|
|
87
|
+
separate(name)
|
|
88
|
+
end
|
|
89
|
+
|
|
61
90
|
def finish(name)
|
|
91
|
+
flattened = @flattened.rindex(name)
|
|
92
|
+
return @flattened.slice!(flattened..).each { |left_out| separate(left_out) } if flattened
|
|
93
|
+
|
|
62
94
|
index = @stack.rindex { |element| element.name == name }
|
|
63
|
-
|
|
95
|
+
return unless index&.positive?
|
|
96
|
+
|
|
97
|
+
@stack.slice!(index..)
|
|
98
|
+
@flattened.clear
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A flattened block no longer parts its text from its neighbours'; a space does.
|
|
102
|
+
def separate(name)
|
|
103
|
+
@stack.last.children << " " if BLOCKS.include?(name)
|
|
64
104
|
end
|
|
65
105
|
|
|
66
106
|
def close_nearest(names, boundary)
|
|
@@ -21,10 +21,23 @@ module Stationery
|
|
|
21
21
|
|
|
22
22
|
def <<(child)
|
|
23
23
|
@children << child
|
|
24
|
+
@breaks = nil
|
|
24
25
|
forget_measures
|
|
25
26
|
self
|
|
26
27
|
end
|
|
27
28
|
|
|
29
|
+
# A flow holding a page break (at any depth) is cut there even when it
|
|
30
|
+
# would fit on the page it is on.
|
|
31
|
+
def breaks?
|
|
32
|
+
@breaks = @children.any? { |child| child.page_break? || child.breaks? } if @breaks.nil?
|
|
33
|
+
@breaks
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def leading_break?
|
|
37
|
+
first = @children.first
|
|
38
|
+
!first.nil? && (first.page_break? || first.leading_break?)
|
|
39
|
+
end
|
|
40
|
+
|
|
28
41
|
def splittable? = true
|
|
29
42
|
def natural_width = @children.map(&:natural_width).max || 0
|
|
30
43
|
def min_width = @children.map(&:min_width).max || 0
|
|
@@ -93,6 +106,8 @@ module Stationery
|
|
|
93
106
|
|
|
94
107
|
gap = @placed.empty? ? 0 : @flow.gap
|
|
95
108
|
remaining = @height - @used - gap
|
|
109
|
+
return broken(child, gap, remaining, rest) if child.breaks?
|
|
110
|
+
|
|
96
111
|
height = child.measure(child.width_in(@width))
|
|
97
112
|
return fits(child, height + gap, remaining - height, rest) if height <= remaining + EPSILON
|
|
98
113
|
return [part(@placed), part(rest)] if child.is_a?(Spacer)
|
|
@@ -106,6 +121,20 @@ module Stationery
|
|
|
106
121
|
[part(@placed), part(rest)]
|
|
107
122
|
end
|
|
108
123
|
|
|
124
|
+
# A child holding a page break: what comes before the break stays on
|
|
125
|
+
# this page, the rest goes to the next, however much room is left. One
|
|
126
|
+
# that starts with the break goes to the next page whole.
|
|
127
|
+
def broken(child, gap, remaining, rest)
|
|
128
|
+
return [part(@placed), part([child, *rest])] if child.leading_break? && !@placed.empty?
|
|
129
|
+
|
|
130
|
+
head, tail = child.split(child.width_in(@width), remaining, fresh: @fresh && @placed.empty?)
|
|
131
|
+
return split_or_move(child, remaining, rest) unless head
|
|
132
|
+
return [part(@placed + [head]), part([tail, *rest])] if tail
|
|
133
|
+
|
|
134
|
+
height = head.measure(head.width_in(@width))
|
|
135
|
+
fits(head, height + gap, remaining - height, rest)
|
|
136
|
+
end
|
|
137
|
+
|
|
109
138
|
def fits(child, consumed, left_after, rest)
|
|
110
139
|
return [part(@placed), part([child, *rest])] if strand?(child, left_after, rest)
|
|
111
140
|
|
|
@@ -4,7 +4,8 @@ module Stationery
|
|
|
4
4
|
module Layout
|
|
5
5
|
# An image sized by width, height, both, or a box to fit into (aspect
|
|
6
6
|
# preserved), never wider than the space it is given. One pixel is one
|
|
7
|
-
# point when no size is given
|
|
7
|
+
# point when no size is given; a Float width up to 1 is a fraction of the
|
|
8
|
+
# space (`width: 0.5`), as for boxes and columns. `fit: :cover` fills `width:` × `height:`
|
|
8
9
|
# instead, scaling up and clipping the excess around the centre.
|
|
9
10
|
# `radius:` clips to rounded corners and `rotate:` (degrees, clockwise)
|
|
10
11
|
# turns the painted image around the centre of its rectangle; neither
|
|
@@ -25,7 +26,7 @@ module Stationery
|
|
|
25
26
|
super()
|
|
26
27
|
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
|
|
27
28
|
@image = source.respond_to?(:build) ? source : Images.load(source)
|
|
28
|
-
@name =
|
|
29
|
+
@name = name_of(source)
|
|
29
30
|
@max_ppi = max_ppi
|
|
30
31
|
@downscale = downscale
|
|
31
32
|
@width = width
|
|
@@ -37,14 +38,16 @@ module Stationery
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def size(available)
|
|
40
|
-
width, height = requested
|
|
41
|
+
width, height = requested(available)
|
|
41
42
|
return [width, height] if width <= available
|
|
42
43
|
|
|
43
44
|
[available, height * available / width]
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def measure(width) = size(width)[1]
|
|
47
|
-
|
|
48
|
+
# A fractional width is a share of the space offered here; measuring and
|
|
49
|
+
# painting are then given the width that came out of it.
|
|
50
|
+
def fixed_width(available) = fraction? ? tidy(available * @width) : size(available)[0]
|
|
48
51
|
def natural_width = requested[0]
|
|
49
52
|
def min_width = 0
|
|
50
53
|
|
|
@@ -58,6 +61,14 @@ module Stationery
|
|
|
58
61
|
|
|
59
62
|
private
|
|
60
63
|
|
|
64
|
+
# What a warning calls the image: the file's name for a path, a
|
|
65
|
+
# Pathname or an open File (anything with a path), else "inline image".
|
|
66
|
+
# Asked by duck type: Pathname is not loaded in plain Ruby.
|
|
67
|
+
def name_of(source)
|
|
68
|
+
path = source.is_a?(String) ? source : (source.to_path if source.respond_to?(:to_path))
|
|
69
|
+
path ? File.basename(path) : "inline image"
|
|
70
|
+
end
|
|
71
|
+
|
|
61
72
|
def paint_clipped(canvas, x, y, w, h)
|
|
62
73
|
return paint_image(canvas, x, y, w, h) unless @fit == :cover || @radius.positive?
|
|
63
74
|
|
|
@@ -92,19 +103,22 @@ module Stationery
|
|
|
92
103
|
@image
|
|
93
104
|
end
|
|
94
105
|
|
|
95
|
-
def requested
|
|
106
|
+
def requested(available = nil)
|
|
96
107
|
iw = @image.width.to_f
|
|
97
108
|
ih = @image.height.to_f
|
|
109
|
+
width = fraction? ? (available || tidy(iw * @width)) : @width
|
|
98
110
|
if @fit.is_a?(Array)
|
|
99
111
|
scale = [@fit[0] / iw, @fit[1] / ih].min
|
|
100
112
|
[iw * scale, ih * scale].map { |v| tidy(v) }
|
|
101
|
-
elsif
|
|
102
|
-
elsif
|
|
113
|
+
elsif width && @height then [width, @height]
|
|
114
|
+
elsif width then [width, tidy(ih * width / iw)]
|
|
103
115
|
elsif @height then [tidy(iw * @height / ih), @height]
|
|
104
116
|
else [@image.width, @image.height]
|
|
105
117
|
end
|
|
106
118
|
end
|
|
107
119
|
|
|
120
|
+
def fraction? = @width.is_a?(Float) && @width <= 1
|
|
121
|
+
|
|
108
122
|
def tidy(value) = value.round(6)
|
|
109
123
|
end
|
|
110
124
|
end
|
|
@@ -31,6 +31,8 @@ module Stationery
|
|
|
31
31
|
def fixed_width(available) = @child.fixed_width(available)
|
|
32
32
|
def splittable? = @child.splittable?
|
|
33
33
|
def page_break? = @child.page_break?
|
|
34
|
+
def breaks? = @child.breaks?
|
|
35
|
+
def leading_break? = @child.leading_break?
|
|
34
36
|
def avoid_break? = @child.avoid_break?
|
|
35
37
|
def keep_with_next = @child.keep_with_next
|
|
36
38
|
|
|
@@ -42,6 +42,10 @@ module Stationery
|
|
|
42
42
|
# The width this node is laid out at inside a parent of `available`.
|
|
43
43
|
def width_in(available) = fixed_width(available) || available
|
|
44
44
|
def page_break? = false
|
|
45
|
+
# Whether a page break lies somewhere inside (a flow holding one).
|
|
46
|
+
def breaks? = false
|
|
47
|
+
# Whether it starts with a page break (a flow whose first node is one).
|
|
48
|
+
def leading_break? = false
|
|
45
49
|
|
|
46
50
|
def split(width, height, **)
|
|
47
51
|
measure(width) <= height + EPSILON ? [self, nil] : [nil, self]
|
|
@@ -6,6 +6,7 @@ module Stationery
|
|
|
6
6
|
# One table cell: content (a String, markup String or layout node) and its
|
|
7
7
|
# options, which selections change until the table is laid out.
|
|
8
8
|
class Cell
|
|
9
|
+
SIDES = %i[top right bottom left].freeze
|
|
9
10
|
TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
|
|
10
11
|
letter_spacing: :letter_spacing }.freeze
|
|
11
12
|
|
|
@@ -103,14 +104,23 @@ module Stationery
|
|
|
103
104
|
canvas.debug_rect(inner.x, inner.y, inner.width, inner.height, :cell_padding)
|
|
104
105
|
end
|
|
105
106
|
|
|
107
|
+
# One path for the cell's border, centred on its edges: the rectangle
|
|
108
|
+
# when all four sides are drawn, else a line per side.
|
|
106
109
|
def paint_borders(canvas, rect)
|
|
107
110
|
width = @options[:border_width]
|
|
108
111
|
return if width.nil? || width.zero?
|
|
109
112
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
sides = Array(@options[:borders]).uniq
|
|
114
|
+
return if sides.empty?
|
|
115
|
+
|
|
116
|
+
canvas.path(stroke: @options[:border_color], line_width: width) do |path|
|
|
117
|
+
next path.rect(rect.x, rect.y, rect.width, rect.height) if (SIDES - sides).empty?
|
|
118
|
+
|
|
119
|
+
sides.each do |side|
|
|
120
|
+
x1, y1, x2, y2 = edge(side, rect)
|
|
121
|
+
path.move_to(x1, y1)
|
|
122
|
+
path.line_to(x2, y2)
|
|
123
|
+
end
|
|
114
124
|
end
|
|
115
125
|
end
|
|
116
126
|
|
|
@@ -6,6 +6,13 @@ module Stationery
|
|
|
6
6
|
# header rows repeated after a page break, and splitting between rows. A
|
|
7
7
|
# row taller than a fresh page, or any row with split_rows: true, is cut
|
|
8
8
|
# through its cells and continues below the repeated header.
|
|
9
|
+
#
|
|
10
|
+
# The fragments a page break cuts a table into keep the whole table's
|
|
11
|
+
# column metrics, so every page resolves the same column widths, and the
|
|
12
|
+
# widths and row heights measured for the cut, so a page break measures
|
|
13
|
+
# nothing again. Laid out at another width they resolve and measure anew.
|
|
14
|
+
# Where no cell spans rows a cut can fall before any row, so the rows
|
|
15
|
+
# still to come are not placed on a grid until a page paints them.
|
|
9
16
|
class Table < Node
|
|
10
17
|
DEFAULT_CELL = { padding: 5, borders: %i[top right bottom left], border_width: 0.5,
|
|
11
18
|
border_color: "#000000" }.freeze
|
|
@@ -30,7 +37,7 @@ module Stationery
|
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
def row_count = @cells.size
|
|
33
|
-
def column_count = grid.column_count
|
|
40
|
+
def column_count = @column_count ||= grid.column_count
|
|
34
41
|
def cell(row, column) = grid.at(row, column)
|
|
35
42
|
|
|
36
43
|
def rows(spec) = Selection.new(self, Selection.indexes(spec, row_count), (0...column_count).to_a)
|
|
@@ -53,6 +60,7 @@ module Stationery
|
|
|
53
60
|
@column_widths = nil
|
|
54
61
|
@column_metrics = nil
|
|
55
62
|
@row_heights = nil
|
|
63
|
+
@column_count = nil
|
|
56
64
|
@grid = nil
|
|
57
65
|
end
|
|
58
66
|
|
|
@@ -80,10 +88,11 @@ module Stationery
|
|
|
80
88
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
81
89
|
widths = column_widths(width)
|
|
82
90
|
heights = row_heights(width)
|
|
91
|
+
lefts = offsets(widths)
|
|
92
|
+
tops = offsets(heights)
|
|
83
93
|
canvas.structure(@tag) do
|
|
84
94
|
grid.placements.each do |p|
|
|
85
|
-
rect = Rect.new(x +
|
|
86
|
-
widths[p.columns].sum, heights[p.rows].sum)
|
|
95
|
+
rect = Rect.new(x + lefts[p.column], y + tops[p.row], widths[p.columns].sum, heights[p.rows].sum)
|
|
87
96
|
paint_cell(canvas, p, rect)
|
|
88
97
|
end
|
|
89
98
|
end
|
|
@@ -96,14 +105,45 @@ module Stationery
|
|
|
96
105
|
count += 1 while count < row_count && used + heights[count] <= height + EPSILON && (used += heights[count])
|
|
97
106
|
return [self, nil] if count == row_count
|
|
98
107
|
|
|
99
|
-
count = grid.boundaries.grep(@header..count).max
|
|
100
|
-
split_row(count, width, height - heights.first(count).sum, fresh: options[:fresh]) ||
|
|
108
|
+
count = grid.boundaries.grep(@header..count).max if row_spans?
|
|
109
|
+
split_row(count, width, height - heights.first(count).sum, fresh: options[:fresh]) ||
|
|
110
|
+
split_before(count, width)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
protected
|
|
114
|
+
|
|
115
|
+
# Becomes a fragment holding `rows`, with the column widths and row
|
|
116
|
+
# heights its table measured at `width`.
|
|
117
|
+
def carry(rows, continued, widths, heights)
|
|
118
|
+
@cells = rows
|
|
119
|
+
@continued = continued
|
|
120
|
+
@grid = nil
|
|
121
|
+
@column_widths = widths
|
|
122
|
+
@row_heights = heights
|
|
123
|
+
self
|
|
101
124
|
end
|
|
102
125
|
|
|
103
126
|
private
|
|
104
127
|
|
|
105
128
|
def grid = @grid ||= Grid.new(@cells)
|
|
106
129
|
|
|
130
|
+
# Whether any cell spans rows, of the table a fragment was cut from too.
|
|
131
|
+
def row_spans?
|
|
132
|
+
return @row_spans if defined?(@row_spans)
|
|
133
|
+
|
|
134
|
+
@row_spans = @cells.any? { |row| row.any? { |cell| cell.rowspan > 1 } }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The placements of one row, which no cell from above reaches into.
|
|
138
|
+
def placements_in(row)
|
|
139
|
+
return grid.placements.select { |placement| placement.row == row } if row_spans?
|
|
140
|
+
|
|
141
|
+
Grid.new([@cells[row]]).placements
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Where each column or row starts: the sizes before it, summed.
|
|
145
|
+
def offsets(sizes) = Array.new(sizes.size) { |index| sizes[0...index].sum }
|
|
146
|
+
|
|
107
147
|
def paint_cell(canvas, placement, rect)
|
|
108
148
|
cell = placement.cell
|
|
109
149
|
paint = lambda do
|
|
@@ -123,28 +163,42 @@ module Stationery
|
|
|
123
163
|
end
|
|
124
164
|
|
|
125
165
|
def cell_tag(cell, header)
|
|
166
|
+
return Tagging::Element.new(:TD) unless header || cell.colspan > 1 || cell.rowspan > 1
|
|
167
|
+
|
|
126
168
|
attributes = { Scope: (:Column if header), ColSpan: (cell.colspan if cell.colspan > 1),
|
|
127
169
|
RowSpan: (cell.rowspan if cell.rowspan > 1) }.compact
|
|
128
170
|
Tagging::Element.new(header ? :TH : :TD, attributes: attributes.empty? ? {} : { Table: attributes })
|
|
129
171
|
end
|
|
130
172
|
|
|
131
|
-
def split_before(count)
|
|
173
|
+
def split_before(count, width)
|
|
132
174
|
return [nil, self] if count == @header
|
|
133
175
|
|
|
134
|
-
|
|
176
|
+
heights = row_heights(width)
|
|
177
|
+
[fragment(@cells.first(count), width, heights.first(count)),
|
|
178
|
+
fragment(@cells.first(@header) + @cells.drop(count), width,
|
|
179
|
+
heights.first(@header) + heights.drop(count), continued: true)]
|
|
135
180
|
end
|
|
136
181
|
|
|
137
182
|
def split_row(row, width, space, fresh:)
|
|
138
183
|
return unless @split_rows || (fresh && row == @header)
|
|
139
|
-
return
|
|
184
|
+
return if row_spans? && !grid.boundaries.include?(row + 1)
|
|
140
185
|
|
|
141
186
|
widths = column_widths(width)
|
|
142
|
-
placements =
|
|
187
|
+
placements = placements_in(row)
|
|
143
188
|
heads, tails = RowSplitter.new(placements, widths:, context: @context).call(space)
|
|
144
189
|
return unless heads
|
|
145
190
|
|
|
146
|
-
|
|
147
|
-
|
|
191
|
+
heights = row_heights(width)
|
|
192
|
+
above, below = [heads, tails].map { |cells| [row_height(placements, cells, widths)] }
|
|
193
|
+
[fragment(@cells.first(row) + [heads], width, heights.first(row) + above),
|
|
194
|
+
fragment(@cells.first(@header) + [tails] + @cells.drop(row + 1), width,
|
|
195
|
+
heights.first(@header) + below + heights.drop(row + 1), continued: true)]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The height of a cut row's part. No cell of a row that can be cut
|
|
199
|
+
# spans rows, so it is its tallest cell.
|
|
200
|
+
def row_height(placements, cells, widths)
|
|
201
|
+
placements.zip(cells).map { |placement, cell| cell.measure(@context, widths[placement.columns].sum) }.max
|
|
148
202
|
end
|
|
149
203
|
|
|
150
204
|
def build_cell(content, defaults)
|
|
@@ -187,9 +241,13 @@ module Stationery
|
|
|
187
241
|
@row_heights[width] ||= grid.row_heights(column_widths(width)) { |p, span| p.cell.measure(@context, span) }
|
|
188
242
|
end
|
|
189
243
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
244
|
+
# A copy holding other rows of this table. Its cells are built and
|
|
245
|
+
# tagged already and its column metrics are this table's.
|
|
246
|
+
def fragment(rows, width, heights, continued: @continued)
|
|
247
|
+
%i[natural_width min_width].each { |metric| column_metric(metric) }
|
|
248
|
+
column_count
|
|
249
|
+
row_spans?
|
|
250
|
+
dup.carry(rows, continued, { width => column_widths(width) }, { width => heights })
|
|
193
251
|
end
|
|
194
252
|
end
|
|
195
253
|
end
|
|
@@ -2,21 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Layout
|
|
5
|
-
# A paragraph of styled runs. Splits between lines
|
|
5
|
+
# A paragraph of styled runs. Splits between lines; `orphans:` is the
|
|
6
|
+
# fewest lines a split leaves at the foot of a page and `widows:` the
|
|
7
|
+
# fewest it carries to the next (1 and 1: any line). A paragraph that
|
|
8
|
+
# cannot meet them moves to the next page whole, unless it is already
|
|
9
|
+
# first on a fresh page, where it splits as best it can.
|
|
6
10
|
class Text < Node
|
|
7
11
|
attr_reader :runs
|
|
8
12
|
|
|
9
|
-
def initialize(runs, context:, align: :left, leading: 0,
|
|
13
|
+
def initialize(runs, context:, align: :left, leading: 0, orphans: 1, widows: 1, paragraph: nil,
|
|
14
|
+
tag: Tagging::Element.new(:P))
|
|
10
15
|
super()
|
|
11
16
|
@tag = tag
|
|
12
17
|
@runs = context.book.fallback(runs)
|
|
13
18
|
@context = context
|
|
14
19
|
@align = align
|
|
15
20
|
@leading = leading
|
|
21
|
+
@orphans = self.class.lines_option(:orphans, orphans)
|
|
22
|
+
@widows = self.class.lines_option(:widows, widows)
|
|
16
23
|
@paragraph = paragraph
|
|
17
24
|
@paragraphs = {}
|
|
18
25
|
end
|
|
19
26
|
|
|
27
|
+
def self.lines_option(name, value)
|
|
28
|
+
return value if value.is_a?(Integer) && value >= 1
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "#{name}: must be an Integer of at least 1, got #{value.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
20
33
|
def splittable? = true
|
|
21
34
|
|
|
22
35
|
def measure(width) = paragraph(width).height
|
|
@@ -25,9 +38,11 @@ module Stationery
|
|
|
25
38
|
paragraph(width).draw(canvas, x, y, tag: @tag)
|
|
26
39
|
end
|
|
27
40
|
|
|
28
|
-
def split(width, height, **)
|
|
41
|
+
def split(width, height, fresh: false, **)
|
|
29
42
|
head, tail = paragraph(width).split(height)
|
|
30
|
-
[head && from(head), tail && from(tail)]
|
|
43
|
+
return [head && from(head), tail && from(tail)] if (@orphans == 1 && @widows == 1) || !(head && tail)
|
|
44
|
+
|
|
45
|
+
keep_lines(paragraph(width), head.lines.size, fresh:)
|
|
31
46
|
end
|
|
32
47
|
|
|
33
48
|
def fit(width, height, overflow:)
|
|
@@ -38,19 +53,40 @@ module Stationery
|
|
|
38
53
|
@natural_width ||= paragraph(Float::INFINITY).lines.map(&:width).max || 0
|
|
39
54
|
end
|
|
40
55
|
|
|
56
|
+
# The widest piece that cannot be broken: a word, or one break unit of a
|
|
57
|
+
# word with CJK characters.
|
|
41
58
|
def min_width
|
|
42
59
|
@min_width ||= @runs.flat_map do |run|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
font = @context.book.resolve(run.style).first
|
|
61
|
+
run.text.delete(::Stationery::Text::Wrapper::SOFT_HYPHEN).split(/[ \t\n\u200B]+/).map do |word|
|
|
62
|
+
next piece_width(font, run.style, word) unless ::Stationery::Text::Breaks.cjk?(word)
|
|
63
|
+
|
|
64
|
+
::Stationery::Text::Breaks.units(word).map { |unit, _| piece_width(font, run.style, unit) }.max
|
|
48
65
|
end
|
|
49
66
|
end.max || 0
|
|
50
67
|
end
|
|
51
68
|
|
|
52
69
|
private
|
|
53
70
|
|
|
71
|
+
# Applies orphans and widows to a split after `fitting` lines: carries
|
|
72
|
+
# more lines over for the widows, then moves the paragraph whole when
|
|
73
|
+
# too few would stay. First on a fresh page nothing can move, so the
|
|
74
|
+
# widows are honoured if a line can still stay and the orphans are not.
|
|
75
|
+
def keep_lines(paragraph, fitting, fresh:)
|
|
76
|
+
total = paragraph.lines.size
|
|
77
|
+
kept = [fitting, total - @widows].min
|
|
78
|
+
kept = fitting if fresh && kept < 1
|
|
79
|
+
return [nil, self] if !fresh && (kept < @orphans || total < @orphans + @widows)
|
|
80
|
+
|
|
81
|
+
head, tail = paragraph.split_at(kept)
|
|
82
|
+
[head && from(head), tail && from(tail)]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def piece_width(font, style, text)
|
|
86
|
+
font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
|
|
87
|
+
ligatures: style.ligatures, features: style.features)
|
|
88
|
+
end
|
|
89
|
+
|
|
54
90
|
def paragraph(width)
|
|
55
91
|
@paragraph || (@paragraphs[width] ||= ::Stationery::Text::Paragraph.new(
|
|
56
92
|
@runs, book: @context.book, width:, align: @align, leading: @leading, fallback_style: @context.style
|
|
@@ -58,7 +94,8 @@ module Stationery
|
|
|
58
94
|
end
|
|
59
95
|
|
|
60
96
|
def from(paragraph)
|
|
61
|
-
self.class.new(@runs, context: @context, align: @align, leading: @leading,
|
|
97
|
+
self.class.new(@runs, context: @context, align: @align, leading: @leading, orphans: @orphans, widows: @widows,
|
|
98
|
+
paragraph:, tag: @tag)
|
|
62
99
|
end
|
|
63
100
|
end
|
|
64
101
|
end
|
|
@@ -9,9 +9,30 @@ module Stationery
|
|
|
9
9
|
module Containers
|
|
10
10
|
QUOTE = /\A {0,3}> ?/
|
|
11
11
|
LIST = /\A(?<indent> {0,3})(?<marker>[-+*]|(?<number>\d{1,9})[.)])(?<space> *)(?<rest>.*)\z/
|
|
12
|
+
MARKER = /\G[ \t]*(?:>|(?:[-+*]|\d{1,9}[.)])(?=[ \t]|\z))/
|
|
12
13
|
|
|
13
14
|
private
|
|
14
15
|
|
|
16
|
+
def container?(line) = QUOTE.match?(line) || !list_marker(line).nil?
|
|
17
|
+
|
|
18
|
+
# Block quotes and lists nested past the limit: their lines, up to a blank one, as one
|
|
19
|
+
# paragraph with the markers dropped.
|
|
20
|
+
def flattened
|
|
21
|
+
lines = []
|
|
22
|
+
while (line = @lines[@index]) && !blank?(line)
|
|
23
|
+
lines << unmarked(line)
|
|
24
|
+
@index += 1
|
|
25
|
+
end
|
|
26
|
+
lines.reject!(&:empty?)
|
|
27
|
+
Rich::Paragraph.new(inlines: text(lines)) if lines.any?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def unmarked(line)
|
|
31
|
+
markers = line.scan(MARKER).size
|
|
32
|
+
@nesting.record(@depth + markers)
|
|
33
|
+
line.sub(/\A(?:#{MARKER.source.delete_prefix("\\G")})*[ \t]*/, "")
|
|
34
|
+
end
|
|
35
|
+
|
|
15
36
|
def blockquote
|
|
16
37
|
lines = []
|
|
17
38
|
while (line = @lines[@index])
|
|
@@ -10,6 +10,8 @@ module Stationery
|
|
|
10
10
|
# Splits CommonMark source into blocks, line by line. Paragraph, heading and table cell text is left
|
|
11
11
|
# raw (a String in place of inlines) for Markdown.parse to run through the inline parser once every
|
|
12
12
|
# link reference definition, which this parser collects into `refs`, is known. Raw HTML stays text.
|
|
13
|
+
# Every block quote and list is parsed by a parser of its own, one level deeper; at the
|
|
14
|
+
# Rich::Nesting limit a further one becomes a paragraph of its text instead.
|
|
13
15
|
class BlockParser
|
|
14
16
|
include Containers
|
|
15
17
|
include Tables
|
|
@@ -24,7 +26,7 @@ module Stationery
|
|
|
24
26
|
(?:[ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?[ \t]*\z
|
|
25
27
|
/x
|
|
26
28
|
|
|
27
|
-
def self.parse(source, refs) = new(lines(source), refs).blocks
|
|
29
|
+
def self.parse(source, refs, nesting = Rich::Nesting.new) = new(lines(source), refs, nesting).blocks
|
|
28
30
|
|
|
29
31
|
def self.lines(source) = source.to_s.split(/\r\n?|\n/).map { |line| expand_tabs(line) }
|
|
30
32
|
|
|
@@ -39,9 +41,12 @@ module Stationery
|
|
|
39
41
|
expanded + line[prefix.length..]
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
# `depth` counts the block quotes and lists this parser is inside.
|
|
45
|
+
def initialize(lines, refs, nesting = Rich::Nesting.new, depth = 0)
|
|
43
46
|
@lines = lines
|
|
44
47
|
@refs = refs
|
|
48
|
+
@nesting = nesting
|
|
49
|
+
@depth = depth
|
|
45
50
|
@index = 0
|
|
46
51
|
end
|
|
47
52
|
|
|
@@ -62,6 +67,7 @@ module Stationery
|
|
|
62
67
|
elsif (match = FENCE.match(line)) then fenced_code(match)
|
|
63
68
|
elsif (match = ATX.match(line)) then atx(match)
|
|
64
69
|
elsif THEMATIC.match?(line) then skip(Rich::Rule.new)
|
|
70
|
+
elsif container?(line) && @depth >= @nesting.limit then flattened
|
|
65
71
|
elsif QUOTE.match?(line) then blockquote
|
|
66
72
|
elsif (match = list_marker(line)) then list(match)
|
|
67
73
|
elsif table_start? then table
|
|
@@ -135,7 +141,7 @@ module Stationery
|
|
|
135
141
|
|
|
136
142
|
def blank?(line) = line.strip.empty?
|
|
137
143
|
def indent(line) = line[/\A */].length
|
|
138
|
-
def nested(lines) = BlockParser.new(lines, @refs).blocks
|
|
144
|
+
def nested(lines) = BlockParser.new(lines, @refs, @nesting, @depth + 1).blocks
|
|
139
145
|
end
|
|
140
146
|
end
|
|
141
147
|
end
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../rich/nesting"
|
|
3
4
|
require_relative "block_parser"
|
|
4
5
|
require_relative "inline_parser"
|
|
5
6
|
|
|
6
7
|
module Stationery
|
|
7
8
|
# A CommonMark subset (plus GFM tables and strikethrough) to rich-text blocks (see Stationery::Rich).
|
|
8
9
|
module Markdown
|
|
9
|
-
|
|
10
|
+
# Block quotes and lists nested deeper than `max_depth` are flattened into
|
|
11
|
+
# a paragraph; the block is then given how deep the source went.
|
|
12
|
+
def self.parse(source, max_depth: Rich::Nesting::DEFAULT)
|
|
13
|
+
nesting = Rich::Nesting.new(max_depth)
|
|
10
14
|
Stationery.instrument("parse.stationery", format: :markdown, bytes: source.bytesize) do
|
|
11
15
|
refs = {}
|
|
12
|
-
Resolver.new(refs).blocks(BlockParser.parse(source, refs))
|
|
16
|
+
Resolver.new(refs).blocks(BlockParser.parse(source, refs, nesting)).tap do
|
|
17
|
+
yield nesting.deepest if nesting.exceeded? && block_given?
|
|
18
|
+
end
|
|
13
19
|
end
|
|
14
20
|
end
|
|
15
21
|
|