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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Asciidoctor
6
+ module Latexmath
7
+ class CommandRunner
8
+ Result = Struct.new(:stdout, :stderr, :exit_status, :duration, keyword_init: true)
9
+
10
+ class << self
11
+ attr_writer :backend
12
+
13
+ def backend
14
+ @backend ||= SystemRunner.new
15
+ end
16
+
17
+ def run(command, timeout:, chdir:, env: {}, stdin: nil)
18
+ backend.run(command, timeout: timeout, chdir: chdir, env: env, stdin: stdin)
19
+ end
20
+
21
+ def with_backend(temp_backend)
22
+ previous = backend
23
+ self.backend = temp_backend
24
+ yield
25
+ ensure
26
+ self.backend = previous
27
+ end
28
+ end
29
+
30
+ class NullRunner
31
+ def run(_command, timeout:, **_rest)
32
+ raise ArgumentError, "timeout must be positive" unless timeout&.positive?
33
+
34
+ Result.new(stdout: "", stderr: "", exit_status: 0, duration: 0.0)
35
+ end
36
+ end
37
+
38
+ class SystemRunner
39
+ DEFAULT_WAIT_INTERVAL = 0.05
40
+ KILL_GRACE_SECONDS = 2.0
41
+
42
+ def run(command, timeout:, chdir:, env: {}, stdin: nil)
43
+ raise ArgumentError, "timeout must be positive" unless timeout&.positive?
44
+
45
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
+ stdout_read, stdout_write = IO.pipe
47
+ stderr_read, stderr_write = IO.pipe
48
+
49
+ spawn_options = {
50
+ chdir: chdir,
51
+ out: stdout_write,
52
+ err: stderr_write,
53
+ pgroup: true
54
+ }
55
+
56
+ pid = Process.spawn(env, *command, spawn_options)
57
+ stdout_write.close
58
+ stderr_write.close
59
+
60
+ stdout_thread = Thread.new { stdout_read.read }
61
+ stderr_thread = Thread.new { stderr_read.read }
62
+
63
+ wait_with_timeout(pid, timeout)
64
+
65
+ exit_status = $?.exitstatus
66
+ stdout_output = stdout_thread.value
67
+ stderr_output = stderr_thread.value
68
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
69
+
70
+ Result.new(stdout: stdout_output, stderr: stderr_output, exit_status: exit_status, duration: duration)
71
+ rescue Errno::ENOENT => error
72
+ raise StageFailureError, "Executable not found: #{error.message.split.last}"
73
+ ensure
74
+ stdout_read&.close unless stdout_read&.closed?
75
+ stderr_read&.close unless stderr_read&.closed?
76
+ end
77
+
78
+ private
79
+
80
+ def wait_with_timeout(pid, timeout)
81
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
82
+
83
+ loop do
84
+ result = Process.wait(pid, Process::WNOHANG)
85
+ return result if result
86
+
87
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
88
+ terminate_process_group(pid)
89
+ raise RenderTimeoutError, "command timed out after #{timeout}s"
90
+ end
91
+
92
+ sleep(DEFAULT_WAIT_INTERVAL)
93
+ end
94
+ end
95
+
96
+ def terminate_process_group(pid)
97
+ Process.kill("TERM", -pid)
98
+ grace_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + KILL_GRACE_SECONDS
99
+
100
+ loop do
101
+ result = Process.wait(pid, Process::WNOHANG)
102
+ return result if result
103
+
104
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= grace_deadline
105
+
106
+ sleep(DEFAULT_WAIT_INTERVAL)
107
+ end
108
+
109
+ Process.kill("KILL", -pid)
110
+ Process.wait(pid)
111
+ rescue Errno::ESRCH, Errno::ECHILD
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asciidoctor/converter/html5"
4
+
5
+ require_relative "../html_builder"
6
+ require_relative "../renderer_service"
7
+
8
+ module Asciidoctor
9
+ module Latexmath
10
+ module Converters
11
+ module Html5
12
+ def convert_stem(node)
13
+ if latexmath_block?(node)
14
+ convert_latexmath_block(node)
15
+ else
16
+ super
17
+ end
18
+ end
19
+
20
+ def convert_inline_quoted(node)
21
+ if latexmath_inline?(node)
22
+ convert_latexmath_inline(node)
23
+ else
24
+ super
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def latexmath_block?(node)
31
+ node.style == "latexmath"
32
+ end
33
+
34
+ def latexmath_inline?(node)
35
+ node.type == :latexmath
36
+ end
37
+
38
+ def convert_latexmath_block(node)
39
+ result = latexmath_renderer_service(node.document)
40
+ .render_block_content(node.parent || node.document, node.source, safe_attributes(node.attributes))
41
+
42
+ return result.placeholder_html if result.type == :placeholder
43
+
44
+ HtmlBuilder.block_html(node, result)
45
+ end
46
+
47
+ def convert_latexmath_inline(node)
48
+ result = latexmath_renderer_service(node.document)
49
+ .render_inline_content(node.parent || node.document, node.text, safe_attributes(node.attributes))
50
+
51
+ return result.placeholder_html if result.type == :placeholder
52
+
53
+ HtmlBuilder.inline_html(node, result)
54
+ end
55
+
56
+ def latexmath_renderer_service(document)
57
+ document.instance_variable_get(:@latexmath_renderer_service) || begin
58
+ service = RendererService.new(document)
59
+ document.instance_variable_set(:@latexmath_renderer_service, service)
60
+ service
61
+ end
62
+ end
63
+
64
+ def safe_attributes(attrs)
65
+ attrs ? attrs.dup : {}
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ Asciidoctor::Converter::Html5Converter.prepend(Asciidoctor::Latexmath::Converters::Html5)
@@ -0,0 +1,134 @@
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
+ module Asciidoctor
8
+ module Latexmath
9
+ class LatexmathError < StandardError; end
10
+
11
+ class UnsupportedFormatError < LatexmathError; end
12
+
13
+ class MissingToolError < LatexmathError
14
+ attr_reader :tool
15
+
16
+ def initialize(tool, message = nil)
17
+ @tool = tool
18
+ super(message || "Missing required tool: #{tool}")
19
+ end
20
+ end
21
+
22
+ class InvalidAttributeError < LatexmathError; end
23
+
24
+ class UnsupportedValueError < InvalidAttributeError
25
+ attr_reader :category, :subject, :value, :supported, :hints
26
+
27
+ def initialize(category:, subject:, value:, supported:, hint: nil)
28
+ @category = category
29
+ @subject = subject
30
+ @value = value
31
+ @supported = supported
32
+ @hints = Array(hint).compact
33
+ super(build_message)
34
+ end
35
+
36
+ private
37
+
38
+ def build_message
39
+ descriptor = subject ? "#{subject}=#{present_value}" : present_value
40
+ base = "unsupported #{category}: '#{descriptor}' (supported: #{formatted_supported})"
41
+ hints.empty? ? base : "#{base}\nhint: #{formatted_hints}"
42
+ end
43
+
44
+ def present_value
45
+ (value.nil? || value.to_s.empty?) ? "<empty>" : value.to_s
46
+ end
47
+
48
+ def formatted_supported
49
+ case supported
50
+ when Array
51
+ values = supported.map { |entry| entry.to_s.strip }.reject(&:empty?)
52
+ values.sort_by { |entry| entry.downcase }.join(", ")
53
+ else
54
+ supported.to_s
55
+ end
56
+ end
57
+
58
+ def formatted_hints
59
+ hints.map(&:to_s).reject(&:empty?).join("; ")
60
+ end
61
+ end
62
+
63
+ class TargetConflictError < LatexmathError; end
64
+
65
+ class RenderTimeoutError < LatexmathError; end
66
+
67
+ class StageFailureError < LatexmathError; end
68
+
69
+ module ErrorHandling
70
+ class Policy
71
+ attr_reader :mode
72
+
73
+ def initialize(mode)
74
+ @mode = mode
75
+ end
76
+
77
+ def abort?
78
+ mode == :abort
79
+ end
80
+
81
+ def log?
82
+ mode == :log
83
+ end
84
+
85
+ alias_method :abort, :abort?
86
+ alias_method :log, :log?
87
+ end
88
+
89
+ SUPPORTED_POLICIES = {
90
+ abort: Policy.new(:abort),
91
+ log: Policy.new(:log)
92
+ }.freeze
93
+
94
+ def self.policy(name)
95
+ SUPPORTED_POLICIES.fetch(name.to_sym) do
96
+ raise ArgumentError, "Unknown on-error policy: #{name}"
97
+ end
98
+ end
99
+
100
+ module Placeholder
101
+ HTML_HEADER = %(<pre class="highlight latexmath-error" role="note" data-latex-error="1">)
102
+ HTML_FOOTER = "</pre>"
103
+
104
+ def self.render(message:, command:, stdout:, stderr:, source:, latex_source:)
105
+ body = [
106
+ "Error: #{message}",
107
+ "Command: #{command}",
108
+ "Stdout: #{present_or_placeholder(stdout)}",
109
+ "Stderr: #{present_or_placeholder(stderr)}",
110
+ "Source (AsciiDoc): #{source}",
111
+ "Source (LaTeX): #{latex_source}"
112
+ ].join("\n")
113
+
114
+ <<~HTML.rstrip
115
+ #{HTML_HEADER}
116
+ #{escape_html(body)}
117
+ #{HTML_FOOTER}
118
+ HTML
119
+ end
120
+
121
+ def self.present_or_placeholder(value)
122
+ return "<empty>" if value.nil? || value.to_s.empty?
123
+
124
+ value
125
+ end
126
+
127
+ def self.escape_html(text)
128
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
129
+ end
130
+ private_class_method :escape_html, :present_or_placeholder
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Asciidoctor
6
+ module Latexmath
7
+ module HtmlBuilder
8
+ LINE_FEED = "\n"
9
+
10
+ module_function
11
+
12
+ def block_html(node, result)
13
+ attrs = result.attributes.dup
14
+ target = attrs.delete("target") || result.target
15
+ alt_text = attrs.delete("alt") || result.alt_text || ""
16
+ original = attrs.delete("data-latex-original") || result.alt_text || ""
17
+ role_attr = attrs.delete("role") || "math"
18
+ width = attrs.delete("width")
19
+ height = attrs.delete("height")
20
+
21
+ residual_attrs = attrs.except("format")
22
+
23
+ classes = ["imageblock"]
24
+ classes.concat(node.roles) if node.respond_to?(:roles)
25
+ classes << "math" unless classes.include?("math")
26
+ class_value = classes.reject(&:empty?).uniq.join(" ")
27
+
28
+ div_attrs = []
29
+ div_attrs << %(id="#{escape_html_attribute(node.id)}") if node.id
30
+ div_attrs << %(class="#{escape_html_attribute(class_value)}") unless class_value.empty?
31
+ div_attrs << %(role="#{escape_html_attribute(role_attr)}") if role_attr
32
+ div_attrs << %(data-latex-original="#{escape_html_attribute(original)}") unless original.to_s.empty?
33
+ if node.respond_to?(:attr) && (align = node.attr("align"))
34
+ div_attrs << %(style="text-align: #{escape_html_attribute(align)};")
35
+ end
36
+
37
+ img_attrs = []
38
+ img_attrs << %(src="#{escape_html_attribute(target)}") if target
39
+ img_attrs << %(alt="#{escape_html_attribute(alt_text)}") unless alt_text.to_s.empty?
40
+ img_attrs << %(role="#{escape_html_attribute(role_attr)}") if role_attr
41
+ img_attrs << %(data-latex-original="#{escape_html_attribute(original)}") unless original.to_s.empty?
42
+ img_attrs << %(width="#{escape_html_attribute(width)}") if width
43
+ img_attrs << %(height="#{escape_html_attribute(height)}") if height
44
+ residual_attrs.each do |key, value|
45
+ next if value.nil?
46
+
47
+ img_attrs << %(#{key}="#{escape_html_attribute(value)}")
48
+ end
49
+
50
+ div_attr_string = div_attrs.empty? ? "" : " " + div_attrs.join(" ")
51
+ lines = []
52
+ lines << %(<div#{div_attr_string}>)
53
+ if node.respond_to?(:title?) && node.title?
54
+ caption = node.respond_to?(:captioned_title) ? node.captioned_title : node.title
55
+ lines << %(<div class="title">#{caption}</div>)
56
+ end
57
+ lines << %(<div class="content">)
58
+ lines << %(<img #{img_attrs.join(" ")}>)
59
+ lines << %(</div>)
60
+ lines << %(</div>)
61
+ lines.join(LINE_FEED)
62
+ end
63
+
64
+ def inline_html(node, result)
65
+ attrs = result.attributes.dup
66
+ target = attrs.delete("target") || result.target
67
+ alt_text = attrs.delete("alt") || result.alt_text || ""
68
+ role_attr = attrs.delete("role") || "math"
69
+ original = attrs.delete("data-latex-original") || result.alt_text || ""
70
+ width = attrs.delete("width")
71
+ height = attrs.delete("height")
72
+
73
+ classes = ["image"]
74
+ classes.concat(role_attr.to_s.split) if role_attr
75
+ if node.respond_to?(:role) && node.role
76
+ classes.concat(node.role.to_s.split)
77
+ end
78
+ classes << "math" unless classes.include?("math")
79
+ span_class = classes.reject(&:empty?).uniq.join(" ")
80
+
81
+ img_attrs = []
82
+ img_attrs << %(src="#{escape_html_attribute(target)}") if target
83
+ img_attrs << %(alt="#{escape_html_attribute(alt_text)}") unless alt_text.to_s.empty?
84
+ img_attrs << %(role="#{escape_html_attribute(role_attr)}") if role_attr
85
+ img_attrs << %(data-latex-original="#{escape_html_attribute(original)}") unless original.to_s.empty?
86
+ img_attrs << %(width="#{escape_html_attribute(width)}") if width
87
+ img_attrs << %(height="#{escape_html_attribute(height)}") if height
88
+ attrs.each do |key, value|
89
+ next if key == "format" || value.nil?
90
+
91
+ img_attrs << %(#{key}="#{escape_html_attribute(value)}")
92
+ end
93
+
94
+ span_attr = span_class.empty? ? "" : %( class="#{escape_html_attribute(span_class)}")
95
+ %(<span#{span_attr}><img #{img_attrs.join(" ")}></span>)
96
+ end
97
+
98
+ def escape_html_attribute(value)
99
+ ::CGI.escapeHTML(value.to_s)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,22 @@
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
+ module Asciidoctor
8
+ module Latexmath
9
+ class MathExpression
10
+ attr_reader :content, :entry_type, :target_basename, :attributes, :options, :location
11
+
12
+ def initialize(content:, entry_type:, target_basename: nil, attributes: {}, options: [], location: nil)
13
+ @content = content
14
+ @entry_type = entry_type
15
+ @target_basename = target_basename
16
+ @attributes = attributes
17
+ @options = options
18
+ @location = location
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Asciidoctor
6
+ module Latexmath
7
+ module PathUtils
8
+ module_function
9
+
10
+ def normalize_separators(path)
11
+ return path unless path.is_a?(String) || path.is_a?(Symbol)
12
+
13
+ path.to_s.tr("\\", "/")
14
+ end
15
+
16
+ def absolute_path?(path)
17
+ return false if path.nil?
18
+
19
+ normalized = normalize_separators(path).to_s
20
+ return false if normalized.empty?
21
+
22
+ normalized.start_with?("/", "~") ||
23
+ normalized.match?(/\A[A-Za-z]:\//) ||
24
+ normalized.start_with?("//")
25
+ end
26
+
27
+ def expand_path(path, base_dir)
28
+ return nil if path.nil?
29
+
30
+ normalized_path = normalize_separators(path)
31
+ normalized_base = normalize_separators(base_dir || Dir.pwd)
32
+
33
+ if absolute_path?(normalized_path)
34
+ File.expand_path(normalized_path)
35
+ else
36
+ File.expand_path(normalized_path, normalized_base)
37
+ end
38
+ end
39
+
40
+ def clean_join(*parts)
41
+ normalized_parts = parts.compact.map { |part| normalize_separators(part) }
42
+ File.join(*normalized_parts)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,74 @@
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 "asciidoctor"
8
+ require "asciidoctor/extensions"
9
+
10
+ require_relative "../renderer_service"
11
+
12
+ module Asciidoctor
13
+ module Latexmath
14
+ module Processors
15
+ class BlockProcessor < ::Asciidoctor::Extensions::BlockProcessor
16
+ use_dsl
17
+
18
+ named :latexmath
19
+ on_context :listing, :literal, :open, :pass, :paragraph, :stem
20
+ parse_content_as :raw
21
+
22
+ def process(parent, reader, attrs)
23
+ result = renderer_service(parent).render_block(parent, reader, attrs)
24
+ return create_block(parent, :pass, result.placeholder_html, {}) if result.type == :placeholder
25
+
26
+ create_image_block(parent, build_block_attributes(result, attrs))
27
+ end
28
+
29
+ private
30
+
31
+ def renderer_service(parent)
32
+ document = parent.document
33
+ document.instance_variable_get(:@latexmath_renderer_service) || begin
34
+ service = RendererService.new(document)
35
+ document.instance_variable_set(:@latexmath_renderer_service, service)
36
+ service
37
+ end
38
+ end
39
+
40
+ def build_block_attributes(result, original_attrs)
41
+ attributes = result.attributes.dup
42
+ attributes["target"] ||= result.target
43
+ attributes["alt"] = sanitize_alt(fetch_attr(original_attrs, :alt), result.alt_text)
44
+ attributes["data-latex-original"] = result.alt_text
45
+ attributes["role"] = merge_roles(attributes["role"], fetch_attr(original_attrs, :role))
46
+ if (align = fetch_attr(original_attrs, :align))
47
+ attributes["align"] = align
48
+ end
49
+ attributes
50
+ end
51
+
52
+ def fetch_attr(attrs, name)
53
+ return nil unless attrs
54
+
55
+ attrs[name.to_s] || attrs[name]
56
+ end
57
+
58
+ def sanitize_alt(user_value, fallback)
59
+ return fallback if user_value.nil?
60
+
61
+ value = user_value.to_s
62
+ value.empty? ? fallback : value
63
+ end
64
+
65
+ def merge_roles(existing_role, additional_role)
66
+ existing = existing_role.to_s.split
67
+ additional = additional_role.to_s.split
68
+ combined = (existing + additional).uniq
69
+ combined.empty? ? nil : combined.join(" ")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,70 @@
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 "asciidoctor"
8
+ require "asciidoctor/extensions"
9
+
10
+ require_relative "../renderer_service"
11
+
12
+ module Asciidoctor
13
+ module Latexmath
14
+ module Processors
15
+ class InlineMacroProcessor < ::Asciidoctor::Extensions::InlineMacroProcessor
16
+ use_dsl
17
+
18
+ named :latexmath
19
+
20
+ def process(parent, target, attrs)
21
+ content = attrs["text"] || target
22
+ result = renderer_service(parent).render_inline_content(parent, content, attrs)
23
+ return create_inline(parent, :quoted, result.placeholder_html) if result.type == :placeholder
24
+
25
+ create_inline(parent, :image, nil, build_inline_attributes(result, attrs))
26
+ end
27
+
28
+ private
29
+
30
+ def renderer_service(parent)
31
+ document = parent.document
32
+ document.instance_variable_get(:@latexmath_renderer_service) || begin
33
+ service = RendererService.new(document)
34
+ document.instance_variable_set(:@latexmath_renderer_service, service)
35
+ service
36
+ end
37
+ end
38
+
39
+ def build_inline_attributes(result, original_attrs)
40
+ attributes = result.attributes.dup
41
+ attributes["target"] ||= result.target
42
+ attributes["alt"] = sanitize_alt(fetch_attr(original_attrs, :alt), result.alt_text)
43
+ attributes["data-latex-original"] = result.alt_text
44
+ attributes["role"] = merge_roles(attributes["role"], fetch_attr(original_attrs, :role))
45
+ attributes
46
+ end
47
+
48
+ def fetch_attr(attrs, name)
49
+ return nil unless attrs
50
+
51
+ attrs[name.to_s] || attrs[name]
52
+ end
53
+
54
+ def sanitize_alt(user_value, fallback)
55
+ return fallback if user_value.nil?
56
+
57
+ value = user_value.to_s
58
+ value.empty? ? fallback : value
59
+ end
60
+
61
+ def merge_roles(existing_role, additional_role)
62
+ existing = existing_role.to_s.split
63
+ additional = additional_role.to_s.split
64
+ combined = (existing + additional).uniq
65
+ combined.empty? ? nil : combined.join(" ")
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asciidoctor"
4
+ require "asciidoctor/extensions"
5
+
6
+ module Asciidoctor
7
+ module Latexmath
8
+ module Processors
9
+ class StatisticsPostprocessor < ::Asciidoctor::Extensions::Postprocessor
10
+ def process(document, output)
11
+ service = document.instance_variable_get(:@latexmath_renderer_service)
12
+ service&.send(:flush_statistics)
13
+ output
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
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
+ module Asciidoctor
8
+ module Latexmath
9
+ class RenderRequest
10
+ attr_reader :expression, :format, :engine, :preamble, :fontsize, :ppi, :timeout, :keep_artifacts,
11
+ :nocache, :cachedir, :artifacts_dir, :tool_overrides, :content_hash, :preamble_hash, :fontsize_hash
12
+
13
+ def initialize(expression:, format:, engine:, preamble:, fontsize:, ppi:, timeout:, keep_artifacts:, nocache:, cachedir:, artifacts_dir:, tool_overrides:, content_hash:, preamble_hash:, fontsize_hash:)
14
+ @expression = expression
15
+ @format = format
16
+ @engine = engine
17
+ @preamble = preamble
18
+ @fontsize = fontsize
19
+ @ppi = ppi
20
+ @timeout = timeout
21
+ @keep_artifacts = keep_artifacts
22
+ @nocache = nocache
23
+ @cachedir = cachedir
24
+ @artifacts_dir = artifacts_dir
25
+ @tool_overrides = tool_overrides
26
+ @content_hash = content_hash
27
+ @preamble_hash = preamble_hash
28
+ @fontsize_hash = fontsize_hash
29
+ end
30
+ end
31
+ end
32
+ end