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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +119 -36
  3. data/lib/asciidoctor/latexmath/attribute_resolver.rb +485 -0
  4. data/lib/asciidoctor/latexmath/cache/cache_entry.rb +31 -0
  5. data/lib/asciidoctor/latexmath/cache/cache_key.rb +33 -0
  6. data/lib/asciidoctor/latexmath/cache/disk_cache.rb +151 -0
  7. data/lib/asciidoctor/latexmath/command_runner.rb +116 -0
  8. data/lib/asciidoctor/latexmath/converters/html5.rb +72 -0
  9. data/lib/asciidoctor/latexmath/errors.rb +134 -0
  10. data/lib/asciidoctor/latexmath/html_builder.rb +103 -0
  11. data/lib/asciidoctor/latexmath/math_expression.rb +22 -0
  12. data/lib/asciidoctor/latexmath/path_utils.rb +46 -0
  13. data/lib/asciidoctor/latexmath/processors/block_processor.rb +74 -0
  14. data/lib/asciidoctor/latexmath/processors/inline_macro_processor.rb +70 -0
  15. data/lib/asciidoctor/latexmath/processors/statistics_postprocessor.rb +18 -0
  16. data/lib/asciidoctor/latexmath/render_request.rb +32 -0
  17. data/lib/asciidoctor/latexmath/renderer_service.rb +748 -0
  18. data/lib/asciidoctor/latexmath/rendering/pdf_to_png_renderer.rb +97 -0
  19. data/lib/asciidoctor/latexmath/rendering/pdf_to_svg_renderer.rb +84 -0
  20. data/lib/asciidoctor/latexmath/rendering/pdflatex_renderer.rb +166 -0
  21. data/lib/asciidoctor/latexmath/rendering/pipeline.rb +58 -0
  22. data/lib/asciidoctor/latexmath/rendering/renderer.rb +33 -0
  23. data/lib/asciidoctor/latexmath/rendering/tool_detector.rb +320 -0
  24. data/lib/asciidoctor/latexmath/rendering/toolchain_record.rb +21 -0
  25. data/lib/asciidoctor/latexmath/statistics/collector.rb +47 -0
  26. data/lib/asciidoctor/latexmath/support/conflict_registry.rb +75 -0
  27. data/lib/{asciidoctor-latexmath → asciidoctor/latexmath}/version.rb +1 -1
  28. data/lib/asciidoctor-latexmath.rb +93 -3
  29. metadata +34 -12
  30. data/lib/asciidoctor-latexmath/renderer.rb +0 -515
  31. 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