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
|
@@ -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|
|
|
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 =
|
|
24
|
-
def cell(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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
@
|
|
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: @
|
|
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
|
-
|
|
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,
|
|
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
|