asciidoctor-latexmath 1.0.0.pre.dev.1 → 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 -515
  31. data/lib/asciidoctor-latexmath/treeprocessor.rb +0 -369
@@ -0,0 +1,97 @@
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
+
9
+ require_relative "renderer"
10
+ require_relative "tool_detector"
11
+ require_relative "../command_runner"
12
+ require_relative "../errors"
13
+
14
+ module Asciidoctor
15
+ module Latexmath
16
+ module Rendering
17
+ class PdfToPngRenderer < Renderer
18
+ def name
19
+ "pdf_to_png"
20
+ end
21
+
22
+ def render(request, context)
23
+ previous = context.respond_to?(:[]) ? context[:previous_output] : nil
24
+ return previous if request.format != :png && previous
25
+
26
+ pdf_path = select_pdf_path(previous, context)
27
+ return pdf_path unless request.format == :png
28
+
29
+ record = context.fetch(:tool_detector).ensure_png_tool!
30
+
31
+ artifact_dir = context.fetch(:artifact_dir)
32
+ FileUtils.mkdir_p(artifact_dir)
33
+ output_path = File.join(artifact_dir, "#{context.fetch(:artifact_basename)}.png")
34
+
35
+ command = build_command(record, pdf_path, output_path, request)
36
+ result = CommandRunner.run(command, timeout: request.timeout, chdir: File.dirname(pdf_path), env: {})
37
+ unless result.exit_status.zero?
38
+ message = <<~MSG.strip
39
+ #{record.id} exited with status #{result.exit_status}
40
+ command: #{command.join(" ")}
41
+ stdout: #{truncate_output(result.stdout)}
42
+ stderr: #{truncate_output(result.stderr)}
43
+ MSG
44
+ raise StageFailureError, message
45
+ end
46
+
47
+ unless File.exist?(output_path)
48
+ fallback = detect_fallback_png(output_path)
49
+ if fallback
50
+ FileUtils.mv(fallback, output_path)
51
+ else
52
+ raise StageFailureError, "PNG conversion did not produce expected output: #{output_path}"
53
+ end
54
+ end
55
+
56
+ output_path
57
+ end
58
+
59
+ private
60
+
61
+ def select_pdf_path(previous, context)
62
+ if previous && File.extname(previous.to_s).casecmp(".pdf").zero?
63
+ previous
64
+ else
65
+ File.join(context.fetch(:tmp_dir), "#{context.fetch(:artifact_basename)}.pdf")
66
+ end
67
+ end
68
+
69
+ def build_command(record, pdf_path, output_path, request)
70
+ case record.id
71
+ when :pdftoppm
72
+ prefix = output_path.sub(/\.png\z/, "")
73
+ [record.path, "-png", "-singlefile", "-r", effective_ppi(request).to_s, pdf_path, prefix]
74
+ when :magick
75
+ [record.path, "-density", effective_ppi(request).to_s, pdf_path, "-quality", "90", output_path]
76
+ when :gs
77
+ [record.path, "-dSAFER", "-dBATCH", "-dNOPAUSE", "-sDEVICE=pngalpha", "-r#{effective_ppi(request)}", "-sOutputFile=#{output_path}", pdf_path]
78
+ else
79
+ [record.path, pdf_path, output_path]
80
+ end
81
+ end
82
+
83
+ def effective_ppi(request)
84
+ request.ppi || 300
85
+ end
86
+
87
+ def detect_fallback_png(expected_path)
88
+ base = expected_path.sub(/\.png\z/, "")
89
+ candidate = "#{base}-1.png"
90
+ return candidate if File.exist?(candidate)
91
+
92
+ nil
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,84 @@
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
+
9
+ require_relative "renderer"
10
+ require_relative "tool_detector"
11
+ require_relative "../command_runner"
12
+ require_relative "../errors"
13
+
14
+ module Asciidoctor
15
+ module Latexmath
16
+ module Rendering
17
+ class PdfToSvgRenderer < Renderer
18
+ def name
19
+ "pdf_to_svg"
20
+ end
21
+
22
+ def render(request, context)
23
+ previous = context.respond_to?(:[]) ? context[:previous_output] : nil
24
+ pdf_path = previous || pdf_path_for(context)
25
+ return previous if request.format != :svg && previous
26
+ return pdf_path unless request.format == :svg
27
+
28
+ # `ensure_svg_tool!` raises MissingToolError when no converter is available;
29
+ # allow it to bubble so upstream handling can surface detailed hints.
30
+ record = context.fetch(:tool_detector).ensure_svg_tool!
31
+ artifact_dir = context.fetch(:artifact_dir)
32
+ FileUtils.mkdir_p(artifact_dir)
33
+ output_path = File.join(artifact_dir, "#{context.fetch(:artifact_basename)}.svg")
34
+
35
+ command = build_command(record, pdf_path, output_path)
36
+ result = CommandRunner.run(command, timeout: request.timeout, chdir: File.dirname(pdf_path), env: {})
37
+ unless result.exit_status.zero?
38
+ message = <<~MSG.strip
39
+ #{record.id} exited with status #{result.exit_status}
40
+ command: #{command.join(" ")}
41
+ stdout: #{truncate_output(result.stdout)}
42
+ stderr: #{truncate_output(result.stderr)}
43
+ MSG
44
+ raise StageFailureError, message
45
+ end
46
+
47
+ unless File.exist?(output_path)
48
+ fallback = detect_fallback_svg(output_path)
49
+ if fallback
50
+ FileUtils.mv(fallback, output_path)
51
+ else
52
+ raise StageFailureError, "SVG conversion did not produce expected output: #{output_path}"
53
+ end
54
+ end
55
+
56
+ output_path
57
+ end
58
+
59
+ private
60
+
61
+ def pdf_path_for(context)
62
+ File.join(context.fetch(:tmp_dir), "#{context.fetch(:artifact_basename)}.pdf")
63
+ end
64
+
65
+ def build_command(record, pdf_path, output_path)
66
+ case record.id
67
+ when :dvisvgm
68
+ [record.path, "--pdf", "--page=1", "-n", "-o", output_path, pdf_path]
69
+ else
70
+ [record.path, pdf_path, output_path]
71
+ end
72
+ end
73
+
74
+ def detect_fallback_svg(expected_path)
75
+ base = expected_path.sub(/\.svg\z/, "")
76
+ candidate = "#{base}-1.svg"
77
+ return candidate if File.exist?(candidate)
78
+
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,166 @@
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 "shellwords"
9
+
10
+ require_relative "renderer"
11
+ require_relative "../command_runner"
12
+ require_relative "../errors"
13
+
14
+ module Asciidoctor
15
+ module Latexmath
16
+ module Rendering
17
+ class PdflatexRenderer < Renderer
18
+ DEFAULT_FLAGS = ["-interaction=nonstopmode", "-halt-on-error", "-file-line-error"].freeze
19
+ SECURE_FLAG = "-no-shell-escape"
20
+ DISALLOWED_FLAGS = %w[-shell-escape --shell-escape -enable-write18 --enable-write18].freeze
21
+
22
+ def name
23
+ "pdflatex"
24
+ end
25
+
26
+ def render(request, context)
27
+ tmp_dir = context.fetch(:tmp_dir)
28
+ FileUtils.mkdir_p(tmp_dir)
29
+
30
+ tex_path = prepare_tex_source(context.fetch(:tex_artifact_path), tmp_dir, context.fetch(:artifact_basename))
31
+ command = build_command(request, tex_path, tmp_dir)
32
+
33
+ result = CommandRunner.run(command, timeout: request.timeout, chdir: tmp_dir, env: sanitized_env)
34
+ unless result.exit_status.zero?
35
+ message = <<~MSG.strip
36
+ pdflatex exited with status #{result.exit_status}
37
+ command: #{command.join(" ")}
38
+ stdout: #{truncate_output(result.stdout)}
39
+ stderr: #{truncate_output(result.stderr)}
40
+ MSG
41
+ raise StageFailureError, message
42
+ end
43
+
44
+ pdf_path = pdf_path_for(context)
45
+ unless File.exist?(pdf_path)
46
+ raise StageFailureError, "pdflatex did not produce expected output: #{pdf_path}"
47
+ end
48
+
49
+ pdf_path
50
+ rescue RenderTimeoutError
51
+ raise
52
+ rescue => error
53
+ raise StageFailureError, error.message
54
+ end
55
+
56
+ private
57
+
58
+ def pdf_path_for(context)
59
+ File.join(context.fetch(:tmp_dir), "#{context.fetch(:artifact_basename)}.pdf")
60
+ end
61
+
62
+ def prepare_tex_source(artifact_path, tmp_dir, basename)
63
+ filename = "#{basename}.tex"
64
+ destination = File.join(tmp_dir, filename)
65
+ FileUtils.cp(artifact_path, destination)
66
+ destination
67
+ end
68
+
69
+ def build_command(request, tex_path, tmp_dir)
70
+ tokens = Shellwords.shellsplit(request.engine.to_s)
71
+ raise StageFailureError, "pdflatex executable not specified" if tokens.empty?
72
+
73
+ executable = tokens.shift
74
+ engine = identify_engine(executable)
75
+ sanitized_flags = tokens.reject { |flag| disallowed_flag?(flag) }
76
+
77
+ if %i[pdflatex xelatex lualatex].include?(engine)
78
+ DEFAULT_FLAGS.each do |flag|
79
+ sanitized_flags.reject! { |existing| equivalent_flag?(existing, flag) }
80
+ sanitized_flags << flag
81
+ end
82
+
83
+ sanitized_flags.reject! { |existing| equivalent_flag?(existing, SECURE_FLAG) }
84
+ sanitized_flags << SECURE_FLAG
85
+
86
+ sanitized_flags = remove_output_directory_flags(sanitized_flags)
87
+ sanitized_flags << "-output-directory" << tmp_dir
88
+ elsif engine == :tectonic
89
+ sanitized_flags = remove_outdir_flags(sanitized_flags)
90
+ sanitized_flags << "--outdir" << tmp_dir
91
+ else
92
+ sanitized_flags = remove_output_directory_flags(sanitized_flags)
93
+ sanitized_flags << "-output-directory" << tmp_dir
94
+ end
95
+
96
+ [executable, *sanitized_flags, tex_path]
97
+ end
98
+
99
+ def sanitized_env
100
+ {
101
+ "openout_any" => "p",
102
+ "shell_escape" => "f"
103
+ }
104
+ end
105
+
106
+ def identify_engine(executable)
107
+ File.basename(executable.to_s).downcase.gsub(/[^a-z0-9]+/, "_").to_sym
108
+ end
109
+
110
+ def disallowed_flag?(flag)
111
+ base = flag.split("=").first
112
+ DISALLOWED_FLAGS.include?(base)
113
+ end
114
+
115
+ def equivalent_flag?(existing, desired)
116
+ base_existing = existing.split("=").first
117
+ base_desired = desired.split("=").first
118
+ base_existing == base_desired
119
+ end
120
+
121
+ def remove_output_directory_flags(flags)
122
+ result = []
123
+ skip_next = false
124
+
125
+ flags.each do |flag|
126
+ if skip_next
127
+ skip_next = false
128
+ next
129
+ end
130
+
131
+ base = flag.split("=").first
132
+ if base == "-output-directory"
133
+ skip_next = !flag.include?("=")
134
+ next
135
+ end
136
+
137
+ result << flag
138
+ end
139
+
140
+ result
141
+ end
142
+
143
+ def remove_outdir_flags(flags)
144
+ result = []
145
+ skip_next = false
146
+
147
+ flags.each do |flag|
148
+ if skip_next
149
+ skip_next = false
150
+ next
151
+ end
152
+
153
+ if flag.start_with?("--outdir")
154
+ skip_next = !flag.include?("=")
155
+ next
156
+ end
157
+
158
+ result << flag
159
+ end
160
+
161
+ result
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,58 @@
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_relative "renderer"
8
+
9
+ module Asciidoctor
10
+ module Latexmath
11
+ module Rendering
12
+ class Pipeline
13
+ DEFAULT_STAGE_IDENTIFIERS = %i[pdflatex pdf_to_svg pdf_to_png].freeze
14
+
15
+ def self.default_stage_identifiers
16
+ DEFAULT_STAGE_IDENTIFIERS
17
+ end
18
+
19
+ def self.signature
20
+ DEFAULT_STAGE_IDENTIFIERS.join("|")
21
+ end
22
+
23
+ def initialize(stages)
24
+ @stages = stages
25
+ end
26
+
27
+ def execute(request, context)
28
+ current_output = nil
29
+
30
+ stages.each do |stage|
31
+ stage_context = enrich_context(context, current_output)
32
+ current_output = stage.render(request, stage_context)
33
+ end
34
+
35
+ current_output
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :stages
41
+
42
+ def enrich_context(context, previous_output)
43
+ return context if previous_output.nil?
44
+
45
+ if context.respond_to?(:merge)
46
+ context.merge(previous_output: previous_output)
47
+ elsif context.respond_to?(:dup) && context.respond_to?(:[]=)
48
+ duplicated = context.dup
49
+ duplicated[:previous_output] = previous_output
50
+ duplicated
51
+ else
52
+ context
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,33 @@
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
+ module Rendering
10
+ RendererResult = Struct.new(:output_path, :format, :intermediate, keyword_init: true)
11
+
12
+ class Renderer
13
+ def name
14
+ raise NotImplementedError, "Renderer subclasses must implement #name"
15
+ end
16
+
17
+ def render(_request, _context)
18
+ raise NotImplementedError, "Renderer subclasses must implement #render"
19
+ end
20
+
21
+ private
22
+
23
+ def truncate_output(output, limit = 500)
24
+ return "" unless output
25
+
26
+ return output if output.length <= limit
27
+
28
+ "#{output[0, limit]}..."
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end