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
@@ -1,526 +0,0 @@
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 "tmpdir"
8
- require "fileutils"
9
- require "open3"
10
- require "shellwords"
11
- require "digest"
12
- require "pathname"
13
- require "json"
14
- require "cgi"
15
- require "asciidoctor"
16
-
17
- module Asciidoctor
18
- module Latexmath
19
- class RenderingError < StandardError; end
20
-
21
- RenderResult = Struct.new(
22
- :format,
23
- :data,
24
- :extension,
25
- :width,
26
- :height,
27
- :inline_markup,
28
- :basename,
29
- keyword_init: true
30
- )
31
-
32
- class Renderer
33
- DEFAULT_FORMAT = :svg
34
- CACHE_VERSION = 1
35
- LATEX_TEMPLATE = <<~'LATEX'
36
- \documentclass[preview,border=2bp]{standalone}
37
- \usepackage{amsmath}
38
- \usepackage{amssymb}
39
- %<PREAMBLE>
40
- \begin{document}
41
- %<BODY>
42
- \end{document}
43
- LATEX
44
-
45
- def initialize(document)
46
- @document = document
47
- @format = resolve_format
48
- @inline_attribute = document.attr? "latexmath-inline"
49
- @ppi = resolve_ppi
50
- @keep_artifacts = truthy_attr?("latexmath-keep-artifacts")
51
- @pdf_engine = resolve_command(document.attr("pdflatex") || "pdflatex")
52
- @pdf2svg = resolve_command(document.attr("latexmath-pdf2svg") || "pdf2svg") if @format == :svg || @inline_attribute
53
- @png_tool = resolve_png_tool if @format == :png
54
- @preamble = document.attr("latexmath-preamble")
55
- @cache_enabled = cache_enabled?
56
- @cache_dir = resolve_cache_dir if @cache_enabled
57
- end
58
-
59
- def render(equation:, display:, inline: false, id: nil, asciidoc_source: nil, source_location: nil)
60
- normalized_equation = normalize_equation(equation)
61
- basename = sanitize_basename(id) || auto_basename(normalized_equation)
62
- inline_embed = inline && @inline_attribute
63
- signature = cache_signature(
64
- equation: normalized_equation,
65
- display: display,
66
- inline: inline,
67
- inline_embed: inline_embed
68
- )
69
- equation_digest = Digest::SHA256.hexdigest(normalized_equation)
70
-
71
- if @cache_enabled
72
- if (cached = load_cached_render(basename, signature, equation_digest, inline_embed))
73
- copy_cached_artifacts(signature, basename) if @keep_artifacts
74
- return cached
75
- end
76
- end
77
-
78
- result = nil
79
- Dir.mktmpdir("asciidoctor-latexmath-") do |dir|
80
- tex_path = File.join(dir, "#{basename}.tex")
81
- pdf_path = File.join(dir, "#{basename}.pdf")
82
-
83
- latex_source = build_document(normalized_equation, display)
84
- File.write(tex_path, latex_source)
85
- begin
86
- run_pdflatex(
87
- tex_path,
88
- dir,
89
- latex_source: latex_source,
90
- asciidoc_source: asciidoc_source,
91
- source_location: source_location
92
- )
93
-
94
- unless File.exist?(pdf_path)
95
- raise RenderingError, "pdflatex did not produce #{basename}.pdf"
96
- end
97
- rescue RenderingError
98
- copy_artifacts(dir, basename)
99
- raise
100
- end
101
-
102
- result = case @format
103
- when :pdf
104
- handle_pdf(pdf_path, dir, basename, inline_embed)
105
- when :svg
106
- handle_svg(pdf_path, dir, basename, inline_embed)
107
- when :png
108
- handle_png(pdf_path, dir, basename, inline_embed)
109
- else
110
- raise RenderingError, "Unsupported format: #{@format}"
111
- end
112
-
113
- if @cache_enabled && result
114
- persist_cached_render(signature, equation_digest, inline_embed, result, dir, basename)
115
- end
116
- end
117
- result
118
- end
119
-
120
- private
121
-
122
- def resolve_format
123
- raw = (@document.attr("latexmath-format") || DEFAULT_FORMAT).to_s.strip
124
- raw = DEFAULT_FORMAT if raw.empty?
125
- fmt = raw.downcase.to_sym
126
- return fmt if %i[pdf svg png].include?(fmt)
127
-
128
- warn %(Unknown latexmath-format '#{raw}', falling back to #{DEFAULT_FORMAT})
129
- DEFAULT_FORMAT
130
- end
131
-
132
- def resolve_ppi
133
- value = (@document.attr("latexmath-ppi") || "300").to_f
134
- value.positive? ? value : 300.0
135
- end
136
-
137
- def resolve_png_tool
138
- tool = @document.attr("latexmath-png-tool")
139
- candidates = [tool, "magick", "convert", "pdftoppm"]
140
- candidates.compact.each do |candidate|
141
- resolved = resolve_command(candidate, silent: true)
142
- return resolved if resolved
143
- end
144
-
145
- raise RenderingError, "No PNG conversion tool found; set latexmath-png-tool to a valid command (magick, convert, or pdftoppm)."
146
- end
147
-
148
- def build_document(equation, display)
149
- body = if latex_environment?(equation)
150
- equation
151
- elsif display
152
- "\\[#{equation}\\]"
153
- else
154
- "\\(#{equation}\\)"
155
- end
156
-
157
- LATEX_TEMPLATE
158
- .sub("%<PREAMBLE>") { @preamble ? "\n#{@preamble}\n" : "" }
159
- .sub("%<BODY>") { body }
160
- end
161
-
162
- def run_pdflatex(tex_path, dir, latex_source:, asciidoc_source: nil, source_location: nil)
163
- command = [
164
- @pdf_engine,
165
- "-interaction=nonstopmode",
166
- "-halt-on-error",
167
- "-file-line-error",
168
- "-output-directory",
169
- dir,
170
- tex_path
171
- ]
172
-
173
- execute(command, work_dir: dir)
174
- rescue RenderingError => e
175
- latex_source ||= File.read(tex_path, mode: "r:UTF-8")
176
- message_parts = [e.message.rstrip]
177
-
178
- message_parts << "LaTeX source (#{tex_path}):\n#{latex_source}"
179
-
180
- if asciidoc_source
181
- location_hint = format_source_location(source_location)
182
- header = "Asciidoc source#{location_hint}:"
183
- message_parts << "#{header}\n#{asciidoc_source}"
184
- end
185
-
186
- raise RenderingError, message_parts.join("\n\n")
187
- end
188
-
189
- def handle_pdf(pdf_path, dir, basename, inline_embed)
190
- copy_artifacts(dir, basename)
191
- warn "latexmath-inline is ignored for pdf format." if inline_embed
192
- RenderResult.new(format: :pdf, data: File.read(pdf_path, mode: "rb"), extension: "pdf", basename: basename)
193
- end
194
-
195
- def handle_svg(pdf_path, dir, basename, inline_embed)
196
- svg_path = File.join(dir, "#{basename}.svg")
197
- execute([@pdf2svg, pdf_path, svg_path], work_dir: dir)
198
- svg_data = sanitize_svg(File.read(svg_path))
199
- width, height = svg_dimensions(svg_data)
200
- copy_artifacts(dir, basename)
201
-
202
- if inline_embed
203
- inline_markup = %(<span class="latexmath-inline">#{svg_data}</span>)
204
- RenderResult.new(format: :svg, inline_markup: inline_markup, width: width, height: height, data: svg_data, extension: "svg", basename: basename)
205
- else
206
- RenderResult.new(format: :svg, data: File.read(svg_path, mode: "rb"), width: width, height: height, extension: "svg", basename: basename)
207
- end
208
- end
209
-
210
- def handle_png(pdf_path, dir, basename, inline_embed)
211
- png_path = File.join(dir, "#{basename}.png")
212
- convert_pdf_to_png(pdf_path, png_path)
213
- png_data = File.read(png_path, mode: "rb")
214
- width, height = png_dimensions(png_path)
215
- copy_artifacts(dir, basename)
216
-
217
- if inline_embed
218
- encoded = [png_data].pack("m0")
219
- inline_markup = %(<span class="latexmath-inline"><img src="data:image/png;base64,#{encoded}" alt="latexmath"/></span>)
220
- RenderResult.new(format: :png, inline_markup: inline_markup, width: width, height: height, data: png_data, extension: "png", basename: basename)
221
- else
222
- RenderResult.new(format: :png, data: png_data, width: width, height: height, extension: "png", basename: basename)
223
- end
224
- end
225
-
226
- def convert_pdf_to_png(pdf_path, png_path)
227
- if File.basename(@png_tool) == "pdftoppm"
228
- base = png_path.sub(/\.png\z/, "")
229
- command = [@png_tool, "-png", "-r", @ppi.to_i.to_s, pdf_path, base]
230
- execute(command, work_dir: File.dirname(pdf_path))
231
- generated = Dir["#{base}*.png"].first
232
- raise RenderingError, "pdftoppm did not produce a PNG file" unless generated
233
- FileUtils.mv(generated, png_path)
234
- else
235
- density = @ppi.to_i
236
- command = [@png_tool, "-density", density.to_s, pdf_path, "-quality", "100", png_path]
237
- execute(command, work_dir: File.dirname(pdf_path))
238
- end
239
- end
240
-
241
- def svg_dimensions(svg_data)
242
- if (match = svg_data.match(/viewBox="\s*0\s+0\s+([0-9.]+)\s+([0-9.]+)/))
243
- [match[1].to_f, match[2].to_f]
244
- elsif (match = svg_data.match(/width="([0-9.]+)(px)?"\s+height="([0-9.]+)(px)?"/))
245
- [match[1].to_f, match[3].to_f]
246
- end
247
- end
248
-
249
- def png_dimensions(png_path)
250
- IO.popen(["identify", "-format", "%w %h", png_path]) do |io|
251
- output = io.read
252
- return output.split.map!(&:to_i) if output
253
- end
254
- rescue Errno::ENOENT
255
- nil
256
- end
257
-
258
- def execute(command, work_dir: nil)
259
- stdout_str, stderr_str, status = Open3.capture3(*command, chdir: work_dir)
260
- return if status.success?
261
-
262
- raise RenderingError, <<~MSG
263
- Command failed: #{Shellwords.join(command)}
264
- stdout: #{stdout_str}
265
- stderr: #{stderr_str}
266
- MSG
267
- end
268
-
269
- def sanitize_svg(svg_data)
270
- svg_data.sub(/\A<\?xml.*?\?>\s*/m, "").sub(/<!DOCTYPE.*?>\s*/m, "")
271
- end
272
-
273
- def cache_enabled?
274
- value = @document.attr("latexmath-cache")
275
- return true if value.nil?
276
- !%w[false off no 0].include?(value.to_s.downcase)
277
- end
278
-
279
- def resolve_cache_dir
280
- attr = @document.attr("latexmath-cache-dir")
281
- docdir = @document.attr("docdir")
282
- if attr && !attr.to_s.strip.empty?
283
- @document.normalize_system_path(attr, docdir)
284
- else
285
- base_attr = @document.attr("outdir") || docdir || Dir.pwd
286
- base = @document.normalize_system_path(base_attr, docdir)
287
- File.join(base, ".asciidoctor", "latexmath")
288
- end
289
- rescue
290
- File.join(Dir.pwd, ".asciidoctor", "latexmath")
291
- end
292
-
293
- def cache_signature(equation:, display:, inline:, inline_embed:)
294
- components = [
295
- @format,
296
- equation,
297
- display,
298
- inline,
299
- inline_embed,
300
- @preamble.to_s,
301
- @ppi,
302
- @pdf_engine,
303
- @pdf2svg,
304
- @png_tool
305
- ]
306
- Digest::SHA256.hexdigest(components.join("\u0000"))
307
- end
308
-
309
- def cache_entry_dir(basename, signature)
310
- File.join(@cache_dir, basename, signature)
311
- end
312
-
313
- def cache_metadata_path(basename, signature)
314
- File.join(cache_entry_dir(basename, signature), "metadata.json")
315
- end
316
-
317
- def load_cached_render(basename, signature, equation_digest, inline_embed)
318
- return unless @cache_dir
319
- metadata_path = cache_metadata_path(basename, signature)
320
- return unless File.file?(metadata_path)
321
-
322
- metadata = JSON.parse(File.read(metadata_path), symbolize_names: true)
323
- return unless metadata[:version] == CACHE_VERSION
324
- return unless metadata[:signature] == signature
325
- return unless metadata[:source_digest] == equation_digest
326
- return unless metadata[:inline_embed] == inline_embed
327
-
328
- data_path = metadata[:data_path]
329
- return unless data_path
330
-
331
- data_file = File.join(cache_entry_dir(basename, signature), data_path)
332
- return unless File.file?(data_file)
333
-
334
- data = File.binread(data_file)
335
- if (encoding = metadata[:encoding])
336
- begin
337
- data.force_encoding(Encoding.find(encoding))
338
- rescue
339
- data.force_encoding(Encoding::BINARY)
340
- end
341
- end
342
-
343
- RenderResult.new(
344
- format: metadata[:format]&.to_sym,
345
- data: data,
346
- extension: metadata[:extension],
347
- width: metadata[:width],
348
- height: metadata[:height],
349
- inline_markup: metadata[:inline_markup],
350
- basename: basename
351
- )
352
- rescue
353
- nil
354
- end
355
-
356
- def persist_cached_render(signature, equation_digest, inline_embed, result, dir, basename)
357
- return unless @cache_dir
358
-
359
- entry_dir = cache_entry_dir(basename, signature)
360
- FileUtils.rm_rf(entry_dir)
361
- FileUtils.mkdir_p(entry_dir)
362
-
363
- data_filename = if result.extension
364
- "result.#{result.extension}"
365
- else
366
- "result.bin"
367
- end
368
-
369
- if result.data
370
- File.binwrite(File.join(entry_dir, data_filename), result.data)
371
- else
372
- data_filename = nil
373
- end
374
-
375
- metadata = {
376
- version: CACHE_VERSION,
377
- signature: signature,
378
- format: result.format.to_s,
379
- extension: result.extension,
380
- width: result.width && result.width.to_f,
381
- height: result.height && result.height.to_f,
382
- inline_embed: inline_embed,
383
- encoding: result.data&.encoding&.name,
384
- data_path: data_filename,
385
- source_digest: equation_digest,
386
- inline_markup: result.inline_markup
387
- }
388
-
389
- artifacts = store_cache_artifacts(entry_dir, dir, basename)
390
- metadata[:artifacts] = artifacts unless artifacts.empty?
391
- metadata.delete(:data_path) unless data_filename
392
-
393
- File.write(cache_metadata_path(basename, signature), JSON.pretty_generate(stringify_keys(metadata)))
394
- rescue
395
- nil
396
- end
397
-
398
- def store_cache_artifacts(entry_dir, dir, basename)
399
- return [] unless @keep_artifacts
400
-
401
- artifacts = Dir.glob(File.join(dir, "#{basename}.*"))
402
- return [] if artifacts.empty?
403
-
404
- target_dir = File.join(entry_dir, "artifacts")
405
- FileUtils.mkdir_p(target_dir)
406
-
407
- artifacts.map do |path|
408
- filename = File.basename(path)
409
- FileUtils.cp(path, File.join(target_dir, filename))
410
- filename
411
- end
412
- end
413
-
414
- def copy_cached_artifacts(signature, basename)
415
- return unless @cache_dir
416
-
417
- artifacts_dir = File.join(cache_entry_dir(basename, signature), "artifacts")
418
- return unless Dir.exist?(artifacts_dir)
419
-
420
- copy_artifacts(artifacts_dir, basename)
421
- end
422
-
423
- def stringify_keys(hash)
424
- hash.each_with_object({}) do |(key, value), memo|
425
- memo[key.to_s] = value
426
- end
427
- end
428
-
429
- def copy_artifacts(dir, basename)
430
- return unless @keep_artifacts
431
-
432
- out_dir = artifacts_output_dir
433
- FileUtils.mkdir_p(out_dir)
434
- Dir.glob(File.join(dir, "#{basename}.*")).each do |path|
435
- FileUtils.cp(path, File.join(out_dir, File.basename(path)))
436
- end
437
- end
438
-
439
- def artifacts_output_dir
440
- attr = @document.attr("latexmath-artifacts-dir")
441
- if attr && !attr.strip.empty?
442
- @document.normalize_system_path(attr, @document.attr("docdir"))
443
- else
444
- @document.attr("imagesoutdir") || @document.attr("docdir")
445
- end
446
- end
447
-
448
- def auto_basename(equation)
449
- "latexmath-#{Digest::MD5.hexdigest(equation)}"
450
- end
451
-
452
- def sanitize_basename(id)
453
- return unless id
454
- id.to_s.gsub(/[^a-zA-Z0-9_.-]/, "-")
455
- end
456
-
457
- def resolve_command(cmd, silent: false)
458
- return cmd if Pathname.new(cmd).absolute? && File.executable?(cmd)
459
-
460
- resolved = which(cmd)
461
- return resolved if resolved
462
-
463
- return nil if silent
464
-
465
- raise RenderingError, "Command '#{cmd}' could not be found in PATH"
466
- end
467
-
468
- def which(cmd)
469
- path_ext = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
470
- ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path|
471
- path_ext.each do |ext|
472
- candidate = File.join(path, "#{cmd}#{ext}")
473
- return candidate if File.executable?(candidate) && !File.directory?(candidate)
474
- end
475
- end
476
- nil
477
- end
478
-
479
- def format_source_location(source_location)
480
- return "" unless source_location
481
-
482
- file = if source_location.respond_to?(:file)
483
- source_location.file
484
- elsif source_location.respond_to?(:path)
485
- source_location.path
486
- elsif source_location.respond_to?(:filename)
487
- source_location.filename
488
- end
489
-
490
- line = if source_location.respond_to?(:lineno)
491
- source_location.lineno
492
- end
493
-
494
- parts = []
495
- parts << file if file && !file.to_s.empty?
496
- parts << line if line
497
- parts.empty? ? "" : " (#{parts.join(":")})"
498
- end
499
-
500
- def truthy_attr?(name)
501
- value = @document.attr(name)
502
- case value
503
- when nil
504
- false
505
- when true
506
- true
507
- else
508
- !%w[false off no 0].include?(value.to_s.downcase)
509
- end
510
- end
511
-
512
- def latex_environment?(equation)
513
- equation.match?(/\\begin\s*\{[^}]+\}/) && equation.match?(/\\end\s*\{[^}]+\}/)
514
- end
515
-
516
- def normalize_equation(equation)
517
- return "" if equation.nil?
518
-
519
- stripped = equation.to_s.strip
520
- return "" if stripped.empty?
521
-
522
- CGI.unescapeHTML(stripped).tr("\u00A0", " ")
523
- end
524
- end
525
- end
526
- end