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,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "typst"
|
|
4
|
+
|
|
5
|
+
module Typstify
|
|
6
|
+
# The only file in this gem that knows the `typst` binding exists.
|
|
7
|
+
#
|
|
8
|
+
# Everything upstream might rename — the constructor shape, the option names,
|
|
9
|
+
# how compiled bytes come back — is contained here, so a binding release
|
|
10
|
+
# breaks one file instead of five. Verified against typst 0.15.1.5.
|
|
11
|
+
module Adapter
|
|
12
|
+
# The binding surfaces compile failures as ArgumentError, carrying the same
|
|
13
|
+
# codespan-formatted diagnostic the CLI prints.
|
|
14
|
+
BINDING_ERROR = ArgumentError
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param main_path [Pathname] the workspace's main.typ
|
|
19
|
+
# @param root [Pathname] the workspace; Typst's sandbox boundary
|
|
20
|
+
# @param template [String] the name to show in errors
|
|
21
|
+
# @param config [Typstify::Config] supplies the fonts and the PDF standard
|
|
22
|
+
# @return [String] PDF bytes, ASCII-8BIT
|
|
23
|
+
def compile_pdf(main_path:, root:, template:, config: Typstify.config)
|
|
24
|
+
document = Typst(
|
|
25
|
+
main_path.to_s,
|
|
26
|
+
root: root.to_s,
|
|
27
|
+
font_paths: config.font_paths.map(&:to_s),
|
|
28
|
+
ignore_system_fonts: config.ignore_system_fonts,
|
|
29
|
+
pdf_standards: config.pdf_standards
|
|
30
|
+
).compile(:pdf)
|
|
31
|
+
|
|
32
|
+
# PdfDocument#bytes is a per-page array of integer arrays; #pages packs
|
|
33
|
+
# each one. A PDF is always a single "page" here — the whole document.
|
|
34
|
+
document.pages.first.to_s.b
|
|
35
|
+
rescue BINDING_ERROR => e
|
|
36
|
+
raise translate(e, root: root, template: template, config: config)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Turn the binding's flat diagnostic blob into a CompileError that names the
|
|
40
|
+
# developer's template rather than a tmpdir, and surface any warnings that
|
|
41
|
+
# came along with it through the on_warning hook.
|
|
42
|
+
def translate(error, root:, template:, config:)
|
|
43
|
+
readable = Warnings.rewrite_paths(error.message, root, template)
|
|
44
|
+
diagnostics = Warnings.parse(readable)
|
|
45
|
+
Warnings.dispatch(diagnostics, template, config)
|
|
46
|
+
|
|
47
|
+
first = diagnostics.find { |d| d.severity == :error }
|
|
48
|
+
CompileError.new(
|
|
49
|
+
template: template,
|
|
50
|
+
typst_message: readable.strip,
|
|
51
|
+
line: first&.line,
|
|
52
|
+
column: first&.column
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typstify
|
|
4
|
+
# Everything tunable, set once in `config/initializers/typstify.rb`.
|
|
5
|
+
#
|
|
6
|
+
# Typstify.configure do |c|
|
|
7
|
+
# c.font_paths = [Rails.root.join("app/assets/fonts")]
|
|
8
|
+
# c.pdf_standard = :ua_1
|
|
9
|
+
# end
|
|
10
|
+
class Config
|
|
11
|
+
# Symbol names for the PDF standards Typst can emit, mapped to the strings
|
|
12
|
+
# the compiler wants. Verified against typst_pdf::PdfStandard.
|
|
13
|
+
PDF_STANDARDS = {
|
|
14
|
+
pdf_1_7: "1.7",
|
|
15
|
+
pdf_2_0: "2.0",
|
|
16
|
+
a_1b: "a-1b",
|
|
17
|
+
a_2b: "a-2b",
|
|
18
|
+
a_3b: "a-3b",
|
|
19
|
+
a_4: "a-4",
|
|
20
|
+
ua_1: "ua-1"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
attr_writer :template_root, :shared_dir, :font_paths, :package_cache, :on_warning,
|
|
24
|
+
:strict_fonts, :ignore_system_fonts
|
|
25
|
+
|
|
26
|
+
# Where `.typ` templates live. Also the boundary a template path may not
|
|
27
|
+
# escape (see Resolver).
|
|
28
|
+
def template_root
|
|
29
|
+
@template_root ||= defined?(::Rails) && ::Rails.root ? ::Rails.root.join("app/views") : Pathname.pwd
|
|
30
|
+
Pathname.new(@template_root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Subdirectory of `template_root` copied into every workspace, so
|
|
34
|
+
# `#import "shared/branding.typ"` resolves from any template.
|
|
35
|
+
def shared_dir
|
|
36
|
+
@shared_dir ||= "shared"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Directories searched for fonts, in addition to system fonts. The gem's
|
|
40
|
+
# own bundled faces are always appended, so starter templates work with no
|
|
41
|
+
# configuration at all.
|
|
42
|
+
def font_paths
|
|
43
|
+
Array(@font_paths).map { |p| Pathname.new(p) } + [Typstify.bundled_font_path]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Directory holding vendored Typst Universe packages, for network-free
|
|
47
|
+
# builds. See docs/fonts-and-docker.md for the platform caveat.
|
|
48
|
+
def package_cache
|
|
49
|
+
@package_cache && Pathname.new(@package_cache)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Skip the operating system's font directories entirely and use only
|
|
53
|
+
# `font_paths` plus Typst's embedded faces.
|
|
54
|
+
#
|
|
55
|
+
# Worth knowing: scanning system fonts costs roughly 50 ms *per render*. On
|
|
56
|
+
# a styled A4 invoice on an M-series Mac, 58 ms becomes 4 ms with this on.
|
|
57
|
+
# Once you have vendored the fonts your templates name — which you should;
|
|
58
|
+
# see docs/fonts-and-docker.md — nothing on the system is being used
|
|
59
|
+
# anyway, and a slim container has no system fonts to find at all.
|
|
60
|
+
#
|
|
61
|
+
# Off by default, because turning it on changes which face a template that
|
|
62
|
+
# names "Helvetica" ends up with.
|
|
63
|
+
def ignore_system_fonts
|
|
64
|
+
@ignore_system_fonts.nil? ? false : @ignore_system_fonts
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# nil (plain PDF), or one of PDF_STANDARDS' keys.
|
|
68
|
+
attr_accessor :pdf_standard
|
|
69
|
+
|
|
70
|
+
# The compiler flag string, or nil.
|
|
71
|
+
def pdf_standards
|
|
72
|
+
return [] if pdf_standard.nil?
|
|
73
|
+
|
|
74
|
+
key = pdf_standard.to_sym
|
|
75
|
+
value = PDF_STANDARDS[key]
|
|
76
|
+
unless value
|
|
77
|
+
raise ArgumentError,
|
|
78
|
+
"Unknown pdf_standard #{pdf_standard.inspect}. " \
|
|
79
|
+
"Expected one of: #{PDF_STANDARDS.keys.map(&:inspect).join(", ")}"
|
|
80
|
+
end
|
|
81
|
+
[value]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Raise FontMissingError instead of warning when a template asks for a font
|
|
85
|
+
# nothing can supply. On in development and test, off in production, where
|
|
86
|
+
# a substituted glyph beats a 500.
|
|
87
|
+
def strict_fonts
|
|
88
|
+
return @strict_fonts unless @strict_fonts.nil?
|
|
89
|
+
|
|
90
|
+
@strict_fonts = !defined?(::Rails) || ::Rails.env.nil? || ::Rails.env.development? || ::Rails.env.test?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Called as `on_warning.call(warnings, template)` — warnings is an Array of
|
|
94
|
+
# strings. Defaults to Rails.logger.warn.
|
|
95
|
+
def on_warning
|
|
96
|
+
@on_warning ||= lambda do |warnings, template|
|
|
97
|
+
message = "[typstify] #{template}: #{warnings.join("; ")}"
|
|
98
|
+
if defined?(::Rails) && ::Rails.logger
|
|
99
|
+
::Rails.logger.warn(message)
|
|
100
|
+
else
|
|
101
|
+
warn(message)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module Typstify
|
|
7
|
+
# Turns the `data:` you pass into the `data.json` a template reads.
|
|
8
|
+
#
|
|
9
|
+
# The validation pass exists so that a bad value fails immediately, naming the
|
|
10
|
+
# exact key path, instead of surfacing later as a template that silently
|
|
11
|
+
# renders "#<Invoice:0x00007f…>" — or, under Rails, as an empty object.
|
|
12
|
+
#
|
|
13
|
+
# That last case is the subtle one. ActiveSupport defines `as_json` on
|
|
14
|
+
# `Object` itself, returning the instance variables of anything at all, so a
|
|
15
|
+
# naive `respond_to?(:as_json)` check would happily serialise a Proc, an IO or
|
|
16
|
+
# a half-built value object into `{}` and render a blank invoice. We therefore
|
|
17
|
+
# accept `as_json` only from classes that actually define it — models,
|
|
18
|
+
# serializers, Hash, Array, Time — and reject the generic fallback.
|
|
19
|
+
module Data
|
|
20
|
+
PRIMITIVES = [String, Integer, TrueClass, FalseClass, NilClass].freeze
|
|
21
|
+
|
|
22
|
+
# `as_json` inherited from one of these is ActiveSupport's catch-all, not a
|
|
23
|
+
# deliberate serialization.
|
|
24
|
+
GENERIC_AS_JSON_OWNERS = [Object, Kernel, BasicObject].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param data [Object] any JSON-serializable structure
|
|
29
|
+
# @return [String] pretty JSON, ready to write as data.json
|
|
30
|
+
# @raise [ArgumentError] naming the key path of the first bad value
|
|
31
|
+
def dump(data)
|
|
32
|
+
JSON.pretty_generate(normalize(data, ["data"]))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize(value, path)
|
|
36
|
+
case value
|
|
37
|
+
when *PRIMITIVES then value
|
|
38
|
+
when Symbol then value.to_s
|
|
39
|
+
when Float then normalize_float(value, path)
|
|
40
|
+
when Hash then normalize_hash(value, path)
|
|
41
|
+
when Array then value.each_with_index.map { |v, i| normalize(v, path + ["[#{i}]"]) }
|
|
42
|
+
when Time, Date then value.iso8601
|
|
43
|
+
else normalize_object(value, path)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_hash(hash, path)
|
|
48
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
49
|
+
unless key.is_a?(String) || key.is_a?(Symbol) || key.is_a?(Numeric)
|
|
50
|
+
raise ArgumentError, "#{join(path)} has a #{key.class} key (#{key.inspect}); " \
|
|
51
|
+
"JSON object keys must be strings, symbols or numbers."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
out[key.to_s] = normalize(value, path + [".#{key}"])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_float(float, path)
|
|
59
|
+
return float if float.finite?
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, "#{join(path)} is #{float}, which JSON cannot represent."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize_object(value, path)
|
|
65
|
+
if deliberate_as_json?(value)
|
|
66
|
+
normalize(value.as_json, path)
|
|
67
|
+
elsif deliberate_to_h?(value)
|
|
68
|
+
normalize(value.to_h, path)
|
|
69
|
+
else
|
|
70
|
+
raise ArgumentError, <<~MSG.strip
|
|
71
|
+
#{join(path)} is a #{value.class}, which is not JSON-serializable.
|
|
72
|
+
|
|
73
|
+
Convert it first — a serializer, a class that defines #as_json, or a plain
|
|
74
|
+
Hash of strings and numbers. Passing it through would put an empty object
|
|
75
|
+
or an inspect string into your document.
|
|
76
|
+
MSG
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# True when the object's class defines as_json itself, rather than picking
|
|
81
|
+
# up ActiveSupport's Object-level fallback.
|
|
82
|
+
def deliberate_as_json?(value)
|
|
83
|
+
return false unless value.respond_to?(:as_json)
|
|
84
|
+
|
|
85
|
+
!GENERIC_AS_JSON_OWNERS.include?(value.method(:as_json).owner)
|
|
86
|
+
rescue NameError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def deliberate_to_h?(value)
|
|
91
|
+
return false unless value.respond_to?(:to_h)
|
|
92
|
+
|
|
93
|
+
!GENERIC_AS_JSON_OWNERS.include?(value.method(:to_h).owner)
|
|
94
|
+
rescue NameError
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ["data", ".line_items", "[0]", ".amount"] => data.line_items[0].amount
|
|
99
|
+
def join(path)
|
|
100
|
+
path.join
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typstify
|
|
4
|
+
# The pipeline: resolve a template, build an isolated workspace, check fonts,
|
|
5
|
+
# compile, hand back bytes. Everything public in this gem funnels through here.
|
|
6
|
+
class Document
|
|
7
|
+
def initialize(template:, data: nil, assigns: {}, config: Typstify.config)
|
|
8
|
+
@template = template
|
|
9
|
+
@data = data
|
|
10
|
+
@assigns = assigns
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [String] PDF bytes, ASCII-8BIT
|
|
15
|
+
def to_pdf
|
|
16
|
+
# Validate first: a bad value should fail before we create a workspace,
|
|
17
|
+
# let alone start the compiler.
|
|
18
|
+
payload = @data.nil? ? nil : Data.dump(@data)
|
|
19
|
+
resolution = Resolver.new(@config).call(@template)
|
|
20
|
+
source = read_source(resolution)
|
|
21
|
+
|
|
22
|
+
Workspace.build(source: source, data: payload, config: @config) do |workspace|
|
|
23
|
+
check_fonts(workspace)
|
|
24
|
+
Adapter.compile_pdf(
|
|
25
|
+
main_path: workspace.main_path,
|
|
26
|
+
root: workspace.dir,
|
|
27
|
+
template: resolution.name,
|
|
28
|
+
config: @config
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def read_source(resolution)
|
|
36
|
+
# Explicit UTF-8: templates contain typographic characters, and a
|
|
37
|
+
# container with no LANG set defaults to US-ASCII, which would turn an
|
|
38
|
+
# em dash into an encoding error at render time.
|
|
39
|
+
raw = File.read(resolution.path, encoding: Encoding::UTF_8)
|
|
40
|
+
return raw unless resolution.erb?
|
|
41
|
+
|
|
42
|
+
ErbPipeline.render(raw, data: @data, assigns: @assigns)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stands in for the compiler warning we cannot see yet. Checks the main
|
|
46
|
+
# template and everything copied alongside it, because a missing family is
|
|
47
|
+
# just as likely to be declared in shared/branding.typ.
|
|
48
|
+
def check_fonts(workspace)
|
|
49
|
+
sources = [workspace.main_path, *Dir.glob(workspace.dir.join("**", "*.typ"))]
|
|
50
|
+
combined = sources.uniq.map { |path| File.read(path, encoding: Encoding::UTF_8) }.join("\n")
|
|
51
|
+
include_system = !@config.ignore_system_fonts
|
|
52
|
+
missing = Fonts.missing(combined, @config.font_paths, include_system: include_system)
|
|
53
|
+
return if missing.empty?
|
|
54
|
+
|
|
55
|
+
raise FontMissingError.new(missing, Fonts.search_paths(@config.font_paths)) if @config.strict_fonts
|
|
56
|
+
|
|
57
|
+
@config.on_warning&.call(
|
|
58
|
+
missing.map { |family| "unknown font family: #{family.inspect} (substituted)" },
|
|
59
|
+
@template
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Typstify
|
|
6
|
+
# Wires the gem into a Rails application: the `pdf` renderer, sensible
|
|
7
|
+
# defaults taken from the app, and the rake tasks.
|
|
8
|
+
class Engine < ::Rails::Engine
|
|
9
|
+
isolate_namespace Typstify
|
|
10
|
+
|
|
11
|
+
# Generators live in this gem, not the host app.
|
|
12
|
+
config.app_generators.templates.unshift(File.expand_path("../generators", __dir__))
|
|
13
|
+
|
|
14
|
+
initializer "typstify.renderer" do
|
|
15
|
+
ActiveSupport.on_load(:action_controller) do
|
|
16
|
+
require "typstify/renderer"
|
|
17
|
+
Typstify::Renderer.install!
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
initializer "typstify.defaults" do |app|
|
|
22
|
+
unless Typstify.config.instance_variable_get(:@template_root)
|
|
23
|
+
Typstify.config.template_root = app.root.join("app/views")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Applied after the host's initializers have had their say, so a
|
|
28
|
+
# package_cache set in config/initializers/typstify.rb is honoured.
|
|
29
|
+
config.after_initialize do
|
|
30
|
+
Typstify.apply_package_cache!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# No `rake_tasks do load … end` here: Rails::Engine already loads every
|
|
34
|
+
# .rake file under the engine's lib/tasks. Loading it again re-opens the
|
|
35
|
+
# task and Rake *appends* the second body, so `typstify:preview` would
|
|
36
|
+
# render — and announce — the same PDF twice.
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Typstify
|
|
6
|
+
# ERB mode: `.typ.erb` templates are ordinary ERB whose *output* is Typst
|
|
7
|
+
# source. Provided for teams migrating from wicked_pdf who want their existing
|
|
8
|
+
# view habits; data mode is the one to reach for.
|
|
9
|
+
#
|
|
10
|
+
# The distinction that matters: in data mode a user string is data all the way
|
|
11
|
+
# down and cannot change the document. Here it is spliced into source, so
|
|
12
|
+
# every dynamic value must go through `typ()`. That is not a style preference
|
|
13
|
+
# — an unescaped value is code injection.
|
|
14
|
+
module ErbPipeline
|
|
15
|
+
# The object templates are evaluated against. Deliberately small: `typ`,
|
|
16
|
+
# `data`, and whatever instance variables the controller had.
|
|
17
|
+
class Context
|
|
18
|
+
def initialize(data:, assigns: {})
|
|
19
|
+
@data = data
|
|
20
|
+
assigns.each { |name, value| instance_variable_set(:"@#{name}", value) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The data hash passed to `render pdf:` / `Typstify.render`.
|
|
24
|
+
attr_reader :data
|
|
25
|
+
|
|
26
|
+
# Escape a value so Typst renders it literally. See Typstify::Escaping.
|
|
27
|
+
def typ(value)
|
|
28
|
+
Escaping.typ(value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def template_binding
|
|
32
|
+
binding
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @return [String] Typst source
|
|
39
|
+
def render(source, data: nil, assigns: {})
|
|
40
|
+
context = Context.new(data: data, assigns: assigns)
|
|
41
|
+
ERB.new(source, trim_mode: "-").result(context.template_binding)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typstify
|
|
4
|
+
# Base class for everything this gem raises. Rescue this to catch any
|
|
5
|
+
# Typstify failure without also swallowing unrelated errors.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# The template could not be found as either `.typ` or `.typ.erb`.
|
|
9
|
+
class MissingTemplate < Error
|
|
10
|
+
attr_reader :template, :tried
|
|
11
|
+
|
|
12
|
+
def initialize(template, tried)
|
|
13
|
+
@template = template
|
|
14
|
+
@tried = tried
|
|
15
|
+
super(<<~MSG.strip)
|
|
16
|
+
Could not find a Typst template for #{template.inspect}.
|
|
17
|
+
|
|
18
|
+
Tried:
|
|
19
|
+
#{tried.map { |p| " - #{p}" }.join("\n")}
|
|
20
|
+
|
|
21
|
+
Templates live under Typstify.config.template_root
|
|
22
|
+
(#{Typstify.config.template_root}).
|
|
23
|
+
MSG
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A template path resolved outside `template_root`, or a workspace copy tried
|
|
28
|
+
# to escape its root. Always a bug or an attack; never a normal condition.
|
|
29
|
+
class PathError < Error; end
|
|
30
|
+
|
|
31
|
+
# The Typst compiler rejected the document. Carries the compiler's own
|
|
32
|
+
# annotated diagnostic, which points at the offending line.
|
|
33
|
+
class CompileError < Error
|
|
34
|
+
attr_reader :template, :typst_message, :line, :column
|
|
35
|
+
|
|
36
|
+
def initialize(template:, typst_message:, line: nil, column: nil)
|
|
37
|
+
@template = template
|
|
38
|
+
@typst_message = typst_message
|
|
39
|
+
@line = line
|
|
40
|
+
@column = column
|
|
41
|
+
location = line ? " (line #{line}#{", column #{column}" if column})" : ""
|
|
42
|
+
super("Typst failed to compile #{template}#{location}:\n\n#{typst_message}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A font family a template asks for is not installed and is not in any
|
|
47
|
+
# configured `font_paths`. Raised only when `strict_fonts` is on.
|
|
48
|
+
class FontMissingError < Error
|
|
49
|
+
attr_reader :families
|
|
50
|
+
|
|
51
|
+
def initialize(families, searched)
|
|
52
|
+
@families = families
|
|
53
|
+
super(<<~MSG.strip)
|
|
54
|
+
Missing font #{families.size == 1 ? "family" : "families"}: #{families.map(&:inspect).join(", ")}
|
|
55
|
+
|
|
56
|
+
Typst would silently substitute a different face, which changes your
|
|
57
|
+
layout without telling you. Vendor the font and add its directory:
|
|
58
|
+
|
|
59
|
+
Typstify.configure { |c| c.font_paths = [Rails.root.join("app/assets/fonts")] }
|
|
60
|
+
|
|
61
|
+
Searched:
|
|
62
|
+
#{searched.map { |p| " - #{p}" }.join("\n")}
|
|
63
|
+
|
|
64
|
+
Set `c.strict_fonts = false` to downgrade this to a warning.
|
|
65
|
+
MSG
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typstify
|
|
4
|
+
# Escaping for ERB mode (`.typ.erb`).
|
|
5
|
+
#
|
|
6
|
+
# In data mode this file is irrelevant: values arrive as JSON and Typst reads
|
|
7
|
+
# them as strings, so they can never become code. ERB mode splices Ruby
|
|
8
|
+
# strings straight into Typst *source*, which means an unescaped value is
|
|
9
|
+
# code injection — Typst can read files and run script inside its root.
|
|
10
|
+
#
|
|
11
|
+
# `typ()` renders any value as literal text by backslash-escaping every
|
|
12
|
+
# character Typst treats as markup.
|
|
13
|
+
module Escaping
|
|
14
|
+
# Order matters only for the backslash, which has to go first so the
|
|
15
|
+
# backslashes introduced below are not escaped a second time.
|
|
16
|
+
SIGNIFICANT = [
|
|
17
|
+
"\\",
|
|
18
|
+
"#", # code / function call
|
|
19
|
+
"*", # strong
|
|
20
|
+
"_", # emphasis
|
|
21
|
+
"`", # raw
|
|
22
|
+
"$", # math
|
|
23
|
+
"@", # reference / package
|
|
24
|
+
"<", # label open
|
|
25
|
+
">", # label close
|
|
26
|
+
"[", # content block open
|
|
27
|
+
"]", # content block close
|
|
28
|
+
'"' # string delimiter in code context
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
PATTERN = Regexp.union(SIGNIFICANT).freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Escape a value so Typst renders it as literal text.
|
|
36
|
+
#
|
|
37
|
+
# typ('#read("/etc/passwd")') # => '\#read\("/etc/passwd"\)' — printed, not run
|
|
38
|
+
#
|
|
39
|
+
# @param value [Object] anything; converted with #to_s
|
|
40
|
+
# @return [String] Typst source that renders as the original text
|
|
41
|
+
def typ(value)
|
|
42
|
+
value.to_s.gsub(PATTERN) { |char| "\\#{char}" }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|