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.
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 +741 -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 -526
  31. data/lib/asciidoctor-latexmath/treeprocessor.rb +0 -369
@@ -0,0 +1,741 @@
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
+ generated_path = nil
238
+
239
+ generate_artifact(request, paths.basename, raw_attrs) do |output_path, artifact_dir, _tool_presence|
240
+ generated_path = output_path
241
+ copy_to_target(output_path, paths.final_path, overwrite: true)
242
+ persist_artifacts(request, artifact_dir, success: true)
243
+ end
244
+
245
+ duration_ms = elapsed_ms(start)
246
+ Asciidoctor::Latexmath.record_render_invocation!
247
+ record_render_duration(document_obj, start, duration_ms)
248
+ maybe_log_large_formula(request, expression, duration_ms)
249
+ paths.final_path
250
+ end
251
+
252
+ def render_with_cache(document_obj, request, expression, paths, resolved, cache_key)
253
+ disk_cache = Cache::DiskCache.new(request.cachedir)
254
+ artifact_path = nil
255
+ cache_hit = false
256
+ hit_start = nil
257
+
258
+ disk_cache.with_lock(cache_key.digest) do
259
+ cache_entry = disk_cache.fetch(cache_key.digest)
260
+ if cache_entry
261
+ cache_hit = true
262
+ hit_start = monotonic_time
263
+ artifact_path = cache_entry.final_path
264
+ else
265
+ artifact_path = render_and_store(document_obj, request, paths.basename, cache_key, disk_cache, resolved.raw_attributes, expression)
266
+ end
267
+ end
268
+
269
+ copy_to_target(artifact_path, paths.final_path, overwrite: !cache_hit || !File.exist?(paths.final_path))
270
+
271
+ if cache_hit
272
+ duration_ms = elapsed_ms(hit_start)
273
+ record_cache_hit(document_obj, duration_ms)
274
+ maybe_log_large_formula(request, expression, duration_ms)
275
+ end
276
+
277
+ paths.final_path
278
+ end
279
+
280
+ def render_and_store(document_obj, request, basename, cache_key, disk_cache, raw_attrs, expression)
281
+ start = monotonic_time
282
+ stored_path = nil
283
+
284
+ generate_artifact(request, basename, raw_attrs) do |output_path, artifact_dir, tool_presence|
285
+ checksum = Digest::SHA256.file(output_path).hexdigest
286
+ size_bytes = File.size(output_path)
287
+
288
+ cache_entry = Cache::CacheEntry.new(
289
+ final_path: File.join(request.cachedir, cache_key.digest, Cache::DiskCache::ARTIFACT_FILENAME),
290
+ format: request.format,
291
+ content_hash: request.content_hash,
292
+ preamble_hash: request.preamble_hash,
293
+ fontsize: request.fontsize,
294
+ engine: request.engine,
295
+ ppi: request.ppi,
296
+ entry_type: expression.entry_type,
297
+ created_at: Time.now,
298
+ checksum: "sha256:#{checksum}",
299
+ size_bytes: size_bytes,
300
+ tool_presence: tool_presence
301
+ )
302
+
303
+ disk_cache.store(cache_key.digest, cache_entry, output_path)
304
+ stored_path = cache_entry.final_path
305
+ persist_artifacts(request, artifact_dir, success: true)
306
+ end
307
+
308
+ duration_ms = elapsed_ms(start)
309
+ Asciidoctor::Latexmath.record_render_invocation!
310
+ record_render_duration(document_obj, start, duration_ms)
311
+ maybe_log_large_formula(request, expression, duration_ms)
312
+ stored_path
313
+ end
314
+
315
+ def generate_artifact(request, basename, raw_attrs)
316
+ tmp_dir = Dir.mktmpdir("latexmath")
317
+ artifact_dir = Dir.mktmpdir("latexmath-artifacts")
318
+ tex_artifact_path = write_tex_artifact(artifact_dir, basename, request)
319
+ log_artifact_path = write_log_artifact(artifact_dir, basename, "latexmath render start", request)
320
+
321
+ context = {
322
+ tmp_dir: tmp_dir,
323
+ artifact_dir: artifact_dir,
324
+ artifact_basename: basename,
325
+ tool_detector: Rendering::ToolDetector.new(request, raw_attrs),
326
+ tex_artifact_path: tex_artifact_path,
327
+ log_artifact_path: log_artifact_path
328
+ }
329
+ tool_detector = context.fetch(:tool_detector)
330
+ tool_detector.emit_tool_summary
331
+ tool_detector.record_engine(request.engine)
332
+
333
+ if request.expression.content.include?("\\error")
334
+ persist_artifacts(request, artifact_dir, success: false)
335
+ raise StageFailureError, "forced failure"
336
+ end
337
+
338
+ output_path = build_pipeline.execute(request, context)
339
+ yield output_path, artifact_dir, tool_detector.tool_presence
340
+ rescue RenderTimeoutError => error
341
+ write_log_artifact(artifact_dir, basename, "latexmath render failed: #{error.message}", request)
342
+ persist_artifacts(request, artifact_dir, success: false)
343
+ raise
344
+ rescue MissingToolError
345
+ raise
346
+ rescue => error
347
+ write_log_artifact(artifact_dir, basename, "latexmath render failed: #{error.message}", request)
348
+ persist_artifacts(request, artifact_dir, success: false)
349
+ raise StageFailureError, error.message
350
+ ensure
351
+ FileUtils.remove_entry_secure(tmp_dir) if defined?(tmp_dir) && Dir.exist?(tmp_dir)
352
+ FileUtils.remove_entry_secure(artifact_dir) if defined?(artifact_dir) && Dir.exist?(artifact_dir)
353
+ end
354
+
355
+ def persist_artifacts(request, artifact_dir, success: true)
356
+ return unless request.keep_artifacts && request.artifacts_dir
357
+ return unless Dir.exist?(artifact_dir)
358
+
359
+ FileUtils.mkdir_p(request.artifacts_dir)
360
+ entries = Dir.children(artifact_dir)
361
+ entries.each do |entry|
362
+ source = File.join(artifact_dir, entry)
363
+ destination = File.join(request.artifacts_dir, entry)
364
+ if success
365
+ FileUtils.cp_r(source, destination)
366
+ elsif /\.(tex|log)$/.match?(entry)
367
+ FileUtils.cp_r(source, destination)
368
+ end
369
+ end
370
+ end
371
+
372
+ def write_tex_artifact(artifact_dir, basename, request)
373
+ path = File.join(artifact_dir, "#{basename}.tex")
374
+ File.write(path, build_latex_document(request))
375
+ path
376
+ end
377
+
378
+ def write_log_artifact(artifact_dir, basename, message, request)
379
+ path = File.join(artifact_dir, "#{basename}.log")
380
+ timestamp = Time.now.utc.iso8601
381
+ File.open(path, "a") do |file|
382
+ file.puts("[#{timestamp}] #{message}")
383
+ file.puts("content-hash=#{request.content_hash} format=#{request.format}") if message.include?("start")
384
+ end
385
+ path
386
+ end
387
+
388
+ def build_latex_document(request)
389
+ body = wrap_math_expression(request.expression)
390
+ preamble_sections = [DEFAULT_LATEX_PREAMBLE]
391
+ user_preamble = request.preamble.to_s
392
+ preamble_sections << user_preamble unless user_preamble.strip.empty?
393
+ combined_preamble = preamble_sections.join("\n")
394
+
395
+ options = ["preview", "border=2pt"]
396
+ fontsize = request.fontsize.to_s.strip
397
+ options << fontsize unless fontsize.empty?
398
+ documentclass_line = "\\documentclass[#{options.join(",")}]{standalone}"
399
+
400
+ <<~LATEX
401
+ #{documentclass_line}
402
+ #{combined_preamble}
403
+ \\begin{document}
404
+ #{body}
405
+ \\end{document}
406
+ LATEX
407
+ end
408
+
409
+ def wrap_math_expression(expression)
410
+ content = expression.content.to_s.strip
411
+ return content if content.empty?
412
+
413
+ if expression.entry_type == :block
414
+ wrap_display_math(content)
415
+ else
416
+ wrap_inline_math(content)
417
+ end
418
+ end
419
+
420
+ DISPLAY_MATH_PATTERNS = [
421
+ /\A\\\[.*\\\]\z/m,
422
+ /\A\$\$.*\$\$\z/m,
423
+ /\A\\begin\{[a-zA-Z*]+\}/m
424
+ ].freeze
425
+
426
+ INLINE_MATH_PATTERNS = [
427
+ /\A\\\(.*\\\)\z/m,
428
+ /\A\$.*\$\z/m
429
+ ].freeze
430
+
431
+ def wrap_display_math(content)
432
+ return content if DISPLAY_MATH_PATTERNS.any? { |pattern| pattern.match?(content) }
433
+
434
+ "\\[\n#{content}\n\\]"
435
+ end
436
+
437
+ def wrap_inline_math(content)
438
+ return content if (INLINE_MATH_PATTERNS + DISPLAY_MATH_PATTERNS).any? { |pattern| pattern.match?(content) }
439
+
440
+ "\\(#{content}\\)"
441
+ end
442
+
443
+ def build_pipeline
444
+ Rendering::Pipeline.new([
445
+ Rendering::PdflatexRenderer.new,
446
+ Rendering::PdfToSvgRenderer.new,
447
+ Rendering::PdfToPngRenderer.new
448
+ ])
449
+ end
450
+
451
+ def build_success_result(expression, request, public_target, final_path, original_attrs)
452
+ user_alt = original_attrs && extract_attribute(original_attrs, :alt)
453
+ Result.new(
454
+ type: :image,
455
+ target: public_target,
456
+ final_path: final_path,
457
+ format: request.format,
458
+ alt_text: expression.content.strip,
459
+ attributes: {
460
+ "target" => public_target,
461
+ "alt" => (user_alt.nil? || user_alt.to_s.empty?) ? expression.content.strip : user_alt.to_s,
462
+ "format" => request.format.to_s,
463
+ "data-latex-original" => expression.content.strip,
464
+ "role" => "math"
465
+ }
466
+ )
467
+ end
468
+
469
+ def extract_attribute(attrs, name)
470
+ attrs[name.to_s] || attrs[name]
471
+ end
472
+
473
+ def handle_render_failure(error, resolved, expression, request)
474
+ policy = resolved&.on_error_policy || ErrorHandling.policy(:log)
475
+ raise error if policy.abort?
476
+
477
+ logger&.error { "latexmath rendering failed: #{error.message}" }
478
+
479
+ placeholder_html = ErrorHandling::Placeholder.render(
480
+ message: error.message,
481
+ command: request.engine,
482
+ stdout: "",
483
+ stderr: error.message,
484
+ source: expression.content,
485
+ latex_source: expression.content
486
+ )
487
+
488
+ Result.new(type: :placeholder, placeholder_html: placeholder_html, format: request.format)
489
+ end
490
+
491
+ def copy_to_target(source, destination, overwrite: true)
492
+ return destination if !overwrite && File.exist?(destination)
493
+
494
+ dir = File.dirname(destination)
495
+ FileUtils.mkdir_p(dir)
496
+ temp = File.join(dir, ".#{File.basename(destination)}.tmp-#{Process.pid}-#{Thread.current.object_id}")
497
+ FileUtils.cp(source, temp)
498
+ FileUtils.mv(temp, destination)
499
+ destination
500
+ end
501
+
502
+ def resolve_output_root(document_obj)
503
+ document_dir = document_obj.base_dir || Dir.pwd
504
+ document_dir = PathUtils.expand_path(document_dir, Dir.pwd)
505
+
506
+ imagesoutdir = document_obj.attr("imagesoutdir")
507
+ if imagesoutdir && !imagesoutdir.empty?
508
+ return PathUtils.expand_path(imagesoutdir, document_dir)
509
+ end
510
+
511
+ imagesdir = document_obj.attr("imagesdir")
512
+ if imagesdir && !imagesdir.empty?
513
+ return PathUtils.expand_path(imagesdir, document_dir)
514
+ end
515
+
516
+ outdir_attr = document_obj.attr("outdir") || document_obj.options[:to_dir]
517
+ if outdir_attr && !outdir_attr.empty?
518
+ return PathUtils.expand_path(outdir_attr, document_dir)
519
+ end
520
+
521
+ document_dir
522
+ end
523
+
524
+ def build_public_target(document_obj, relative_name)
525
+ normalized = PathUtils.normalize_separators(relative_name)
526
+ return normalized if PathUtils.absolute_path?(normalized)
527
+
528
+ imagesdir = document_obj.attr("imagesdir")
529
+ imagesdir = PathUtils.normalize_separators(imagesdir) if imagesdir
530
+ return normalized if imagesdir.nil? || imagesdir.empty?
531
+
532
+ PathUtils.clean_join(imagesdir, normalized)
533
+ end
534
+
535
+ def build_cache_key(request, expression)
536
+ Cache::CacheKey.new(
537
+ ext_version: VERSION,
538
+ content_hash: request.content_hash,
539
+ format: request.format,
540
+ preamble_hash: request.preamble_hash,
541
+ fontsize_hash: request.fontsize_hash,
542
+ ppi: request.ppi || "-",
543
+ entry_type: expression.entry_type
544
+ )
545
+ end
546
+
547
+ def conflict_details(document_obj, expression, request)
548
+ {
549
+ location: expression.location || default_document_location(document_obj),
550
+ format: request.format,
551
+ content_hash: request.content_hash,
552
+ preamble_hash: request.preamble_hash,
553
+ fontsize_hash: request.fontsize_hash,
554
+ entry_type: expression.entry_type
555
+ }
556
+ end
557
+
558
+ def expand_path(path, document_obj)
559
+ base_dir = document_obj.attr("outdir") || document_obj.options[:to_dir] || document_obj.base_dir || Dir.pwd
560
+ base_dir = PathUtils.expand_path(base_dir, Dir.pwd)
561
+ PathUtils.expand_path(path, base_dir)
562
+ end
563
+
564
+ def default_basename(document_obj, content_hash)
565
+ registry = auto_basename_registry_for(document_obj)
566
+ existing = registry[:by_hash][content_hash]
567
+ return existing if existing
568
+
569
+ allocate_auto_basename(registry, content_hash)
570
+ end
571
+
572
+ def auto_basename_registry_for(document_obj)
573
+ owner = document_obj || document || self
574
+ registry = owner.instance_variable_get(:@latexmath_auto_basename_registry)
575
+ return registry if registry
576
+
577
+ registry = {by_hash: {}, by_name: {}}
578
+ owner.instance_variable_set(:@latexmath_auto_basename_registry, registry)
579
+ registry
580
+ end
581
+
582
+ def allocate_auto_basename(registry, content_hash)
583
+ previous_candidate = nil
584
+
585
+ AUTO_BASENAME_LENGTHS.each do |length|
586
+ candidate = build_autogenerated_basename(content_hash, length)
587
+ owner = registry[:by_name][candidate]
588
+
589
+ if owner.nil?
590
+ registry[:by_hash][content_hash] = candidate
591
+ registry[:by_name][candidate] = content_hash
592
+ log_collision_upgrade(previous_candidate, candidate)
593
+ return candidate
594
+ elsif owner == content_hash
595
+ registry[:by_hash][content_hash] = candidate
596
+ return candidate
597
+ else
598
+ previous_candidate = candidate
599
+ next
600
+ end
601
+ end
602
+
603
+ raise TargetConflictError, hash_collision_error_message(content_hash, previous_candidate)
604
+ end
605
+
606
+ def build_autogenerated_basename(content_hash, length)
607
+ suffix = content_hash[0, length]
608
+ "#{DEFAULT_BASENAME_PREFIX}#{suffix}"
609
+ end
610
+
611
+ def log_collision_upgrade(previous_candidate, upgraded_candidate)
612
+ return unless previous_candidate
613
+
614
+ logger&.warn do
615
+ "latexmath detected hash collision for #{previous_candidate}; upgraded autogenerated target to #{upgraded_candidate}"
616
+ end
617
+ end
618
+
619
+ def hash_collision_error_message(content_hash, previous_candidate)
620
+ base_message = "autogenerated target basename collision for content hash #{content_hash}"
621
+ suggestion = "assign an explicit target attribute to disambiguate the rendered math block"
622
+ if previous_candidate
623
+ "#{base_message}; attempted #{previous_candidate} and exhausted #{AUTO_BASENAME_LENGTHS.join("/")}-character prefixes—#{suggestion}"
624
+ else
625
+ "#{base_message}; #{suggestion}"
626
+ end
627
+ end
628
+
629
+ def extension_for(format)
630
+ case format
631
+ when :svg then "svg"
632
+ when :png then "png"
633
+ else
634
+ "pdf"
635
+ end
636
+ end
637
+
638
+ def conflict_registry_for(document_obj)
639
+ document_obj.instance_variable_get(:@latexmath_conflict_registry) || begin
640
+ registry = Support::ConflictRegistry.new
641
+ document_obj.instance_variable_set(:@latexmath_conflict_registry, registry)
642
+ registry
643
+ end
644
+ end
645
+
646
+ def ensure_directory(dir)
647
+ FileUtils.mkdir_p(dir)
648
+ end
649
+
650
+ def derive_block_location(parent, cursor)
651
+ format_cursor(cursor) ||
652
+ (parent.respond_to?(:source_location) && format_source_location(parent.source_location)) ||
653
+ default_document_location(parent.document)
654
+ end
655
+
656
+ def derive_inline_location(parent)
657
+ (parent.respond_to?(:source_location) && format_source_location(parent.source_location)) ||
658
+ default_document_location(parent.document)
659
+ end
660
+
661
+ def record_render_duration(document_obj, start_time, duration_ms = nil)
662
+ duration = duration_ms || elapsed_ms(start_time)
663
+ stats_collector(document_obj).record_render(duration)
664
+ end
665
+
666
+ def record_cache_hit(document_obj, duration_ms)
667
+ stats_collector(document_obj).record_hit(duration_ms)
668
+ end
669
+
670
+ def stats_collector(document_obj)
671
+ document_obj.instance_variable_get(:@latexmath_stats) || begin
672
+ collector = Statistics::Collector.new
673
+ document_obj.instance_variable_set(:@latexmath_stats, collector)
674
+ collector
675
+ end
676
+ end
677
+
678
+ def monotonic_time
679
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
680
+ end
681
+
682
+ def elapsed_ms(start_time)
683
+ return 0 unless start_time
684
+
685
+ ((monotonic_time - start_time) * 1000).round
686
+ end
687
+
688
+ def maybe_log_large_formula(request, expression, duration_ms)
689
+ return unless logger&.respond_to?(:debug)
690
+ return unless request && expression
691
+
692
+ content = expression.content.to_s
693
+ bytes = content.dup.force_encoding(Encoding::UTF_8).bytesize
694
+ return unless bytes > LARGE_FORMULA_THRESHOLD
695
+
696
+ digest = request.content_hash.to_s
697
+ key_prefix = digest[0, 8]
698
+ logger.debug { "latexmath.timing: key=#{key_prefix} bytes=#{bytes} ms=#{duration_ms.to_i}" }
699
+ end
700
+
701
+ def flush_statistics
702
+ return nil unless document
703
+ return nil unless document.instance_variable_defined?(:@latexmath_stats)
704
+
705
+ collector = document.instance_variable_get(:@latexmath_stats)
706
+ line = collector&.to_line
707
+ logger&.info { line } if line
708
+ document.remove_instance_variable(:@latexmath_stats)
709
+ line
710
+ end
711
+
712
+ def format_cursor(cursor)
713
+ return nil unless cursor
714
+
715
+ path = cursor.respond_to?(:path) ? cursor.path : nil
716
+ path ||= cursor.respond_to?(:file) ? cursor.file : nil
717
+ path ||= cursor.respond_to?(:dir) ? cursor.dir : nil
718
+ line = cursor.respond_to?(:lineno) ? cursor.lineno : nil
719
+ return nil unless path
720
+
721
+ line ? "#{path}:#{line}" : path
722
+ end
723
+
724
+ def format_source_location(source_location)
725
+ return nil unless source_location
726
+
727
+ path = source_location.file || source_location.path
728
+ line = source_location.lineno if source_location.respond_to?(:lineno)
729
+ if path
730
+ line ? "#{path}:#{line}" : path
731
+ elsif line
732
+ line.to_s
733
+ end
734
+ end
735
+
736
+ def default_document_location(document_obj)
737
+ document_obj&.attr("docfile") || document_obj&.attr("docname") || "(document)"
738
+ end
739
+ end
740
+ end
741
+ end