typstify 0.1.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 +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE +21 -0
- data/README.md +235 -0
- data/SECURITY.md +47 -0
- data/fonts/inter/Inter-Regular.ttf +0 -0
- data/fonts/inter/Inter-SemiBold.ttf +0 -0
- data/fonts/inter/OFL.txt +92 -0
- data/lib/generators/typstify/install/install_generator.rb +39 -0
- data/lib/generators/typstify/install/templates/typstify.rb +43 -0
- data/lib/generators/typstify/template/template_generator.rb +67 -0
- data/lib/tasks/typstify.rake +43 -0
- data/lib/typstify/adapter.rb +56 -0
- data/lib/typstify/config.rb +106 -0
- data/lib/typstify/data.rb +103 -0
- data/lib/typstify/document.rb +63 -0
- data/lib/typstify/engine.rb +38 -0
- data/lib/typstify/erb_pipeline.rb +44 -0
- data/lib/typstify/errors.rb +68 -0
- data/lib/typstify/escaping.rb +45 -0
- data/lib/typstify/fonts.rb +175 -0
- data/lib/typstify/renderer.rb +43 -0
- data/lib/typstify/resolver.rb +62 -0
- data/lib/typstify/version.rb +5 -0
- data/lib/typstify/warnings.rb +65 -0
- data/lib/typstify/workspace.rb +85 -0
- data/lib/typstify.rb +104 -0
- data/templates/certificate/certificate.typ +70 -0
- data/templates/certificate/sample_data.json +13 -0
- data/templates/invoice/invoice.typ +57 -0
- data/templates/invoice/sample_data.json +21 -0
- data/templates/receipt/receipt.typ +69 -0
- data/templates/receipt/sample_data.json +17 -0
- data/templates/report/report.typ +103 -0
- data/templates/report/sample_data.json +49 -0
- data/templates/shared/branding.typ +140 -0
- metadata +164 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Typstify
|
|
7
|
+
# Font resolution, done by us rather than by the compiler.
|
|
8
|
+
#
|
|
9
|
+
# Typst warns about an unknown font family and then quietly substitutes
|
|
10
|
+
# another face — which is how a production invoice ends up in a font nobody
|
|
11
|
+
# chose. We would rather surface that, but the `typst` binding currently
|
|
12
|
+
# discards compiler warnings on a successful compile (it only formats them
|
|
13
|
+
# into the message when compilation *fails*), so there is nothing to listen
|
|
14
|
+
# to. See the upstream PR linked in the README.
|
|
15
|
+
#
|
|
16
|
+
# Until that lands, we answer the question ourselves: read the family names a
|
|
17
|
+
# template asks for out of its source, and check them against the fonts we
|
|
18
|
+
# can actually see. Cheap, deterministic, and honest about what it covers —
|
|
19
|
+
# it reads static `font:` declarations, not families computed at runtime.
|
|
20
|
+
module Fonts
|
|
21
|
+
# Faces compiled into the Typst binary; always available, never on disk.
|
|
22
|
+
EMBEDDED = [
|
|
23
|
+
"libertinus serif",
|
|
24
|
+
"new computer modern",
|
|
25
|
+
"new computer modern math",
|
|
26
|
+
"deja vu sans mono",
|
|
27
|
+
"dejavu sans mono"
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# `font: "Inter"` or `font: ("Inter", "Noto Sans")`
|
|
31
|
+
DECLARATION = /\bfont\s*:\s*(\((?:[^()]*)\)|"(?:[^"\\]|\\.)*")/m
|
|
32
|
+
STRING = /"((?:[^"\\]|\\.)*)"/
|
|
33
|
+
|
|
34
|
+
SYSTEM_DIRECTORIES = [
|
|
35
|
+
"/System/Library/Fonts",
|
|
36
|
+
"/System/Library/Fonts/Supplemental",
|
|
37
|
+
"/Library/Fonts",
|
|
38
|
+
"~/Library/Fonts",
|
|
39
|
+
"/usr/share/fonts",
|
|
40
|
+
"/usr/local/share/fonts",
|
|
41
|
+
"~/.fonts",
|
|
42
|
+
"~/.local/share/fonts"
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
EXTENSIONS = %w[.ttf .otf .ttc .otc].freeze
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Families a chunk of Typst source asks for, in declaration order.
|
|
50
|
+
def declared_families(source)
|
|
51
|
+
source.to_s.scan(DECLARATION).flat_map do |(declaration)|
|
|
52
|
+
declaration.scan(STRING).flatten
|
|
53
|
+
end.map(&:strip).reject(&:empty?).uniq
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Families the compiler will be able to find, downcased for comparison.
|
|
57
|
+
def available_families(font_paths, include_system: true)
|
|
58
|
+
key = [font_paths.map(&:to_s).sort, include_system]
|
|
59
|
+
@available ||= {}
|
|
60
|
+
@available[key] ||= begin
|
|
61
|
+
families = EMBEDDED.dup
|
|
62
|
+
search_paths(font_paths, include_system: include_system).each do |directory|
|
|
63
|
+
families.concat(families_in(directory))
|
|
64
|
+
end
|
|
65
|
+
families.map(&:downcase).uniq.to_set
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reset the memoized scan. Fonts installed mid-process are rare; specs are not.
|
|
70
|
+
def reset!
|
|
71
|
+
@available = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<String>] families the template wants and nothing provides
|
|
75
|
+
def missing(source, font_paths, include_system: true)
|
|
76
|
+
available = available_families(font_paths, include_system: include_system)
|
|
77
|
+
declared_families(source).reject { |family| available.include?(family.downcase) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The directories the compiler will look in. Kept in step with
|
|
81
|
+
# `ignore_system_fonts`, so the check never calls a family available that
|
|
82
|
+
# the compiler will not actually reach for.
|
|
83
|
+
def search_paths(font_paths, include_system: true)
|
|
84
|
+
paths = font_paths.map { |path| File.expand_path(path.to_s) }
|
|
85
|
+
paths += SYSTEM_DIRECTORIES.map { |directory| File.expand_path(directory) } if include_system
|
|
86
|
+
paths
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def families_in(directory)
|
|
90
|
+
return [] unless File.directory?(directory)
|
|
91
|
+
|
|
92
|
+
Dir.glob(File.join(directory, "**", "*")).flat_map do |path|
|
|
93
|
+
next [] unless EXTENSIONS.include?(File.extname(path).downcase)
|
|
94
|
+
|
|
95
|
+
read_families(path)
|
|
96
|
+
end
|
|
97
|
+
rescue SystemCallError
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Family names out of an SFNT `name` table (nameID 1 and 16).
|
|
102
|
+
def read_families(path)
|
|
103
|
+
File.open(path, "rb") do |file|
|
|
104
|
+
header = file.read(4)
|
|
105
|
+
return [] if header.nil?
|
|
106
|
+
|
|
107
|
+
offsets = header == "ttcf" ? collection_offsets(file) : [0]
|
|
108
|
+
offsets.flat_map { |offset| families_at(file, offset) }
|
|
109
|
+
end
|
|
110
|
+
rescue SystemCallError, EOFError
|
|
111
|
+
[]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def collection_offsets(file)
|
|
115
|
+
file.seek(8)
|
|
116
|
+
count = file.read(4).unpack1("N")
|
|
117
|
+
return [] if count.nil? || count.zero? || count > 1024
|
|
118
|
+
|
|
119
|
+
Array(file.read(4 * count)&.unpack("N*"))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def families_at(file, base)
|
|
123
|
+
file.seek(base + 4)
|
|
124
|
+
table_count = file.read(2)&.unpack1("n")
|
|
125
|
+
return [] if table_count.nil? || table_count.zero?
|
|
126
|
+
|
|
127
|
+
file.seek(base + 12)
|
|
128
|
+
records = file.read(16 * table_count).to_s
|
|
129
|
+
name_offset = nil
|
|
130
|
+
table_count.times do |index|
|
|
131
|
+
record = records[index * 16, 16]
|
|
132
|
+
break if record.nil?
|
|
133
|
+
|
|
134
|
+
name_offset = record[8, 4].unpack1("N") if record[0, 4] == "name"
|
|
135
|
+
end
|
|
136
|
+
return [] if name_offset.nil?
|
|
137
|
+
|
|
138
|
+
parse_name_table(file, name_offset)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_name_table(file, offset)
|
|
142
|
+
file.seek(offset)
|
|
143
|
+
header = file.read(6)
|
|
144
|
+
return [] if header.nil? || header.bytesize < 6
|
|
145
|
+
|
|
146
|
+
_format, count, storage = header.unpack("n3")
|
|
147
|
+
records = file.read(12 * count).to_s
|
|
148
|
+
|
|
149
|
+
count.times.filter_map do |index|
|
|
150
|
+
record = records[index * 12, 12]
|
|
151
|
+
next if record.nil?
|
|
152
|
+
|
|
153
|
+
platform, _encoding, _language, name_id, length, string_offset = record.unpack("n6")
|
|
154
|
+
next unless [1, 16].include?(name_id)
|
|
155
|
+
|
|
156
|
+
file.seek(offset + storage + string_offset)
|
|
157
|
+
decode(file.read(length), platform)
|
|
158
|
+
end.compact.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def decode(bytes, platform)
|
|
162
|
+
return nil if bytes.nil? || bytes.empty?
|
|
163
|
+
|
|
164
|
+
string =
|
|
165
|
+
if platform == 3 || platform.zero?
|
|
166
|
+
bytes.force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8)
|
|
167
|
+
else
|
|
168
|
+
bytes.force_encoding(Encoding::BINARY).encode(Encoding::UTF_8, Encoding::ISO_8859_1)
|
|
169
|
+
end
|
|
170
|
+
string.strip.empty? ? nil : string.strip
|
|
171
|
+
rescue EncodingError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_controller"
|
|
4
|
+
|
|
5
|
+
module Typstify
|
|
6
|
+
# Registers `render pdf:` on every controller.
|
|
7
|
+
#
|
|
8
|
+
# render pdf: "invoices/show",
|
|
9
|
+
# data: { number: "A-1" },
|
|
10
|
+
# filename: "invoice-A-1.pdf",
|
|
11
|
+
# disposition: :inline
|
|
12
|
+
#
|
|
13
|
+
# `filename` defaults to the template's basename; `disposition` to attachment,
|
|
14
|
+
# matching `send_data`.
|
|
15
|
+
module Renderer
|
|
16
|
+
DEFAULT_DISPOSITION = :attachment
|
|
17
|
+
|
|
18
|
+
def self.install!
|
|
19
|
+
ActionController::Renderers.add :pdf do |template, options|
|
|
20
|
+
pdf = Typstify.render(
|
|
21
|
+
template: template,
|
|
22
|
+
data: options[:data],
|
|
23
|
+
assigns: view_assigns
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
send_data(
|
|
27
|
+
pdf,
|
|
28
|
+
type: options[:type] || "application/pdf",
|
|
29
|
+
filename: Typstify::Renderer.filename_for(template, options),
|
|
30
|
+
disposition: options[:disposition] || DEFAULT_DISPOSITION,
|
|
31
|
+
status: options[:status] || :ok
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.filename_for(template, options)
|
|
37
|
+
return options[:filename] if options[:filename]
|
|
38
|
+
|
|
39
|
+
base = File.basename(template.to_s).sub(/\.typ(\.erb)?\z/, "")
|
|
40
|
+
"#{base}.pdf"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Typstify
|
|
6
|
+
# Turns a template name ("invoices/show") into a file on disk, and refuses to
|
|
7
|
+
# look outside `template_root` while doing it.
|
|
8
|
+
#
|
|
9
|
+
# This is the first of two containment layers. The second — and the one that
|
|
10
|
+
# actually matters — is the workspace: Typst compiles with the tmpdir as its
|
|
11
|
+
# root, so even a template that somehow got resolved elsewhere could not read
|
|
12
|
+
# past it. This layer exists to turn a traversal attempt into a clear error
|
|
13
|
+
# rather than a confusing "file not found".
|
|
14
|
+
class Resolver
|
|
15
|
+
Resolution = Struct.new(:name, :path, :mode, keyword_init: true) do
|
|
16
|
+
def erb? = mode == :erb
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Data mode wins: a directory holding both show.typ and show.typ.erb
|
|
20
|
+
# renders the safe one.
|
|
21
|
+
EXTENSIONS = { ".typ" => :data, ".typ.erb" => :erb }.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(config = Typstify.config)
|
|
24
|
+
@config = config
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param name [String] e.g. "invoices/show", with or without extension
|
|
28
|
+
# @return [Resolution]
|
|
29
|
+
# @raise [PathError] if the name points outside template_root
|
|
30
|
+
# @raise [MissingTemplate] if nothing is there
|
|
31
|
+
def call(name)
|
|
32
|
+
stem = name.to_s.sub(/\.typ(\.erb)?\z/, "")
|
|
33
|
+
root = @config.template_root.expand_path
|
|
34
|
+
|
|
35
|
+
tried = EXTENSIONS.keys.map { |ext| candidate(root, stem, ext) }
|
|
36
|
+
|
|
37
|
+
EXTENSIONS.each_with_index do |(_ext, mode), index|
|
|
38
|
+
path = tried[index]
|
|
39
|
+
return Resolution.new(name: stem, path: path, mode: mode) if path.file?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
raise MissingTemplate.new(name, tried)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def candidate(root, stem, extension)
|
|
48
|
+
if stem.start_with?("/", "~")
|
|
49
|
+
raise PathError, "Template name #{stem.inspect} must be relative to template_root (#{root})."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
path = root.join("#{stem}#{extension}").expand_path
|
|
53
|
+
unless path.to_s == root.to_s || path.to_s.start_with?("#{root}#{File::SEPARATOR}")
|
|
54
|
+
raise PathError,
|
|
55
|
+
"Template name #{stem.inspect} resolves to #{path}, which is outside " \
|
|
56
|
+
"template_root (#{root}). Path traversal is not allowed."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
path
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typstify
|
|
4
|
+
# Parsing for the compiler's diagnostic output.
|
|
5
|
+
#
|
|
6
|
+
# The binding gives us diagnostics as one formatted blob — the same codespan
|
|
7
|
+
# rendering the `typst` CLI prints — raised as an ArgumentError:
|
|
8
|
+
#
|
|
9
|
+
# error: expected expression
|
|
10
|
+
# ┌─ /tmp/typstify-abc/main.typ:12:8
|
|
11
|
+
# │
|
|
12
|
+
# 12 │ #let x =
|
|
13
|
+
# │ ^
|
|
14
|
+
#
|
|
15
|
+
# Warnings share that format and are included *only when compilation fails*,
|
|
16
|
+
# so a successful compile with warnings tells us nothing. Everything here is
|
|
17
|
+
# therefore best-effort presentation, not a contract.
|
|
18
|
+
module Warnings
|
|
19
|
+
BLOCK = /^(?<severity>error|warning):\s*(?<message>.*)$/
|
|
20
|
+
LOCATION = /┌─\s*(?<path>.+?):(?<line>\d+):(?<column>\d+)/
|
|
21
|
+
|
|
22
|
+
Diagnostic = Struct.new(:severity, :message, :line, :column, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Split a raw diagnostic blob into its individual errors and warnings.
|
|
27
|
+
def parse(raw)
|
|
28
|
+
text = raw.to_s
|
|
29
|
+
starts = text.enum_for(:scan, BLOCK).map { Regexp.last_match.begin(0) }
|
|
30
|
+
return [] if starts.empty?
|
|
31
|
+
|
|
32
|
+
bounds = starts.zip(starts.drop(1) + [text.length])
|
|
33
|
+
bounds.map do |(from, to)|
|
|
34
|
+
chunk = text[from...to]
|
|
35
|
+
header = chunk.match(BLOCK)
|
|
36
|
+
location = chunk.match(LOCATION)
|
|
37
|
+
Diagnostic.new(
|
|
38
|
+
severity: header[:severity].to_sym,
|
|
39
|
+
message: header[:message].strip,
|
|
40
|
+
line: location && location[:line].to_i,
|
|
41
|
+
column: location && location[:column].to_i
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The workspace is a tmpdir with a random name; showing it to a developer
|
|
47
|
+
# who wrote `app/views/invoices/show.typ` is noise at best. Swap it for the
|
|
48
|
+
# name they used.
|
|
49
|
+
def rewrite_paths(raw, workspace_dir, template_name)
|
|
50
|
+
raw.to_s
|
|
51
|
+
.gsub(%r{#{Regexp.escape(workspace_dir.to_s)}/?main\.typ}, "#{template_name}.typ")
|
|
52
|
+
.gsub(%r{#{Regexp.escape(workspace_dir.to_s)}/?}, "")
|
|
53
|
+
.gsub(%r{[^\s:]*/main\.typ}, "#{template_name}.typ")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def dispatch(diagnostics, template, config)
|
|
57
|
+
messages = diagnostics.select { |d| d.severity == :warning }.map do |diagnostic|
|
|
58
|
+
diagnostic.line ? "#{diagnostic.message} (line #{diagnostic.line})" : diagnostic.message
|
|
59
|
+
end
|
|
60
|
+
return if messages.empty?
|
|
61
|
+
|
|
62
|
+
config.on_warning&.call(messages, template)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Typstify
|
|
8
|
+
# A throwaway directory holding everything one compile is allowed to see.
|
|
9
|
+
#
|
|
10
|
+
# <tmp>/main.typ the template
|
|
11
|
+
# <tmp>/data.json your data
|
|
12
|
+
# <tmp>/shared/ config.shared_dir, copied
|
|
13
|
+
#
|
|
14
|
+
# Typst is invoked with this directory as its root, so `#read("../.env")`
|
|
15
|
+
# cannot reach your application — not because we filter it, but because the
|
|
16
|
+
# file is not there. That is the whole security model, and it is why the
|
|
17
|
+
# template is copied to the *root* of the workspace rather than nested: Typst
|
|
18
|
+
# resolves relative imports against the importing file, so main-at-root is
|
|
19
|
+
# what makes `#import "shared/branding.typ"` work from any view subdirectory.
|
|
20
|
+
#
|
|
21
|
+
# Every render gets its own workspace, which is also what makes concurrent
|
|
22
|
+
# rendering safe.
|
|
23
|
+
class Workspace
|
|
24
|
+
MAIN = "main.typ"
|
|
25
|
+
DATA = "data.json"
|
|
26
|
+
|
|
27
|
+
attr_reader :dir
|
|
28
|
+
|
|
29
|
+
# Yields a Workspace and removes it afterwards, exception or not.
|
|
30
|
+
def self.build(source:, data:, config: Typstify.config)
|
|
31
|
+
Dir.mktmpdir("typstify-") do |tmp|
|
|
32
|
+
workspace = new(Pathname.new(tmp), config)
|
|
33
|
+
workspace.write_main(source)
|
|
34
|
+
workspace.write_data(data)
|
|
35
|
+
workspace.copy_shared
|
|
36
|
+
yield workspace
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(dir, config)
|
|
41
|
+
@dir = dir
|
|
42
|
+
@config = config
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def main_path = dir.join(MAIN)
|
|
46
|
+
|
|
47
|
+
def write_main(source)
|
|
48
|
+
File.write(main_path, source, encoding: Encoding::UTF_8)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Takes the already-serialised JSON string: validation happens in Document,
|
|
52
|
+
# before a workspace exists.
|
|
53
|
+
def write_data(data)
|
|
54
|
+
File.write(dir.join(DATA), data || "{}", encoding: Encoding::UTF_8)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Copy config.shared_dir in, if it exists. Copies rather than symlinks:
|
|
58
|
+
# a symlink inside the root points outside the root, which would hand back
|
|
59
|
+
# exactly the file-system access the workspace exists to remove. Symlinks
|
|
60
|
+
# found in the source tree are skipped for the same reason.
|
|
61
|
+
def copy_shared
|
|
62
|
+
source = @config.template_root.join(@config.shared_dir)
|
|
63
|
+
return unless source.directory?
|
|
64
|
+
|
|
65
|
+
destination = dir.join(@config.shared_dir)
|
|
66
|
+
copy_tree(source, destination)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def copy_tree(source, destination)
|
|
72
|
+
FileUtils.mkdir_p(destination)
|
|
73
|
+
source.children.each do |child|
|
|
74
|
+
next if child.symlink?
|
|
75
|
+
|
|
76
|
+
target = destination.join(child.basename)
|
|
77
|
+
if child.directory?
|
|
78
|
+
copy_tree(child, target)
|
|
79
|
+
elsif child.file?
|
|
80
|
+
FileUtils.cp(child, target)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/typstify.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
require_relative "typstify/version"
|
|
7
|
+
require_relative "typstify/errors"
|
|
8
|
+
require_relative "typstify/config"
|
|
9
|
+
require_relative "typstify/escaping"
|
|
10
|
+
require_relative "typstify/data"
|
|
11
|
+
require_relative "typstify/warnings"
|
|
12
|
+
require_relative "typstify/fonts"
|
|
13
|
+
require_relative "typstify/resolver"
|
|
14
|
+
require_relative "typstify/workspace"
|
|
15
|
+
require_relative "typstify/erb_pipeline"
|
|
16
|
+
require_relative "typstify/adapter"
|
|
17
|
+
require_relative "typstify/document"
|
|
18
|
+
|
|
19
|
+
require_relative "typstify/engine" if defined?(Rails::Engine)
|
|
20
|
+
|
|
21
|
+
# PDF generation for Rails on the Typst engine.
|
|
22
|
+
#
|
|
23
|
+
# Typstify.render(template: "invoices/show", data: { number: "A-1" })
|
|
24
|
+
#
|
|
25
|
+
# In a controller, prefer the renderer:
|
|
26
|
+
#
|
|
27
|
+
# render pdf: "invoices/show", data: { ... }, filename: "invoice.pdf"
|
|
28
|
+
module Typstify
|
|
29
|
+
class << self
|
|
30
|
+
def config
|
|
31
|
+
@config ||= Config.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def configure
|
|
35
|
+
yield config
|
|
36
|
+
apply_package_cache!
|
|
37
|
+
config
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Mostly for specs: drop all configuration and cached font scans.
|
|
41
|
+
def reset!
|
|
42
|
+
@config = Config.new
|
|
43
|
+
Fonts.reset!
|
|
44
|
+
config
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Compile a template to PDF.
|
|
48
|
+
#
|
|
49
|
+
# @param template [String] e.g. "invoices/show"
|
|
50
|
+
# @param data [Object] anything JSON-serializable; read in the template
|
|
51
|
+
# with `#let data = json("data.json")`
|
|
52
|
+
# @param assigns [Hash] instance variables for ERB mode only
|
|
53
|
+
# @return [String] PDF bytes, ASCII-8BIT
|
|
54
|
+
def render(template:, data: nil, assigns: {})
|
|
55
|
+
Document.new(template: template, data: data, assigns: assigns, config: config).to_pdf
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Render and attach in one step.
|
|
59
|
+
#
|
|
60
|
+
# Typstify.render_and_attach(user.documents,
|
|
61
|
+
# template: "certificates/completion",
|
|
62
|
+
# data: cert_data, filename: "certificate.pdf")
|
|
63
|
+
#
|
|
64
|
+
# @param attachable [#attach] an ActiveStorage has_one/has_many attachment proxy
|
|
65
|
+
def render_and_attach(attachable, template:, filename:, data: nil, assigns: {},
|
|
66
|
+
content_type: "application/pdf")
|
|
67
|
+
pdf = render(template: template, data: data, assigns: assigns)
|
|
68
|
+
attachable.attach(
|
|
69
|
+
io: StringIO.new(pdf),
|
|
70
|
+
filename: filename,
|
|
71
|
+
content_type: content_type
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def root
|
|
76
|
+
@root ||= Pathname.new(__dir__).join("..").expand_path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Faces shipped with the gem, always on the font search path so the starter
|
|
80
|
+
# templates render with no configuration.
|
|
81
|
+
def bundled_font_path
|
|
82
|
+
root.join("fonts")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The starter template pack, copied by `rails g typstify:template`.
|
|
86
|
+
def template_pack_path
|
|
87
|
+
root.join("templates")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Point Typst's package resolution at the configured cache.
|
|
91
|
+
#
|
|
92
|
+
# Typst looks in the platform data directory, which on Linux — and so in
|
|
93
|
+
# every Docker image — is XDG_DATA_HOME. Set once at boot rather than around
|
|
94
|
+
# each compile, because mutating ENV per render is not thread-safe and
|
|
95
|
+
# concurrent rendering is a supported use. On macOS the platform directory
|
|
96
|
+
# is fixed and this has no effect; see docs/fonts-and-docker.md.
|
|
97
|
+
def apply_package_cache!
|
|
98
|
+
cache = config.package_cache
|
|
99
|
+
return if cache.nil?
|
|
100
|
+
|
|
101
|
+
ENV["XDG_DATA_HOME"] = cache.expand_path.to_s
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Certificate — Typstify starter template.
|
|
2
|
+
//
|
|
3
|
+
// Landscape, centred, and deliberately restrained: a border, a name, a reason.
|
|
4
|
+
// Data mode; see templates/invoice for the fuller commentary.
|
|
5
|
+
|
|
6
|
+
#import "shared/branding.typ": *
|
|
7
|
+
|
|
8
|
+
#let data = json("data.json")
|
|
9
|
+
|
|
10
|
+
#set page(
|
|
11
|
+
paper: "a4",
|
|
12
|
+
flipped: true,
|
|
13
|
+
margin: 16mm,
|
|
14
|
+
background: {
|
|
15
|
+
place(center + horizon, rect(
|
|
16
|
+
width: 100% - 10mm,
|
|
17
|
+
height: 100% - 10mm,
|
|
18
|
+
stroke: 1.5pt + brand.accent,
|
|
19
|
+
radius: 2pt,
|
|
20
|
+
))
|
|
21
|
+
place(center + horizon, rect(
|
|
22
|
+
width: 100% - 14mm,
|
|
23
|
+
height: 100% - 14mm,
|
|
24
|
+
stroke: 0.5pt + brand.rule,
|
|
25
|
+
))
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
#set text(font: brand.font, size: 11pt, fill: brand.ink)
|
|
29
|
+
|
|
30
|
+
#align(center)[
|
|
31
|
+
#v(14mm)
|
|
32
|
+
#text(size: 9pt, fill: brand.muted, tracking: 2pt)[#upper(data.at("eyebrow", default: "Certificate"))]
|
|
33
|
+
|
|
34
|
+
#v(6mm)
|
|
35
|
+
#text(size: 30pt, weight: "semibold", fill: brand.accent)[#data.title]
|
|
36
|
+
|
|
37
|
+
#v(8mm)
|
|
38
|
+
#text(size: 10pt, fill: brand.muted)[#data.presented_to_label]
|
|
39
|
+
|
|
40
|
+
#v(3mm)
|
|
41
|
+
#text(size: 26pt, weight: "semibold")[#data.recipient]
|
|
42
|
+
|
|
43
|
+
#v(3mm)
|
|
44
|
+
#line(length: 70mm, stroke: 0.5pt + brand.rule)
|
|
45
|
+
|
|
46
|
+
#v(6mm)
|
|
47
|
+
#block(width: 60%)[
|
|
48
|
+
#set par(justify: false, leading: 0.8em)
|
|
49
|
+
#text(size: 11pt, fill: brand.muted)[#data.description]
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
#v(14mm)
|
|
53
|
+
|
|
54
|
+
#grid(
|
|
55
|
+
columns: (1fr, 1fr),
|
|
56
|
+
gutter: 30mm,
|
|
57
|
+
..data.signatories.map(person => [
|
|
58
|
+
#line(length: 100%, stroke: 0.5pt + brand.ink)
|
|
59
|
+
#v(2pt)
|
|
60
|
+
#text(size: 10pt, weight: "semibold")[#person.name]
|
|
61
|
+
#linebreak()
|
|
62
|
+
#text(size: 8.5pt, fill: brand.muted)[#person.role]
|
|
63
|
+
]),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
#v(8mm)
|
|
67
|
+
#text(size: 8pt, fill: brand.muted)[
|
|
68
|
+
#data.issued_on #h(6pt) · #h(6pt) #data.reference
|
|
69
|
+
]
|
|
70
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"eyebrow": "Certificate of Completion",
|
|
3
|
+
"title": "Advanced Rails Performance",
|
|
4
|
+
"presented_to_label": "This is to certify that",
|
|
5
|
+
"recipient": "Ada Okonkwo",
|
|
6
|
+
"description": "has successfully completed the twelve-week programme covering query optimisation, caching strategy, background processing and production profiling, including all assessed exercises.",
|
|
7
|
+
"signatories": [
|
|
8
|
+
{ "name": "Priya Raman", "role": "Programme Director" },
|
|
9
|
+
{ "name": "Tom Whelan", "role": "Lead Instructor" }
|
|
10
|
+
],
|
|
11
|
+
"issued_on": "18 June 2026",
|
|
12
|
+
"reference": "Certificate no. CRT-00412"
|
|
13
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Invoice — Typstify starter template.
|
|
2
|
+
//
|
|
3
|
+
// Data mode: every dynamic value arrives as JSON. Nothing here interpolates a
|
|
4
|
+
// Ruby string into source, so a customer named `#read("/etc/passwd")` renders
|
|
5
|
+
// as those characters and nothing else.
|
|
6
|
+
//
|
|
7
|
+
// Preview it without booting Rails:
|
|
8
|
+
// rake typstify:preview[invoices/show]
|
|
9
|
+
|
|
10
|
+
#import "shared/branding.typ": *
|
|
11
|
+
|
|
12
|
+
#let data = json("data.json")
|
|
13
|
+
|
|
14
|
+
#show: brand-page
|
|
15
|
+
|
|
16
|
+
#brand-header(
|
|
17
|
+
title: "Invoice " + data.number,
|
|
18
|
+
subtitle: data.at("subtitle", default: none),
|
|
19
|
+
meta: (
|
|
20
|
+
("Issued", data.issued_on),
|
|
21
|
+
("Due", data.due_on),
|
|
22
|
+
("Amount due", data.total),
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
#grid(
|
|
27
|
+
columns: (1fr, 1fr),
|
|
28
|
+
gutter: 16pt,
|
|
29
|
+
brand-address(label: "Billed to", lines: data.bill_to),
|
|
30
|
+
brand-address(label: "From", lines: data.bill_from),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
#v(14pt)
|
|
34
|
+
|
|
35
|
+
#brand-table(
|
|
36
|
+
columns: (1fr, auto, auto, auto),
|
|
37
|
+
aligns: (left, right, right, right),
|
|
38
|
+
header: ("Description", "Qty", "Unit", "Amount"),
|
|
39
|
+
rows: data.line_items.map(item => (
|
|
40
|
+
[#item.name],
|
|
41
|
+
[#item.qty],
|
|
42
|
+
[#item.unit_price],
|
|
43
|
+
[#item.amount],
|
|
44
|
+
)),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
#v(12pt)
|
|
48
|
+
|
|
49
|
+
#brand-totals(
|
|
50
|
+
rows: data.at("summary", default: ()).map(row => (row.label, row.value)),
|
|
51
|
+
emphasis: ("Total due", data.total),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
#if data.at("notes", default: none) != none [
|
|
55
|
+
#v(16pt)
|
|
56
|
+
#brand-note[#data.notes]
|
|
57
|
+
]
|