texd 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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