asciidoctor-latexmath 1.0.0.beta.2 → 2.0.0.alpha.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.
- checksums.yaml +4 -4
- data/README.md +119 -36
- data/lib/asciidoctor/latexmath/attribute_resolver.rb +485 -0
- data/lib/asciidoctor/latexmath/cache/cache_entry.rb +31 -0
- data/lib/asciidoctor/latexmath/cache/cache_key.rb +33 -0
- data/lib/asciidoctor/latexmath/cache/disk_cache.rb +151 -0
- data/lib/asciidoctor/latexmath/command_runner.rb +116 -0
- data/lib/asciidoctor/latexmath/converters/html5.rb +72 -0
- data/lib/asciidoctor/latexmath/errors.rb +134 -0
- data/lib/asciidoctor/latexmath/html_builder.rb +103 -0
- data/lib/asciidoctor/latexmath/math_expression.rb +22 -0
- data/lib/asciidoctor/latexmath/path_utils.rb +46 -0
- data/lib/asciidoctor/latexmath/processors/block_processor.rb +74 -0
- data/lib/asciidoctor/latexmath/processors/inline_macro_processor.rb +70 -0
- data/lib/asciidoctor/latexmath/processors/statistics_postprocessor.rb +18 -0
- data/lib/asciidoctor/latexmath/render_request.rb +32 -0
- data/lib/asciidoctor/latexmath/renderer_service.rb +741 -0
- data/lib/asciidoctor/latexmath/rendering/pdf_to_png_renderer.rb +97 -0
- data/lib/asciidoctor/latexmath/rendering/pdf_to_svg_renderer.rb +84 -0
- data/lib/asciidoctor/latexmath/rendering/pdflatex_renderer.rb +166 -0
- data/lib/asciidoctor/latexmath/rendering/pipeline.rb +58 -0
- data/lib/asciidoctor/latexmath/rendering/renderer.rb +33 -0
- data/lib/asciidoctor/latexmath/rendering/tool_detector.rb +320 -0
- data/lib/asciidoctor/latexmath/rendering/toolchain_record.rb +21 -0
- data/lib/asciidoctor/latexmath/statistics/collector.rb +47 -0
- data/lib/asciidoctor/latexmath/support/conflict_registry.rb +75 -0
- data/lib/{asciidoctor-latexmath → asciidoctor/latexmath}/version.rb +1 -1
- data/lib/asciidoctor-latexmath.rb +93 -3
- metadata +34 -12
- data/lib/asciidoctor-latexmath/renderer.rb +0 -526
- data/lib/asciidoctor-latexmath/treeprocessor.rb +0 -369
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: 2025 Shuai Zhang
|
|
4
|
+
#
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later WITH LGPL-3.0-linking-exception
|
|
6
|
+
|
|
7
|
+
require "digest"
|
|
8
|
+
require "pathname"
|
|
9
|
+
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "render_request"
|
|
12
|
+
require_relative "path_utils"
|
|
13
|
+
|
|
14
|
+
module Asciidoctor
|
|
15
|
+
module Latexmath
|
|
16
|
+
class AttributeResolver
|
|
17
|
+
MIN_PPI = 72
|
|
18
|
+
MAX_PPI = 600
|
|
19
|
+
DEFAULT_PPI = 300
|
|
20
|
+
DEFAULT_TIMEOUT = 120
|
|
21
|
+
SUPPORTED_ENGINES = %i[pdflatex xelatex lualatex tectonic].freeze
|
|
22
|
+
ENGINE_DEFAULTS = {
|
|
23
|
+
pdflatex: "pdflatex",
|
|
24
|
+
xelatex: "xelatex",
|
|
25
|
+
lualatex: "lualatex",
|
|
26
|
+
tectonic: "tectonic"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
ResolvedAttributes = Struct.new(
|
|
30
|
+
:render_request,
|
|
31
|
+
:on_error_policy,
|
|
32
|
+
:target_basename,
|
|
33
|
+
:format,
|
|
34
|
+
:nocache,
|
|
35
|
+
:keep_artifacts,
|
|
36
|
+
:raw_attributes,
|
|
37
|
+
keyword_init: true
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def initialize(document, logger: Asciidoctor::LoggerManager.logger)
|
|
41
|
+
@document = document
|
|
42
|
+
@logger = logger
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolve(attributes:, options:, expression:)
|
|
46
|
+
normalized = normalize_keys(attributes)
|
|
47
|
+
normalized = apply_aliases(normalized)
|
|
48
|
+
options = Array(options)
|
|
49
|
+
|
|
50
|
+
format = infer_format(normalized)
|
|
51
|
+
engine = infer_engine(normalized)
|
|
52
|
+
ppi = infer_ppi(normalized, format)
|
|
53
|
+
timeout = infer_timeout(normalized)
|
|
54
|
+
nocache = infer_nocache(normalized, options)
|
|
55
|
+
keep_artifacts = infer_keep_artifacts(normalized, options)
|
|
56
|
+
cachedir = infer_cachedir(normalized) unless nocache
|
|
57
|
+
artifacts_dir = infer_artifacts_dir(normalized, keep_artifacts, cachedir)
|
|
58
|
+
preamble = infer_preamble(normalized)
|
|
59
|
+
fontsize = infer_fontsize(normalized)
|
|
60
|
+
on_error_policy = infer_on_error(normalized)
|
|
61
|
+
tool_overrides = infer_tool_overrides(normalized)
|
|
62
|
+
|
|
63
|
+
normalized_content = normalize_text(expression.content.to_s)
|
|
64
|
+
normalized_preamble = normalize_text(preamble)
|
|
65
|
+
normalized_fontsize = normalize_text(fontsize)
|
|
66
|
+
|
|
67
|
+
target_basename = determine_target_basename(normalized)
|
|
68
|
+
|
|
69
|
+
render_request = RenderRequest.new(
|
|
70
|
+
expression: expression,
|
|
71
|
+
format: format,
|
|
72
|
+
engine: engine,
|
|
73
|
+
preamble: preamble,
|
|
74
|
+
fontsize: fontsize,
|
|
75
|
+
ppi: ppi,
|
|
76
|
+
timeout: timeout,
|
|
77
|
+
keep_artifacts: keep_artifacts,
|
|
78
|
+
nocache: nocache,
|
|
79
|
+
cachedir: cachedir,
|
|
80
|
+
artifacts_dir: artifacts_dir,
|
|
81
|
+
tool_overrides: tool_overrides,
|
|
82
|
+
content_hash: Digest::SHA256.hexdigest(normalized_content),
|
|
83
|
+
preamble_hash: Digest::SHA256.hexdigest(normalized_preamble),
|
|
84
|
+
fontsize_hash: Digest::SHA256.hexdigest(normalized_fontsize)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
ResolvedAttributes.new(
|
|
88
|
+
render_request: render_request,
|
|
89
|
+
on_error_policy: on_error_policy,
|
|
90
|
+
target_basename: target_basename,
|
|
91
|
+
format: format,
|
|
92
|
+
nocache: nocache,
|
|
93
|
+
keep_artifacts: keep_artifacts,
|
|
94
|
+
raw_attributes: normalized
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
attr_reader :document, :logger
|
|
101
|
+
|
|
102
|
+
def normalize_keys(attributes)
|
|
103
|
+
attributes.each_with_object({}) do |(key, value), memo|
|
|
104
|
+
memo[key.to_s] = value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def apply_aliases(attrs)
|
|
109
|
+
if attrs.key?("cache-dir")
|
|
110
|
+
log_cache_dir_alias_once(:element)
|
|
111
|
+
attrs["cachedir"] = attrs.delete("cache-dir")
|
|
112
|
+
end
|
|
113
|
+
attrs
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def determine_target_basename(attrs)
|
|
117
|
+
explicit = attrs["target"]
|
|
118
|
+
return explicit unless explicit.to_s.empty?
|
|
119
|
+
|
|
120
|
+
positional = attrs["2"]
|
|
121
|
+
style = attrs["style"]
|
|
122
|
+
return positional if positional && !positional.to_s.empty? && positional != style
|
|
123
|
+
|
|
124
|
+
positional = attrs["1"]
|
|
125
|
+
return positional if positional && !positional.to_s.empty? && positional != style
|
|
126
|
+
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def infer_format(attrs)
|
|
131
|
+
value = attrs["format"] || document.attr("latexmath-format") || "svg"
|
|
132
|
+
normalized = value.to_s.downcase.to_sym
|
|
133
|
+
return normalized if %i[svg pdf png].include?(normalized)
|
|
134
|
+
|
|
135
|
+
raise UnsupportedFormatError, "Unsupported format '#{value}'"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def infer_engine(attrs)
|
|
139
|
+
engine_name = determine_engine_name(attrs)
|
|
140
|
+
command = resolve_engine_command(engine_name, attrs)
|
|
141
|
+
command.to_s.strip
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def infer_preamble(attrs)
|
|
145
|
+
override = attrs["preamble"]
|
|
146
|
+
return override.to_s if override
|
|
147
|
+
|
|
148
|
+
(document.attr("latexmath-preamble") || "").to_s
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def infer_fontsize(attrs)
|
|
152
|
+
raw_value, subject = fetch_attribute_value(attrs, "fontsize", "latexmath-fontsize")
|
|
153
|
+
effective_value = value_or_default(raw_value, "12pt")
|
|
154
|
+
value = effective_value.to_s.strip
|
|
155
|
+
value = "12pt" if value.empty?
|
|
156
|
+
|
|
157
|
+
unless valid_fontsize?(value)
|
|
158
|
+
raise_unsupported_attribute(subject, raw_value || value,
|
|
159
|
+
supported: "values ending with 'pt' (e.g., 10pt, 12pt)",
|
|
160
|
+
hint: "set #{subject} to a positive value ending with 'pt'")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
value
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def infer_ppi(attrs, format)
|
|
167
|
+
return nil unless format == :png
|
|
168
|
+
|
|
169
|
+
raw_value, subject = fetch_attribute_value(attrs, "ppi", "latexmath-ppi")
|
|
170
|
+
effective_value = value_or_default(raw_value, DEFAULT_PPI)
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
integer = Integer(effective_value)
|
|
174
|
+
rescue ArgumentError, TypeError
|
|
175
|
+
raise_unsupported_attribute(subject, raw_value, supported: "integer between #{MIN_PPI} and #{MAX_PPI}",
|
|
176
|
+
hint: "set #{subject} to an integer between #{MIN_PPI} and #{MAX_PPI}")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
unless integer.between?(MIN_PPI, MAX_PPI)
|
|
180
|
+
raise_unsupported_attribute(subject, raw_value || integer,
|
|
181
|
+
supported: "integer between #{MIN_PPI} and #{MAX_PPI}",
|
|
182
|
+
hint: "set #{subject} between #{MIN_PPI} and #{MAX_PPI}")
|
|
183
|
+
end
|
|
184
|
+
integer
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def infer_timeout(attrs)
|
|
188
|
+
raw_value, subject = fetch_attribute_value(attrs, "timeout", "latexmath-timeout")
|
|
189
|
+
effective_value = value_or_default(raw_value, DEFAULT_TIMEOUT)
|
|
190
|
+
|
|
191
|
+
begin
|
|
192
|
+
integer = Integer(effective_value)
|
|
193
|
+
rescue ArgumentError, TypeError
|
|
194
|
+
raise_unsupported_attribute(subject, raw_value,
|
|
195
|
+
supported: "positive integer seconds",
|
|
196
|
+
hint: "set #{subject} to a positive integer")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
unless integer.positive?
|
|
200
|
+
raise_unsupported_attribute(subject, raw_value || integer,
|
|
201
|
+
supported: "positive integer seconds",
|
|
202
|
+
hint: "set #{subject} to a positive integer")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
integer
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def infer_cachedir(attrs)
|
|
209
|
+
explicit = attrs["cachedir"]
|
|
210
|
+
doc_level = canonical_cachedir_attr
|
|
211
|
+
|
|
212
|
+
return expand_path(explicit) if explicit
|
|
213
|
+
return expand_path(doc_level) if doc_level
|
|
214
|
+
|
|
215
|
+
File.join(resolve_outdir, ".asciidoctor", "latexmath")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def infer_artifacts_dir(attrs, keep_artifacts, cachedir)
|
|
219
|
+
return nil unless keep_artifacts
|
|
220
|
+
|
|
221
|
+
explicit = attrs["artifacts-dir"] || attrs["artifactsdir"]
|
|
222
|
+
doc_level = document&.attr("latexmath-artifacts-dir") || document&.attr("latexmath-artifactsdir")
|
|
223
|
+
chosen = explicit || doc_level
|
|
224
|
+
|
|
225
|
+
if chosen
|
|
226
|
+
expand_path(chosen)
|
|
227
|
+
elsif cachedir
|
|
228
|
+
File.join(cachedir, "artifacts")
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def infer_tool_overrides(attrs)
|
|
233
|
+
svg_tool = fetch_string(attrs, "latexmath-svg-tool") || document&.attr("latexmath-svg-tool")
|
|
234
|
+
svg_path = fetch_string(attrs, "latexmath-pdf2svg") || document&.attr("latexmath-pdf2svg")
|
|
235
|
+
png_tool = fetch_string(attrs, "latexmath-png-tool") || fetch_string(attrs, "png-tool") || document&.attr("latexmath-png-tool") || document&.attr("png-tool")
|
|
236
|
+
png_path = fetch_string(attrs, "latexmath-pdftoppm") || document&.attr("latexmath-pdftoppm")
|
|
237
|
+
engine_tool = fetch_string(attrs, "pdflatex") || document&.attr("latexmath-pdflatex")
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
svg: normalize_override(svg_tool),
|
|
241
|
+
svg_path: normalize_override(svg_path),
|
|
242
|
+
png: normalize_override(png_tool),
|
|
243
|
+
png_path: normalize_override(png_path),
|
|
244
|
+
engine: normalize_override(engine_tool)
|
|
245
|
+
}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def determine_engine_name(attrs)
|
|
249
|
+
explicit = fetch_string(attrs, "engine")
|
|
250
|
+
return normalize_engine_name(explicit) if explicit
|
|
251
|
+
|
|
252
|
+
SUPPORTED_ENGINES.each do |engine|
|
|
253
|
+
key = engine.to_s
|
|
254
|
+
return engine if attrs.key?(key) || attrs.key?(engine)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
document_engine = document&.attr("latexmath-engine")
|
|
258
|
+
return normalize_engine_name(document_engine) if document_engine
|
|
259
|
+
|
|
260
|
+
:pdflatex
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def resolve_engine_command(engine_name, attrs)
|
|
264
|
+
element_override = fetch_string(attrs, engine_name.to_s)
|
|
265
|
+
return element_override if element_override
|
|
266
|
+
|
|
267
|
+
doc_override = document_engine_override(engine_name)
|
|
268
|
+
return doc_override if doc_override
|
|
269
|
+
|
|
270
|
+
ENGINE_DEFAULTS.fetch(engine_name) { ENGINE_DEFAULTS[:pdflatex] }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def document_engine_override(engine_name)
|
|
274
|
+
return nil unless document
|
|
275
|
+
|
|
276
|
+
candidates = []
|
|
277
|
+
candidates << document.attr("latexmath-#{engine_name}")
|
|
278
|
+
candidates << document.attr(engine_name.to_s)
|
|
279
|
+
if engine_name != :pdflatex
|
|
280
|
+
candidates << document.attr("latexmath-pdflatex")
|
|
281
|
+
candidates << document.attr("pdflatex")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
candidates.compact.each do |candidate|
|
|
285
|
+
normalized = candidate.to_s.strip
|
|
286
|
+
return normalized unless normalized.empty?
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def normalize_engine_name(value)
|
|
293
|
+
return nil if value.nil?
|
|
294
|
+
|
|
295
|
+
normalized = value.to_s.strip.downcase
|
|
296
|
+
raise InvalidAttributeError, "engine cannot be blank" if normalized.empty?
|
|
297
|
+
|
|
298
|
+
candidate = normalized.gsub(/[^a-z0-9]+/, "_").to_sym
|
|
299
|
+
return candidate if SUPPORTED_ENGINES.include?(candidate)
|
|
300
|
+
|
|
301
|
+
raise InvalidAttributeError, "Unknown engine '#{value}'"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def infer_nocache(attrs, options)
|
|
305
|
+
if attrs.key?("cache")
|
|
306
|
+
parsed = parse_boolean(attrs["cache"])
|
|
307
|
+
return !parsed unless parsed.nil?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
if attrs.key?("nocache")
|
|
311
|
+
parsed = parse_boolean(attrs["nocache"])
|
|
312
|
+
return parsed unless parsed.nil?
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
return true if options.include?("nocache")
|
|
316
|
+
|
|
317
|
+
doc_value = document && parse_boolean(document.attr("latexmath-cache"))
|
|
318
|
+
return !doc_value unless doc_value.nil?
|
|
319
|
+
|
|
320
|
+
false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def infer_keep_artifacts(attrs, options)
|
|
324
|
+
if attrs.key?("keep-artifacts")
|
|
325
|
+
parsed = parse_boolean(attrs["keep-artifacts"])
|
|
326
|
+
return parsed unless parsed.nil?
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
return true if options.include?("keep-artifacts")
|
|
330
|
+
|
|
331
|
+
doc_value = document && parse_boolean(document.attr("latexmath-keep-artifacts"))
|
|
332
|
+
doc_value || false
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def infer_on_error(attrs)
|
|
336
|
+
raw_value, subject = fetch_attribute_value(attrs, "on-error", "latexmath-on-error")
|
|
337
|
+
effective_value = value_or_default(raw_value, :log)
|
|
338
|
+
normalized = effective_value.to_s.strip
|
|
339
|
+
normalized = "log" if normalized.empty?
|
|
340
|
+
|
|
341
|
+
valid = %w[abort log]
|
|
342
|
+
if valid.include?(normalized.downcase)
|
|
343
|
+
ErrorHandling.policy(normalized.downcase.to_sym)
|
|
344
|
+
else
|
|
345
|
+
raise_unsupported_attribute(subject, raw_value,
|
|
346
|
+
supported: valid,
|
|
347
|
+
hint: "set #{subject} to one of [abort, log]")
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def expand_path(path)
|
|
352
|
+
PathUtils.expand_path(path, resolve_outdir)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def resolve_outdir
|
|
356
|
+
base_dir = document&.attr("outdir") || document&.options&.[](:to_dir) || document&.base_dir || document&.attr("docdir") || Dir.pwd
|
|
357
|
+
PathUtils.expand_path(base_dir, Dir.pwd)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def canonical_cachedir_attr
|
|
361
|
+
return nil unless document
|
|
362
|
+
|
|
363
|
+
primary = document.attr("latexmath-cachedir")
|
|
364
|
+
return primary if primary
|
|
365
|
+
|
|
366
|
+
deprecated = document.attr("latexmath-cache-dir")
|
|
367
|
+
if deprecated
|
|
368
|
+
log_cache_dir_alias_once(:document)
|
|
369
|
+
deprecated
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def log_cache_dir_alias_once(_scope)
|
|
374
|
+
return unless document
|
|
375
|
+
|
|
376
|
+
flag_name = "latexmath-deprecated-cache-dir-logged"
|
|
377
|
+
return if parse_boolean(document.attr(flag_name))
|
|
378
|
+
|
|
379
|
+
logger&.info { "latexmath: cache-dir is deprecated, use cachedir instead" }
|
|
380
|
+
set_internal_document_attr(flag_name, true)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def fetch_string(attrs, key)
|
|
384
|
+
value = attrs[key] || attrs[key.to_s]
|
|
385
|
+
value = value.to_s if value
|
|
386
|
+
(value && !value.strip.empty?) ? value.strip : nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def normalize_override(value)
|
|
390
|
+
return nil if value.nil?
|
|
391
|
+
|
|
392
|
+
stripped = value.to_s.strip
|
|
393
|
+
stripped.empty? ? nil : stripped
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def normalize_text(text)
|
|
397
|
+
text.to_s.sub(/^\uFEFF/, "")
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def parse_boolean(value)
|
|
401
|
+
return value if value == true || value == false
|
|
402
|
+
return nil if value.nil?
|
|
403
|
+
|
|
404
|
+
string = value.to_s.strip
|
|
405
|
+
normalized = string.downcase
|
|
406
|
+
return true if string.empty?
|
|
407
|
+
return true if %w[true yes on 1].include?(normalized)
|
|
408
|
+
return false if %w[false no off 0].include?(normalized)
|
|
409
|
+
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def fetch_attribute_value(attrs, element_key, document_key)
|
|
414
|
+
if attrs.key?(element_key)
|
|
415
|
+
return [attrs[element_key], element_key]
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
if document && (document_value = document.attr(document_key))
|
|
419
|
+
return [document_value, document_key]
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
[nil, element_key]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def value_or_default(raw_value, default_value)
|
|
426
|
+
return default_value if raw_value.nil?
|
|
427
|
+
|
|
428
|
+
if raw_value.respond_to?(:strip)
|
|
429
|
+
stripped = raw_value.strip
|
|
430
|
+
return default_value if stripped.empty?
|
|
431
|
+
stripped
|
|
432
|
+
else
|
|
433
|
+
raw_value
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def raise_unsupported_attribute(subject, raw_value, supported:, hint:)
|
|
438
|
+
raise UnsupportedValueError.new(
|
|
439
|
+
category: :attribute,
|
|
440
|
+
subject: subject,
|
|
441
|
+
value: normalize_error_value(raw_value),
|
|
442
|
+
supported: supported,
|
|
443
|
+
hint: hint
|
|
444
|
+
)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def normalize_error_value(raw_value)
|
|
448
|
+
return raw_value if raw_value.nil?
|
|
449
|
+
|
|
450
|
+
raw_value.respond_to?(:strip) ? raw_value.strip : raw_value
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def valid_fontsize?(value)
|
|
454
|
+
/
|
|
455
|
+
\A
|
|
456
|
+
(?:
|
|
457
|
+
\d+(?:\.\d+)?
|
|
458
|
+
)
|
|
459
|
+
pt
|
|
460
|
+
\z
|
|
461
|
+
/x.match?(value)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def set_internal_document_attr(name, value)
|
|
465
|
+
normalized = normalize_attribute_value(value)
|
|
466
|
+
if document.respond_to?(:set_attribute)
|
|
467
|
+
document.set_attribute(name, normalized)
|
|
468
|
+
elsif document.respond_to?(:set_attr)
|
|
469
|
+
document.set_attr(name, normalized)
|
|
470
|
+
elsif document.respond_to?(:attributes)
|
|
471
|
+
document.attributes[name] = normalized
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def normalize_attribute_value(value)
|
|
476
|
+
case value
|
|
477
|
+
when true then "true"
|
|
478
|
+
when false then "false"
|
|
479
|
+
else
|
|
480
|
+
value
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: 2025 Shuai Zhang
|
|
4
|
+
#
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later WITH LGPL-3.0-linking-exception
|
|
6
|
+
|
|
7
|
+
module Asciidoctor
|
|
8
|
+
module Latexmath
|
|
9
|
+
module Cache
|
|
10
|
+
class CacheEntry
|
|
11
|
+
attr_reader :final_path, :format, :content_hash, :preamble_hash, :fontsize, :engine,
|
|
12
|
+
:ppi, :entry_type, :created_at, :checksum, :size_bytes, :tool_presence
|
|
13
|
+
|
|
14
|
+
def initialize(final_path:, format:, content_hash:, preamble_hash:, fontsize:, engine:, ppi:, entry_type:, created_at:, checksum:, size_bytes:, tool_presence: {})
|
|
15
|
+
@final_path = final_path
|
|
16
|
+
@format = format
|
|
17
|
+
@content_hash = content_hash
|
|
18
|
+
@preamble_hash = preamble_hash
|
|
19
|
+
@fontsize = fontsize
|
|
20
|
+
@engine = engine
|
|
21
|
+
@ppi = ppi
|
|
22
|
+
@entry_type = entry_type
|
|
23
|
+
@created_at = created_at
|
|
24
|
+
@checksum = checksum
|
|
25
|
+
@size_bytes = size_bytes
|
|
26
|
+
@tool_presence = (tool_presence || {}).transform_keys(&:to_s)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: 2025 Shuai Zhang
|
|
4
|
+
#
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later WITH LGPL-3.0-linking-exception
|
|
6
|
+
|
|
7
|
+
require "digest"
|
|
8
|
+
|
|
9
|
+
module Asciidoctor
|
|
10
|
+
module Latexmath
|
|
11
|
+
module Cache
|
|
12
|
+
class CacheKey
|
|
13
|
+
FIELDS_ORDER = %i[ext_version content_hash format preamble_hash fontsize_hash ppi entry_type].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader(*FIELDS_ORDER)
|
|
16
|
+
|
|
17
|
+
def initialize(ext_version:, content_hash:, format:, preamble_hash:, fontsize_hash:, ppi:, entry_type:)
|
|
18
|
+
@ext_version = ext_version
|
|
19
|
+
@content_hash = content_hash
|
|
20
|
+
@format = format
|
|
21
|
+
@preamble_hash = preamble_hash
|
|
22
|
+
@fontsize_hash = fontsize_hash || "-"
|
|
23
|
+
@ppi = ppi || "-"
|
|
24
|
+
@entry_type = entry_type
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def digest
|
|
28
|
+
@digest ||= Digest::SHA256.hexdigest(FIELDS_ORDER.map { |field| public_send(field).to_s }.join("\n"))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: 2025 Shuai Zhang
|
|
4
|
+
#
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later WITH LGPL-3.0-linking-exception
|
|
6
|
+
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "json"
|
|
9
|
+
require "digest"
|
|
10
|
+
require "time"
|
|
11
|
+
|
|
12
|
+
module Asciidoctor
|
|
13
|
+
module Latexmath
|
|
14
|
+
module Cache
|
|
15
|
+
class DiskCache
|
|
16
|
+
METADATA_FILENAME = "metadata.json"
|
|
17
|
+
ARTIFACT_FILENAME = "artifact"
|
|
18
|
+
METADATA_VERSION = 1
|
|
19
|
+
|
|
20
|
+
def initialize(root)
|
|
21
|
+
@root = root
|
|
22
|
+
FileUtils.mkdir_p(@root)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch(key_digest)
|
|
26
|
+
entry_dir = entry_dir(key_digest)
|
|
27
|
+
metadata_path = File.join(entry_dir, METADATA_FILENAME)
|
|
28
|
+
artifact_path = File.join(entry_dir, ARTIFACT_FILENAME)
|
|
29
|
+
return nil unless File.file?(metadata_path) && File.file?(artifact_path)
|
|
30
|
+
|
|
31
|
+
metadata = JSON.parse(File.read(metadata_path))
|
|
32
|
+
return nil unless valid_checksum?(artifact_path, metadata["checksum"])
|
|
33
|
+
|
|
34
|
+
CacheEntry.new(
|
|
35
|
+
final_path: artifact_path,
|
|
36
|
+
format: to_symbol(metadata["format"]),
|
|
37
|
+
content_hash: metadata["content_hash"],
|
|
38
|
+
preamble_hash: metadata["preamble_hash"],
|
|
39
|
+
fontsize: metadata["fontsize"],
|
|
40
|
+
engine: metadata["engine"],
|
|
41
|
+
ppi: metadata["ppi"],
|
|
42
|
+
entry_type: to_symbol(metadata["entry_type"]),
|
|
43
|
+
created_at: Time.parse(metadata["created_at"]),
|
|
44
|
+
checksum: metadata["checksum"],
|
|
45
|
+
size_bytes: metadata["size_bytes"],
|
|
46
|
+
tool_presence: metadata["tool_presence"] || {}
|
|
47
|
+
)
|
|
48
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def store(key_digest, cache_entry, source_path)
|
|
53
|
+
entry_dir = entry_dir(key_digest)
|
|
54
|
+
FileUtils.mkdir_p(entry_dir)
|
|
55
|
+
artifact_path = File.join(entry_dir, ARTIFACT_FILENAME)
|
|
56
|
+
metadata_path = File.join(entry_dir, METADATA_FILENAME)
|
|
57
|
+
|
|
58
|
+
checksum = Digest::SHA256.file(source_path).hexdigest
|
|
59
|
+
size_bytes = File.size(source_path)
|
|
60
|
+
|
|
61
|
+
temp_artifact = prepare_temp_path(artifact_path)
|
|
62
|
+
temp_metadata = prepare_temp_path(metadata_path)
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
FileUtils.cp(source_path, temp_artifact)
|
|
66
|
+
|
|
67
|
+
metadata = {
|
|
68
|
+
"version" => METADATA_VERSION,
|
|
69
|
+
"key" => key_digest,
|
|
70
|
+
"format" => cache_entry.format.to_s,
|
|
71
|
+
"content_hash" => cache_entry.content_hash,
|
|
72
|
+
"preamble_hash" => cache_entry.preamble_hash,
|
|
73
|
+
"fontsize" => cache_entry.fontsize,
|
|
74
|
+
"engine" => cache_entry.engine,
|
|
75
|
+
"ppi" => cache_entry.ppi,
|
|
76
|
+
"entry_type" => cache_entry.entry_type.to_s,
|
|
77
|
+
"created_at" => cache_entry.created_at.utc.iso8601,
|
|
78
|
+
"checksum" => "sha256:#{checksum}",
|
|
79
|
+
"size_bytes" => size_bytes,
|
|
80
|
+
"tool_presence" => cache_entry.tool_presence
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
File.write(temp_metadata, JSON.pretty_generate(metadata))
|
|
84
|
+
|
|
85
|
+
FileUtils.mv(temp_artifact, artifact_path)
|
|
86
|
+
FileUtils.mv(temp_metadata, metadata_path)
|
|
87
|
+
ensure
|
|
88
|
+
FileUtils.rm_f(temp_artifact) if File.exist?(temp_artifact)
|
|
89
|
+
FileUtils.rm_f(temp_metadata) if File.exist?(temp_metadata)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def with_lock(key_digest)
|
|
94
|
+
lock_path = File.join(root, "#{key_digest}.lock")
|
|
95
|
+
FileUtils.mkdir_p(File.dirname(lock_path))
|
|
96
|
+
|
|
97
|
+
attempts = 0
|
|
98
|
+
base_sleep = 0.05
|
|
99
|
+
|
|
100
|
+
loop do
|
|
101
|
+
attempts += 1
|
|
102
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
103
|
+
if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
104
|
+
begin
|
|
105
|
+
return yield
|
|
106
|
+
ensure
|
|
107
|
+
file.flock(File::LOCK_UN)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
raise IOError, "could not obtain cache lock for #{key_digest}" if attempts >= 5
|
|
113
|
+
|
|
114
|
+
sleep(base_sleep * (2**(attempts - 1)))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
attr_reader :root
|
|
121
|
+
|
|
122
|
+
def entry_dir(key_digest)
|
|
123
|
+
File.join(root, key_digest)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def valid_checksum?(artifact_path, checksum_field)
|
|
127
|
+
return false unless checksum_field
|
|
128
|
+
|
|
129
|
+
algorithm, value = checksum_field.split(":", 2)
|
|
130
|
+
return false unless algorithm == "sha256" && value
|
|
131
|
+
|
|
132
|
+
Digest::SHA256.file(artifact_path).hexdigest == value
|
|
133
|
+
rescue Errno::ENOENT
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def to_symbol(value)
|
|
138
|
+
return nil if value.nil?
|
|
139
|
+
|
|
140
|
+
value.to_s.strip.empty? ? nil : value.to_s.downcase.to_sym
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def prepare_temp_path(path)
|
|
144
|
+
dir = File.dirname(path)
|
|
145
|
+
basename = File.basename(path)
|
|
146
|
+
File.join(dir, ".#{basename}.tmp-#{Process.pid}-#{Thread.current.object_id}")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|