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