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
@@ -4,7 +4,7 @@ module Stationery
4
4
  # Collects the nodes a component tree describes. Holds the container stack
5
5
  # (where the next node goes) and the text defaults in effect.
6
6
  class Builder
7
- STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity].freeze
7
+ STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning].freeze
8
8
 
9
9
  # Collects a row's columns; anything that is not already a column box is
10
10
  # wrapped in one.
@@ -19,13 +19,33 @@ module Stationery
19
19
  end
20
20
  end
21
21
 
22
- attr_reader :root, :book
22
+ # Collects a list's items: every node added becomes one item.
23
+ class Items
24
+ attr_reader :nodes
25
+
26
+ def initialize = @nodes = []
27
+
28
+ def <<(node)
29
+ @nodes << node
30
+ self
31
+ end
32
+ end
33
+
34
+ attr_reader :root, :book, :list_depth
23
35
 
24
36
  def initialize(book:, text: {})
25
37
  @book = book
26
38
  @root = Layout::Flow.new
27
39
  @containers = [@root]
28
40
  @text = [text]
41
+ @list_depth = 0
42
+ end
43
+
44
+ def nested_list
45
+ @list_depth += 1
46
+ yield
47
+ ensure
48
+ @list_depth -= 1
29
49
  end
30
50
 
31
51
  def add(node)
@@ -49,10 +69,12 @@ module Stationery
49
69
  end
50
70
 
51
71
  def text_defaults = @text.last
72
+ def outline = @outline ||= Outline.new
73
+ def warnings = @book.warnings
52
74
 
53
75
  def style(options = {})
54
76
  options = text_defaults.merge(options)
55
- family = options[:font] || @book.families.keys.first || "default"
77
+ family = options[:font] || @book.families.keys.first || Fonts::Bundled::DEFAULT
56
78
  Text::Style.new(family: family.to_s, **options.slice(*STYLE_KEYS))
57
79
  end
58
80
 
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ class Canvas
5
+ # Layout outlines for `to_pdf(debug: true)`: a thin stroke around each
6
+ # layout rectangle, coloured by what drew it. `debug:` is true (every
7
+ # kind) or an Array of kinds.
8
+ module Debug
9
+ DEBUG_COLORS = {
10
+ box: "#E11D48", padding: "#E11D48", column: "#2563EB", cell: "#16A34A", cell_padding: "#16A34A",
11
+ flow: "#9CA3AF", positioned: "#DB2777", image: "#0D9488", page: "#06B6D4", region: "#0EA5E9"
12
+ }.freeze
13
+ DEBUG_DASHES = { padding: [2, 2], cell_padding: [2, 2], flow: [1, 2] }.freeze
14
+
15
+ def debug? = @debug == true || (@debug.is_a?(Array) && @debug.any?)
16
+
17
+ def debug_rect(x, y, w, h, kind)
18
+ return unless @debug == true || (@debug.is_a?(Array) && @debug.include?(kind))
19
+
20
+ color = DEBUG_COLORS.fetch(kind) { raise ArgumentError, "unknown debug kind: #{kind.inspect}" }
21
+ rounded_rect(x + 0.25, y + 0.25, w - 0.5, h - 0.5, radius: 0, stroke: color, line_width: 0.5,
22
+ dash: DEBUG_DASHES[kind])
23
+ end
24
+ end
25
+ end
26
+ end
@@ -31,20 +31,21 @@ module Stationery
31
31
  close
32
32
  end
33
33
 
34
+ # r is one radius for every corner or [top_left, top_right, bottom_right,
35
+ # bottom_left]; radii are scaled down together until each side fits.
34
36
  def rounded_rect(x, y, w, h, r)
35
- r = [r, w / 2.0, h / 2.0].min
36
- return rect(x, y, w, h) if r <= 0
37
+ tl, tr, br, bl = corner_radii(r, w, h)
38
+ return rect(x, y, w, h) if [tl, tr, br, bl].none?(&:positive?)
37
39
 
38
- k = r * KAPPA
39
- move_to(x + r, y)
40
- line_to(x + w - r, y)
41
- curve_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r)
42
- line_to(x + w, y + h - r)
43
- curve_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y + h)
44
- line_to(x + r, y + h)
45
- curve_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r)
46
- line_to(x, y + r)
47
- curve_to(x, y + r - k, x + r - k, y, x + r, y)
40
+ move_to(x + tl, y)
41
+ line_to(x + w - tr, y)
42
+ curve_to(x + w - tr + (tr * KAPPA), y, x + w, y + tr - (tr * KAPPA), x + w, y + tr) if tr.positive?
43
+ line_to(x + w, y + h - br)
44
+ curve_to(x + w, y + h - br + (br * KAPPA), x + w - br + (br * KAPPA), y + h, x + w - br, y + h) if br.positive?
45
+ line_to(x + bl, y + h)
46
+ curve_to(x + bl - (bl * KAPPA), y + h, x, y + h - bl + (bl * KAPPA), x, y + h - bl) if bl.positive?
47
+ line_to(x, y + tl)
48
+ curve_to(x, y + tl - (tl * KAPPA), x + tl - (tl * KAPPA), y, x + tl, y) if tl.positive?
48
49
  close
49
50
  end
50
51
 
@@ -68,6 +69,15 @@ module Stationery
68
69
  self
69
70
  end
70
71
 
72
+ def corner_radii(r, w, h)
73
+ return Array.new(4, [r, w / 2.0, h / 2.0].min) if r.is_a?(Numeric)
74
+
75
+ tl, tr, br, bl = r.map { |v| [v, 0].max }
76
+ scale = [[w, tl + tr], [w, bl + br], [h, tl + bl], [h, tr + br]]
77
+ .filter_map { |side, sum| side.fdiv(sum) if sum.positive? }.push(1).min
78
+ [tl, tr, br, bl].map { |v| v * scale }
79
+ end
80
+
71
81
  def point(x, y)
72
82
  x, y = apply(x, y) if @transform
73
83
  "#{@canvas.num(x)} #{@canvas.num(@canvas.page.height - y)}"
@@ -9,15 +9,18 @@ module Stationery
9
9
  # Draws `string` with its baseline at (x, y) in top-left coordinates and
10
10
  # returns its advance width.
11
11
  def text(string, x:, y:, font:, size:, color: "#000000", letter_spacing: 0, rise: 0, opacity: nil,
12
- synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false)
12
+ synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false,
13
+ kerning: false, word_spacing: 0)
13
14
  return 0 if string.empty?
14
15
 
15
16
  color = Color.parse(color)
16
- width = font.width_of(string, size, letter_spacing:)
17
+ run = font.glyph_run(string, kerning:)
18
+ run = run.with_word_spacing(string, word_spacing, size) unless word_spacing.zero?
19
+ width = run.width(size, letter_spacing:)
17
20
  graphics(opacity:) do |ops|
18
21
  ops << color.fill
19
22
  ops.push(color.stroke, "#{num(size * BOLD_STROKE)} w") if synthetic_bold
20
- ops << text_object(string, x, y, font, size,
23
+ ops << text_object(run, x, y, font, size,
21
24
  letter_spacing:, rise:, bold: synthetic_bold, oblique: synthetic_oblique)
22
25
  end
23
26
  decorate(x, y, width, font, size, color:, underline:, strikethrough:)
@@ -26,7 +29,7 @@ module Stationery
26
29
 
27
30
  private
28
31
 
29
- def text_object(string, x, y, font, size, letter_spacing:, rise:, bold:, oblique:)
32
+ def text_object(run, x, y, font, size, letter_spacing:, rise:, bold:, oblique:)
30
33
  skew = oblique ? Fonts::Font::OBLIQUE_SKEW : 0
31
34
  ops = ["BT", "/#{@page.use(:Font, @resources.font(font))} #{num(size)} Tf"]
32
35
  ops << "#{num(letter_spacing)} Tc" unless letter_spacing.zero?
@@ -39,7 +42,7 @@ module Stationery
39
42
  else
40
43
  "1 0 #{num(skew)} 1 #{num(x)} #{num(@page.height - y)} Tm"
41
44
  end
42
- ops << "<#{font.encode(string).unpack1("H*").upcase}> Tj"
45
+ ops << run.to_operator
43
46
  ops << "ET"
44
47
  ops.join("\n")
45
48
  end
@@ -6,15 +6,19 @@ module Stationery
6
6
  # settings never leak into the next one.
7
7
  class Canvas
8
8
  include Text
9
+ include Debug
9
10
 
10
11
  CAPS = { butt: 0, round: 1, square: 2 }.freeze
11
12
  JOINS = { miter: 0, round: 1, bevel: 2 }.freeze
12
13
 
13
14
  attr_reader :page
14
15
 
15
- def initialize(page, resources)
16
+ # `template: true` records anchors apart, for canvases page templates draw on.
17
+ def initialize(page, resources, template: false, debug: false)
16
18
  @page = page
17
19
  @resources = resources
20
+ @template = template
21
+ @debug = debug
18
22
  end
19
23
 
20
24
  def save
@@ -34,8 +38,8 @@ module Stationery
34
38
  shape(fill: color, opacity:) { |p| p.rect(x, y, w, h) }
35
39
  end
36
40
 
37
- def rounded_rect(x, y, w, h, radius:, fill: nil, stroke: nil, line_width: 1, opacity: nil)
38
- shape(fill:, stroke:, line_width:, opacity:) { |p| p.rounded_rect(x, y, w, h, radius) }
41
+ def rounded_rect(x, y, w, h, radius:, fill: nil, stroke: nil, line_width: 1, dash: nil, opacity: nil)
42
+ shape(fill:, stroke:, line_width:, dash:, opacity:) { |p| p.rounded_rect(x, y, w, h, radius) }
39
43
  end
40
44
 
41
45
  def circle(cx, cy, r, fill: nil, stroke: nil, line_width: 1, opacity: nil)
@@ -62,11 +66,24 @@ module Stationery
62
66
  end
63
67
  end
64
68
 
65
- # A clickable area opening `url`. Annotation rectangles live in absolute,
66
- # untransformed page space, so they ignore clips and path transforms.
67
- def link(x, y, w, h, url)
68
- @page.annotations << { rect: [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) },
69
- url: url.to_s }
69
+ # A clickable area opening `target`: a URL, or `#name` for an anchor in
70
+ # this document. Annotation rectangles live in absolute, untransformed
71
+ # page space, so they ignore clips and path transforms.
72
+ def link(x, y, w, h, target)
73
+ target = target.to_s
74
+ rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
75
+ @page.annotations << (target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target })
76
+ end
77
+
78
+ # Names the point `y` on this page as a link target.
79
+ def anchor(name, y)
80
+ (@template ? @page.template_anchors : @page.anchors) << [name.to_s, num_value(@page.height - y)]
81
+ end
82
+
83
+ # Leaves room for the page number `anchor` lands on; Structure fills it in
84
+ # and adds the `link:` area ([x, y, w, h]) when the anchor exists.
85
+ def number_slot(anchor, x:, baseline:, width:, style:, link: nil)
86
+ @page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link)
70
87
  end
71
88
 
72
89
  def num(value) = PDF::Serializer.number(num_value(value))
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ class CLI
5
+ # `stationery fonts list` and `stationery fonts install PACK...`: copies
6
+ # pinned, SHA-256-checked font packs into vendor/fonts/<pack>/.
7
+ class Fonts
8
+ SUMMARY = "List or install font packs (Noto, Liberation, Inter) into vendor/fonts"
9
+ RESERVED_NAME_NOTE = 'Liberation fonts carry the Reserved Font Name "Liberation": copying them unmodified ' \
10
+ "is fine; a modified copy must be renamed."
11
+
12
+ def initialize(out:, err:)
13
+ @out = out
14
+ @err = err
15
+ @options = { into: Stationery::Fonts::DEFAULT_DIR, force: false, from: nil }
16
+ end
17
+
18
+ def run(argv)
19
+ parser.parse!(argv)
20
+ return OK if @options.delete(:help)
21
+
22
+ case argv.shift
23
+ when "list" then list
24
+ when "install"
25
+ raise OptionParser::MissingArgument, "PACK" if argv.empty?
26
+
27
+ install(argv)
28
+ else usage
29
+ end
30
+ rescue OptionParser::ParseError => e
31
+ @err.puts "stationery fonts: #{e.message}"
32
+ usage
33
+ rescue Stationery::Error => e
34
+ raise Error, e.message
35
+ end
36
+
37
+ private
38
+
39
+ def parser
40
+ @parser ||= OptionParser.new do |opts|
41
+ opts.banner = "Usage: stationery fonts list [--into DIR]\n " \
42
+ "stationery fonts install PACK... [--into DIR] [--force] [--from PATH]"
43
+ opts.on("--into DIR", "Where packs live (default: #{Stationery::Fonts::DEFAULT_DIR})") do |dir|
44
+ @options[:into] = dir
45
+ end
46
+ opts.on("--force", "Replace files that are already installed") { @options[:force] = true }
47
+ opts.on("--from PATH", "Install offline from a directory or .tar.gz holding the files") do |path|
48
+ @options[:from] = path
49
+ end
50
+ opts.on("-h", "--help", "Show this help") do
51
+ @out.puts opts
52
+ @options[:help] = true
53
+ end
54
+ end
55
+ end
56
+
57
+ def usage
58
+ @err.puts parser
59
+ USAGE
60
+ end
61
+
62
+ def list
63
+ rows = Stationery::Fonts.catalog.map do |pack|
64
+ installed = File.file?(Stationery::Fonts.paths(pack.key, dir: @options[:into]).fetch(:regular))
65
+ [pack.key.to_s, pack.family, pack.license, installed ? "installed" : "-"]
66
+ end
67
+ widths = rows.transpose.map { |column| column.map(&:size).max }
68
+ rows.each { |row| @out.puts row.zip(widths).map { |cell, width| cell.ljust(width) }.join(" ").rstrip }
69
+ @out.puts "", RESERVED_NAME_NOTE
70
+ OK
71
+ end
72
+
73
+ def install(keys)
74
+ installer = Stationery::Fonts::Installer.new(**@options, out: @out)
75
+ keys.each do |key|
76
+ installer.install(key)
77
+ @out.puts "", "Use it with:", *Stationery::Fonts.usage(key, @options[:into]).map { " #{it}" }
78
+ end
79
+ OK
80
+ end
81
+ end
82
+
83
+ register "fonts", Fonts
84
+ end
85
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ class CLI
5
+ # `stationery render FILE`: loads a Ruby file, finds the Document it
6
+ # defines and writes its PDF. A document whose constructor needs
7
+ # arguments renders from `def self.preview` returning an instance.
8
+ class Render
9
+ SUMMARY = "Render the Stationery::Document defined in a Ruby file to PDF"
10
+
11
+ def initialize(out:, err:)
12
+ @out = out
13
+ @err = err
14
+ @options = {}
15
+ end
16
+
17
+ def run(argv)
18
+ parser.parse!(argv)
19
+ return OK if @options[:help]
20
+
21
+ file = argv.shift or raise OptionParser::MissingArgument, "FILE"
22
+ render(file)
23
+ rescue OptionParser::ParseError => e
24
+ @err.puts "stationery render: #{e.message}", parser
25
+ USAGE
26
+ end
27
+
28
+ private
29
+
30
+ def parser
31
+ @parser ||= OptionParser.new do |opts|
32
+ opts.banner = "Usage: stationery render FILE [options]"
33
+ opts.on("-o", "--out PATH", "Where to write the PDF (default: FILE.pdf); - writes to stdout") do |path|
34
+ @options[:out] = path
35
+ end
36
+ opts.on("-c", "--class NAME", "The document class to render when FILE defines several") do |name|
37
+ @options[:class] = name
38
+ end
39
+ opts.on("--strict", "Fail without writing when layout reports warnings") { @options[:strict] = true }
40
+ opts.on("--debug", "Render with debug: true when the document supports it") { @options[:debug] = true }
41
+ opts.on("-h", "--help", "Show this help") do
42
+ @out.puts opts
43
+ @options[:help] = true
44
+ end
45
+ end
46
+ end
47
+
48
+ def render(file)
49
+ path = File.expand_path(file)
50
+ raise Error, "no such file: #{file}" unless File.file?(path)
51
+
52
+ document = instantiate(pick(load_documents(path), path))
53
+ pdf = to_pdf(document)
54
+ return FAILURE unless warnings_ok?(document)
55
+
56
+ write(pdf, @options[:out] || File.join(File.dirname(path), "#{File.basename(path, ".*")}.pdf"))
57
+ OK
58
+ end
59
+
60
+ # Documents that are new after loading FILE or whose view_template lives
61
+ # in it (so a file already loaded by the host still resolves).
62
+ def load_documents(path)
63
+ before = descendants(Document)
64
+ load(path)
65
+ after = descendants(Document)
66
+ (after - before) | after.select { |klass| template_file(klass) == path }
67
+ end
68
+
69
+ def pick(candidates, path)
70
+ return named_class if @options[:class]
71
+
72
+ candidates = candidates.reject { |klass| template_file(klass).nil? }
73
+ raise Error, "no Stationery::Document defined in #{path}" if candidates.empty?
74
+ return candidates.first if candidates.one?
75
+
76
+ names = candidates.map(&:name).sort.join(", ")
77
+ raise Error, "several documents defined in #{path} (#{names}); choose one with --class NAME"
78
+ end
79
+
80
+ def named_class
81
+ klass = Object.const_get(@options[:class])
82
+ raise Error, "#{klass} is not a Stationery::Document" unless klass.is_a?(Class) && klass < Document
83
+
84
+ klass
85
+ rescue NameError => e
86
+ raise Error, e.message
87
+ end
88
+
89
+ def instantiate(klass)
90
+ return klass.preview if klass.respond_to?(:preview)
91
+
92
+ begin
93
+ klass.new
94
+ rescue ArgumentError
95
+ raise Error, "#{klass} needs arguments; define `def self.preview` returning an instance to render"
96
+ end
97
+ end
98
+
99
+ def to_pdf(document)
100
+ return document.to_pdf unless @options[:debug]
101
+ return document.to_pdf(debug: true) if document.method(:to_pdf).parameters.include?(%i[key debug])
102
+
103
+ @err.puts "note: --debug is not supported by this version of Stationery; rendering without it"
104
+ document.to_pdf
105
+ end
106
+
107
+ def warnings_ok?(document)
108
+ warnings = Array(document.warnings)
109
+ warnings.each { |warning| @err.puts "warning: #{warning.message}" }
110
+ return true unless @options[:strict] && warnings.any?
111
+
112
+ @err.puts "stationery render: #{warnings.size} warning(s) under --strict; nothing written"
113
+ false
114
+ end
115
+
116
+ def write(pdf, target)
117
+ if target == "-"
118
+ @out.binmode
119
+ @out.write(pdf)
120
+ else
121
+ File.binwrite(target, pdf)
122
+ pages = pdf.scan(%r{/Type /Page\b}).size
123
+ @out.puts "wrote #{target} (#{pages} #{pages == 1 ? "page" : "pages"}, #{pdf.bytesize} bytes)"
124
+ end
125
+ end
126
+
127
+ def descendants(klass) = klass.subclasses.flat_map { |sub| [sub, *descendants(sub)] }
128
+
129
+ def template_file(klass)
130
+ method = klass.instance_method(:view_template)
131
+ method.source_location&.first unless method.owner == Component
132
+ end
133
+ end
134
+
135
+ register "render", Render
136
+ end
137
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "stationery"
5
+
6
+ module Stationery
7
+ # The `stationery` executable. Commands register themselves by name:
8
+ #
9
+ # Stationery::CLI.register("render", Stationery::CLI::Render)
10
+ #
11
+ # A command is a class built with `new(out:, err:)` whose `#run(argv)`
12
+ # returns an exit code and that has a one-line `SUMMARY`.
13
+ class CLI
14
+ OK = 0
15
+ FAILURE = 1
16
+ USAGE = 2
17
+
18
+ # Raised by a command to fail with a message and exit code 1.
19
+ class Error < StandardError; end
20
+
21
+ class << self
22
+ def commands = @commands ||= {}
23
+
24
+ def register(name, command)
25
+ commands[name.to_s] = command
26
+ end
27
+
28
+ def start(argv, out: $stdout, err: $stderr) = new(out:, err:).run(argv.dup)
29
+ end
30
+
31
+ def initialize(out:, err:)
32
+ @out = out
33
+ @err = err
34
+ end
35
+
36
+ def run(argv)
37
+ case (name = argv.shift)
38
+ when nil then usage(@err, USAGE)
39
+ when "help", "--help", "-h" then usage(@out, OK)
40
+ when "--version", "-v" then version
41
+ else dispatch(name, argv)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def dispatch(name, argv)
48
+ command = self.class.commands.fetch(name) do
49
+ @err.puts "stationery: unknown command #{name.inspect}"
50
+ return usage(@err, USAGE)
51
+ end
52
+ command.new(out: @out, err: @err).run(argv)
53
+ rescue Error => e
54
+ @err.puts "stationery: #{e.message}"
55
+ FAILURE
56
+ end
57
+
58
+ def version
59
+ @out.puts VERSION
60
+ OK
61
+ end
62
+
63
+ def usage(io, code)
64
+ io.puts "Usage: stationery COMMAND [options]", "", "Commands:"
65
+ width = self.class.commands.keys.map(&:size).max.to_i
66
+ self.class.commands.each { |name, command| io.puts " #{name.ljust(width)} #{command::SUMMARY}" }
67
+ io.puts "", "Run `stationery COMMAND --help` for a command's options; `stationery --version` for the version."
68
+ code
69
+ end
70
+ end
71
+ end
72
+
73
+ require_relative "cli/render"
74
+ require_relative "cli/fonts"
@@ -5,7 +5,7 @@ module Stationery
5
5
  #
6
6
  # class Invoice < Stationery::Document
7
7
  # page size: :a4, margin: 40
8
- # font_family "Inter", regular: "Inter-Regular.ttf", bold: "Inter-Bold.ttf"
8
+ # font_family "Brand", regular: "Brand-Regular.ttf", bold: "Brand-Bold.ttf"
9
9
  # default_text font: "Inter", size: 9
10
10
  # metadata title: "Invoice"
11
11
  # page_template { |page| box(at: [40, page.height - 30]) { text "#{page.number}/#{page.count}" } }
@@ -23,7 +23,8 @@ module Stationery
23
23
  @config ||= if superclass.respond_to?(:config)
24
24
  superclass.config.transform_values(&:dup)
25
25
  else
26
- { page: { size: :letter, margin: 36 }, families: {}, text: {}, metadata: {}, templates: [] }
26
+ { page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
27
+ templates: [], regions: [], strict: false }
27
28
  end
28
29
  end
29
30
 
@@ -32,7 +33,13 @@ module Stationery
32
33
  end
33
34
 
34
35
  def font_family(name, **paths)
35
- config[:families][name.to_s] = Fonts::Family.new(name, **paths)
36
+ config[:families][name.to_s] = Fonts::Family.build(name, **paths)
37
+ end
38
+
39
+ # Families tried, in order, for characters the text's own family has no
40
+ # glyph for; bundled Inter is tried last.
41
+ def font_fallbacks(*names)
42
+ config[:fallbacks] = names.map(&:to_s)
36
43
  end
37
44
 
38
45
  def default_text(**options)
@@ -43,11 +50,28 @@ module Stationery
43
50
  config[:metadata] = config[:metadata].merge(info)
44
51
  end
45
52
 
53
+ # Raise WarningsError instead of writing a PDF that produced warnings.
54
+ def strict(value = true) # rubocop:disable Style/OptionalBooleanParameter
55
+ config[:strict] = value
56
+ end
57
+
46
58
  # Runs after pagination on every page. `layer: :background` paints under
47
59
  # the page's content.
48
60
  def page_template(layer: :foreground, &block)
49
61
  config[:templates] << [layer, block]
50
62
  end
63
+
64
+ # Reserves space at the top of the pages `on:` matches and draws the
65
+ # block there. Without `height:` the block is measured once, on the
66
+ # first page that asks; pass `height:` when its content varies per page.
67
+ def header(height: nil, gap: 8, on: :all, &block)
68
+ config[:regions] << Region.new(slot: :header, height:, gap:, on: Regions.validate!(on), block:)
69
+ end
70
+
71
+ # Like header, at the bottom of the page; the block is bottom-aligned.
72
+ def footer(height: nil, gap: 8, on: :all, &block)
73
+ config[:regions] << Region.new(slot: :footer, height:, gap:, on: Regions.validate!(on), block:)
74
+ end
51
75
  end
52
76
 
53
77
  attr_reader :warnings
@@ -55,15 +79,20 @@ module Stationery
55
79
  def page_options = self.class.config[:page]
56
80
  def metadata = self.class.config[:metadata]
57
81
 
58
- def to_pdf(target = nil)
59
- book = Fonts::FontBook.new(self.class.config[:families])
82
+ def to_pdf(target = nil, strict: self.class.config[:strict], debug: false)
83
+ warnings = Warnings.new
84
+ book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
60
85
  call(builder = Builder.new(book:, text: self.class.config[:text]))
61
86
  resources = Resources.new
62
- paginator = Layout::Paginator.new(resources:, page: page_options)
87
+ regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
88
+ paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:)
63
89
  pages = paginator.paginate(builder.root)
64
- @warnings = paginator.warnings
65
- PageTemplates.new(self, book:, resources:).apply(pages)
66
- write(PDF::Assembler.new(pages:, resources:, info:).render, target)
90
+ PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:).apply(pages)
91
+ outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:))
92
+ @warnings = warnings
93
+ raise WarningsError, warnings if strict && warnings.any?
94
+
95
+ write(PDF::Assembler.new(pages:, resources:, info:, outline:).render, target)
67
96
  end
68
97
 
69
98
  # Used by page templates to build nodes into their own root.
@@ -75,8 +104,23 @@ module Stationery
75
104
  @_builder = previous
76
105
  end
77
106
 
107
+ # Builds a page template or region block into a fresh root node.
108
+ def template_root(info, book:, &)
109
+ builder = Builder.new(book:, text: self.class.config[:text])
110
+ build_with(builder) { instance_exec(info, &) }
111
+ builder.root
112
+ end
113
+
78
114
  private
79
115
 
116
+ def region_measure(book)
117
+ page = Page.new(**page_options)
118
+ lambda do |region, number|
119
+ info = PageInfo.new(number, number, page.width, page.height, page.margin, page.margin_box)
120
+ template_root(info, book:, &region.block).measure(page.margin_box.width)
121
+ end
122
+ end
123
+
80
124
  def info
81
125
  metadata.to_h do |key, value|
82
126
  [INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]