oicana 0.10.0.rc.1

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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oicana
4
+ # @api private
5
+ # Frees a generated object on #close, once no call is using it anymore. Objects that
6
+ # are never closed are freed by the garbage collector.
7
+ class NativeHandle
8
+ def initialize(object, free_function, name)
9
+ @object = object
10
+ @free_function = free_function
11
+ @name = name
12
+ @lock = Mutex.new
13
+ @calls = 0
14
+ @closed = false
15
+ end
16
+
17
+ def use
18
+ @lock.synchronize do
19
+ raise ClosedError, "#{@name} is already closed" if @closed
20
+
21
+ @calls += 1
22
+ end
23
+ begin
24
+ Oicana.translate_errors { yield @object }
25
+ ensure
26
+ @lock.synchronize do
27
+ @calls -= 1
28
+ free if @closed && @calls.zero?
29
+ end
30
+ end
31
+ end
32
+
33
+ def close
34
+ @lock.synchronize do
35
+ next if @closed
36
+
37
+ @closed = true
38
+ free if @calls.zero?
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def free
45
+ ObjectSpace.undefine_finalizer(@object)
46
+ OicanaUniffi.rust_call(@free_function, @object.instance_variable_get(:@handle))
47
+ @object = nil
48
+ end
49
+ end
50
+
51
+ # A packed Oicana template, ready to be compiled repeatedly.
52
+ #
53
+ # Create templates at startup, not per request. A template is safe to use from
54
+ # multiple threads, and native calls release the GVL.
55
+ #
56
+ # Oicana::Template.open(File.binread("invoice-0.1.0.zip")) do |template|
57
+ # pdf = template.export_pdf(json_inputs: { "invoice" => { "number" => "2026-001" } })
58
+ # end
59
+ class Template
60
+ # Warnings of the warm-up compilation, or nil.
61
+ attr_reader :warnings
62
+
63
+ # Load a packed template and compile it once to warm up its caches.
64
+ #
65
+ # json_inputs map keys to JSON documents as Strings, or to values that are encoded
66
+ # with JSON.generate. blob_inputs map keys to BlobInput values or binary Strings.
67
+ def initialize(template, json_inputs: {}, blob_inputs: {}, mode: :development, limits: nil)
68
+ object = Oicana.translate_errors do
69
+ OicanaUniffi::Template.new(template.b, Convert.json_inputs(json_inputs), Convert.blob_inputs(blob_inputs),
70
+ Convert.mode(mode), Convert.limits(limits))
71
+ end
72
+ @warnings = object.warnings
73
+ @handle = NativeHandle.new(object, :uniffi_oicana_uniffi_fn_free_template, "template")
74
+ end
75
+
76
+ # Create a template, yield it, and close it afterwards.
77
+ def self.open(template, **options)
78
+ instance = new(template, **options)
79
+ return instance unless block_given?
80
+
81
+ begin
82
+ yield instance
83
+ ensure
84
+ instance.close
85
+ end
86
+ end
87
+
88
+ # Compile and export a template in a single native call, without caching anything.
89
+ #
90
+ # Skips the warm-up compilation, so it is the fastest way to render a template
91
+ # exactly once. Returns an ExportOnceResult.
92
+ def self.export_once(template, json_inputs: {}, blob_inputs: {}, format: :pdf, mode: :production,
93
+ pixels_per_pt: 1.0, pages: nil, limits: nil)
94
+ result = Oicana.translate_errors do
95
+ OicanaUniffi.export_once(template.b, Convert.json_inputs(json_inputs), Convert.blob_inputs(blob_inputs),
96
+ Convert.mode(mode), Convert.format(format, pixels_per_pt), Convert.pages(pages),
97
+ Convert.limits(limits))
98
+ end
99
+ ExportOnceResult.new(document: result.bytes, warnings: result.warnings)
100
+ end
101
+
102
+ # Compile the template into a CompiledDocument whose pages can be exported repeatedly.
103
+ def compile(json_inputs: {}, blob_inputs: {}, mode: :production)
104
+ json = Convert.json_inputs(json_inputs)
105
+ blobs = Convert.blob_inputs(blob_inputs)
106
+ native_mode = Convert.mode(mode)
107
+ CompiledDocument.new(@handle.use { |template| template.compile(json, blobs, native_mode) })
108
+ end
109
+
110
+ # Compile the template and export it as :pdf, :png or :svg.
111
+ #
112
+ # pages is a 0-based page index or Range, for example 0, 1..2 or 3.., and defaults to
113
+ # the whole document.
114
+ def export(format: :pdf, json_inputs: {}, blob_inputs: {}, mode: :production, pixels_per_pt: 1.0, pages: nil)
115
+ document = compile(json_inputs: json_inputs, blob_inputs: blob_inputs, mode: mode)
116
+ begin
117
+ document.export(format: format, pixels_per_pt: pixels_per_pt, pages: pages)
118
+ ensure
119
+ document.close
120
+ end
121
+ end
122
+
123
+ # Compile the template and export it to PDF. Tagging is turned off when exporting a
124
+ # subset of pages.
125
+ def export_pdf(json_inputs: {}, blob_inputs: {}, mode: :production, pages: nil)
126
+ export(format: :pdf, json_inputs: json_inputs, blob_inputs: blob_inputs, mode: mode, pages: pages)
127
+ end
128
+
129
+ # Compile the template and export it to PNG. Multiple pages are stacked vertically.
130
+ def export_png(json_inputs: {}, blob_inputs: {}, mode: :production, pixels_per_pt: 1.0, pages: nil)
131
+ export(format: :png, json_inputs: json_inputs, blob_inputs: blob_inputs, mode: mode,
132
+ pixels_per_pt: pixels_per_pt, pages: pages)
133
+ end
134
+
135
+ # Compile the template and export it to SVG.
136
+ def export_svg(json_inputs: {}, blob_inputs: {}, mode: :production, pages: nil)
137
+ export(format: :svg, json_inputs: json_inputs, blob_inputs: blob_inputs, mode: mode, pages: pages)
138
+ end
139
+
140
+ # The template's Manifest.
141
+ def manifest
142
+ Convert.manifest(@handle.use(&:manifest))
143
+ end
144
+
145
+ # The content of a text file inside the template.
146
+ def source(path)
147
+ @handle.use { |template| template.source(path) }
148
+ end
149
+
150
+ # The content of a file inside the template, as a binary String.
151
+ def file(path)
152
+ @handle.use { |template| template.file(path) }
153
+ end
154
+
155
+ # Enable or disable the validation of JSON inputs against their schemas. Validation
156
+ # is enabled by default.
157
+ def validate_inputs=(validate)
158
+ @handle.use { |template| template.set_validate_inputs(validate ? true : false) }
159
+ end
160
+
161
+ # Free the template. Documents compiled from it stay usable.
162
+ def close
163
+ @handle.close
164
+ end
165
+ end
166
+
167
+ # A compiled document that can be exported repeatedly without compiling again.
168
+ class CompiledDocument
169
+ # PageSize of every page, in document order.
170
+ attr_reader :pages
171
+ # Warnings of the compilation, or nil.
172
+ attr_reader :warnings
173
+
174
+ # @api private
175
+ def initialize(object)
176
+ @pages = object.pages.map { |page| PageSize.new(width: page.width, height: page.height) }.freeze
177
+ @warnings = object.warnings
178
+ @handle = NativeHandle.new(object, :uniffi_oicana_uniffi_fn_free_compileddocument, "document")
179
+ end
180
+
181
+ # The number of pages.
182
+ def page_count
183
+ @pages.length
184
+ end
185
+
186
+ # Export the document as :pdf, :png or :svg. See Template#export for pages.
187
+ def export(format: :pdf, pixels_per_pt: 1.0, pages: nil)
188
+ native_format = Convert.format(format, pixels_per_pt)
189
+ native_pages = Convert.pages(pages)
190
+ @handle.use { |document| document.export(native_format, native_pages) }
191
+ end
192
+
193
+ # Export the document to PDF. Tagging is turned off when exporting a subset of pages.
194
+ def export_pdf(pages: nil)
195
+ export(format: :pdf, pages: pages)
196
+ end
197
+
198
+ # Export the document to PNG. Multiple pages are stacked vertically.
199
+ def export_png(pixels_per_pt: 1.0, pages: nil)
200
+ export(format: :png, pixels_per_pt: pixels_per_pt, pages: pages)
201
+ end
202
+
203
+ # Export the document to SVG.
204
+ def export_svg(pages: nil)
205
+ export(format: :svg, pages: pages)
206
+ end
207
+
208
+ # Free the document.
209
+ def close
210
+ @handle.close
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Oicana
6
+ # The value of a blob input. Metadata is passed to the template along with the data
7
+ # and must be a Hash, or nil for none.
8
+ BlobInput = Data.define(:data, :metadata) do
9
+ def initialize(data:, metadata: nil)
10
+ super
11
+ end
12
+ end
13
+
14
+ # Limits for reading a packed template. nil uses the default of Oicana.
15
+ ZipLimits = Data.define(:max_entries, :max_total_decompressed_bytes) do
16
+ def initialize(max_entries: nil, max_total_decompressed_bytes: nil)
17
+ super
18
+ end
19
+ end
20
+
21
+ # The size of a page in points.
22
+ PageSize = Data.define(:width, :height)
23
+
24
+ # A font face registered by the host. path is nil for fonts registered from memory.
25
+ RegisteredFont = Data.define(:family, :path)
26
+
27
+ # The result of Template.export_once. warnings is nil when there are none.
28
+ ExportOnceResult = Data.define(:document, :warnings)
29
+
30
+ # A template's manifest.
31
+ Manifest = Data.define(:package, :oicana)
32
+
33
+ # The Typst package a template is.
34
+ PackageInfo = Data.define(:name, :version, :entrypoint, :authors, :license, :description, :homepage,
35
+ :repository)
36
+
37
+ # The Oicana configuration of a template. inputs holds JsonInputDefinition and
38
+ # BlobInputDefinition values in manifest order.
39
+ OicanaConfig = Data.define(:manifest_version, :inputs, :validate_json_inputs_by_default, :export, :fonts)
40
+
41
+ # An input taking a JSON value. default, development and schema name files in the template.
42
+ JsonInputDefinition = Data.define(:key, :required, :default, :development, :schema, :validate)
43
+
44
+ # An input taking arbitrary bytes. default and development are BlobFallback values or nil.
45
+ BlobInputDefinition = Data.define(:key, :required, :default, :development)
46
+
47
+ # A blob from the template, used when no value is supplied.
48
+ BlobFallback = Data.define(:file, :meta)
49
+
50
+ # How compiled documents are exported.
51
+ ExportConfig = Data.define(:pdf)
52
+
53
+ # How documents are exported to PDF.
54
+ PdfExportConfig = Data.define(:standards, :tagged)
55
+
56
+ # Fonts a template expects from its host.
57
+ FontConfig = Data.define(:require)
58
+
59
+ # @api private
60
+ # Conversions between the public types and the generated bindings.
61
+ module Convert
62
+ MODES = {
63
+ development: OicanaUniffi::CompilationMode::DEVELOPMENT,
64
+ production: OicanaUniffi::CompilationMode::PRODUCTION
65
+ }.freeze
66
+
67
+ module_function
68
+
69
+ def mode(mode)
70
+ MODES.fetch(mode) { raise ArgumentError, "unknown compilation mode #{mode.inspect}, use :development or :production" }
71
+ end
72
+
73
+ def format(format, pixels_per_pt)
74
+ case format
75
+ when :pdf then OicanaUniffi::ExportFormat::PDF.new
76
+ when :svg then OicanaUniffi::ExportFormat::SVG.new
77
+ when :png then OicanaUniffi::ExportFormat::PNG.new(Float(pixels_per_pt))
78
+ else raise ArgumentError, "unknown export format #{format.inspect}, use :pdf, :png or :svg"
79
+ end
80
+ end
81
+
82
+ def json_inputs(inputs)
83
+ inputs.to_h do |key, value|
84
+ [key.to_s, value.is_a?(String) ? utf8(value, key) : JSON.generate(value)]
85
+ end
86
+ end
87
+
88
+ # Binary strings, as read by File.binread or from HTTP bodies, are taken to be UTF-8.
89
+ def utf8(json, key)
90
+ json = json.encoding == Encoding::BINARY ? json.dup.force_encoding(Encoding::UTF_8) : json.encode(Encoding::UTF_8)
91
+ raise InvalidInputError, "the JSON input #{key.to_s.inspect} is not valid UTF-8" unless json.valid_encoding?
92
+
93
+ json
94
+ rescue EncodingError => e
95
+ raise InvalidInputError, "the JSON input #{key.to_s.inspect} cannot be converted to UTF-8: #{e.message}"
96
+ end
97
+
98
+ def blob_inputs(inputs)
99
+ inputs.to_h do |key, value|
100
+ blob = value.is_a?(BlobInput) ? value : BlobInput.new(data: value)
101
+ metadata = blob.metadata.nil? ? "{}" : JSON.generate(blob.metadata)
102
+ [key.to_s, OicanaUniffi::BlobInput.new(data: blob.data.b, metadata: metadata)]
103
+ end
104
+ end
105
+
106
+ def pages(pages)
107
+ return nil if pages.nil?
108
+
109
+ range = pages.is_a?(Integer) ? (pages..pages) : pages
110
+ raise ArgumentError, "pages must be an Integer or a Range, got #{pages.inspect}" unless range.is_a?(Range)
111
+
112
+ first = range.begin
113
+ last = range.end
114
+ last -= 1 if last && range.exclude_end?
115
+ [first, last].compact.each do |index|
116
+ raise InvalidInputError, "page indices must not be negative, got #{pages.inspect}" if index.negative?
117
+ end
118
+ OicanaUniffi::PageRange.new(start: first, _end: last)
119
+ end
120
+
121
+ def limits(limits)
122
+ return nil if limits.nil?
123
+
124
+ OicanaUniffi::ZipLimits.new(max_entries: limits.max_entries,
125
+ max_total_decompressed_bytes: limits.max_total_decompressed_bytes)
126
+ end
127
+
128
+ def manifest(json)
129
+ data = JSON.parse(json)
130
+ package = data.fetch("package")
131
+ oicana = data.fetch("oicana")
132
+ Manifest.new(
133
+ package: PackageInfo.new(**package.slice(*PackageInfo.members.map(&:to_s)).transform_keys(&:to_sym)),
134
+ oicana: OicanaConfig.new(
135
+ manifest_version: oicana.fetch("manifestVersion"),
136
+ inputs: oicana.fetch("inputs").map { |input| input_definition(input) },
137
+ validate_json_inputs_by_default: oicana.fetch("validateJsonInputsByDefault"),
138
+ export: ExportConfig.new(pdf: PdfExportConfig.new(standards: oicana.dig("export", "pdf", "standards"),
139
+ tagged: oicana.dig("export", "pdf", "tagged"))),
140
+ fonts: FontConfig.new(require: oicana.dig("fonts", "require"))
141
+ )
142
+ )
143
+ end
144
+
145
+ def input_definition(input)
146
+ if input.fetch("type") == "json"
147
+ JsonInputDefinition.new(**input.slice(*JsonInputDefinition.members.map(&:to_s)).transform_keys(&:to_sym))
148
+ else
149
+ BlobInputDefinition.new(key: input.fetch("key"), required: input.fetch("required"),
150
+ default: blob_fallback(input["default"]),
151
+ development: blob_fallback(input["development"]))
152
+ end
153
+ end
154
+
155
+ def blob_fallback(fallback)
156
+ fallback && BlobFallback.new(file: fallback.fetch("file"), meta: fallback["meta"])
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oicana
4
+ VERSION = "0.10.0.rc.1"
5
+ end
data/lib/oicana.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "oicana/version"
4
+ require_relative "oicana/native"
5
+ require_relative "oicana/oicana_uniffi"
6
+ require_relative "oicana/errors"
7
+ require_relative "oicana/types"
8
+ require_relative "oicana/template"
9
+
10
+ Oicana::Native.patch_bindings
11
+
12
+ # Generate documents from Typst templates packed with the Oicana CLI.
13
+ module Oicana
14
+ # Make fonts available to every template created afterwards. Takes the content of font
15
+ # files; data that holds no font is ignored. Returns the number of font faces added.
16
+ def self.register_fonts(*fonts)
17
+ OicanaUniffi.register_fonts(fonts.flatten.map(&:b))
18
+ end
19
+
20
+ # Make font files, or every font file in the given directories, available to every
21
+ # template created afterwards. Returns the number of font faces added.
22
+ def self.register_font_paths(*paths)
23
+ OicanaUniffi.register_font_paths(paths.flatten.map(&:to_s))
24
+ end
25
+
26
+ # The RegisteredFont faces registered with register_fonts or register_font_paths.
27
+ def self.registered_fonts
28
+ OicanaUniffi.registered_fonts.map { |font| RegisteredFont.new(family: font.family, path: font.path) }
29
+ end
30
+
31
+ # Drop all registered fonts. Existing templates keep the fonts they were created with.
32
+ def self.clear_fonts
33
+ OicanaUniffi.clear_fonts
34
+ end
35
+
36
+ # Set the coloring of compilation diagnostics for all templates: :none or :ansi.
37
+ def self.configure_diagnostic_color(color)
38
+ native = { none: OicanaUniffi::DiagnosticColor::NONE, ansi: OicanaUniffi::DiagnosticColor::ANSI }.fetch(color) do
39
+ raise ArgumentError, "unknown diagnostic color #{color.inspect}, use :none or :ansi"
40
+ end
41
+ OicanaUniffi.configure_diagnostic_color(native)
42
+ end
43
+
44
+ # Evict cache entries after each compilation that were not used within the last
45
+ # max_age evictions. nil disables the eviction, 0 clears the whole cache every time.
46
+ # The default is 10.
47
+ def self.configure_automatic_cache_eviction(max_age)
48
+ OicanaUniffi.configure_automatic_cache_eviction(max_age)
49
+ end
50
+
51
+ # Evict cache entries that were not used within the last max_age evictions.
52
+ def self.evict_cache(max_age)
53
+ OicanaUniffi.evict_cache(max_age)
54
+ end
55
+
56
+ # The version of the loaded native library.
57
+ def self.native_version
58
+ OicanaUniffi.version
59
+ end
60
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: oicana
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.10.0.rc.1
5
+ platform: ruby
6
+ authors:
7
+ - Oicana
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.16'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.16'
26
+ email:
27
+ - hello@oicana.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - LICENSE.md
33
+ - README.md
34
+ - lib/oicana.rb
35
+ - lib/oicana/errors.rb
36
+ - lib/oicana/native.rb
37
+ - lib/oicana/oicana_uniffi.rb
38
+ - lib/oicana/template.rb
39
+ - lib/oicana/types.rb
40
+ - lib/oicana/version.rb
41
+ homepage: https://oicana.com
42
+ licenses:
43
+ - PolyForm-Noncommercial-1.0.0
44
+ metadata:
45
+ source_code_uri: https://github.com/oicana/oicana/tree/main/integrations/ruby
46
+ documentation_uri: https://oicana.com/docs
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.2'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.6.9
63
+ specification_version: 4
64
+ summary: Generate typeset PDFs in Ruby from Typst templates, in process. No headless
65
+ browser, no per-document fees.
66
+ test_files: []