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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +325 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +238 -0
- data/LICENSE +22 -0
- data/Makefile +54 -0
- data/README.md +96 -0
- data/Rakefile +16 -0
- data/gemfiles/rails-6.0 +7 -0
- data/gemfiles/rails-6.0.lock +181 -0
- data/gemfiles/rails-6.1 +7 -0
- data/gemfiles/rails-6.1.lock +184 -0
- data/gemfiles/rails-7.0 +7 -0
- data/gemfiles/rails-7.0.lock +202 -0
- data/lib/texd/attachment.rb +215 -0
- data/lib/texd/client.rb +164 -0
- data/lib/texd/config.rb +190 -0
- data/lib/texd/document.rb +40 -0
- data/lib/texd/helpers.rb +51 -0
- data/lib/texd/lookup_context.rb +64 -0
- data/lib/texd/railtie.rb +13 -0
- data/lib/texd/version.rb +5 -0
- data/lib/texd.rb +121 -0
- data/texd.gemspec +42 -0
- metadata +163 -0
@@ -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
|
data/lib/texd/client.rb
ADDED
@@ -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
|
data/lib/texd/config.rb
ADDED
@@ -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
|
data/lib/texd/helpers.rb
ADDED
@@ -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
|