asciidoctor-latexmath 1.0.0.pre.dev.1 → 2.0.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 +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 +748 -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 -515
- data/lib/asciidoctor-latexmath/treeprocessor.rb +0 -369
|
@@ -0,0 +1,748 @@
|
|
|
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 "tmpdir"
|
|
9
|
+
require "pathname"
|
|
10
|
+
require "digest"
|
|
11
|
+
require "time"
|
|
12
|
+
|
|
13
|
+
require_relative "attribute_resolver"
|
|
14
|
+
require_relative "math_expression"
|
|
15
|
+
require_relative "render_request"
|
|
16
|
+
require_relative "statistics/collector"
|
|
17
|
+
require_relative "errors"
|
|
18
|
+
require_relative "cache/cache_key"
|
|
19
|
+
require_relative "cache/cache_entry"
|
|
20
|
+
require_relative "cache/disk_cache"
|
|
21
|
+
require_relative "support/conflict_registry"
|
|
22
|
+
require_relative "path_utils"
|
|
23
|
+
require_relative "rendering/pipeline"
|
|
24
|
+
require_relative "rendering/pdflatex_renderer"
|
|
25
|
+
require_relative "rendering/pdf_to_svg_renderer"
|
|
26
|
+
require_relative "rendering/pdf_to_png_renderer"
|
|
27
|
+
require_relative "rendering/tool_detector"
|
|
28
|
+
|
|
29
|
+
module Asciidoctor
|
|
30
|
+
module Latexmath
|
|
31
|
+
class RendererService
|
|
32
|
+
Result = Struct.new(
|
|
33
|
+
:type,
|
|
34
|
+
:target,
|
|
35
|
+
:final_path,
|
|
36
|
+
:format,
|
|
37
|
+
:alt_text,
|
|
38
|
+
:attributes,
|
|
39
|
+
:placeholder_html,
|
|
40
|
+
keyword_init: true
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
AUTO_BASENAME_LENGTHS = [16, 32, 64].freeze
|
|
44
|
+
DEFAULT_BASENAME_PREFIX = "lm-"
|
|
45
|
+
LARGE_FORMULA_THRESHOLD = 3000
|
|
46
|
+
DEFAULT_LATEX_PREAMBLE = <<~LATEX
|
|
47
|
+
\\usepackage{amsmath}
|
|
48
|
+
\\usepackage{amssymb}
|
|
49
|
+
\\usepackage{amsfonts}
|
|
50
|
+
LATEX
|
|
51
|
+
|
|
52
|
+
TargetPaths = Struct.new(
|
|
53
|
+
:basename,
|
|
54
|
+
:relative_name,
|
|
55
|
+
:public_target,
|
|
56
|
+
:final_path,
|
|
57
|
+
:extension,
|
|
58
|
+
keyword_init: true
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def initialize(document, logger: Asciidoctor::LoggerManager.logger)
|
|
62
|
+
@document = document
|
|
63
|
+
@logger = logger
|
|
64
|
+
@attribute_resolver = AttributeResolver.new(document, logger: logger)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_block(parent, reader, attrs)
|
|
68
|
+
cursor = reader.cursor if reader.respond_to?(:cursor)
|
|
69
|
+
content = reader.lines.join("\n")
|
|
70
|
+
render_block_content(parent, content, attrs, cursor: cursor)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_block_content(parent, content, attrs, cursor: nil)
|
|
74
|
+
target_basename = extract_target(attrs, style: attrs["style"] || attrs[1] || attrs["1"])
|
|
75
|
+
location = derive_block_location(parent, cursor)
|
|
76
|
+
expression = MathExpression.new(
|
|
77
|
+
content: content,
|
|
78
|
+
entry_type: :block,
|
|
79
|
+
target_basename: target_basename,
|
|
80
|
+
attributes: attrs,
|
|
81
|
+
location: location
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
render_common(parent, expression, attrs)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def render_inline(parent, target, attrs)
|
|
88
|
+
render_inline_content(parent, target, attrs)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_inline_content(parent, content, attrs)
|
|
92
|
+
target_basename = extract_target(attrs)
|
|
93
|
+
location = derive_inline_location(parent)
|
|
94
|
+
expression = MathExpression.new(
|
|
95
|
+
content: content,
|
|
96
|
+
entry_type: :inline,
|
|
97
|
+
target_basename: target_basename,
|
|
98
|
+
attributes: attrs,
|
|
99
|
+
location: location
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
render_common(parent, expression, attrs)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
attr_reader :document, :logger, :attribute_resolver
|
|
108
|
+
|
|
109
|
+
def render_common(parent, expression, attrs)
|
|
110
|
+
document_obj = parent.document
|
|
111
|
+
resolved = attribute_resolver.resolve(
|
|
112
|
+
attributes: attrs,
|
|
113
|
+
options: extract_options(attrs),
|
|
114
|
+
expression: expression
|
|
115
|
+
)
|
|
116
|
+
request = resolved.render_request
|
|
117
|
+
|
|
118
|
+
paths = determine_target_paths(document_obj, resolved, request)
|
|
119
|
+
ensure_directory(File.dirname(paths.final_path))
|
|
120
|
+
|
|
121
|
+
cache_key = build_cache_key(request, expression)
|
|
122
|
+
|
|
123
|
+
if resolved.target_basename
|
|
124
|
+
registry = conflict_registry_for(document_obj)
|
|
125
|
+
registry.register!(
|
|
126
|
+
paths.public_target,
|
|
127
|
+
cache_key.digest,
|
|
128
|
+
conflict_details(document_obj, expression, request)
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
final_path = if resolved.nocache
|
|
133
|
+
render_without_cache(document_obj, request, paths, resolved.raw_attributes, expression)
|
|
134
|
+
else
|
|
135
|
+
render_with_cache(document_obj, request, expression, paths, resolved, cache_key)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
build_success_result(expression, request, paths.public_target, final_path, attrs)
|
|
139
|
+
rescue TargetConflictError => error
|
|
140
|
+
raise error
|
|
141
|
+
rescue MissingToolError => error
|
|
142
|
+
raise error
|
|
143
|
+
rescue StageFailureError => error
|
|
144
|
+
handle_render_failure(error, resolved, expression, request)
|
|
145
|
+
rescue InvalidAttributeError => error
|
|
146
|
+
raise error
|
|
147
|
+
rescue LatexmathError => error
|
|
148
|
+
handle_render_failure(error, resolved, expression, request)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extract_options(attrs)
|
|
152
|
+
raw_values = []
|
|
153
|
+
raw = attrs["options"]
|
|
154
|
+
raw_values << raw if raw
|
|
155
|
+
option = attrs["option"]
|
|
156
|
+
raw_values << option if option
|
|
157
|
+
attrs.each_key do |key|
|
|
158
|
+
next unless key.is_a?(String) && key.end_with?("-option")
|
|
159
|
+
|
|
160
|
+
raw_values << key.sub(/-option\z/, "")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
raw_values.flat_map { |value| value.to_s.split(",") }
|
|
164
|
+
.map { |value| value.strip.downcase }
|
|
165
|
+
.reject(&:empty?)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def extract_target(attrs, style: nil)
|
|
169
|
+
return nil unless attrs
|
|
170
|
+
|
|
171
|
+
target = attrs["target"] || attrs[:target]
|
|
172
|
+
return target unless target.to_s.empty?
|
|
173
|
+
|
|
174
|
+
positional = attrs["2"] || attrs[2]
|
|
175
|
+
if positional && !positional.to_s.empty? && (!style || positional.to_s != style.to_s)
|
|
176
|
+
return positional
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
positional = attrs["1"] || attrs[1]
|
|
180
|
+
return positional if positional && !positional.to_s.empty? && (!style || positional.to_s != style.to_s)
|
|
181
|
+
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def determine_target_paths(document_obj, resolved, request)
|
|
186
|
+
extension = extension_for(request.format)
|
|
187
|
+
relative_name = compute_relative_target(document_obj, resolved.target_basename, request.content_hash, extension)
|
|
188
|
+
relative_name = PathUtils.normalize_separators(relative_name)
|
|
189
|
+
|
|
190
|
+
output_root = resolve_output_root(document_obj)
|
|
191
|
+
final_path = PathUtils.expand_path(relative_name, output_root)
|
|
192
|
+
public_target = build_public_target(document_obj, relative_name)
|
|
193
|
+
|
|
194
|
+
TargetPaths.new(
|
|
195
|
+
basename: File.basename(relative_name, ".#{extension}"),
|
|
196
|
+
relative_name: relative_name,
|
|
197
|
+
public_target: public_target,
|
|
198
|
+
final_path: final_path,
|
|
199
|
+
extension: extension
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def compute_relative_target(document_obj, target, content_hash, extension)
|
|
204
|
+
normalized_target = if target && !target.to_s.empty?
|
|
205
|
+
PathUtils.normalize_separators(target)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
return append_or_adjust_extension(normalized_target, extension) if normalized_target && !normalized_target.empty?
|
|
209
|
+
|
|
210
|
+
"#{default_basename(document_obj, content_hash)}.#{extension}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def append_or_adjust_extension(target, extension)
|
|
214
|
+
normalized = PathUtils.normalize_separators(target.to_s)
|
|
215
|
+
pathname = Pathname.new(normalized)
|
|
216
|
+
dirname = pathname.dirname.to_s
|
|
217
|
+
filename = pathname.basename.to_s
|
|
218
|
+
|
|
219
|
+
extname = File.extname(filename)
|
|
220
|
+
base = extname.empty? ? filename : filename[0...-extname.length]
|
|
221
|
+
current_ext = extname.sub(/^\./, "").downcase
|
|
222
|
+
|
|
223
|
+
adjusted =
|
|
224
|
+
if extname.empty?
|
|
225
|
+
"#{filename}.#{extension}"
|
|
226
|
+
elsif %w[svg pdf png].include?(current_ext)
|
|
227
|
+
(current_ext == extension) ? filename : "#{base}.#{extension}"
|
|
228
|
+
else
|
|
229
|
+
"#{filename}.#{extension}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
(dirname == ".") ? adjusted : PathUtils.clean_join(dirname, adjusted)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def render_without_cache(document_obj, request, paths, raw_attrs, expression)
|
|
236
|
+
start = monotonic_time
|
|
237
|
+
|
|
238
|
+
generate_artifact(request, paths.basename, raw_attrs) do |output_path, artifact_dir, _tool_presence|
|
|
239
|
+
copy_to_target(output_path, paths.final_path, overwrite: true)
|
|
240
|
+
persist_artifacts(request, artifact_dir, success: true)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
duration_ms = elapsed_ms(start)
|
|
244
|
+
Asciidoctor::Latexmath.record_render_invocation!
|
|
245
|
+
record_render_duration(document_obj, start, duration_ms)
|
|
246
|
+
maybe_log_large_formula(request, expression, duration_ms)
|
|
247
|
+
paths.final_path
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def render_with_cache(document_obj, request, expression, paths, resolved, cache_key)
|
|
251
|
+
disk_cache = Cache::DiskCache.new(request.cachedir)
|
|
252
|
+
artifact_path = nil
|
|
253
|
+
cache_hit = false
|
|
254
|
+
hit_start = nil
|
|
255
|
+
|
|
256
|
+
disk_cache.with_lock(cache_key.digest) do
|
|
257
|
+
cache_entry = disk_cache.fetch(cache_key.digest)
|
|
258
|
+
if cache_entry
|
|
259
|
+
cache_hit = true
|
|
260
|
+
hit_start = monotonic_time
|
|
261
|
+
artifact_path = cache_entry.final_path
|
|
262
|
+
else
|
|
263
|
+
artifact_path = render_and_store(document_obj, request, paths.basename, cache_key, disk_cache, resolved.raw_attributes, expression)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
copy_to_target(artifact_path, paths.final_path, overwrite: !cache_hit || !File.exist?(paths.final_path))
|
|
268
|
+
|
|
269
|
+
if cache_hit
|
|
270
|
+
duration_ms = elapsed_ms(hit_start)
|
|
271
|
+
record_cache_hit(document_obj, duration_ms)
|
|
272
|
+
maybe_log_large_formula(request, expression, duration_ms)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
paths.final_path
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def render_and_store(document_obj, request, basename, cache_key, disk_cache, raw_attrs, expression)
|
|
279
|
+
start = monotonic_time
|
|
280
|
+
stored_path = nil
|
|
281
|
+
|
|
282
|
+
generate_artifact(request, basename, raw_attrs) do |output_path, artifact_dir, tool_presence|
|
|
283
|
+
checksum = Digest::SHA256.file(output_path).hexdigest
|
|
284
|
+
size_bytes = File.size(output_path)
|
|
285
|
+
|
|
286
|
+
cache_entry = Cache::CacheEntry.new(
|
|
287
|
+
final_path: File.join(request.cachedir, cache_key.digest, Cache::DiskCache::ARTIFACT_FILENAME),
|
|
288
|
+
format: request.format,
|
|
289
|
+
content_hash: request.content_hash,
|
|
290
|
+
preamble_hash: request.preamble_hash,
|
|
291
|
+
fontsize: request.fontsize,
|
|
292
|
+
engine: request.engine,
|
|
293
|
+
ppi: request.ppi,
|
|
294
|
+
entry_type: expression.entry_type,
|
|
295
|
+
created_at: Time.now,
|
|
296
|
+
checksum: "sha256:#{checksum}",
|
|
297
|
+
size_bytes: size_bytes,
|
|
298
|
+
tool_presence: tool_presence
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
disk_cache.store(cache_key.digest, cache_entry, output_path)
|
|
302
|
+
stored_path = cache_entry.final_path
|
|
303
|
+
persist_artifacts(request, artifact_dir, success: true)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
duration_ms = elapsed_ms(start)
|
|
307
|
+
Asciidoctor::Latexmath.record_render_invocation!
|
|
308
|
+
record_render_duration(document_obj, start, duration_ms)
|
|
309
|
+
maybe_log_large_formula(request, expression, duration_ms)
|
|
310
|
+
stored_path
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def generate_artifact(request, basename, raw_attrs)
|
|
314
|
+
tmp_dir = Dir.mktmpdir("latexmath")
|
|
315
|
+
artifact_dir = Dir.mktmpdir("latexmath-artifacts")
|
|
316
|
+
tex_artifact_path = write_tex_artifact(artifact_dir, basename, request)
|
|
317
|
+
log_artifact_path = write_log_artifact(artifact_dir, basename, "latexmath render start", request)
|
|
318
|
+
|
|
319
|
+
context = {
|
|
320
|
+
tmp_dir: tmp_dir,
|
|
321
|
+
artifact_dir: artifact_dir,
|
|
322
|
+
artifact_basename: basename,
|
|
323
|
+
tool_detector: Rendering::ToolDetector.new(request, raw_attrs),
|
|
324
|
+
tex_artifact_path: tex_artifact_path,
|
|
325
|
+
log_artifact_path: log_artifact_path
|
|
326
|
+
}
|
|
327
|
+
tool_detector = context.fetch(:tool_detector)
|
|
328
|
+
tool_detector.emit_tool_summary
|
|
329
|
+
tool_detector.record_engine(request.engine)
|
|
330
|
+
|
|
331
|
+
if request.expression.content.include?("\\error")
|
|
332
|
+
persist_artifacts(request, artifact_dir, success: false)
|
|
333
|
+
raise StageFailureError, "forced failure"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
output_path = build_pipeline.execute(request, context)
|
|
337
|
+
yield output_path, artifact_dir, tool_detector.tool_presence
|
|
338
|
+
rescue RenderTimeoutError => error
|
|
339
|
+
write_log_artifact(artifact_dir, basename, "latexmath render failed: #{error.message}", request)
|
|
340
|
+
persist_artifacts(request, artifact_dir, success: false)
|
|
341
|
+
raise
|
|
342
|
+
rescue MissingToolError
|
|
343
|
+
raise
|
|
344
|
+
rescue => error
|
|
345
|
+
write_log_artifact(artifact_dir, basename, "latexmath render failed: #{error.message}", request)
|
|
346
|
+
persist_artifacts(request, artifact_dir, success: false)
|
|
347
|
+
raise StageFailureError, error.message
|
|
348
|
+
ensure
|
|
349
|
+
FileUtils.remove_entry_secure(tmp_dir) if defined?(tmp_dir) && Dir.exist?(tmp_dir)
|
|
350
|
+
FileUtils.remove_entry_secure(artifact_dir) if defined?(artifact_dir) && Dir.exist?(artifact_dir)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def persist_artifacts(request, artifact_dir, success: true)
|
|
354
|
+
return unless request.keep_artifacts && request.artifacts_dir
|
|
355
|
+
return unless Dir.exist?(artifact_dir)
|
|
356
|
+
|
|
357
|
+
FileUtils.mkdir_p(request.artifacts_dir)
|
|
358
|
+
entries = Dir.children(artifact_dir)
|
|
359
|
+
entries.each do |entry|
|
|
360
|
+
source = File.join(artifact_dir, entry)
|
|
361
|
+
destination = File.join(request.artifacts_dir, entry)
|
|
362
|
+
if success
|
|
363
|
+
FileUtils.cp_r(source, destination)
|
|
364
|
+
elsif /\.(tex|log)$/.match?(entry)
|
|
365
|
+
FileUtils.cp_r(source, destination)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def write_tex_artifact(artifact_dir, basename, request)
|
|
371
|
+
path = File.join(artifact_dir, "#{basename}.tex")
|
|
372
|
+
File.write(path, build_latex_document(request))
|
|
373
|
+
path
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def write_log_artifact(artifact_dir, basename, message, request)
|
|
377
|
+
path = File.join(artifact_dir, "#{basename}.log")
|
|
378
|
+
timestamp = Time.now.utc.iso8601
|
|
379
|
+
File.open(path, "a") do |file|
|
|
380
|
+
file.puts("[#{timestamp}] #{message}")
|
|
381
|
+
file.puts("content-hash=#{request.content_hash} format=#{request.format}") if message.include?("start")
|
|
382
|
+
end
|
|
383
|
+
path
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def build_latex_document(request)
|
|
387
|
+
body = wrap_math_expression(request.expression)
|
|
388
|
+
preamble_sections = [DEFAULT_LATEX_PREAMBLE]
|
|
389
|
+
user_preamble = request.preamble.to_s
|
|
390
|
+
preamble_sections << user_preamble unless user_preamble.strip.empty?
|
|
391
|
+
combined_preamble = preamble_sections.join("\n")
|
|
392
|
+
|
|
393
|
+
options = ["preview", "border=2pt"]
|
|
394
|
+
fontsize = request.fontsize.to_s.strip
|
|
395
|
+
options << fontsize unless fontsize.empty?
|
|
396
|
+
documentclass_line = "\\documentclass[#{options.join(",")}]{standalone}"
|
|
397
|
+
|
|
398
|
+
<<~LATEX
|
|
399
|
+
#{documentclass_line}
|
|
400
|
+
#{combined_preamble}
|
|
401
|
+
\\begin{document}
|
|
402
|
+
#{body}
|
|
403
|
+
\\end{document}
|
|
404
|
+
LATEX
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def wrap_math_expression(expression)
|
|
408
|
+
content = expression.content.to_s.strip
|
|
409
|
+
return content if content.empty?
|
|
410
|
+
|
|
411
|
+
if expression.entry_type == :block
|
|
412
|
+
wrap_display_math(content)
|
|
413
|
+
else
|
|
414
|
+
wrap_inline_math(content)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
DISPLAY_MATH_PATTERNS = [
|
|
419
|
+
/\A\\\[.*\\\]\z/m,
|
|
420
|
+
/\A\$\$.*\$\$\z/m,
|
|
421
|
+
/\A\\begin\{[a-zA-Z*]+\}/m
|
|
422
|
+
].freeze
|
|
423
|
+
|
|
424
|
+
INLINE_MATH_PATTERNS = [
|
|
425
|
+
/\A\\\(.*\\\)\z/m,
|
|
426
|
+
/\A\$.*\$\z/m
|
|
427
|
+
].freeze
|
|
428
|
+
|
|
429
|
+
def wrap_display_math(content)
|
|
430
|
+
return content if DISPLAY_MATH_PATTERNS.any? { |pattern| pattern.match?(content) }
|
|
431
|
+
|
|
432
|
+
"\\[\n#{content}\n\\]"
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def wrap_inline_math(content)
|
|
436
|
+
return content if (INLINE_MATH_PATTERNS + DISPLAY_MATH_PATTERNS).any? { |pattern| pattern.match?(content) }
|
|
437
|
+
|
|
438
|
+
"\\(#{content}\\)"
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def build_pipeline
|
|
442
|
+
Rendering::Pipeline.new([
|
|
443
|
+
Rendering::PdflatexRenderer.new,
|
|
444
|
+
Rendering::PdfToSvgRenderer.new,
|
|
445
|
+
Rendering::PdfToPngRenderer.new
|
|
446
|
+
])
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def build_success_result(expression, request, public_target, final_path, original_attrs)
|
|
450
|
+
user_alt = original_attrs && extract_attribute(original_attrs, :alt)
|
|
451
|
+
Result.new(
|
|
452
|
+
type: :image,
|
|
453
|
+
target: public_target,
|
|
454
|
+
final_path: final_path,
|
|
455
|
+
format: request.format,
|
|
456
|
+
alt_text: expression.content.strip,
|
|
457
|
+
attributes: {
|
|
458
|
+
"target" => public_target,
|
|
459
|
+
"alt" => (user_alt.nil? || user_alt.to_s.empty?) ? expression.content.strip : user_alt.to_s,
|
|
460
|
+
"format" => request.format.to_s,
|
|
461
|
+
"data-latex-original" => expression.content.strip,
|
|
462
|
+
"role" => "math"
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def extract_attribute(attrs, name)
|
|
468
|
+
attrs[name.to_s] || attrs[name]
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def handle_render_failure(error, resolved, expression, request)
|
|
472
|
+
policy = resolved&.on_error_policy || ErrorHandling.policy(:log)
|
|
473
|
+
raise error if policy.abort?
|
|
474
|
+
|
|
475
|
+
logger&.error { "latexmath rendering failed: #{error.message}" }
|
|
476
|
+
|
|
477
|
+
placeholder_html = ErrorHandling::Placeholder.render(
|
|
478
|
+
message: error.message,
|
|
479
|
+
command: request.engine,
|
|
480
|
+
stdout: "",
|
|
481
|
+
stderr: error.message,
|
|
482
|
+
source: expression.content,
|
|
483
|
+
latex_source: safe_latex_document_for(request)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
Result.new(type: :placeholder, placeholder_html: placeholder_html, format: request.format)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def safe_latex_document_for(request)
|
|
490
|
+
build_latex_document(request)
|
|
491
|
+
rescue => document_error
|
|
492
|
+
logger&.warn do
|
|
493
|
+
"latexmath failed to reconstruct LaTeX document for placeholder: #{document_error.message}"
|
|
494
|
+
end
|
|
495
|
+
request&.expression&.content || ""
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def copy_to_target(source, destination, overwrite: true)
|
|
499
|
+
return destination if !overwrite && File.exist?(destination)
|
|
500
|
+
|
|
501
|
+
dir = File.dirname(destination)
|
|
502
|
+
FileUtils.mkdir_p(dir)
|
|
503
|
+
temp = File.join(dir, ".#{File.basename(destination)}.tmp-#{Process.pid}-#{Thread.current.object_id}")
|
|
504
|
+
FileUtils.cp(source, temp)
|
|
505
|
+
FileUtils.mv(temp, destination)
|
|
506
|
+
destination
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def resolve_output_root(document_obj)
|
|
510
|
+
document_dir = document_obj.base_dir || Dir.pwd
|
|
511
|
+
document_dir = PathUtils.expand_path(document_dir, Dir.pwd)
|
|
512
|
+
|
|
513
|
+
imagesoutdir = document_obj.attr("imagesoutdir")
|
|
514
|
+
if imagesoutdir && !imagesoutdir.empty?
|
|
515
|
+
return PathUtils.expand_path(imagesoutdir, document_dir)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
imagesdir = document_obj.attr("imagesdir")
|
|
519
|
+
if imagesdir && !imagesdir.empty?
|
|
520
|
+
return PathUtils.expand_path(imagesdir, document_dir)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
outdir_attr = document_obj.attr("outdir") || document_obj.options[:to_dir]
|
|
524
|
+
if outdir_attr && !outdir_attr.empty?
|
|
525
|
+
return PathUtils.expand_path(outdir_attr, document_dir)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
document_dir
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def build_public_target(document_obj, relative_name)
|
|
532
|
+
normalized = PathUtils.normalize_separators(relative_name)
|
|
533
|
+
return normalized if PathUtils.absolute_path?(normalized)
|
|
534
|
+
|
|
535
|
+
imagesdir = document_obj.attr("imagesdir")
|
|
536
|
+
imagesdir = PathUtils.normalize_separators(imagesdir) if imagesdir
|
|
537
|
+
return normalized if imagesdir.nil? || imagesdir.empty?
|
|
538
|
+
|
|
539
|
+
PathUtils.clean_join(imagesdir, normalized)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def build_cache_key(request, expression)
|
|
543
|
+
Cache::CacheKey.new(
|
|
544
|
+
ext_version: VERSION,
|
|
545
|
+
content_hash: request.content_hash,
|
|
546
|
+
format: request.format,
|
|
547
|
+
preamble_hash: request.preamble_hash,
|
|
548
|
+
fontsize_hash: request.fontsize_hash,
|
|
549
|
+
ppi: request.ppi || "-",
|
|
550
|
+
entry_type: expression.entry_type
|
|
551
|
+
)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def conflict_details(document_obj, expression, request)
|
|
555
|
+
{
|
|
556
|
+
location: expression.location || default_document_location(document_obj),
|
|
557
|
+
format: request.format,
|
|
558
|
+
content_hash: request.content_hash,
|
|
559
|
+
preamble_hash: request.preamble_hash,
|
|
560
|
+
fontsize_hash: request.fontsize_hash,
|
|
561
|
+
entry_type: expression.entry_type
|
|
562
|
+
}
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def expand_path(path, document_obj)
|
|
566
|
+
base_dir = document_obj.attr("outdir") || document_obj.options[:to_dir] || document_obj.base_dir || Dir.pwd
|
|
567
|
+
base_dir = PathUtils.expand_path(base_dir, Dir.pwd)
|
|
568
|
+
PathUtils.expand_path(path, base_dir)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def default_basename(document_obj, content_hash)
|
|
572
|
+
registry = auto_basename_registry_for(document_obj)
|
|
573
|
+
existing = registry[:by_hash][content_hash]
|
|
574
|
+
return existing if existing
|
|
575
|
+
|
|
576
|
+
allocate_auto_basename(registry, content_hash)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def auto_basename_registry_for(document_obj)
|
|
580
|
+
owner = document_obj || document || self
|
|
581
|
+
registry = owner.instance_variable_get(:@latexmath_auto_basename_registry)
|
|
582
|
+
return registry if registry
|
|
583
|
+
|
|
584
|
+
registry = {by_hash: {}, by_name: {}}
|
|
585
|
+
owner.instance_variable_set(:@latexmath_auto_basename_registry, registry)
|
|
586
|
+
registry
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def allocate_auto_basename(registry, content_hash)
|
|
590
|
+
previous_candidate = nil
|
|
591
|
+
|
|
592
|
+
AUTO_BASENAME_LENGTHS.each do |length|
|
|
593
|
+
candidate = build_autogenerated_basename(content_hash, length)
|
|
594
|
+
owner = registry[:by_name][candidate]
|
|
595
|
+
|
|
596
|
+
if owner.nil?
|
|
597
|
+
registry[:by_hash][content_hash] = candidate
|
|
598
|
+
registry[:by_name][candidate] = content_hash
|
|
599
|
+
log_collision_upgrade(previous_candidate, candidate)
|
|
600
|
+
return candidate
|
|
601
|
+
elsif owner == content_hash
|
|
602
|
+
registry[:by_hash][content_hash] = candidate
|
|
603
|
+
return candidate
|
|
604
|
+
else
|
|
605
|
+
previous_candidate = candidate
|
|
606
|
+
next
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
raise TargetConflictError, hash_collision_error_message(content_hash, previous_candidate)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def build_autogenerated_basename(content_hash, length)
|
|
614
|
+
suffix = content_hash[0, length]
|
|
615
|
+
"#{DEFAULT_BASENAME_PREFIX}#{suffix}"
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def log_collision_upgrade(previous_candidate, upgraded_candidate)
|
|
619
|
+
return unless previous_candidate
|
|
620
|
+
|
|
621
|
+
logger&.warn do
|
|
622
|
+
"latexmath detected hash collision for #{previous_candidate}; upgraded autogenerated target to #{upgraded_candidate}"
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def hash_collision_error_message(content_hash, previous_candidate)
|
|
627
|
+
base_message = "autogenerated target basename collision for content hash #{content_hash}"
|
|
628
|
+
suggestion = "assign an explicit target attribute to disambiguate the rendered math block"
|
|
629
|
+
if previous_candidate
|
|
630
|
+
"#{base_message}; attempted #{previous_candidate} and exhausted #{AUTO_BASENAME_LENGTHS.join("/")}-character prefixes—#{suggestion}"
|
|
631
|
+
else
|
|
632
|
+
"#{base_message}; #{suggestion}"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def extension_for(format)
|
|
637
|
+
case format
|
|
638
|
+
when :svg then "svg"
|
|
639
|
+
when :png then "png"
|
|
640
|
+
else
|
|
641
|
+
"pdf"
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def conflict_registry_for(document_obj)
|
|
646
|
+
document_obj.instance_variable_get(:@latexmath_conflict_registry) || begin
|
|
647
|
+
registry = Support::ConflictRegistry.new
|
|
648
|
+
document_obj.instance_variable_set(:@latexmath_conflict_registry, registry)
|
|
649
|
+
registry
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def ensure_directory(dir)
|
|
654
|
+
FileUtils.mkdir_p(dir)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def derive_block_location(parent, cursor)
|
|
658
|
+
format_cursor(cursor) ||
|
|
659
|
+
(parent.respond_to?(:source_location) && format_source_location(parent.source_location)) ||
|
|
660
|
+
default_document_location(parent.document)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def derive_inline_location(parent)
|
|
664
|
+
(parent.respond_to?(:source_location) && format_source_location(parent.source_location)) ||
|
|
665
|
+
default_document_location(parent.document)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def record_render_duration(document_obj, start_time, duration_ms = nil)
|
|
669
|
+
duration = duration_ms || elapsed_ms(start_time)
|
|
670
|
+
stats_collector(document_obj).record_render(duration)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def record_cache_hit(document_obj, duration_ms)
|
|
674
|
+
stats_collector(document_obj).record_hit(duration_ms)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def stats_collector(document_obj)
|
|
678
|
+
document_obj.instance_variable_get(:@latexmath_stats) || begin
|
|
679
|
+
collector = Statistics::Collector.new
|
|
680
|
+
document_obj.instance_variable_set(:@latexmath_stats, collector)
|
|
681
|
+
collector
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def monotonic_time
|
|
686
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def elapsed_ms(start_time)
|
|
690
|
+
return 0 unless start_time
|
|
691
|
+
|
|
692
|
+
((monotonic_time - start_time) * 1000).round
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def maybe_log_large_formula(request, expression, duration_ms)
|
|
696
|
+
return unless logger&.respond_to?(:debug)
|
|
697
|
+
return unless request && expression
|
|
698
|
+
|
|
699
|
+
content = expression.content.to_s
|
|
700
|
+
bytes = content.dup.force_encoding(Encoding::UTF_8).bytesize
|
|
701
|
+
return unless bytes > LARGE_FORMULA_THRESHOLD
|
|
702
|
+
|
|
703
|
+
digest = request.content_hash.to_s
|
|
704
|
+
key_prefix = digest[0, 8]
|
|
705
|
+
logger.debug { "latexmath.timing: key=#{key_prefix} bytes=#{bytes} ms=#{duration_ms.to_i}" }
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def flush_statistics
|
|
709
|
+
return nil unless document
|
|
710
|
+
return nil unless document.instance_variable_defined?(:@latexmath_stats)
|
|
711
|
+
|
|
712
|
+
collector = document.instance_variable_get(:@latexmath_stats)
|
|
713
|
+
line = collector&.to_line
|
|
714
|
+
logger&.info { line } if line
|
|
715
|
+
document.remove_instance_variable(:@latexmath_stats)
|
|
716
|
+
line
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def format_cursor(cursor)
|
|
720
|
+
return nil unless cursor
|
|
721
|
+
|
|
722
|
+
path = cursor.respond_to?(:path) ? cursor.path : nil
|
|
723
|
+
path ||= cursor.respond_to?(:file) ? cursor.file : nil
|
|
724
|
+
path ||= cursor.respond_to?(:dir) ? cursor.dir : nil
|
|
725
|
+
line = cursor.respond_to?(:lineno) ? cursor.lineno : nil
|
|
726
|
+
return nil unless path
|
|
727
|
+
|
|
728
|
+
line ? "#{path}:#{line}" : path
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def format_source_location(source_location)
|
|
732
|
+
return nil unless source_location
|
|
733
|
+
|
|
734
|
+
path = source_location.file || source_location.path
|
|
735
|
+
line = source_location.lineno if source_location.respond_to?(:lineno)
|
|
736
|
+
if path
|
|
737
|
+
line ? "#{path}:#{line}" : path
|
|
738
|
+
elsif line
|
|
739
|
+
line.to_s
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def default_document_location(document_obj)
|
|
744
|
+
document_obj&.attr("docfile") || document_obj&.attr("docname") || "(document)"
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
end
|