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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -1
- data/README.md +395 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +26 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +10 -6
- data/lib/stationery/canvas.rb +38 -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 +60 -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 +68 -88
- data/lib/stationery/fonts/font_book.rb +49 -8
- data/lib/stationery/fonts/glyph_run.rb +39 -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 +64 -0
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -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/ligatures.rb +16 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- 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 +64 -57
- data/lib/stationery/fonts/woff.rb +52 -0
- 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 +102 -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 +48 -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 +6 -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 +20 -8
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +22 -10
- data/lib/stationery/pdf/writer.rb +28 -5
- 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/resources.rb +8 -2
- 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/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +103 -0
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +58 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +85 -0
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +54 -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 +33 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +4 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +67 -0
- 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
|
data/lib/stationery/builder.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
36
|
-
return rect(x, y, w, h) if
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 <<
|
|
46
|
+
ops << run.to_operator
|
|
43
47
|
ops << "ET"
|
|
44
48
|
ops.join("\n")
|
|
45
49
|
end
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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"
|