stationery 0.1.0 → 0.3.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -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 +10 -6
  10. data/lib/stationery/canvas.rb +38 -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 +60 -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 +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -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 +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. metadata +101 -4
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "stationery"
5
+
6
+ module Stationery
7
+ module Generators
8
+ # bin/rails generate stationery:fonts noto_sans liberation_serif [--into vendor/fonts] [--force]
9
+ class FontsGenerator < ::Rails::Generators::Base
10
+ desc "Install pinned, SHA-256-checked font packs (#{Stationery::Fonts.catalog.map(&:key).join(", ")})"
11
+ argument :names, type: :array, banner: "PACK PACK"
12
+ class_option :into, type: :string, default: Stationery::Fonts::DEFAULT_DIR, desc: "Where packs live"
13
+ class_option :force, type: :boolean, default: false, desc: "Replace files that are already installed"
14
+
15
+ def install_packs
16
+ dir = File.expand_path(options[:into], destination_root)
17
+ names.each do |name|
18
+ Stationery::Fonts.install(name, into: dir, force: options[:force], out: $stdout)
19
+ say "Use it with: #{Stationery::Fonts.usage(name, options[:into]).last}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -4,7 +4,8 @@ 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
8
+ ligatures].freeze
8
9
 
9
10
  # Collects a row's columns; anything that is not already a column box is
10
11
  # wrapped in one.
@@ -19,13 +20,33 @@ module Stationery
19
20
  end
20
21
  end
21
22
 
22
- attr_reader :root, :book
23
+ # Collects a list's items: every node added becomes one item.
24
+ class Items
25
+ attr_reader :nodes
26
+
27
+ def initialize = @nodes = []
28
+
29
+ def <<(node)
30
+ @nodes << node
31
+ self
32
+ end
33
+ end
34
+
35
+ attr_reader :root, :book, :list_depth
23
36
 
24
37
  def initialize(book:, text: {})
25
38
  @book = book
26
39
  @root = Layout::Flow.new
27
40
  @containers = [@root]
28
41
  @text = [text]
42
+ @list_depth = 0
43
+ end
44
+
45
+ def nested_list
46
+ @list_depth += 1
47
+ yield
48
+ ensure
49
+ @list_depth -= 1
29
50
  end
30
51
 
31
52
  def add(node)
@@ -49,10 +70,12 @@ module Stationery
49
70
  end
50
71
 
51
72
  def text_defaults = @text.last
73
+ def outline = @outline ||= Outline.new
74
+ def warnings = @book.warnings
52
75
 
53
76
  def style(options = {})
54
77
  options = text_defaults.merge(options)
55
- family = options[:font] || @book.families.keys.first || "default"
78
+ family = options[:font] || @book.families.keys.first || Fonts::Bundled::DEFAULT
56
79
  Text::Style.new(family: family.to_s, **options.slice(*STYLE_KEYS))
57
80
  end
58
81
 
@@ -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)}"
@@ -7,17 +7,21 @@ module Stationery
7
7
  BOLD_STROKE = 0.03
8
8
 
9
9
  # Draws `string` with its baseline at (x, y) in top-left coordinates and
10
- # returns its advance width.
10
+ # returns its advance width. Standard ligatures form unless `ligatures:`
11
+ # is false or letter spacing is set.
11
12
  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)
13
+ synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false,
14
+ kerning: false, ligatures: true, word_spacing: 0)
13
15
  return 0 if string.empty?
14
16
 
15
17
  color = Color.parse(color)
16
- width = font.width_of(string, size, letter_spacing:)
18
+ run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero?)
19
+ run = run.with_word_spacing(word_spacing, size) unless word_spacing.zero?
20
+ width = run.width(size, letter_spacing:)
17
21
  graphics(opacity:) do |ops|
18
22
  ops << color.fill
19
23
  ops.push(color.stroke, "#{num(size * BOLD_STROKE)} w") if synthetic_bold
20
- ops << text_object(string, x, y, font, size,
24
+ ops << text_object(run, x, y, font, size,
21
25
  letter_spacing:, rise:, bold: synthetic_bold, oblique: synthetic_oblique)
22
26
  end
23
27
  decorate(x, y, width, font, size, color:, underline:, strikethrough:)
@@ -26,7 +30,7 @@ module Stationery
26
30
 
27
31
  private
28
32
 
29
- def text_object(string, x, y, font, size, letter_spacing:, rise:, bold:, oblique:)
33
+ def text_object(run, x, y, font, size, letter_spacing:, rise:, bold:, oblique:)
30
34
  skew = oblique ? Fonts::Font::OBLIQUE_SKEW : 0
31
35
  ops = ["BT", "/#{@page.use(:Font, @resources.font(font))} #{num(size)} Tf"]
32
36
  ops << "#{num(letter_spacing)} Tc" unless letter_spacing.zero?
@@ -39,7 +43,7 @@ module Stationery
39
43
  else
40
44
  "1 0 #{num(skew)} 1 #{num(x)} #{num(@page.height - y)} Tm"
41
45
  end
42
- ops << "<#{font.encode(string).unpack1("H*").upcase}> Tj"
46
+ ops << run.to_operator
43
47
  ops << "ET"
44
48
  ops.join("\n")
45
49
  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)
@@ -54,6 +58,19 @@ module Stationery
54
58
  shape(fill:, stroke:, line_width:, cap:, join:, dash:, even_odd:, opacity:, transform:, &)
55
59
  end
56
60
 
61
+ # Paints `shading` inside the path the block traces. `matrix` maps the
62
+ # shading's coordinates into top-left page space.
63
+ def shade(shading, matrix:, transform: nil, even_odd: false, opacity: nil)
64
+ path = Path.new(self, transform:)
65
+ yield path
66
+ name = @page.use(:Shading, @resources.shading(shading))
67
+ a, b, c, d, e, f = matrix
68
+ graphics(opacity:) do |ops|
69
+ ops << path.to_s << (even_odd ? "W* n" : "W n")
70
+ ops << "#{[a, -b, c, -d, e, @page.height - f].map { |v| num(v) }.join(" ")} cm" << "/#{name} sh"
71
+ end
72
+ end
73
+
57
74
  def image(image, x:, y:, width:, height:, opacity: nil)
58
75
  name = @page.use(:XObject, @resources.image(image))
59
76
  graphics(opacity:) do |ops|
@@ -62,11 +79,24 @@ module Stationery
62
79
  end
63
80
  end
64
81
 
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 }
82
+ # A clickable area opening `target`: a URL, or `#name` for an anchor in
83
+ # this document. Annotation rectangles live in absolute, untransformed
84
+ # page space, so they ignore clips and path transforms.
85
+ def link(x, y, w, h, target)
86
+ target = target.to_s
87
+ rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
88
+ @page.annotations << (target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target })
89
+ end
90
+
91
+ # Names the point `y` on this page as a link target.
92
+ def anchor(name, y)
93
+ (@template ? @page.template_anchors : @page.anchors) << [name.to_s, num_value(@page.height - y)]
94
+ end
95
+
96
+ # Leaves room for the page number `anchor` lands on; Structure fills it in
97
+ # and adds the `link:` area ([x, y, w, h]) when the anchor exists.
98
+ def number_slot(anchor, x:, baseline:, width:, style:, link: nil)
99
+ @page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link)
70
100
  end
71
101
 
72
102
  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"