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.
@@ -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