texd 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,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texd
4
+ # AttachmentList will contain file references used in tex documents.
5
+ # This class is commonly interacted with in the `attach_file` view
6
+ # helper:
7
+ #
8
+ # @example Name mangling is active by default:
9
+ # \input{<%= attach_file "/path/to/reference.tex" %>}
10
+ # % might render as:
11
+ # \input{att-1234.tex}
12
+ #
13
+ # @example Name mangling can be deactivated:
14
+ # \input{<%= attach_file "/path/to/reference.tex", rename: false %>}
15
+ # % will render as:
16
+ # \input{reference.tex}
17
+ #
18
+ # @example Some situations require to drop the file extension:
19
+ # \usepackage{<%= attach_file "helper.sty", rename: false, extension: false %>}
20
+ # % will render as:
21
+ # \usepackage{helper}
22
+ class AttachmentList
23
+ attr_reader :items
24
+ attr_reader :lookup_context
25
+
26
+ # @param [Texd::LookupContext] lookup_context
27
+ def initialize(lookup_context)
28
+ @items = {}
29
+ @lookup_context = lookup_context
30
+ end
31
+
32
+ # Adds a file with the given `path` to the list. The file path must
33
+ # either be an absolute filename or be relative to app/tex/ of the
34
+ # host application. See `Texd::LookupContext` for details.
35
+ #
36
+ # The output file name will be mangled, unless `rename` specifies a
37
+ # name to use. Setting `rename` to false also disables renaming (the
38
+ # output will then use the file's basename unaltered).
39
+ #
40
+ # Note: Adding the same `path` twice with different arguments will
41
+ # have no effect: The returned value on the second attempt will be the
42
+ # same as on the first one.
43
+ #
44
+ # @param [String, Pathname] path partial path
45
+ # @param [Boolean, String] rename affects the output file name. If
46
+ # `true`, a random file name is generated for the TeX template,
47
+ # `false` will use the basename of path.
48
+ # When a string is given, that string is used instead. Be careful
49
+ # to avoid name collisions.
50
+ # @return [Attachment::File]
51
+ # @api private
52
+ def attach(path, rename = true) # rubocop:disable Style/OptionalBooleanParameter
53
+ add(Attachment::File, path, rename)
54
+ end
55
+
56
+ # Adds a file reference with the given path to the list. Similar to #attach,
57
+ # the file path must either be an absolute filename or be relative to
58
+ # app/tex/ of the host application.
59
+ #
60
+ # File name mangling applies as well, with the same rules as in #attach.
61
+ #
62
+ # File references allow to reduce the amount of data sent to the texd server
63
+ # instance, by initially only sending the file's checksums. If the server
64
+ # can identify that checksum from an internal store, it'll use the stored
65
+ # file. Otherwise we receive a list of unknown references, and can retry
66
+ # the render request with the missing content attached in full.
67
+ #
68
+ # References will be stored on the server (for some amount of time), so
69
+ # you should only attach static files, which change seldomly. Do not add
70
+ # dynamic content.
71
+ #
72
+ # @param [String, Pathname] path partial path
73
+ # @param [Boolean, String] rename affects the output file name. If
74
+ # `true`, a random file name is generated for the TeX template,
75
+ # `false` will use the basename of path.
76
+ # When a string is given, that string is used instead. Be careful
77
+ # to avoid name collisions.
78
+ # @return [Attachment::Reference]
79
+ # @api private
80
+ def reference(path, rename = false) # rubocop:disable Style/OptionalBooleanParameter
81
+ add(Attachment::Reference, path, rename)
82
+ end
83
+
84
+ # Adds main input file for the render request. The returned name for
85
+ # this file is either "input.tex", or (if that name already exist)
86
+ # some alternative. You should add the return value as input parameter
87
+ # to the client's render call.
88
+ #
89
+ # @param [String] contents of main input.
90
+ # @return [String] a generated name, usually "input.tex"
91
+ # @api private
92
+ def main_input(contents)
93
+ name = "input.tex"
94
+ i = 0
95
+ name = format("doc%05d.tex", i += 1) while items.key?(name)
96
+ att = Attachment::Dynamic.new(name, contents)
97
+
98
+ items[name] = att
99
+ items[name]
100
+ end
101
+
102
+ # Transforms this list to UploadIO objects suitable for Texd::Client#render.
103
+ #
104
+ # @param [Set|nil] unknown_reference_ids file references to be included fully.
105
+ # @api private
106
+ def to_upload_ios(unknown_reference_ids = nil)
107
+ items.values.each_with_object({}) { |att, ios|
108
+ ios[att.name] = if unknown_reference_ids && att.is_a?(Attachment::Reference)
109
+ att.to_upload_io(full: unknown_reference_ids.include?(att.checksum))
110
+ else
111
+ att.to_upload_io
112
+ end
113
+ }
114
+ end
115
+
116
+ private
117
+
118
+ def add(kind, path, rename)
119
+ att = kind.new(lookup_context.find(path), rename, items.size)
120
+
121
+ items[att.name] ||= att
122
+ items[att.name]
123
+ end
124
+ end
125
+
126
+ module Attachment
127
+ class RenameError < ::Texd::Error
128
+ def initialize(rename)
129
+ super "invalid renaming: expected true, false, or a string, got #{rename.class} (#{rename})"
130
+ end
131
+ end
132
+
133
+ Dynamic = Struct.new(:name, :contents) do
134
+ def to_upload_io(**)
135
+ UploadIO.new(StringIO.new(contents), nil, name).tap { |io|
136
+ io.instance_variable_set :@original_filename, name
137
+ }
138
+ end
139
+ end
140
+
141
+ class Base
142
+ # absolute path to attachment on local file system
143
+ attr_reader :absolute_path
144
+
145
+ # @param [Pathname] path see AttachmentList#attach
146
+ # @param [Boolean, String] rename see AttachmentList#attach
147
+ # @param [Integer] serial number of files currently in the parent
148
+ # AttachmentList (used for renaming purposes)
149
+ # @api private
150
+ def initialize(path, rename, serial)
151
+ @absolute_path = path.expand_path
152
+
153
+ @name = case rename
154
+ when true then format("att%04d%s", serial, path.extname)
155
+ when false then path.basename.to_s
156
+ when String then rename
157
+ else raise RenameError, rename
158
+ end
159
+ end
160
+
161
+ # Returns the (renamed) output file name. When `with_extension` is
162
+ # `true`, the file extension is chopped.
163
+ #
164
+ # @param [Boolean] with_extension
165
+ # @return [String] output file name
166
+ def name(with_extension = true) # rubocop:disable Style/OptionalBooleanParameter
167
+ basename = ::File.basename(@name)
168
+ return basename if with_extension
169
+
170
+ dot = basename.rindex(".")
171
+ return basename if dot == 0 # file starts with "."
172
+
173
+ basename.slice(0, dot)
174
+ end
175
+ end
176
+
177
+ class File < Base
178
+ # @api private
179
+ def to_upload_io(**)
180
+ UploadIO.new(absolute_path.open("rb"), nil, name).tap { |io|
181
+ io.instance_variable_set :@original_filename, name
182
+ }
183
+ end
184
+ end
185
+
186
+ class Reference < Base
187
+ # Special Content-Type header to instruct texd server to interpret
188
+ # contents as reference identifier and re-use persisted file on server.
189
+ USE_REF = "application/x.texd; ref=use"
190
+
191
+ # Special Content-Type header to instruct texd server to store the
192
+ # content body for later reference.
193
+ STORE_REF = "application/x.texd; ref=store"
194
+
195
+ # @api private
196
+ def to_upload_io(full: false)
197
+ f = full ? absolute_path.open("rb") : StringIO.new(checksum)
198
+ ct = full ? STORE_REF : USE_REF
199
+
200
+ UploadIO.new(f, ct, name).tap { |io|
201
+ io.instance_variable_set :@original_filename, name
202
+ }
203
+ end
204
+
205
+ # @api private
206
+ def checksum
207
+ @checksum ||= begin
208
+ digest = Digest::SHA256.file(absolute_path).digest
209
+ encoded = Base64.urlsafe_encode64(digest)
210
+ "sha256:#{encoded}"
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "net/http/post/multipart"
5
+ require "json"
6
+
7
+ module Texd
8
+ class Client
9
+ # Generic error for execptions caused by the render endpoint.
10
+ # You will likely receive a subclass of this error, but you may
11
+ # rescue from this error, if you're not interested in the details.
12
+ class RenderError < Error
13
+ # additional details
14
+ attr_reader :details
15
+
16
+ def initialize(message, details: nil)
17
+ @details = details
18
+ super(message)
19
+ end
20
+ end
21
+
22
+ # @!parse
23
+ # # Raised if the server is busy.
24
+ # class QueueError < RenderError; end
25
+ QueueError = Class.new RenderError
26
+
27
+ # @!parse
28
+ # # Raised when input file pocessing failed.
29
+ # class InputError < RenderError; end
30
+ InputError = Class.new RenderError
31
+
32
+ # Raised if the TeX compilation process failed.
33
+ class CompilationError < RenderError
34
+ # TeX compiler logs. Only available if Texd.config.error_format
35
+ # is "full" or "condensed".
36
+ attr_reader :logs
37
+
38
+ def initialize(message, details: nil, logs: nil)
39
+ @logs = logs
40
+ super(message, details: details)
41
+ end
42
+ end
43
+
44
+ # Raised when the texd server encountered one or more unknown file
45
+ # references.
46
+ class ReferenceError < RenderError
47
+ # List of unknown file references
48
+ attr_reader :references
49
+
50
+ def initialize(message, references:)
51
+ @references = Set.new(references)
52
+ super(message)
53
+ end
54
+ end
55
+
56
+ ERRORS_BY_CATEGORY = {
57
+ "input" => InputError,
58
+ "compilation" => CompilationError,
59
+ "queue" => QueueError,
60
+ "reference" => ReferenceError,
61
+ }.freeze
62
+
63
+ class ResponseError < Error
64
+ attr_reader :body, :content_type
65
+
66
+ def initialize(code, content_type, body)
67
+ @body = body
68
+ @content_type = content_type
69
+
70
+ if json?
71
+ super format("%s error: %s", body.delete("category"), body.delete("error"))
72
+ elsif log?
73
+ tex_errors = body.lines.select { |l| l.start_with?("!") }.map(&:strip)
74
+ super "Compilation failed:\n\t#{tex_errors.join('\n\t')}"
75
+ else
76
+ super "Server responded with status #{code} (#{content_type})"
77
+ end
78
+ end
79
+ end
80
+
81
+ USER_AGENT = "texd-ruby/#{VERSION} Ruby/#{RUBY_VERSION}"
82
+
83
+ attr_reader :config
84
+
85
+ def initialize(config)
86
+ @config = config
87
+ end
88
+
89
+ def status
90
+ http("/status") { |uri| Net::HTTP::Get.new(uri) }
91
+ end
92
+
93
+ def render(upload_ios, **params)
94
+ params = config.default_render_params.merge(params)
95
+
96
+ http("/render", params: params) { |uri|
97
+ Net::HTTP::Post::Multipart.new uri, upload_ios
98
+ }
99
+ end
100
+
101
+ private
102
+
103
+ def http(path, params: nil)
104
+ uri = build_request_uri(path, params)
105
+
106
+ Net::HTTP.start uri.host, uri.port, **request_options(uri) do |http|
107
+ req = yield(uri)
108
+ decode_response http.request(req)
109
+ end
110
+ end
111
+
112
+ def request_options(uri)
113
+ {
114
+ use_ssl: uri.scheme == "https",
115
+ open_timeout: config.open_timeout,
116
+ write_timeout: config.write_timeout,
117
+ read_timeout: config.read_timeout,
118
+ }
119
+ end
120
+
121
+ def build_request_uri(path, params)
122
+ uri = config.endpoint.dup
123
+ uri.path = File.join(uri.path, path)
124
+ uri.query = URI.encode_www_form(params) if params
125
+ uri
126
+ end
127
+
128
+ def decode_response(res)
129
+ ct = res["Content-Type"]
130
+ body = case ct.split(";").first
131
+ when "application/json"
132
+ JSON.parse(res.body)
133
+ when "application/pdf", "text/plain"
134
+ res.body
135
+ else
136
+ raise RenderError, "unexpected content type: #{ct}"
137
+ end
138
+
139
+ return body if res.is_a?(Net::HTTPOK)
140
+
141
+ raise resolve_error(res.code, ct, body)
142
+ end
143
+
144
+ def resolve_error(status, content_type, body)
145
+ if body.is_a?(Hash)
146
+ category = body.delete("category")
147
+ message = body.delete("error")
148
+ err = ERRORS_BY_CATEGORY.fetch(category, RenderError)
149
+
150
+ if category == "reference"
151
+ return err.new(message, references: body.delete("references"))
152
+ end
153
+
154
+ return err.new(message, details: body)
155
+ end
156
+
157
+ if content_type.start_with?("text/plain")
158
+ return CompilationError.new("compilation failed", logs: body)
159
+ end
160
+
161
+ RenderError.new("Server responded with status #{status} (#{content_type})", details: body)
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "set"
5
+
6
+ module Texd
7
+ class Configuration
8
+ class InvalidConfig < Texd::Error
9
+ attr_reader :option, :expected
10
+
11
+ def initialize(msg, option:, got:, expected: nil)
12
+ @option = option
13
+ @expected = expected
14
+
15
+ message = [format("Invalid configuration option for %p, got %p", option, got)]
16
+ message << msg if msg
17
+ message << format("Valid options are: %p", expected) if expected
18
+
19
+ super message.join(" ")
20
+ end
21
+ end
22
+
23
+ # This is the default configuration. It is applied in the constructor.
24
+ DEFAULT_CONFIGURATION = {
25
+ endpoint: ENV.fetch("TEXD_ENDPOINT", "http://localhost:2201/"),
26
+ open_timeout: ENV.fetch("TEXD_OPEN_TIMEOUT", 60),
27
+ read_timeout: ENV.fetch("TEXD_READ_TIMEOUT", 180),
28
+ write_timeout: ENV.fetch("TEXD_WRITE_TIMEOUT", 60),
29
+ error_format: ENV.fetch("TEXD_ERRORS", "full"),
30
+ tex_engine: ENV["TEXD_ENGINE"],
31
+ tex_image: ENV["TEXD_IMAGE"],
32
+ helpers: Set.new,
33
+ lookup_paths: [], # Rails.root.join("app/tex") is inserted in railtie.rb
34
+ }.freeze
35
+
36
+ # Supported endpoint protocols.
37
+ ENDPOINT_CLASSES = [URI::HTTP, URI::HTTPS].freeze
38
+
39
+ # Supported error formats.
40
+ ERROR_FORMATS = %w[json full condensed].freeze
41
+
42
+ # Supported TeX engines.
43
+ TEX_ENGINES = %w[xelatex lualatex pdflatex].freeze
44
+
45
+ # Endpoint is a URI pointing to the texd server instance.
46
+ #
47
+ # The default is `http://localhost:2201/` and can be overriden by the
48
+ # `TEXD_ENDPOINT` environment variable.
49
+ attr_reader :endpoint
50
+
51
+ # Timeout (in seconds) for the initial connect to the endpoint.
52
+ #
53
+ # The default is 60 (1 min) and can be overriden by the `TEXD_OPEN_TIMEOUT`
54
+ # environment variable.
55
+ attr_reader :open_timeout
56
+
57
+ # Timeout (in seconds) for reads from the endpoint. You want this value to
58
+ # be in the same ballbark as texd's `--compile-timoeut` option.
59
+ #
60
+ # The default is 180 (3 min) and can be overriden by the `TEXD_OPEN_TIMEOUT`
61
+ # environment variable.
62
+ attr_reader :read_timeout
63
+
64
+ # Timeout (in seconds) for writing the request to the endpoint. You want
65
+ # this value to be in the same ballpark as texd's `--queue-timeout` option.
66
+ #
67
+ # The default is 60 (1 min) and can be overriden by the `TEXD_WRITE_TIMEOUT`
68
+ # environment variable.
69
+ attr_reader :write_timeout
70
+
71
+ # The texd server usually reports errors in JSON format, however, when the
72
+ # compilation fails, the TeX compiler's output ist often most useful.
73
+ #
74
+ # Supported values are described in ERROR_FORMATS.
75
+ #
76
+ # The default is "full" and can be overriden by the `TEXD_WRITE_TIMEOUT`
77
+ # environment variable.
78
+ attr_reader :error_format
79
+
80
+ # This is the selected TeX engine. Supported values are described in
81
+ # TEX_ENGINES.
82
+ #
83
+ # The default is blank (meaning the server shall default to its `--tex-engine`
84
+ # option), and can be overriden by the `TEXD_ENGINE` environment variable.
85
+ attr_reader :tex_engine
86
+
87
+ # When texd runs in container mode, it may provide multiple Docker images to
88
+ # select from. This setting selects a specific container image.
89
+ #
90
+ # The default value is blank (meaning texd will select an image), and can be
91
+ # overriden byt the `TEXD_IMAGE` environment variable.
92
+ attr_accessor :tex_image
93
+
94
+ # List of additional helper modules to make available in the template views.
95
+ # Texd::Helpers is always included, and you may add additional ones.
96
+ #
97
+ # This can't be influenced by environment variables.
98
+ attr_accessor :helpers
99
+
100
+ # Set of paths to perform file lookups in. The set is searched in order,
101
+ # meaning files found in later entries won't be returned if entries with the
102
+ # same name exist in earlier entries.
103
+ #
104
+ # By default, this only contains `Rails.root.join("app/tex")`, however
105
+ # Rails engines might append additional entries.
106
+ #
107
+ # A Texd::LookupContext is constructed from this set.
108
+ attr_accessor :lookup_paths
109
+
110
+ def initialize(**options)
111
+ DEFAULT_CONFIGURATION.each do |key, default_value|
112
+ public_send "#{key}=", options.fetch(key, default_value.dup)
113
+ end
114
+ end
115
+
116
+ def to_h
117
+ DEFAULT_CONFIGURATION.keys.each_with_object({}) do |key, hash|
118
+ hash[key] = public_send(key)
119
+ end
120
+ end
121
+
122
+ # @api private
123
+ def default_render_params
124
+ {
125
+ errors: error_format,
126
+ engine: tex_engine,
127
+ image: tex_image,
128
+ }.compact
129
+ end
130
+
131
+ def endpoint=(val)
132
+ uri = val.is_a?(URI::Generic) ? val : URI.parse(val)
133
+
134
+ unless ENDPOINT_CLASSES.any? { |klass| uri.is_a? klass }
135
+ raise InvalidConfig.new("Value must be a URL", :endpoint, got: val, expected: ENDPOINT_CLASSES)
136
+ end
137
+
138
+ @endpoint = uri
139
+ end
140
+
141
+ def open_timeout=(val)
142
+ set_timeout :open, val
143
+ end
144
+
145
+ def read_timeout=(val)
146
+ set_timeout :read, val
147
+ end
148
+
149
+ def write_timeout=(val)
150
+ set_timeout :write, val
151
+ end
152
+
153
+ def error_format=(val)
154
+ val ||= "json"
155
+ val = val.to_s
156
+
157
+ unless ERROR_FORMATS.include?(val)
158
+ raise InvalidConfig.new(nil, got: val, expected: ERROR_FORMATS)
159
+ end
160
+
161
+ @error_format = val
162
+ end
163
+
164
+ def tex_engine=(val)
165
+ unless val.nil?
166
+ val = val.to_s
167
+ unless TEX_ENGINES.include?(val)
168
+ raise InvalidConfig.new(nil, got: val, expected: TEX_ENGINES)
169
+ end
170
+ end
171
+
172
+ @tex_engine = val
173
+ end
174
+
175
+ private
176
+
177
+ def set_timeout(name, val)
178
+ val = case val
179
+ when Numeric then val
180
+ when String then val.to_i
181
+ when NilClass then 0
182
+ else
183
+ msg = "expected Numeric, String or NilClass for #{name}_timout, got #{val}:#{val.class}"
184
+ raise TypeError, msg
185
+ end
186
+
187
+ instance_variable_set "@#{name}_timeout", val
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texd
4
+ # Document handles compiling of templates into TeX sources.
5
+ class Document
6
+ attr_reader :attachments
7
+
8
+ # Shorthand for `new.compile`.
9
+ def self.compile(*args)
10
+ new.compile(*args)
11
+ end
12
+
13
+ def initialize
14
+ context = LookupContext.new(Texd.config.lookup_paths)
15
+ @attachments = AttachmentList.new(context)
16
+ end
17
+
18
+ # Compile converts templates into TeX sources and collects file
19
+ # references (created with `texd_attach` and `texd_reference` helpers).
20
+ #
21
+ # @param args are forwarded to ApplicationController#render (which in turn
22
+ # forwards to ActionView::Renderer#render).
23
+ # @return [Compilation]
24
+ def compile(*args)
25
+ helper_mod = ::Texd.helpers(attachments)
26
+ tex_source = Class.new(ApplicationController) {
27
+ helper helper_mod
28
+ }.render(*args)
29
+
30
+ main = attachments.main_input(tex_source)
31
+ Compilation.new(main.name, attachments)
32
+ end
33
+
34
+ Compilation = Struct.new(:main_input_name, :attachments) do
35
+ def to_upload_ios(missing_refs: Set.new)
36
+ attachments.to_upload_ios(missing_refs)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texd
4
+ module Helpers
5
+ ESCAPE_RE = /([{}_$&%#])|([\\^~|<>])/.freeze
6
+ ESC_MAP = {
7
+ "\\" => "backslash",
8
+ "^" => "asciicircum",
9
+ "~" => "asciitilde",
10
+ "|" => "bar",
11
+ "<" => "less",
12
+ ">" => "greater",
13
+ }.freeze
14
+
15
+ TYPOGRAPHIC_REPLACEMENTS = [
16
+ # nested quotes
17
+ [/(^|\W)"'\b/, '\1\\glqq{}\\glq{}'],
18
+ [/\b'"(\W|$)/, '\\grq{}\\grqq{}\1'],
19
+ # double quotes
20
+ [/(^|\W)"\b/, '\1\\glqq{}'],
21
+ [/\b"(\W|$)/, '\\grqq{}\1'],
22
+ # single quotes
23
+ [/(^|\W)'\b/, '\1\\glq{}'],
24
+ [/\b'(\W|$)/, '\\grq{}\1'],
25
+ # proper hyphenation
26
+ [/(\w)-(\w)/, '\1"=\2'],
27
+ ].freeze
28
+
29
+ # Escapes the given text, making it safe for use in TeX documents.
30
+ def escape(text, line_break = "\\\\\\", typographic: true)
31
+ text = +text
32
+ text.to_s.tap do |str|
33
+ str.gsub!(ESCAPE_RE) do |m|
34
+ if Regexp.last_match(1)
35
+ "\\#{m}"
36
+ else
37
+ "\\text#{ESC_MAP[m]}{}"
38
+ end
39
+ end
40
+
41
+ if typographic
42
+ TYPOGRAPHIC_REPLACEMENTS.each do |re, replacement|
43
+ str.gsub!(re, replacement)
44
+ end
45
+ end
46
+
47
+ str.gsub!(/\r?\n/, line_break)
48
+ end.freeze
49
+ end
50
+ end
51
+ end