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