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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +251 -37
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/{svg → css}/selector.rb +1 -1
  6. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  7. data/lib/stationery/css.rb +20 -0
  8. data/lib/stationery/document.rb +35 -7
  9. data/lib/stationery/elements/forms.rb +11 -6
  10. data/lib/stationery/elements/rich.rb +21 -6
  11. data/lib/stationery/elements.rb +20 -7
  12. data/lib/stationery/errors.rb +4 -0
  13. data/lib/stationery/fonts/fallback.rb +1 -1
  14. data/lib/stationery/fonts/font.rb +28 -18
  15. data/lib/stationery/forms/acro_form.rb +95 -25
  16. data/lib/stationery/forms/appearance.rb +92 -30
  17. data/lib/stationery/forms/field.rb +20 -11
  18. data/lib/stationery/forms/metrics.rb +3 -17
  19. data/lib/stationery/forms/typeface.rb +113 -0
  20. data/lib/stationery/html/css.rb +276 -0
  21. data/lib/stationery/html/document.rb +10 -2
  22. data/lib/stationery/html/tokenizer.rb +15 -4
  23. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  24. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  25. data/lib/stationery/html/tree_builder.rb +44 -4
  26. data/lib/stationery/layout/flow.rb +29 -0
  27. data/lib/stationery/layout/image.rb +21 -7
  28. data/lib/stationery/layout/mark.rb +2 -0
  29. data/lib/stationery/layout/node.rb +4 -0
  30. data/lib/stationery/layout/table/cell.rb +14 -4
  31. data/lib/stationery/layout/table.rb +72 -14
  32. data/lib/stationery/layout/text.rb +47 -10
  33. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  34. data/lib/stationery/markdown/block_parser.rb +9 -3
  35. data/lib/stationery/markdown/document.rb +8 -2
  36. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  37. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  38. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  39. data/lib/stationery/markdown/inline_parser.rb +9 -3
  40. data/lib/stationery/minitest.rb +4 -0
  41. data/lib/stationery/pdf/assembler.rb +31 -11
  42. data/lib/stationery/pdf/conformance.rb +7 -5
  43. data/lib/stationery/pdf/serializer.rb +3 -1
  44. data/lib/stationery/pdf/signature/cms.rb +95 -0
  45. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  46. data/lib/stationery/pdf/signature.rb +174 -0
  47. data/lib/stationery/pdf/types.rb +4 -0
  48. data/lib/stationery/pdf/writer.rb +73 -19
  49. data/lib/stationery/rich/nesting.rb +30 -0
  50. data/lib/stationery/rich/nodes.rb +37 -11
  51. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  52. data/lib/stationery/rich/renderer/indents.rb +43 -0
  53. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  54. data/lib/stationery/rich/renderer.rb +49 -16
  55. data/lib/stationery/svg/document.rb +8 -4
  56. data/lib/stationery/svg/parser.rb +51 -11
  57. data/lib/stationery/svg/style.rb +4 -6
  58. data/lib/stationery/svg/walker.rb +2 -0
  59. data/lib/stationery/tagging/element.rb +1 -1
  60. data/lib/stationery/testing/inspector.rb +98 -0
  61. data/lib/stationery/testing/matchers.rb +25 -0
  62. data/lib/stationery/text/breaks.rb +123 -0
  63. data/lib/stationery/text/paragraph.rb +8 -0
  64. data/lib/stationery/text/runs_builder.rb +4 -0
  65. data/lib/stationery/text/wrapper.rb +69 -11
  66. data/lib/stationery/version.rb +1 -1
  67. data/lib/stationery/warnings.rb +13 -0
  68. data/lib/stationery.rb +6 -2
  69. 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
- def self.parse(tokens) = Blocks.convert(new.build(tokens))
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
- @stack.slice!(index..) if index&.positive?
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. `fit: :cover` fills `width:` × `height:`
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 = source.is_a?(String) || source.is_a?(Pathname) ? File.basename(source.to_s) : "inline image"
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
- def fixed_width(available) = size(available)[0]
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 @width && @height then [@width, @height]
102
- elsif @width then [@width, tidy(ih * @width / iw)]
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
- color = @options[:border_color]
111
- Array(@options[:borders]).each do |side|
112
- x1, y1, x2, y2 = edge(side, rect)
113
- canvas.line(x1, y1, x2, y2, color:, width:)
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 + widths[0...p.column].sum, y + heights[0...p.row].sum,
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]) || split_before(count)
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
- [with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count), continued: true)]
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 unless grid.boundaries.include?(row + 1)
184
+ return if row_spans? && !grid.boundaries.include?(row + 1)
140
185
 
141
186
  widths = column_widths(width)
142
- placements = grid.placements.select { |p| p.row == row }
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
- [with_rows(@cells.first(row) + [heads], widths:),
147
- with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:, continued: true)]
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
- def with_rows(rows, widths: @widths, continued: @continued)
191
- self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows,
192
- tag: @tag, continued:)
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, paragraph: nil, tag: Tagging::Element.new(:P))
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
- style = run.style
44
- font = @context.book.resolve(style).first
45
- run.text.delete(::Stationery::Text::Wrapper::SOFT_HYPHEN).split(/[ \t\n]+/).map do |word|
46
- font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
47
- ligatures: style.ligatures, features: style.features)
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, paragraph:, tag: @tag)
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
- def initialize(lines, refs)
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
- def self.parse(source)
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