typst-rails 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,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "json"
5
+ require "typst_rails/helpers"
6
+ require "typst_rails/backends"
7
+
8
+ # Only require ActiveSupport extensions if available
9
+ begin
10
+ require "active_support/core_ext/hash/keys" # for symbolize_keys
11
+ require "active_support/json" # for ActiveSupport::JSON, used by to_json below
12
+ require "active_support/core_ext/object/json" # for as_json
13
+ rescue LoadError
14
+ # ActiveSupport not available, define minimal compatibility shims
15
+ class Hash
16
+ unless method_defined?(:symbolize_keys)
17
+ def symbolize_keys
18
+ transform_keys(&:to_sym)
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ module TypstRails
25
+ # The Renderer class is responsible for taking Typst source code,
26
+ # compiling it using the Typst CLI, and returning the resulting PDF data.
27
+ # It can optionally use data passed from a view context (instance variables and locals)
28
+ # to make them available to the Typst template, typically via a temporary JSON file.
29
+ # This class works independently of any web framework.
30
+ #
31
+ # @example Basic standalone usage
32
+ # typst_source = "#let data = json(\"typst_data.json\")\n= #data.title"
33
+ # renderer = TypstRails::Renderer.new(typst_source)
34
+ # pdf_data = renderer.render(nil, { title: "My Document" })
35
+ # File.binwrite("output.pdf", pdf_data)
36
+ #
37
+ # @example With view context (Rails)
38
+ # # In a Rails controller:
39
+ # # @title = "Monthly Report"
40
+ # # render template: "reports/monthly" # Uses .typ template file
41
+ #
42
+ # Key features:
43
+ # - Automatic data serialization to JSON for Typst templates
44
+ # - Secure temporary file handling with proper cleanup
45
+ # - Integration with view contexts for framework support
46
+ # - Comprehensive error handling and logging
47
+ # - Input validation and size limits for security
48
+ class Renderer
49
+ include Helpers
50
+
51
+ # MARK: - Constants
52
+
53
+ # Maximum source size (10MB) to prevent memory issues
54
+ MAX_SOURCE_SIZE = 10 * 1024 * 1024
55
+
56
+ # Holds the filesystem paths used during a single compilation run.
57
+ TempPaths = Struct.new(:typ_file, :dir, :data_file_path)
58
+
59
+ # MARK: - Attributes
60
+
61
+ # @return [String] The Typst template source code
62
+ attr_reader :source
63
+
64
+ # @return [Object, nil] The view context providing access to helpers and instance variables
65
+ attr_reader :view_context
66
+
67
+ # MARK: - Initialization
68
+
69
+ # Initializes the renderer with the Typst template source.
70
+ #
71
+ # @param source [String] The Typst template source code
72
+ # @return [Renderer] A new renderer instance
73
+ # @raise [ArgumentError] if source is nil
74
+ # @raise [ArgumentError] if source is too large (exceeds MAX_SOURCE_SIZE)
75
+ #
76
+ # @example
77
+ # renderer = Renderer.new("= Hello, Typst!")
78
+ def initialize(source)
79
+ raise ArgumentError, "Source cannot be nil" if source.nil?
80
+
81
+ @source = source.to_s
82
+
83
+ return unless @source.bytesize > MAX_SOURCE_SIZE
84
+
85
+ raise ArgumentError, "Source is too large (#{@source.bytesize} bytes, max #{MAX_SOURCE_SIZE} bytes)"
86
+ end
87
+
88
+ # MARK: - Public Methods
89
+
90
+ # Renders the Typst template to PDF.
91
+ #
92
+ # This method compiles the Typst source code to PDF, optionally injecting data
93
+ # from a view context and local variables. The data is serialized to JSON and
94
+ # made available to the Typst template via a temporary JSON file.
95
+ #
96
+ # @param view_context [Object, nil] Optional view context providing access to helpers and instance variables
97
+ # @param local_assigns [Hash] A hash of local variables available in the template
98
+ # @return [String] Binary string containing the PDF data
99
+ # @raise [ArgumentError] if local_assigns is not a Hash
100
+ # @raise [ArgumentError] if source is empty
101
+ # @raise [TypstRails::Error] if Typst compilation fails
102
+ #
103
+ # @example Render with plain data
104
+ # renderer = Renderer.new("= #data.title")
105
+ # pdf = renderer.render(nil, { title: "Report" })
106
+ #
107
+ # @example Render with view context (Rails)
108
+ # renderer = Renderer.new(typst_source)
109
+ # pdf = renderer.render(view_context, { extra_data: "value" })
110
+ def render(view_context = nil, local_assigns = {})
111
+ raise ArgumentError, "local_assigns must be a Hash" unless local_assigns.is_a?(Hash)
112
+ raise ArgumentError, "Source is empty" if @source.empty?
113
+
114
+ @view_context = view_context
115
+
116
+ data_for_typst = if view_context
117
+ collect_data_for_typst(view_context, local_assigns)
118
+ else
119
+ local_assigns
120
+ end
121
+
122
+ compile_typst_source(@source, data_for_typst)
123
+ end
124
+
125
+ # MARK: - Private Methods
126
+
127
+ private
128
+
129
+ # Compiles the Typst source code to PDF, optionally injecting data.
130
+ #
131
+ # This method handles the complete compilation workflow:
132
+ # 1. Creates temporary files for Typst source and data
133
+ # 2. Writes data to JSON file for Typst template access
134
+ # 3. Executes the Typst compiler
135
+ # 4. Reads and returns the generated PDF
136
+ # 5. Cleans up all temporary files
137
+ #
138
+ # @param typst_source [String] The Typst template source
139
+ # @param data [Hash] Data to be made available to the Typst template via JSON
140
+ # @return [String] Binary PDF data
141
+ # @raise [ArgumentError] if typst_source is empty or data is not a Hash
142
+ # @raise [TypstRails::Error] if compilation fails or produces no output
143
+ #
144
+ # @note This method ensures all temporary files are cleaned up even if errors occur
145
+ def compile_typst_source(typst_source, data = {})
146
+ raise ArgumentError, "typst_source cannot be empty" if typst_source.nil? || typst_source.empty?
147
+ raise ArgumentError, "data must be a Hash" unless data.is_a?(Hash)
148
+
149
+ paths = TempPaths.new
150
+ begin
151
+ write_typst_source_file(paths, typst_source)
152
+ write_typst_data_file(paths, data)
153
+ run_typst_compiler(paths)
154
+ rescue Error
155
+ raise
156
+ rescue StandardError => e
157
+ error_message = "Unexpected error during Typst compilation: #{e.class} - #{e.message}"
158
+ log_error(error_message)
159
+ raise Error, error_message
160
+ ensure
161
+ cleanup_temp_paths(paths)
162
+ end
163
+ end
164
+
165
+ def write_typst_source_file(paths, typst_source)
166
+ # Create temp file in a directory that Typst can access.
167
+ # Ensure it's in binary mode for writing source if it contains non-ASCII.
168
+ paths.typ_file = Tempfile.new(["durable_typst_template_", ".typ"], binmode: true)
169
+ paths.dir = File.dirname(paths.typ_file.path)
170
+
171
+ paths.typ_file.write(typst_source)
172
+ paths.typ_file.flush # Ensure content is written before Typst reads it.
173
+ paths.typ_file.close # Close it so Typst can open it, especially important on Windows.
174
+ end
175
+
176
+ def write_typst_data_file(paths, data)
177
+ return if data.nil? || data.empty?
178
+
179
+ # Create a temporary JSON data file in the same directory as the Typst source.
180
+ # The Typst template would then use `json("typst_data.json")` to load this data.
181
+ paths.data_file_path = File.join(paths.dir, "typst_data.json")
182
+ File.write(paths.data_file_path, data.to_json)
183
+ rescue JSON::GeneratorError => e
184
+ raise Error, "Failed to serialize data to JSON: #{e.message}"
185
+ rescue StandardError => e
186
+ raise Error, "Failed to write data file: #{e.message}"
187
+ end
188
+
189
+ def run_typst_compiler(paths)
190
+ backend = Backends::Registry.resolve(configured_backend)
191
+ backend.compile(paths.typ_file.path, paths.dir)
192
+ rescue Error => e
193
+ log_error(e.message)
194
+ raise
195
+ end
196
+
197
+ # @return [Symbol, Backends::Base, nil] the backend preference from
198
+ # TypstRails.configuration, or nil if the top-level TypstRails module
199
+ # (and its Configuration) hasn't been loaded (e.g. when only
200
+ # `typst_rails/renderer` is required directly).
201
+ def configured_backend
202
+ return nil unless TypstRails.respond_to?(:configuration)
203
+
204
+ TypstRails.configuration&.backend
205
+ end
206
+
207
+ def cleanup_temp_paths(paths)
208
+ # Clean up all temporary files, suppressing any errors during cleanup
209
+ rescue_cleanup_errors("temp Typst file") do
210
+ paths.typ_file.unlink if paths.typ_file&.path && File.exist?(paths.typ_file.path)
211
+ end
212
+ rescue_cleanup_errors("temp data file") { safe_unlink(paths.data_file_path) }
213
+ end
214
+
215
+ def safe_unlink(path)
216
+ File.unlink(path) if path && File.exist?(path)
217
+ end
218
+
219
+ def rescue_cleanup_errors(description)
220
+ yield
221
+ rescue StandardError => e
222
+ warn "Failed to clean up #{description}: #{e.message}"
223
+ end
224
+
225
+ # Collects data from view_context (instance variables) and local_assigns.
226
+ #
227
+ # This method extracts instance variables from the view context (if available via
228
+ # `assigns`) and merges them with local variables. The result is a hash suitable
229
+ # for JSON serialization and injection into Typst templates.
230
+ #
231
+ # @param view_context [Object] The view context (typically from Rails/Sinatra)
232
+ # @param local_assigns [Hash] Local variables passed to the template
233
+ # @return [Hash] A hash of data suitable for JSON serialization, with symbolized keys
234
+ # @raise [ArgumentError] if local_assigns is not a Hash
235
+ # @raise [TypstRails::Error] if data collection fails
236
+ #
237
+ # @note Instance variables from view_context take precedence over local_assigns
238
+ # @note All values are transformed for JSON serialization (dates to ISO8601, etc.)
239
+ def collect_data_for_typst(view_context, local_assigns)
240
+ raise ArgumentError, "local_assigns must be a Hash" unless local_assigns.is_a?(Hash)
241
+
242
+ data = {}
243
+
244
+ # Instance variables from the controller are available in view_context.assigns.
245
+ if view_context.respond_to?(:assigns)
246
+ assigns = view_context.assigns
247
+ if assigns.is_a?(Hash)
248
+ assigns.each do |key, value|
249
+ data[key.to_sym] = value # Convert keys to symbols for consistency.
250
+ end
251
+ end
252
+ end
253
+
254
+ # Local assigns (e.g., from `render locals: {...}`) merge in, taking precedence.
255
+ data.merge!(local_assigns.symbolize_keys)
256
+
257
+ # Transform values to ensure they are JSON-serializable.
258
+ data.transform_values do |value|
259
+ transform_value_for_json_serialization(value)
260
+ end
261
+ rescue ArgumentError
262
+ raise
263
+ rescue StandardError => e
264
+ raise Error, "Failed to collect data for Typst template: #{e.message}"
265
+ end
266
+
267
+ # Transforms various Ruby objects into JSON-friendly representations.
268
+ #
269
+ # This method recursively transforms Ruby objects to ensure they can be
270
+ # serialized to JSON for Typst template consumption. It handles:
271
+ # - ActiveRecord objects (via as_json)
272
+ # - Date/Time objects (to ISO8601 strings)
273
+ # - Arrays (recursively transform elements)
274
+ # - Hashes (recursively transform values)
275
+ #
276
+ # @param value [Object] The value to transform
277
+ # @return [Object] A JSON-friendly representation of the value
278
+ #
279
+ # @example Transforming a date
280
+ # transform_value_for_json_serialization(Date.today)
281
+ # # => "2024-01-15"
282
+ #
283
+ # @example Transforming an array
284
+ # transform_value_for_json_serialization([Date.today, "text"])
285
+ # # => ["2024-01-15", "text"]
286
+ def transform_value_for_json_serialization(value)
287
+ if active_record_value?(value)
288
+ value.as_json # Use Rails' built-in JSON serialization for ActiveRecord objects.
289
+ elsif value.is_a?(Date) || value.is_a?(Time) || value.is_a?(DateTime)
290
+ value.iso8601 # Convert date/time objects to ISO8601 strings.
291
+ elsif value.is_a?(Array)
292
+ value.map { |v| transform_value_for_json_serialization(v) } # Recursively transform array elements.
293
+ elsif value.is_a?(Hash)
294
+ # Recursively transform hash values.
295
+ value.transform_values do |v_hash|
296
+ transform_value_for_json_serialization(v_hash)
297
+ end
298
+ else
299
+ value # Return other types as-is, assuming they are JSON-serializable.
300
+ end
301
+ end
302
+
303
+ # @return [Boolean] whether +value+ is an ActiveRecord model or relation
304
+ def active_record_value?(value)
305
+ return false unless defined?(::ActiveRecord::Base)
306
+
307
+ value.is_a?(::ActiveRecord::Base) ||
308
+ (defined?(::ActiveRecord::Relation) && value.is_a?(::ActiveRecord::Relation))
309
+ end
310
+
311
+ # Logs an error message to the appropriate logger.
312
+ #
313
+ # Attempts to use Rails.logger if available, otherwise falls back to warn.
314
+ # Handles logging failures gracefully.
315
+ #
316
+ # @param message [String] The error message to log
317
+ # @return [void]
318
+ #
319
+ # @note Prefixes all messages with "TypstRails:" for easy identification
320
+ # @note Never raises exceptions, even if logging fails
321
+ def log_error(message)
322
+ return if message.nil? || message.empty?
323
+
324
+ formatted_message = "TypstRails: #{message}"
325
+
326
+ begin
327
+ if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
328
+ ::Rails.logger.error formatted_message
329
+ else
330
+ warn formatted_message
331
+ end
332
+ rescue StandardError
333
+ # Fallback to warn if logging fails
334
+ warn formatted_message
335
+ end
336
+ end
337
+
338
+ # Delegates missing methods to the view_context.
339
+ #
340
+ # This allows the renderer to access framework helpers from the view context
341
+ # (e.g., Rails helpers like `link_to`, `image_tag`, etc.) when preparing data.
342
+ #
343
+ # @param method_name [Symbol] The name of the missing method
344
+ # @param args [Array] Arguments to pass to the method
345
+ # @param block [Proc] Block to pass to the method
346
+ # @return [Object] The result of calling the method on view_context
347
+ # @raise [NoMethodError] if neither the renderer nor view_context respond to the method
348
+ #
349
+ # @see #respond_to_missing?
350
+ def method_missing(method_name, *args, &block)
351
+ if @view_context.respond_to?(method_name)
352
+ @view_context.public_send(method_name, *args, &block)
353
+ else
354
+ super
355
+ end
356
+ end
357
+
358
+ # Complements method_missing for `respond_to?` checks.
359
+ #
360
+ # Ensures that `respond_to?` correctly reports whether the renderer can respond
361
+ # to a method, either directly or via delegation to the view_context.
362
+ #
363
+ # @param method_name [Symbol] The name of the method to check
364
+ # @param include_private [Boolean] Whether to include private methods in the check
365
+ # @return [Boolean] true if the method can be responded to, false otherwise
366
+ #
367
+ # @see #method_missing
368
+ def respond_to_missing?(method_name, include_private = false)
369
+ @view_context.respond_to?(method_name, include_private) || super
370
+ end
371
+ end
372
+
373
+ # Error class for Typst-related errors.
374
+ #
375
+ # Raised when Typst compilation fails, when files cannot be created,
376
+ # or when other Typst-specific errors occur during rendering.
377
+ #
378
+ # @example Handling Typst errors
379
+ # begin
380
+ # renderer.render(nil, data)
381
+ # rescue TypstRails::Error => e
382
+ # Rails.logger.error "Typst compilation failed: #{e.message}"
383
+ # render plain: "PDF generation failed", status: 500
384
+ # end
385
+ class Error < StandardError; end
386
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypstRails
4
+ # Integration for Sinatra framework
5
+ module SinatraIntegration
6
+ # Simple view context wrapper providing the `assigns` reader that
7
+ # Renderer expects, mirroring Rails' view context interface.
8
+ ViewContext = Struct.new(:assigns)
9
+
10
+ def self.setup
11
+ return unless defined?(::Sinatra)
12
+
13
+ require "typst_rails/renderer"
14
+
15
+ # Extend Sinatra with Typst rendering capabilities
16
+ ::Sinatra::Base.class_eval do
17
+ # Helper method to render Typst templates in Sinatra
18
+ def typst(template, locals = {}, _options = {})
19
+ typst_source = File.read(template)
20
+ renderer = ::TypstRails::Renderer.new(typst_source)
21
+
22
+ # Sinatra doesn't have the same view context as Rails,
23
+ # so we create a simple wrapper
24
+ view_context = ::TypstRails::SinatraIntegration::ViewContext.new(locals)
25
+ pdf_data = renderer.render(view_context, locals)
26
+
27
+ content_type "application/pdf"
28
+ pdf_data
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypstRails
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "typst_rails/version"
4
+ require "typst_rails/backends"
5
+ require "typst_rails/renderer"
6
+ require "typst_rails/framework_detection"
7
+
8
+ # Conditionally load framework integrations
9
+ require "typst_rails/railtie" if TypstRails::FrameworkDetection.rails?
10
+
11
+ if TypstRails::FrameworkDetection.rage?
12
+ require "typst_rails/rage_integration"
13
+ TypstRails::RageIntegration.setup
14
+ end
15
+
16
+ if TypstRails::FrameworkDetection.sinatra?
17
+ require "typst_rails/sinatra_integration"
18
+ TypstRails::SinatraIntegration.setup
19
+ end
20
+
21
+ # Rails integration module for Typst document generation
22
+ #
23
+ # This module provides seamless integration of the Typst typesetting system
24
+ # with Ruby web applications. It supports Rails, Rage, Sinatra, and standalone
25
+ # Ruby usage through automatic framework detection.
26
+ #
27
+ # @example Basic configuration
28
+ # TypstRails.configure do |config|
29
+ # config.typst_executable_path = "/usr/local/bin/typst"
30
+ # config.default_root_path = Rails.root.join("app", "assets", "typst")
31
+ # end
32
+ #
33
+ # @example Standalone usage
34
+ # renderer = TypstRails::Renderer.new("#let data = json(\"typst_data.json\")\n= #data.title")
35
+ # pdf = renderer.render(nil, { title: "My Document" })
36
+ # File.binwrite("output.pdf", pdf)
37
+ module TypstRails
38
+ class << self
39
+ # @return [Configuration] The current configuration instance
40
+ attr_accessor :configuration
41
+ end
42
+
43
+ # Configures the Typst integration.
44
+ #
45
+ # This method creates a new Configuration with default values and yields
46
+ # it for modification. Each call replaces any previous configuration.
47
+ #
48
+ # @yield [configuration] Yields the configuration object for modification
49
+ # @yieldparam configuration [Configuration] The configuration instance to modify
50
+ # @return [Configuration] The configured instance
51
+ # @raise [ArgumentError] if no block is given
52
+ #
53
+ # @example Configure Typst executable path
54
+ # TypstRails.configure do |config|
55
+ # config.typst_executable_path = "/opt/typst/bin/typst"
56
+ # end
57
+ #
58
+ # @example Configure default root path
59
+ # TypstRails.configure do |config|
60
+ # config.default_root_path = Rails.root.join("app", "typst")
61
+ # end
62
+ #
63
+ # @example Force a specific compilation backend
64
+ # TypstRails.configure do |config|
65
+ # config.backend = :gem # or :cli
66
+ # end
67
+ def self.configure
68
+ raise ArgumentError, "Block required for configuration" unless block_given?
69
+
70
+ self.configuration = Configuration.new
71
+ yield(configuration)
72
+ configuration.validate!
73
+ configuration
74
+ end
75
+
76
+ # Configuration class for Typst rendering options.
77
+ #
78
+ # This class holds configuration values that control how Typst documents
79
+ # are compiled and rendered. It provides validation to ensure configuration
80
+ # values are valid before use.
81
+ #
82
+ # @example Create and configure
83
+ # config = TypstRails::Configuration.new
84
+ # config.typst_executable_path = "/usr/local/bin/typst"
85
+ # config.validate! # Ensures configuration is valid
86
+ class Configuration
87
+ # @return [String] Path to the Typst executable (defaults to "typst" in PATH)
88
+ attr_accessor :typst_executable_path
89
+
90
+ # @return [String, nil] Default root path for Typst template resolution
91
+ attr_accessor :default_root_path
92
+
93
+ # @return [Symbol, TypstRails::Backends::Base] Which compilation backend to use.
94
+ # `:auto` (the default) prefers the `typst` gem when installed, otherwise falls
95
+ # back to shelling out to the `typst` CLI. Set to `:gem` or `:cli` to force a
96
+ # specific built-in backend, or assign a custom backend instance registered
97
+ # via {TypstRails::Backends::Registry.register}.
98
+ attr_accessor :backend
99
+
100
+ # Initializes a new Configuration with default values.
101
+ #
102
+ # Default values:
103
+ # - `typst_executable_path`: "typst" (assumes typst is in PATH)
104
+ # - `default_root_path`: nil (uses temporary directory)
105
+ # - `backend`: `:auto` (prefer the `typst` gem, fall back to the CLI)
106
+ #
107
+ # @return [Configuration] A new configuration instance
108
+ #
109
+ # @example Create configuration with defaults
110
+ # config = TypstRails::Configuration.new
111
+ # config.typst_executable_path #=> "typst"
112
+ # config.default_root_path #=> nil
113
+ # config.backend #=> :auto
114
+ def initialize
115
+ @typst_executable_path = "typst"
116
+ @default_root_path = nil
117
+ @backend = :auto
118
+ end
119
+
120
+ # Validates the configuration settings.
121
+ #
122
+ # Ensures that:
123
+ # - typst_executable_path is a non-empty string
124
+ # - default_root_path (if set) is a valid directory path
125
+ #
126
+ # @return [true] if configuration is valid
127
+ # @raise [ArgumentError] if typst_executable_path is invalid
128
+ # @raise [ArgumentError] if default_root_path is invalid
129
+ #
130
+ # @example Validate configuration
131
+ # config = Configuration.new
132
+ # config.typst_executable_path = ""
133
+ # config.validate! # Raises ArgumentError
134
+ def validate! # rubocop:disable Naming/PredicateMethod -- bang method raises, doesn't predicate
135
+ raise ArgumentError, "typst_executable_path must be a String" unless typst_executable_path.is_a?(String)
136
+
137
+ raise ArgumentError, "typst_executable_path cannot be nil or empty" if typst_executable_path.empty?
138
+
139
+ if default_root_path && !default_root_path.is_a?(String)
140
+ raise ArgumentError, "default_root_path must be a String or nil"
141
+ end
142
+
143
+ true
144
+ end
145
+ end
146
+ end