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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +358 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +25 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +8 -5
  10. data/lib/stationery/canvas.rb +25 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +53 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +52 -88
  35. data/lib/stationery/fonts/font_book.rb +46 -8
  36. data/lib/stationery/fonts/glyph_run.rb +38 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +62 -0
  41. data/lib/stationery/fonts/installer.rb +121 -0
  42. data/lib/stationery/fonts/kern_table.rb +52 -0
  43. data/lib/stationery/fonts/kerning.rb +17 -0
  44. data/lib/stationery/fonts/packs.rb +45 -0
  45. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  46. data/lib/stationery/fonts/to_unicode.rb +36 -0
  47. data/lib/stationery/fonts/true_type.rb +32 -49
  48. data/lib/stationery/html/document.rb +11 -0
  49. data/lib/stationery/html/tokenizer.rb +74 -0
  50. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  51. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  52. data/lib/stationery/html/tree_builder.rb +72 -0
  53. data/lib/stationery/html/whitespace.rb +43 -0
  54. data/lib/stationery/layout/box.rb +85 -16
  55. data/lib/stationery/layout/flow.rb +48 -15
  56. data/lib/stationery/layout/image.rb +1 -0
  57. data/lib/stationery/layout/list_item.rb +89 -0
  58. data/lib/stationery/layout/mark.rb +58 -0
  59. data/lib/stationery/layout/node.rb +26 -2
  60. data/lib/stationery/layout/paginator.rb +35 -6
  61. data/lib/stationery/layout/positioned.rb +3 -1
  62. data/lib/stationery/layout/row.rb +34 -4
  63. data/lib/stationery/layout/svg.rb +45 -0
  64. data/lib/stationery/layout/table/cell.rb +33 -6
  65. data/lib/stationery/layout/table/grid.rb +93 -0
  66. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  67. data/lib/stationery/layout/table/selection.rb +3 -2
  68. data/lib/stationery/layout/table.rb +69 -23
  69. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  70. data/lib/stationery/layout/table_of_contents.rb +43 -0
  71. data/lib/stationery/layout/text.rb +5 -4
  72. data/lib/stationery/layout/wrap.rb +78 -0
  73. data/lib/stationery/list_markers.rb +47 -0
  74. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  75. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  76. data/lib/stationery/markdown/block_parser.rb +141 -0
  77. data/lib/stationery/markdown/document.rb +50 -0
  78. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  79. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  80. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  81. data/lib/stationery/markdown/inline_parser.rb +89 -0
  82. data/lib/stationery/minitest.rb +33 -0
  83. data/lib/stationery/outline.rb +37 -0
  84. data/lib/stationery/page.rb +18 -4
  85. data/lib/stationery/page_templates.rb +33 -10
  86. data/lib/stationery/pdf/assembler.rb +18 -7
  87. data/lib/stationery/pdf/outline_writer.rb +72 -0
  88. data/lib/stationery/pdf/serializer.rb +5 -1
  89. data/lib/stationery/pdf/writer.rb +4 -0
  90. data/lib/stationery/preview.rb +64 -0
  91. data/lib/stationery/rails/previews_controller.rb +61 -0
  92. data/lib/stationery/rails.rb +2 -0
  93. data/lib/stationery/railtie.rb +60 -0
  94. data/lib/stationery/regions.rb +67 -0
  95. data/lib/stationery/rich/nodes.rb +55 -0
  96. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  97. data/lib/stationery/rich/renderer.rb +121 -0
  98. data/lib/stationery/rich/styles.rb +34 -0
  99. data/lib/stationery/rspec.rb +6 -0
  100. data/lib/stationery/structure.rb +78 -0
  101. data/lib/stationery/svg/arc.rb +91 -0
  102. data/lib/stationery/svg/document.rb +73 -0
  103. data/lib/stationery/svg/parser.rb +38 -0
  104. data/lib/stationery/svg/path_data.rb +137 -0
  105. data/lib/stationery/svg/shapes.rb +54 -0
  106. data/lib/stationery/svg/style.rb +68 -0
  107. data/lib/stationery/svg/transform.rb +43 -0
  108. data/lib/stationery/testing/inspector.rb +98 -0
  109. data/lib/stationery/testing/matchers.rb +123 -0
  110. data/lib/stationery/text/entities/html4.rb +49 -0
  111. data/lib/stationery/text/entities.rb +6 -1
  112. data/lib/stationery/text/line.rb +8 -2
  113. data/lib/stationery/text/paragraph.rb +32 -11
  114. data/lib/stationery/text/runs_builder.rb +9 -1
  115. data/lib/stationery/text/style.rb +2 -2
  116. data/lib/stationery/text/wrapper.rb +3 -2
  117. data/lib/stationery/version.rb +1 -1
  118. data/lib/stationery/warnings.rb +65 -0
  119. data/lib/stationery.rb +51 -0
  120. metadata +85 -4
@@ -3,25 +3,29 @@
3
3
  module Stationery
4
4
  module Layout
5
5
  # Rows of cells with column widths, per-cell styling through selections,
6
- # header rows repeated after a page break, and splitting between rows.
6
+ # header rows repeated after a page break, and splitting between rows. A
7
+ # row taller than a fresh page, or any row with split_rows: true, is cut
8
+ # through its cells and continues below the repeated header.
7
9
  class Table < Node
8
10
  DEFAULT_CELL = { padding: 5, borders: %i[top right bottom left], border_width: 0.5,
9
11
  border_color: "#000000" }.freeze
10
12
 
11
- def initialize(rows, context:, widths: nil, width: :auto, header: false, cell: {})
13
+ def initialize(rows, context:, widths: nil, width: :auto, header: false, split_rows: false, cell: {})
12
14
  super()
15
+ @split_rows = split_rows
13
16
  @context = context
14
17
  @widths = widths
15
18
  @width = width
16
19
  @header = header == true ? 1 : (header || 0).to_i
17
20
  defaults = DEFAULT_CELL.merge(cell)
18
- @cells = rows.map { |row| row.map { |content| content.is_a?(Cell) ? content : Cell.new(content, defaults) } }
21
+ @cells = rows.map { |row| row.map { |content| build_cell(content, defaults) } }
22
+ check_header
19
23
  yield self if block_given?
20
24
  end
21
25
 
22
26
  def row_count = @cells.size
23
- def column_count = @cells.map(&:size).max || 0
24
- def cell(row, column) = @cells.dig(row, column)
27
+ def column_count = grid.column_count
28
+ def cell(row, column) = grid.at(row, column)
25
29
 
26
30
  def rows(spec) = Selection.new(self, Selection.indexes(spec, row_count), (0...column_count).to_a)
27
31
  alias row rows
@@ -38,6 +42,14 @@ module Stationery
38
42
 
39
43
  def splittable? = true
40
44
 
45
+ # Forgets measurements taken before a selection restyled cells.
46
+ def invalidate!
47
+ @column_widths = nil
48
+ @column_metrics = nil
49
+ @row_heights = nil
50
+ @grid = nil
51
+ end
52
+
41
53
  def natural_width = column_metric(:natural_width).sum
42
54
  def min_width = column_metric(:min_width).sum
43
55
 
@@ -61,30 +73,65 @@ module Stationery
61
73
 
62
74
  def paint(canvas, x, y, width, _height = nil, **)
63
75
  widths = column_widths(width)
64
- top = y
65
- @cells.zip(row_heights(width)).each_with_index do |(row, height), row_index|
66
- left = x
67
- row.each_with_index do |cell, index|
68
- cell.paint(canvas, @context, Rect.new(left, top, widths[index], height),
69
- last_column: index == row.size - 1, last_row: row_index == @cells.size - 1)
70
- left += widths[index]
71
- end
72
- top += height
76
+ heights = row_heights(width)
77
+ grid.placements.each do |p|
78
+ rect = Rect.new(x + widths[0...p.column].sum, y + heights[0...p.row].sum,
79
+ widths[p.columns].sum, heights[p.rows].sum)
80
+ p.cell.paint(canvas, @context, rect, last_column: p.columns.end == column_count,
81
+ last_row: p.rows.end == row_count)
73
82
  end
74
83
  end
75
84
 
76
- def split(width, height)
85
+ def split(width, height, **options)
77
86
  heights = row_heights(width)
78
87
  used = heights.first(@header).sum
79
88
  count = @header
80
89
  count += 1 while count < row_count && used + heights[count] <= height + EPSILON && (used += heights[count])
81
90
  return [self, nil] if count == row_count
91
+
92
+ count = grid.boundaries.grep(@header..count).max
93
+ split_row(count, width, height - heights.first(count).sum, fresh: options[:fresh]) || split_before(count)
94
+ end
95
+
96
+ private
97
+
98
+ def grid = @grid ||= Grid.new(@cells)
99
+
100
+ def split_before(count)
82
101
  return [nil, self] if count == @header
83
102
 
84
103
  [with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count))]
85
104
  end
86
105
 
87
- private
106
+ def split_row(row, width, space, fresh:)
107
+ return unless @split_rows || (fresh && row == @header)
108
+ return unless grid.boundaries.include?(row + 1)
109
+
110
+ widths = column_widths(width)
111
+ placements = grid.placements.select { |p| p.row == row }
112
+ heads, tails = RowSplitter.new(placements, widths:, context: @context).call(space)
113
+ return unless heads
114
+
115
+ [with_rows(@cells.first(row) + [heads], widths:),
116
+ with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:)]
117
+ end
118
+
119
+ def build_cell(content, defaults)
120
+ case content
121
+ when Cell then content
122
+ when Hash
123
+ options = content.except(:content, :colspan, :rowspan)
124
+ Cell.new(content[:content], defaults.merge(options),
125
+ colspan: content.fetch(:colspan, 1), rowspan: content.fetch(:rowspan, 1))
126
+ else Cell.new(content, defaults)
127
+ end
128
+ end
129
+
130
+ def check_header
131
+ return if @header.zero? || grid.boundaries.include?([@header, row_count].min)
132
+
133
+ raise ArgumentError, "a rowspan crosses the end of the #{@header} header row(s)"
134
+ end
88
135
 
89
136
  def target(available)
90
137
  case @width
@@ -100,18 +147,17 @@ module Stationery
100
147
  end
101
148
 
102
149
  def column_metric(metric)
103
- Array.new(column_count) do |c|
104
- @cells.filter_map { |row| row[c]&.public_send(metric, @context) }.max || 0
105
- end
150
+ @column_metrics ||= {}
151
+ @column_metrics[metric] ||= grid.column_metric { |p| p.cell.public_send(metric, @context) }
106
152
  end
107
153
 
108
154
  def row_heights(width)
109
- widths = column_widths(width)
110
- @cells.map { |row| row.each_with_index.map { |cell, i| cell.measure(@context, widths[i]) }.max || 0 }
155
+ @row_heights ||= {}
156
+ @row_heights[width] ||= grid.row_heights(column_widths(width)) { |p, span| p.cell.measure(@context, span) }
111
157
  end
112
158
 
113
- def with_rows(rows)
114
- self.class.new(rows, context: @context, widths: @widths, width: @width, header: @header)
159
+ def with_rows(rows, widths: @widths)
160
+ self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows)
115
161
  end
116
162
  end
117
163
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ class TableOfContents
6
+ # One contents row. The title wraps within the room the number slot
7
+ # leaves; the leader and the number sit on its last line, and the whole
8
+ # row links to the entry's anchor once that anchor has painted.
9
+ class Entry < Node
10
+ LEADERS = { dots: { dash: [0, 3], cap: :round, width: 1 }, line: { width: 0.5 } }.freeze
11
+
12
+ def initialize(entry, context:, options:, slot:)
13
+ super()
14
+ @entry = entry
15
+ @context = context
16
+ @options = options
17
+ @slot = slot
18
+ @paragraphs = {}
19
+ end
20
+
21
+ def measure(width) = paragraph(width).height
22
+ def natural_width = paragraph(Float::INFINITY).lines.map(&:width).max.to_f + offset + @slot + @options.gap
23
+
24
+ def paint(canvas, x, y, width, _height = nil, **)
25
+ text = paragraph(width)
26
+ text.draw(canvas, x + offset, y)
27
+ last = text.lines.last
28
+ baseline = y + text.height - last.height + last.ascent
29
+ slot_x = x + width - @slot
30
+ draw_leader(canvas, x + offset + last.width + 2, slot_x, baseline)
31
+ canvas.number_slot(@entry.anchor, x: slot_x, baseline:, width: @slot, style: @context.style,
32
+ link: [x + offset, y, width - offset, text.height])
33
+ end
34
+
35
+ private
36
+
37
+ def offset = @options.indent * (@entry.level - 1)
38
+
39
+ def draw_leader(canvas, from, to, baseline)
40
+ leader = LEADERS[@options.leader]
41
+ return unless leader && to > from
42
+
43
+ canvas.line(from, baseline, to, baseline, color: @context.style.color, **leader)
44
+ end
45
+
46
+ def paragraph(width)
47
+ @paragraphs[width] ||= ::Stationery::Text::Paragraph.new(
48
+ [::Stationery::Text::Run.new(@entry.title, @context.style)],
49
+ book: @context.book, width: [width - offset - @slot - @options.gap, 1].max,
50
+ fallback_style: @context.style
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # One row per outline entry: the title (indented by level), a leader and a
6
+ # right-aligned page number filled in once pages are known. Rows are built
7
+ # on first use, so bookmarks declared after the contents still appear.
8
+ class TableOfContents < Node
9
+ Options = Data.define(:levels, :leader, :indent, :number_width, :gap)
10
+
11
+ def initialize(outline, context:, levels: 1.., leader: :dots, indent: 12, number_width: nil, gap: 4)
12
+ super()
13
+ @outline = outline
14
+ @context = context
15
+ @options = Options.new(levels, leader, indent, number_width, gap)
16
+ end
17
+
18
+ def splittable? = true
19
+ def measure(width) = rows.measure(width)
20
+ def natural_width = rows.natural_width
21
+ def min_width = rows.min_width
22
+ def split(width, height, **) = rows.split(width, height, **)
23
+ def paint(canvas, x, y, width, height = nil, **) = rows.paint(canvas, x, y, width, height)
24
+
25
+ private
26
+
27
+ def rows
28
+ @rows ||= Flow.new(entries.map { |entry| Entry.new(entry, context: @context, options: @options, slot:) },
29
+ gap: @options.gap)
30
+ end
31
+
32
+ def entries
33
+ levels = @options.levels
34
+ @outline.entries.select { |entry| levels.is_a?(Integer) ? entry.level <= levels : levels.include?(entry.level) }
35
+ end
36
+
37
+ def slot
38
+ style = @context.style
39
+ @options.number_width || @context.book.resolve(style).first.width_of("0000", style.render_size)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -8,7 +8,7 @@ module Stationery
8
8
 
9
9
  def initialize(runs, context:, align: :left, leading: 0, paragraph: nil)
10
10
  super()
11
- @runs = runs
11
+ @runs = context.book.fallback(runs)
12
12
  @context = context
13
13
  @align = align
14
14
  @leading = leading
@@ -24,7 +24,7 @@ module Stationery
24
24
  paragraph(width).draw(canvas, x, y)
25
25
  end
26
26
 
27
- def split(width, height)
27
+ def split(width, height, **)
28
28
  head, tail = paragraph(width).split(height)
29
29
  [head && from(head), tail && from(tail)]
30
30
  end
@@ -39,9 +39,10 @@ module Stationery
39
39
 
40
40
  def min_width
41
41
  @min_width ||= @runs.flat_map do |run|
42
- font = @context.book.resolve(run.style).first
42
+ style = run.style
43
+ font = @context.book.resolve(style).first
43
44
  run.text.split(/[ \t\n]+/).map do |word|
44
- font.width_of(word, run.style.render_size, letter_spacing: run.style.letter_spacing)
45
+ font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning)
45
46
  end
46
47
  end.max || 0
47
48
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # Children laid out left to right at their own widths, wrapping onto new
6
+ # rows when the width runs out — chips, tags, badges. Splits between rows.
7
+ class Wrap < Node
8
+ attr_reader :children
9
+
10
+ def initialize(children, gap: 0, row_gap: 0, align: :left)
11
+ super()
12
+ @children = children
13
+ @gap = gap
14
+ @row_gap = row_gap
15
+ @align = align
16
+ end
17
+
18
+ def splittable? = true
19
+
20
+ def natural_width
21
+ widths = @children.map { |child| child.fixed_width(Float::INFINITY) || child.natural_width }
22
+ widths.sum + (@gap * [widths.size - 1, 0].max)
23
+ end
24
+
25
+ def min_width = @children.map { |child| child.fixed_width(Float::INFINITY) || child.min_width }.max || 0
26
+
27
+ # [[[child, width], …], …] — one array per row.
28
+ def rows(width)
29
+ @children.each_with_object([[]]) do |child, rows|
30
+ own = [child.fixed_width(width) || width, width].min
31
+ used = rows.last.sum { |_, w| w } + (@gap * rows.last.size)
32
+ rows << [] if rows.last.any? && used + own > width + EPSILON
33
+ rows.last << [child, own]
34
+ end.reject(&:empty?)
35
+ end
36
+
37
+ def measure(width)
38
+ memoize_by_width(width) do
39
+ heights = row_heights(width)
40
+ heights.sum + (@row_gap * [heights.size - 1, 0].max)
41
+ end
42
+ end
43
+
44
+ def paint(canvas, x, y, width, _height = nil, **)
45
+ top = y
46
+ rows(width).zip(row_heights(width)).each do |row, height|
47
+ used = row.sum { |_, w| w } + (@gap * (row.size - 1))
48
+ left = x + Geometry.align_offset(@align, width, used)
49
+ row.each do |child, w|
50
+ child.paint(canvas, left, top, w)
51
+ canvas.debug_rect(left, top, w, child.measure(w), :flow) if canvas.debug?
52
+ left += w + @gap
53
+ end
54
+ top += height + @row_gap
55
+ end
56
+ end
57
+
58
+ def split(width, height, **)
59
+ heights = row_heights(width)
60
+ used = 0
61
+ count = heights.take_while.with_index { |h, i| (used += h + (i.zero? ? 0 : @row_gap)) <= height + EPSILON }.size
62
+ return [self, nil] if count == heights.size
63
+ return [nil, self] if count.zero?
64
+
65
+ taken = rows(width).first(count).sum(&:size)
66
+ [with(@children.first(taken)), with(@children.drop(taken))]
67
+ end
68
+
69
+ private
70
+
71
+ def row_heights(width)
72
+ rows(width).map { |row| row.map { |child, w| child.measure(w) }.max }
73
+ end
74
+
75
+ def with(children) = self.class.new(children, gap: @gap, row_gap: @row_gap, align: @align)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Ordered-list labels: decimal, alpha, roman (lower or upper case) or a
5
+ # Proc given the number. Formats with no numeral for a number fall back to
6
+ # decimal.
7
+ module ListMarkers
8
+ ROMAN = { 1000 => "m", 900 => "cm", 500 => "d", 400 => "cd", 100 => "c", 90 => "xc",
9
+ 50 => "l", 40 => "xl", 10 => "x", 9 => "ix", 5 => "v", 4 => "iv", 1 => "i" }.freeze
10
+
11
+ module_function
12
+
13
+ def label(format, number, suffix = ".") = "#{numeral(format, number)}#{suffix}"
14
+
15
+ def numeral(format, number)
16
+ case format
17
+ when Proc then format.call(number).to_s
18
+ when :decimal then number.to_s
19
+ when :alpha then alpha(number)
20
+ when :upper_alpha then alpha(number).upcase
21
+ when :roman then roman(number)
22
+ when :upper_roman then roman(number).upcase
23
+ else raise ArgumentError, "unknown list format: #{format.inspect}"
24
+ end
25
+ end
26
+
27
+ def alpha(number)
28
+ return number.to_s if number < 1
29
+
30
+ letters = +""
31
+ while number.positive?
32
+ number, rest = (number - 1).divmod(26)
33
+ letters.prepend((97 + rest).chr)
34
+ end
35
+ letters
36
+ end
37
+
38
+ def roman(number)
39
+ return number.to_s unless (1..3999).cover?(number)
40
+
41
+ ROMAN.each_with_object(+"") do |(value, letters), numeral|
42
+ count, number = number.divmod(value)
43
+ numeral << (letters * count)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Markdown
5
+ class BlockParser
6
+ # Blockquotes and lists: each collects its lines (stripping the `>` or the item's content indent,
7
+ # taking lazy continuation lines) and parses them as a nested document. Tight and loose items both
8
+ # hold Paragraphs; the model does not distinguish them.
9
+ module Containers
10
+ QUOTE = /\A {0,3}> ?/
11
+ LIST = /\A(?<indent> {0,3})(?<marker>[-+*]|(?<number>\d{1,9})[.)])(?<space> *)(?<rest>.*)\z/
12
+
13
+ private
14
+
15
+ def blockquote
16
+ lines = []
17
+ while (line = @lines[@index])
18
+ if QUOTE.match?(line) then lines << line.sub(QUOTE, "")
19
+ elsif !blank?(line) && !blank?(lines.last) && !interrupts?(line) then lines << line
20
+ else break
21
+ end
22
+ @index += 1
23
+ end
24
+ Rich::Blockquote.new(blocks: nested(lines))
25
+ end
26
+
27
+ def list(first)
28
+ kind = first[:marker][-1]
29
+ items = []
30
+ while (match = next_item(kind))
31
+ items << nested(item_lines(match))
32
+ end
33
+ ordered = !first[:number].nil?
34
+ Rich::List.new(ordered:, start: (first[:number].to_i if ordered), items:)
35
+ end
36
+
37
+ # The marker of the list's next item, after any blank lines between items.
38
+ def next_item(kind)
39
+ index = @index
40
+ index += 1 while @lines[index] && blank?(@lines[index])
41
+ line = @lines[index].to_s
42
+ match = list_marker(line)
43
+ return unless match && match[:marker][-1] == kind && !THEMATIC.match?(line)
44
+
45
+ @index = index
46
+ match
47
+ end
48
+
49
+ def item_lines(match)
50
+ column, first = content_start(match)
51
+ lines = [first]
52
+ while (line = @lines[@index += 1])
53
+ if blank?(line) then break if lines == [""]
54
+
55
+ lines << ""
56
+ elsif indent(line) >= column then lines << line[column..]
57
+ elsif !lines.last.empty? && !list_marker(line) && !interrupts?(line) then lines << line.lstrip
58
+ else break
59
+ end
60
+ end
61
+ give_back_blanks(lines)
62
+ end
63
+
64
+ def content_start(match)
65
+ width = match[:indent].length + match[:marker].length
66
+ spaces = match[:space].length
67
+ return [width + 1, ""] if match[:rest].empty?
68
+ return [width + 1, (" " * (spaces - 1)) + match[:rest]] if spaces > 4
69
+
70
+ [width + spaces, match[:rest]]
71
+ end
72
+
73
+ def give_back_blanks(lines)
74
+ while lines.length > 1 && lines.last.empty?
75
+ lines.pop
76
+ @index -= 1
77
+ end
78
+ lines
79
+ end
80
+
81
+ def list_marker(line)
82
+ match = LIST.match(line)
83
+ match if match && (!match[:space].empty? || match[:rest].empty?)
84
+ end
85
+
86
+ # Only a non-empty item interrupts a paragraph, and an ordered one only when it starts at 1.
87
+ def list_interrupts?(line)
88
+ match = list_marker(line)
89
+ match && !match[:rest].strip.empty? && (match[:number].nil? || match[:number].to_i == 1)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Markdown
5
+ class BlockParser
6
+ # GFM tables: a header row, a delimiter row of the same width setting each column's alignment, and
7
+ # body rows up to a blank line or another block. Body rows are padded or cut to the header's width.
8
+ module Tables
9
+ DELIMITER = /\A {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*\z/
10
+ ALIGNMENTS = { "::" => :center, ":" => :left, "-:" => :right }.freeze
11
+
12
+ private
13
+
14
+ def table_start?
15
+ header = @lines[@index]
16
+ delimiter = @lines[@index + 1]
17
+ header.include?("|") && delimiter && DELIMITER.match?(delimiter) &&
18
+ cells(header).length == cells(delimiter).length
19
+ end
20
+
21
+ def table
22
+ header = cells(@lines[@index])
23
+ aligns = cells(@lines[@index + 1]).map { |cell| alignment(cell) }
24
+ rows = [row(header, aligns, header: true)]
25
+ @index += 2
26
+ while (line = @lines[@index]) && !blank?(line) && !interrupts?(line)
27
+ texts = cells(line)
28
+ rows << row(Array.new(header.length) { |i| texts[i] || "" }, aligns)
29
+ @index += 1
30
+ end
31
+ Rich::Table.new(rows:)
32
+ end
33
+
34
+ def row(texts, aligns, header: false)
35
+ texts.zip(aligns).map do |text, align|
36
+ Rich::Cell.new(header:, align:, blocks: text.empty? ? [] : [Rich::Paragraph.new(inlines: text)])
37
+ end
38
+ end
39
+
40
+ def alignment(cell)
41
+ key = (cell.start_with?(":") ? ":" : "") + (cell.end_with?(":") ? ":" : "")
42
+ key = "-:" if key == ":" && !cell.start_with?(":")
43
+ ALIGNMENTS[key]
44
+ end
45
+
46
+ def cells(line)
47
+ text = line.strip.delete_prefix("|")
48
+ text = text.delete_suffix("|") unless text.end_with?("\\|")
49
+ text.split(/(?<!\\)\|/, -1).map { |cell| cell.strip.gsub("\\|", "|") }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end