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.
- checksums.yaml +4 -4
- data/README.md +119 -36
- data/lib/asciidoctor/latexmath/attribute_resolver.rb +485 -0
- data/lib/asciidoctor/latexmath/cache/cache_entry.rb +31 -0
- data/lib/asciidoctor/latexmath/cache/cache_key.rb +33 -0
- data/lib/asciidoctor/latexmath/cache/disk_cache.rb +151 -0
- data/lib/asciidoctor/latexmath/command_runner.rb +116 -0
- data/lib/asciidoctor/latexmath/converters/html5.rb +72 -0
- data/lib/asciidoctor/latexmath/errors.rb +134 -0
- data/lib/asciidoctor/latexmath/html_builder.rb +103 -0
- data/lib/asciidoctor/latexmath/math_expression.rb +22 -0
- data/lib/asciidoctor/latexmath/path_utils.rb +46 -0
- data/lib/asciidoctor/latexmath/processors/block_processor.rb +74 -0
- data/lib/asciidoctor/latexmath/processors/inline_macro_processor.rb +70 -0
- data/lib/asciidoctor/latexmath/processors/statistics_postprocessor.rb +18 -0
- data/lib/asciidoctor/latexmath/render_request.rb +32 -0
- data/lib/asciidoctor/latexmath/renderer_service.rb +741 -0
- data/lib/asciidoctor/latexmath/rendering/pdf_to_png_renderer.rb +97 -0
- data/lib/asciidoctor/latexmath/rendering/pdf_to_svg_renderer.rb +84 -0
- data/lib/asciidoctor/latexmath/rendering/pdflatex_renderer.rb +166 -0
- data/lib/asciidoctor/latexmath/rendering/pipeline.rb +58 -0
- data/lib/asciidoctor/latexmath/rendering/renderer.rb +33 -0
- data/lib/asciidoctor/latexmath/rendering/tool_detector.rb +320 -0
- data/lib/asciidoctor/latexmath/rendering/toolchain_record.rb +21 -0
- data/lib/asciidoctor/latexmath/statistics/collector.rb +47 -0
- data/lib/asciidoctor/latexmath/support/conflict_registry.rb +75 -0
- data/lib/{asciidoctor-latexmath → asciidoctor/latexmath}/version.rb +1 -1
- data/lib/asciidoctor-latexmath.rb +93 -3
- metadata +34 -12
- data/lib/asciidoctor-latexmath/renderer.rb +0 -515
- 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
|