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
|
@@ -4,31 +4,38 @@ module Stationery
|
|
|
4
4
|
module PDF
|
|
5
5
|
# Writes finished pages, their shared resources and document info as PDF.
|
|
6
6
|
class Assembler
|
|
7
|
-
def initialize(pages:, resources:, info: {})
|
|
7
|
+
def initialize(pages:, resources:, info: {}, outline: [])
|
|
8
8
|
@pages = pages
|
|
9
9
|
@resources = resources
|
|
10
10
|
@info = info
|
|
11
|
+
@outline = outline
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def render
|
|
14
15
|
writer = Writer.new
|
|
15
16
|
tree = writer.reserve
|
|
16
17
|
refs = @resources.build(writer)
|
|
17
|
-
kids = @pages.map {
|
|
18
|
+
kids = @kids = @pages.map { writer.reserve }
|
|
19
|
+
@pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
|
|
18
20
|
writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
|
|
19
|
-
root = writer.add(
|
|
21
|
+
root = writer.add(catalog(tree, OutlineWriter.new(writer, @outline, kids).write))
|
|
20
22
|
writer.render(root:, info: writer.add(info_dictionary))
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
25
|
-
def
|
|
27
|
+
def catalog(tree, outlines)
|
|
28
|
+
catalog = { Type: :Catalog, Pages: tree }
|
|
29
|
+
outlines ? catalog.merge(Outlines: outlines, PageMode: :UseOutlines) : catalog
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write_page(writer, page, ref, tree, refs)
|
|
26
33
|
dictionary = {
|
|
27
34
|
Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
|
|
28
35
|
Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
|
|
29
36
|
}
|
|
30
37
|
dictionary[:Annots] = page.annotations.map { |link| writer.add(annotation(link)) } if page.annotations.any?
|
|
31
|
-
writer.
|
|
38
|
+
writer.set(ref, dictionary)
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
def page_resources(page, refs)
|
|
@@ -38,8 +45,12 @@ module Stationery
|
|
|
38
45
|
end
|
|
39
46
|
|
|
40
47
|
def annotation(link)
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
target = if (dest = link[:dest])
|
|
49
|
+
{ Dest: [@kids.fetch(dest.page), :XYZ, nil, dest.top, nil] }
|
|
50
|
+
else
|
|
51
|
+
{ A: { Type: :Action, S: :URI, URI: link[:url].b } }
|
|
52
|
+
end
|
|
53
|
+
{ Type: :Annot, Subtype: :Link, Rect: link[:rect], Border: [0, 0, 0], **target }
|
|
43
54
|
end
|
|
44
55
|
|
|
45
56
|
def info_dictionary
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
# Writes outline items as the /Outlines tree. Each item nests under the
|
|
6
|
+
# nearest earlier item of a shallower level, so a level jump (1 → 3) or a
|
|
7
|
+
# leading level-2 item still lands somewhere sensible.
|
|
8
|
+
class OutlineWriter
|
|
9
|
+
Node = Data.define(:item, :ref, :children) do
|
|
10
|
+
def open? = item.nil? || item.open
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(writer, items, page_refs)
|
|
14
|
+
@writer = writer
|
|
15
|
+
@items = items
|
|
16
|
+
@page_refs = page_refs
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The /Outlines root reference, or nil when there is nothing to write.
|
|
20
|
+
def write
|
|
21
|
+
return if @items.empty?
|
|
22
|
+
|
|
23
|
+
root = tree
|
|
24
|
+
write_children(root)
|
|
25
|
+
@writer.set(root.ref, { Type: :Outlines, First: root.children.first.ref, Last: root.children.last.ref,
|
|
26
|
+
Count: visible(root) })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def tree
|
|
32
|
+
root = Node.new(nil, @writer.reserve, [])
|
|
33
|
+
stack = []
|
|
34
|
+
@items.each do |item|
|
|
35
|
+
stack.pop while stack.any? && stack.last.item.level >= item.level
|
|
36
|
+
node = Node.new(item, @writer.reserve, [])
|
|
37
|
+
(stack.last || root).children << node
|
|
38
|
+
stack << node
|
|
39
|
+
end
|
|
40
|
+
root
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write_children(parent)
|
|
44
|
+
parent.children.each_with_index do |node, index|
|
|
45
|
+
write_children(node)
|
|
46
|
+
siblings = { Prev: index.positive? ? parent.children[index - 1].ref : nil,
|
|
47
|
+
Next: parent.children[index + 1]&.ref }
|
|
48
|
+
@writer.set(node.ref, dictionary(node, parent).merge(siblings).compact)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dictionary(node, parent)
|
|
53
|
+
dest = node.item.dest
|
|
54
|
+
{
|
|
55
|
+
Title: TextString.new(node.item.title), Parent: parent.ref,
|
|
56
|
+
First: node.children.first&.ref, Last: node.children.last&.ref, Count: count(node),
|
|
57
|
+
Dest: [@page_refs.fetch(dest.page), :XYZ, nil, dest.top, nil]
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Items shown when `node` is expanded: its children plus the visible
|
|
62
|
+
# descendants of each open child.
|
|
63
|
+
def visible(node) = node.children.sum { |child| 1 + (child.open? ? visible(child) : 0) }
|
|
64
|
+
|
|
65
|
+
def count(node)
|
|
66
|
+
return if node.children.empty?
|
|
67
|
+
|
|
68
|
+
node.open? ? visible(node) : -visible(node)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -41,7 +41,11 @@ module Stationery
|
|
|
41
41
|
def number(value)
|
|
42
42
|
return value.to_s if value.is_a?(Integer)
|
|
43
43
|
|
|
44
|
-
str = format("%.4f", value)
|
|
44
|
+
str = format("%.4f", value)
|
|
45
|
+
last = str.bytesize
|
|
46
|
+
last -= 1 while str.getbyte(last - 1) == 48 # "0"
|
|
47
|
+
last -= 1 if str.getbyte(last - 1) == 46 # "."
|
|
48
|
+
str = str.byteslice(0, last)
|
|
45
49
|
str == "-0" ? "0" : str
|
|
46
50
|
end
|
|
47
51
|
|
|
@@ -30,6 +30,10 @@ module Stationery
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def render(root:, info:)
|
|
33
|
+
if (missing = @objects.index(nil))
|
|
34
|
+
raise Error, "object #{missing + 1} reserved but never set"
|
|
35
|
+
end
|
|
36
|
+
|
|
33
37
|
out = HEADER.dup
|
|
34
38
|
offsets = @objects.each_with_index.map { |object, index| write_object(out, index + 1, object) }
|
|
35
39
|
xref = out.bytesize
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stationery"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
# Browsable sample documents, like ActionMailer previews. Each public method
|
|
7
|
+
# returns one document:
|
|
8
|
+
#
|
|
9
|
+
# # spec/pdfs/previews/invoice_pdf_preview.rb
|
|
10
|
+
# class InvoicePdfPreview < Stationery::Preview
|
|
11
|
+
# def paid = InvoicePdf.new(Invoice.first)
|
|
12
|
+
# def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id")))
|
|
13
|
+
# end
|
|
14
|
+
class Preview
|
|
15
|
+
REGISTRY_LOCK = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def inherited(subclass)
|
|
19
|
+
super
|
|
20
|
+
REGISTRY_LOCK.synchronize { Preview.registry << subclass }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Named subclasses with at least one preview; a name defined again (a
|
|
24
|
+
# reloaded file) keeps its latest class.
|
|
25
|
+
def all
|
|
26
|
+
classes = REGISTRY_LOCK.synchronize { Preview.registry.dup }
|
|
27
|
+
classes.select { it.name && it.pdfs.any? }.reverse.uniq(&:name).sort_by(&:preview_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# `load`, not `require`: edited previews show up without a restart.
|
|
31
|
+
def load(paths)
|
|
32
|
+
paths.each { |path| Dir["#{path}/**/*_preview.rb"].each { |file| Kernel.load(file) } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# "invoice_pdf/paid" => [InvoicePdfPreview, "paid"]
|
|
36
|
+
def find(path)
|
|
37
|
+
name, _, pdf = path.to_s.rpartition("/")
|
|
38
|
+
klass = all.find { it.preview_name == name }
|
|
39
|
+
[klass, pdf] if klass&.pdfs&.include?(pdf)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def preview_name = underscore(name.delete_suffix("Preview"))
|
|
43
|
+
def pdfs = public_instance_methods(false).sort.map(&:to_s)
|
|
44
|
+
|
|
45
|
+
protected
|
|
46
|
+
|
|
47
|
+
def registry = (@registry ||= []) # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def underscore(name)
|
|
52
|
+
name.gsub("::", "/").gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def render(name, params = {})
|
|
57
|
+
method = public_method(name)
|
|
58
|
+
document = method.arity.zero? ? method.call : method.call(params)
|
|
59
|
+
return document if document.respond_to?(:to_pdf)
|
|
60
|
+
|
|
61
|
+
raise Error, "#{self.class}##{name} must return a Stationery::Document (got #{document.class})"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "stationery/preview"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
# Serves Stationery::Preview classes at /rails/stationery/previews. Loaded
|
|
8
|
+
# by the Railtie when config.stationery.show_previews is on.
|
|
9
|
+
class PreviewsController < ActionController::Base
|
|
10
|
+
before_action :require_previews
|
|
11
|
+
before_action :load_previews
|
|
12
|
+
|
|
13
|
+
def index
|
|
14
|
+
paths = Preview.all.flat_map { |klass| klass.pdfs.map { "#{klass.preview_name}/#{it}" } }
|
|
15
|
+
render html: page(paths).html_safe
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
klass, pdf = Preview.find(params[:path])
|
|
20
|
+
return head(:not_found) unless klass
|
|
21
|
+
|
|
22
|
+
document = klass.new.render(pdf, request.query_parameters.except("debug"))
|
|
23
|
+
send_data document.to_pdf(**debug_option(document)), type: "application/pdf", disposition: "inline"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def config = ::Rails.application.config.stationery
|
|
29
|
+
|
|
30
|
+
def require_previews
|
|
31
|
+
head :not_found unless config.show_previews
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def load_previews
|
|
35
|
+
Preview.load(Railtie.preview_paths(::Rails.root, config.preview_paths))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def debug_option(document)
|
|
39
|
+
debug = params[:debug].present? && document.method(:to_pdf).parameters.include?(%i[key debug])
|
|
40
|
+
debug ? { debug: true } : {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def page(paths)
|
|
44
|
+
items = paths.map do |path|
|
|
45
|
+
href = ERB::Util.html_escape("#{request.path.chomp("/")}/#{path}")
|
|
46
|
+
%(<li><a href="#{href}">#{ERB::Util.html_escape(path)}</a> <a href="#{href}?debug=1">debug</a></li>)
|
|
47
|
+
end
|
|
48
|
+
<<~HTML
|
|
49
|
+
<!doctype html>
|
|
50
|
+
<html>
|
|
51
|
+
<head><meta charset="utf-8"><title>PDF previews</title></head>
|
|
52
|
+
<body style="font-family: system-ui, sans-serif; margin: 2rem">
|
|
53
|
+
<h1>PDF previews</h1>
|
|
54
|
+
<ul>#{items.join("\n")}</ul>
|
|
55
|
+
#{"<p>No previews found in #{ERB::Util.html_escape(config.preview_paths.join(", "))}.</p>" if items.empty?}
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
58
|
+
HTML
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/stationery/rails.rb
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Loaded by stationery/rails inside a Rails app. Adds `render pdf:` and the
|
|
5
|
+
# app's font directories. Configure in config/application.rb:
|
|
6
|
+
#
|
|
7
|
+
# config.stationery.renderer = false # keep another gem's render pdf:
|
|
8
|
+
# config.stationery.font_paths = ["app/fonts"] # relative to Rails.root
|
|
9
|
+
# config.stationery.preview_paths = ["spec/pdfs/previews"] # see Stationery::Preview
|
|
10
|
+
# config.stationery.show_previews = true # default: development only
|
|
11
|
+
class Railtie < ::Rails::Railtie
|
|
12
|
+
config.stationery = ActiveSupport::OrderedOptions.new
|
|
13
|
+
config.stationery.renderer = true
|
|
14
|
+
config.stationery.font_paths = ["vendor/fonts"]
|
|
15
|
+
config.stationery.preview_paths = ["spec/pdfs/previews", "test/pdfs/previews"]
|
|
16
|
+
config.stationery.show_previews = ::Rails.env.development?
|
|
17
|
+
|
|
18
|
+
initializer "stationery.font_paths" do |app|
|
|
19
|
+
Railtie.add_font_paths(app.root, app.config.stationery.font_paths)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
initializer "stationery.renderer" do |app|
|
|
23
|
+
Railtie.add_renderer if app.config.stationery.renderer
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
initializer "stationery.previews" do |app|
|
|
27
|
+
Railtie.add_previews(app.routes) if app.config.stationery.show_previews
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.add_font_paths(root, dirs)
|
|
31
|
+
dirs.each do |dir|
|
|
32
|
+
path = root.join(dir).to_s
|
|
33
|
+
Stationery.font_paths << path if File.directory?(path) && !Stationery.font_paths.include?(path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.preview_paths(root, dirs) = dirs.map { root.join(it).to_s }.select { File.directory?(it) }
|
|
38
|
+
|
|
39
|
+
def self.add_previews(routes)
|
|
40
|
+
ActiveSupport.on_load(:action_controller_base) { require "stationery/rails/previews_controller" }
|
|
41
|
+
routes.prepend do
|
|
42
|
+
get "/rails/stationery/previews" => "stationery/previews#index", :as => :stationery_previews
|
|
43
|
+
get "/rails/stationery/previews/*path" => "stationery/previews#show"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.add_renderer
|
|
48
|
+
ActiveSupport.on_load(:action_controller) do
|
|
49
|
+
ActionController::Renderers.add(:pdf) do |document, options|
|
|
50
|
+
unless document.respond_to?(:to_pdf)
|
|
51
|
+
raise ArgumentError, "render pdf: expects a Stationery::Document (got #{document.class})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
send_pdf(document, **{ filename: options[:filename],
|
|
55
|
+
disposition: options.fetch(:disposition, "inline") }.compact)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# A header or footer declaration: which slot, how much space it reserves
|
|
5
|
+
# (nil = measured), the gap to the body, the pages it applies to and the
|
|
6
|
+
# block that draws it.
|
|
7
|
+
Region = Data.define(:slot, :height, :gap, :on, :block)
|
|
8
|
+
|
|
9
|
+
# A document's header and footer declarations. Later declarations win for
|
|
10
|
+
# the pages they match; a region without a height is measured once, at the
|
|
11
|
+
# first page that asks for it.
|
|
12
|
+
class Regions
|
|
13
|
+
SELECTORS = %i[all first rest odd even last].freeze
|
|
14
|
+
SLOTS = %i[header footer].freeze
|
|
15
|
+
|
|
16
|
+
def self.validate!(on)
|
|
17
|
+
return on if SELECTORS.include?(on) || on.is_a?(Integer) || on.is_a?(Range) || on.respond_to?(:call)
|
|
18
|
+
|
|
19
|
+
raise ArgumentError, "on: takes #{SELECTORS.map(&:inspect).join(", ")}, a page number, a range or a proc, " \
|
|
20
|
+
"got #{on.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.matches?(on, number, last: false)
|
|
24
|
+
case on
|
|
25
|
+
when :all then true
|
|
26
|
+
when :first then number == 1
|
|
27
|
+
when :rest then number > 1
|
|
28
|
+
when :odd then number.odd?
|
|
29
|
+
when :even then number.even?
|
|
30
|
+
when :last then last
|
|
31
|
+
when Integer then number == on
|
|
32
|
+
when Range then on.cover?(number)
|
|
33
|
+
else on.call(number)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(entries, measure:)
|
|
38
|
+
@entries = entries
|
|
39
|
+
@measure = measure
|
|
40
|
+
@heights = {}.compare_by_identity
|
|
41
|
+
@last = entries.any? { |entry| entry.on == :last }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def entry_for(slot, number, last: false)
|
|
45
|
+
@entries.reverse_each.find { |entry| entry.slot == slot && self.class.matches?(entry.on, number, last:) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# [top, bottom] points taken from the body on page `number`.
|
|
49
|
+
def reserve(number, last: false)
|
|
50
|
+
SLOTS.map do |slot|
|
|
51
|
+
entry = entry_for(slot, number, last:)
|
|
52
|
+
height = entry ? height_of(entry, number) : 0
|
|
53
|
+
height.positive? ? height + entry.gap : 0
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether page `number` would reserve different space as the last page.
|
|
58
|
+
def last_sensitive?(number) = @last && reserve(number, last: true) != reserve(number)
|
|
59
|
+
|
|
60
|
+
def height_of(entry, number)
|
|
61
|
+
return entry.height if entry.height
|
|
62
|
+
return 0 unless entry.block
|
|
63
|
+
|
|
64
|
+
@heights[entry] ||= @measure.call(entry, number)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# The block model rich-text parsers (HTML, Markdown) produce and elements render.
|
|
5
|
+
module Rich
|
|
6
|
+
# A run of text with its marks: any of bold:, italic:, underline:, strike:, code:, link: (href),
|
|
7
|
+
# script: (:sub/:sup) and break: true, a hard line break whose text is "".
|
|
8
|
+
Inline = Data.define(:text, :marks) do
|
|
9
|
+
def self.break = new(text: "", marks: { break: true })
|
|
10
|
+
|
|
11
|
+
def initialize(text:, marks: {}) = super(text: text.to_s, marks: marks.frozen? ? marks : marks.dup.freeze)
|
|
12
|
+
def break? = marks[:break] == true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Paragraph = Data.define(:inlines)
|
|
16
|
+
Heading = Data.define(:level, :inlines)
|
|
17
|
+
List = Data.define(:ordered, :start, :items)
|
|
18
|
+
Blockquote = Data.define(:blocks)
|
|
19
|
+
CodeBlock = Data.define(:text, :language)
|
|
20
|
+
Rule = Data.define
|
|
21
|
+
Table = Data.define(:rows)
|
|
22
|
+
Cell = Data.define(:header, :align, :blocks)
|
|
23
|
+
Image = Data.define(:src, :alt, :width, :height)
|
|
24
|
+
|
|
25
|
+
# Operations on inline sequences shared by the parsers.
|
|
26
|
+
module Inlines
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Joins adjacent inlines with equal marks and drops empty ones; breaks and images pass through.
|
|
30
|
+
def merge(items)
|
|
31
|
+
items.each_with_object([]) do |item, merged|
|
|
32
|
+
next merged << item unless item.is_a?(Inline) && !item.break?
|
|
33
|
+
next if item.text.empty?
|
|
34
|
+
|
|
35
|
+
last = merged.last
|
|
36
|
+
if last.is_a?(Inline) && !last.break? && last.marks == item.marks
|
|
37
|
+
merged[-1] = last.with(text: last.text + item.text)
|
|
38
|
+
else
|
|
39
|
+
merged << item
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Groups inlines into paragraphs, with each image standing as a block between them.
|
|
45
|
+
def paragraphs(items)
|
|
46
|
+
items.chunk_while { |a, b| !a.is_a?(Image) && !b.is_a?(Image) }.filter_map do |run|
|
|
47
|
+
next run.first if run.first.is_a?(Image)
|
|
48
|
+
|
|
49
|
+
inlines = merge(block_given? ? yield(run) : run)
|
|
50
|
+
Paragraph.new(inlines:) unless inlines.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
class Renderer
|
|
6
|
+
# Writes inlines into a Text::RunsBuilder, nesting one builder call per mark.
|
|
7
|
+
class Inlines
|
|
8
|
+
STYLE_STEPS = {
|
|
9
|
+
weight: ->(value) { [:b] if value == :bold },
|
|
10
|
+
style: ->(value) { [:i] if value == :italic },
|
|
11
|
+
underline: ->(value) { [:u] if value },
|
|
12
|
+
strikethrough: ->(value) { [:strikethrough] if value },
|
|
13
|
+
color: ->(value) { [:color, value] if value },
|
|
14
|
+
size: ->(value) { [:size, value] if value },
|
|
15
|
+
font: ->(value) { [:font, value] if value }
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
MARK_STEPS = {
|
|
19
|
+
bold: [[:b]], italic: [[:i]], underline: [[:u]], strike: [[:strikethrough]]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(runs, styles)
|
|
23
|
+
@runs = runs
|
|
24
|
+
@styles = styles
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(inlines) = inlines.grep(Inline).each { |inline| inline.break? ? @runs.br : write_one(inline) }
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def write_one(inline) = nest(inline.marks.flat_map { |mark, value| steps(mark, value) }, inline.text)
|
|
32
|
+
|
|
33
|
+
def nest(steps, text)
|
|
34
|
+
return @runs.plain(text) if steps.empty?
|
|
35
|
+
|
|
36
|
+
name, *args = steps.first
|
|
37
|
+
@runs.public_send(name, *args) { nest(steps.drop(1), text) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def steps(mark, value)
|
|
41
|
+
case mark
|
|
42
|
+
when :link then [[:link, value], *style_steps(@styles[:a])]
|
|
43
|
+
when :code then style_steps(@styles[:code])
|
|
44
|
+
when :script then [[value]]
|
|
45
|
+
else MARK_STEPS.fetch(mark, [])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def style_steps(style) = style.filter_map { |key, value| STYLE_STEPS[key]&.call(value) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "nodes"
|
|
4
|
+
require_relative "styles"
|
|
5
|
+
require_relative "renderer/inlines"
|
|
6
|
+
|
|
7
|
+
module Stationery
|
|
8
|
+
module Rich
|
|
9
|
+
# Draws rich-text blocks with a component's element DSL. Images come from
|
|
10
|
+
# `images:` (a callable given the src, returning a path, an IO or nil) or
|
|
11
|
+
# from files under `base_path:`; remote URLs are never fetched.
|
|
12
|
+
class Renderer
|
|
13
|
+
REMOTE = /\A[a-z][a-z0-9+.-]*:/i
|
|
14
|
+
|
|
15
|
+
def initialize(component, builder, gap:, styles:, images:, base_path:, bookmarks: false)
|
|
16
|
+
@component = component
|
|
17
|
+
@builder = builder
|
|
18
|
+
@gap = gap
|
|
19
|
+
@styles = Styles.resolve(styles)
|
|
20
|
+
@images = images
|
|
21
|
+
@base_path = base_path && File.expand_path(base_path.to_s)
|
|
22
|
+
@bookmarks = bookmarks
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def block(block)
|
|
30
|
+
case block
|
|
31
|
+
when Paragraph then paragraph(block.inlines, **@styles[:p])
|
|
32
|
+
when Heading then heading(block)
|
|
33
|
+
when List then list(block)
|
|
34
|
+
when Blockquote then @component.box(**@styles[:blockquote]) { render(block.blocks) }
|
|
35
|
+
when CodeBlock then code(block)
|
|
36
|
+
when Rule then @component.rule(**@styles[:hr])
|
|
37
|
+
when Table then table(block)
|
|
38
|
+
when Image then image(block)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def paragraph(inlines, **)
|
|
43
|
+
@component.text(**) { |runs| Inlines.new(runs, @styles).write(inlines) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def heading(heading)
|
|
47
|
+
style = @styles[:"h#{heading.level}"]
|
|
48
|
+
size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
|
|
49
|
+
bookmark = heading_bookmark(heading)
|
|
50
|
+
paragraph(heading.inlines, **style.except(:scale), size:, **({ bookmark: } if bookmark))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def heading_bookmark(heading)
|
|
54
|
+
return unless @bookmarks && heading.level <= 3
|
|
55
|
+
|
|
56
|
+
{ title: heading.inlines.grep(Inline).map(&:text).join, level: heading.level }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def list(list)
|
|
60
|
+
options = list.ordered ? { start: list.start || 1 } : {}
|
|
61
|
+
@component.public_send(list.ordered ? :ol : :ul, **options) do
|
|
62
|
+
list.items.each do |blocks|
|
|
63
|
+
@component.li { @component.text_style(**@styles[:li]) { render(blocks) } }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def code(block)
|
|
69
|
+
font = @styles[:code][:font]
|
|
70
|
+
@component.box(**@styles[:pre]) { @component.text(block.text, **({ font: } if font)) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def table(table)
|
|
74
|
+
header = table.rows.first&.all?(&:header) ? 1 : 0
|
|
75
|
+
rows = table.rows.map { |row| row.map { |cell| cell_content(cell) } }
|
|
76
|
+
@component.table(rows, header:, cell: @styles[:table][:cell])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cell_content(cell)
|
|
80
|
+
style = cell.header ? @styles[:table][:header] : {}
|
|
81
|
+
-> { @component.text_style(**style, align: cell.align) { render(cell.blocks) } }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def image(node)
|
|
85
|
+
source = resolve(node.src)
|
|
86
|
+
return unless source
|
|
87
|
+
|
|
88
|
+
loaded = Images.load(source)
|
|
89
|
+
@component.image(loaded, **dimensions(node, loaded))
|
|
90
|
+
rescue UnsupportedImage => e
|
|
91
|
+
skip(node.src, e.message)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve(src)
|
|
95
|
+
return @images.call(src) || skip(src, "not found") if @images
|
|
96
|
+
return skip(src, "remote") if REMOTE.match?(src)
|
|
97
|
+
return skip(src, "not found") unless @base_path
|
|
98
|
+
|
|
99
|
+
path = File.expand_path(src, @base_path)
|
|
100
|
+
return skip(src, "outside base_path") unless path.start_with?(@base_path + File::SEPARATOR)
|
|
101
|
+
|
|
102
|
+
File.file?(path) ? path : skip(src, "not found")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dimensions(node, loaded)
|
|
106
|
+
width = node.width
|
|
107
|
+
height = node.height
|
|
108
|
+
limit = @styles[:img][:max_width]
|
|
109
|
+
natural = width || (height ? loaded.width * height.fdiv(loaded.height) : loaded.width)
|
|
110
|
+
return { width:, height: } unless limit && natural > limit
|
|
111
|
+
|
|
112
|
+
{ width: limit, height: height && (height * limit.fdiv(natural)) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def skip(source, reason)
|
|
116
|
+
@builder.warnings << Warnings::SkippedImage.new(source:, reason:)
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
# How the html and markdown elements style each kind of block. Heading
|
|
6
|
+
# `scale:` multiplies the current text size; a `size:` in points wins.
|
|
7
|
+
module Styles
|
|
8
|
+
HEADING_SCALES = [2.0, 1.6, 1.3, 1.1, 1.0, 0.9].freeze
|
|
9
|
+
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
**HEADING_SCALES.each_with_index.to_h do |scale, index|
|
|
12
|
+
[:"h#{index + 1}", { scale:, weight: :bold, keep_with_next: true }]
|
|
13
|
+
end,
|
|
14
|
+
p: {},
|
|
15
|
+
a: { color: "#2563EB", underline: true },
|
|
16
|
+
code: { font: nil },
|
|
17
|
+
pre: { background: "#F3F4F6", padding: 8, radius: 4 },
|
|
18
|
+
blockquote: { border: { sides: [:left], width: 3, color: "#E5E7EB" }, padding: [0, 0, 0, 10] },
|
|
19
|
+
hr: { height: 1, color: "#E5E7EB" },
|
|
20
|
+
table: { cell: { padding: 4 }, header: { weight: :bold } },
|
|
21
|
+
li: {},
|
|
22
|
+
img: { max_width: nil }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def resolve(overrides) = merge(DEFAULTS, overrides)
|
|
28
|
+
|
|
29
|
+
def merge(base, overrides)
|
|
30
|
+
base.merge(overrides) { |_, old, new| old.is_a?(Hash) && new.is_a?(Hash) ? merge(old, new) : new }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|