asciidoctor-pdf-mathjax 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5463b0301cbd7e4d75a4f567a9e0b97c19ae4ec607ee167403630cbb4a7f65da
4
+ data.tar.gz: dfcce64d1431856ae431c300d91c1bf0d5739fb95325d38c58931aadda1d4a21
5
+ SHA512:
6
+ metadata.gz: 4b3dd0ed36a5e2fa4d2157a5dbc189d7c84a0fbb7ab15c873bf863e03c4121b0c37637457cfc55af98d1e3a07f4f8e1d3ce284f16a7bd0a3b020fd90953ac769
7
+ data.tar.gz: 2b9a8082c4795438457733cd4d90d8b386e7c46be9e0775dedda29bed809db10f1837b06194bd10caed9a7ea1f0df2f4f38b8d3c5892b3c78e853299480d83c5
data/bin/render.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+
3
+ const mj = require('mathjax-node');
4
+ mj.config({
5
+ MathJax: {
6
+ // MathJax configuration
7
+ }
8
+ });
9
+ mj.start();
10
+
11
+ async function convertToSvg(latex, format, pixels_per_ex) {
12
+ const data = await mj.typeset({
13
+ ex: pixels_per_ex,
14
+ math: latex,
15
+ format: format,
16
+ svg: true,
17
+ });
18
+ return data.svg;
19
+ }
20
+
21
+ const latex = process.argv[2];
22
+ const format = process.argv[3];
23
+ const pixels_per_ex = parseInt(process.argv[4]);
24
+ convertToSvg(latex, format, pixels_per_ex).then(svg => {
25
+ console.log(svg);
26
+ }).catch(err => console.error(err));
@@ -0,0 +1,225 @@
1
+ require 'asciidoctor-pdf' unless Asciidoctor::Converter.for 'pdf'
2
+ require 'open3'
3
+ require 'tempfile'
4
+ require 'rexml/document'
5
+ require 'ttfunk'
6
+ require 'asciimath'
7
+
8
+ POINTS_PER_EX = 6
9
+ MATHJAX_DEFAULT_COLOR_STRING = "currentColor"
10
+
11
+ FALLBACK_FONT_SIZE = 12
12
+ FALLBACK_FONT_STYLE = 'normal'
13
+ FALLBACK_FONT_FAMILY = 'Arial'
14
+ FALLBACK_FONT_COLOR = '#000000'
15
+
16
+ class AsciidoctorPDFExtensions < (Asciidoctor::Converter.for 'pdf')
17
+ register_for 'pdf'
18
+
19
+ @tempfiles = []
20
+ class << self
21
+ attr_reader :tempfiles
22
+ end
23
+
24
+ def convert_stem node
25
+ arrange_block node do |_|
26
+ add_dest_for_block node if node.id
27
+
28
+ latex_content = extract_latex_content(node.content, node.style.to_sym)
29
+
30
+ svg_output, error = stem_to_svg(latex_content, false)
31
+
32
+ if svg_output.nil? || svg_output.empty?
33
+ logger.warn "Failed to convert STEM to SVG: #{error} (Fallback to code block)"
34
+ pad_box @theme.code_padding, node do
35
+ theme_font :code do
36
+ typeset_formatted_text [{ text: (guard_indentation latex_content), color: @font_color }],
37
+ (calc_line_metrics @base_line_height),
38
+ bottom_gutter: @bottom_gutters[-1][node]
39
+ end
40
+ end
41
+ else
42
+ svg_output = adjust_svg_color(svg_output, @font_color)
43
+ svg_file = Tempfile.new(['stem', '.svg'])
44
+ begin
45
+ svg_file.write(svg_output)
46
+ svg_file.close
47
+
48
+ pad_box @theme.code_padding, node do
49
+ begin
50
+ image_obj = image svg_file.path, position: :center
51
+ logger.debug "Successfully embedded stem block (as latex) #{latex_content} as SVG image" if image_obj
52
+ rescue Prawn::Errors::UnsupportedImageType => e
53
+ logger.warn "Unsupported image type error: #{e.message}"
54
+ rescue StandardError => e
55
+ logger.warn "Failed embedding SVG: #{e.message}"
56
+ end
57
+ end
58
+ ensure
59
+ svg_file.unlink
60
+ end
61
+ end
62
+ end
63
+ theme_margin :block, :bottom, (next_enclosed_block node)
64
+ end
65
+
66
+ def convert_inline_quoted node
67
+ latex_content = extract_latex_content(node.text, node.type)
68
+ return super if latex_content.nil?
69
+
70
+ theme = (load_theme node.document)
71
+
72
+ svg_output, error = stem_to_svg(latex_content, true)
73
+ adjusted_svg, svg_width = adjust_svg_to_match_text(svg_output, node, theme)
74
+ if adjusted_svg.nil? || adjusted_svg.empty?
75
+ logger.warn "Error processing stem: #{error || 'No SVG output'}"
76
+ return super
77
+ end
78
+
79
+ tmp_svg = Tempfile.new(['stem-', '.svg'])
80
+ self.class.tempfiles << tmp_svg
81
+ begin
82
+ tmp_svg.write(adjusted_svg)
83
+ tmp_svg.close
84
+
85
+ logger.debug "Successfully embedded stem inline #{node.text} as SVG image"
86
+ quoted_text = "<img src=\"#{tmp_svg.path}\" format=\"svg\" width=\"#{svg_width}\" alt=\"#{node.text}\">"
87
+ node.id ? %(<a id="#{node.id}">#{DummyText}</a>#{quoted_text}) : quoted_text
88
+ rescue => e
89
+ logger.warn "Failed to process SVG: #{e.message}"
90
+ super
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def extract_latex_content(content, type)
97
+ content = content.strip.gsub("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">")
98
+ case type
99
+ when :latexmath
100
+ return content
101
+ when :asciimath
102
+ return AsciiMath.parse(content).to_latex
103
+ else
104
+ return nil
105
+ end
106
+ end
107
+
108
+ def adjust_svg_color(svg_output, font_color)
109
+ svg_output.gsub(MATHJAX_DEFAULT_COLOR_STRING, "##{font_color}")
110
+ end
111
+
112
+ def stem_to_svg(latex_content, is_inline)
113
+ js_script = File.join(File.dirname(__FILE__), '../bin/render.js')
114
+ svg_output, error = nil, nil
115
+ format = is_inline ? 'inline-TeX' : 'TeX'
116
+ Open3.popen3('node', js_script, latex_content, format, POINTS_PER_EX.to_s) do |_, stdout, stderr, wait_thr|
117
+ svg_output = stdout.read
118
+ error = stderr.read unless wait_thr.value.success?
119
+ end
120
+ [svg_output, error]
121
+ end
122
+
123
+ def adjust_svg_to_match_text(svg_content, node, theme)
124
+ node_context = find_font_context(node)
125
+ logger.debug "Found font context #{node_context} for node #{node}"
126
+
127
+ if node_context.is_a?(Asciidoctor::Section)
128
+ level = node_context.level.next
129
+ theme_key = "heading_h#{level}"
130
+ if node_context.sectname == 'abstract'
131
+ theme_key = 'abstract_title'
132
+ end
133
+
134
+ font_family = theme["#{theme_key}_font_family"] || theme['heading_font_family'] || theme['base_font_family'] || FALLBACK_FONT_FAMILY
135
+ font_style = theme["#{theme_key}_font_style"] || theme['heading_font_style'] || theme['base_font_style'] || FALLBACK_FONT_STYLE
136
+ font_size = theme["#{theme_key}_font_size"] || theme['heading_font_size'] || theme['base_font_size'] || FALLBACK_FONT_SIZE
137
+ font_color = theme["#{theme_key}_font_color"] || theme['heading_font_color'] || theme['base_font_color'] || FALLBACK_FONT_COLOR
138
+ else
139
+ if node_context.parent.is_a?(Asciidoctor::Section) && node_context.parent.sectname == 'abstract'
140
+ theme_key = :abstract
141
+ else
142
+ theme_key = :base
143
+ end
144
+
145
+ font_family = nil
146
+ font_style = nil
147
+ font_size = nil
148
+ font_color = nil
149
+ converter = node_context.converter
150
+ converter.theme_font theme_key do
151
+ font_family = converter.font_family || FALLBACK_FONT_FAMILY
152
+ font_style = converter.font_style || FALLBACK_FONT_STYLE
153
+ font_size = converter.font_size || FALLBACK_FONT_SIZE
154
+ font_color = converter.font_color || FALLBACK_FONT_COLOR
155
+ end
156
+ end
157
+
158
+ font_catalog = theme.font_catalog
159
+ font_file = font_catalog[font_family][font_style.to_s]
160
+
161
+ font = TTFunk::File.open(font_file)
162
+ descender_height = font.horizontal_header.descent.abs
163
+ ascender_height = font.horizontal_header.ascent.abs
164
+
165
+ units_per_em = font.header.units_per_em.to_f
166
+ total_height = (descender_height.to_f + ascender_height.to_f)
167
+
168
+ embedding_text_height = total_height / units_per_em * font_size
169
+ embedding_text_baseline_height = descender_height / units_per_em * font_size
170
+
171
+ svg_doc = REXML::Document.new(svg_content)
172
+ svg_width = svg_doc.root.attributes['width'].to_f * POINTS_PER_EX || raise("No width found in SVG")
173
+ svg_height = svg_doc.root.attributes['height'].to_f * POINTS_PER_EX || raise("No height found in SVG")
174
+ view_box = svg_doc.root.attributes['viewBox']&.split(/\s+/)&.map(&:to_f) || raise("No viewBox found in SVG")
175
+ svg_inner_offset = view_box[1]
176
+ svg_inner_height = view_box[3]
177
+
178
+ svg_default_font_size = FALLBACK_FONT_SIZE
179
+
180
+ # Adjust SVG height and width so that math font matches embedding text
181
+ scaling_factor = font_size.to_f / svg_default_font_size
182
+ svg_width = svg_width * scaling_factor
183
+ svg_height = svg_height * scaling_factor
184
+
185
+ svg_height_difference = embedding_text_height - svg_height
186
+ svg_relative_height_difference = embedding_text_height / svg_height
187
+ embedding_text_relative_baseline_height = embedding_text_baseline_height / embedding_text_height
188
+
189
+ logger.debug "Original SVG height: #{svg_height.round(2)}, width: #{svg_width.round(2)}, inner height: #{svg_inner_height.round(2)}, inner offset: #{svg_inner_offset.round(2)}"
190
+ if svg_height_difference < 0
191
+ svg_relative_portion_extending_embedding_text_below = (1 - svg_relative_height_difference) / 2
192
+ svg_relative_baseline_height = embedding_text_relative_baseline_height * svg_relative_height_difference
193
+ svg_inner_relative_offset = svg_relative_baseline_height + svg_relative_portion_extending_embedding_text_below - 1
194
+
195
+ svg_inner_offset = svg_inner_relative_offset * svg_inner_height
196
+ else
197
+ svg_height = embedding_text_height
198
+ svg_inner_height = svg_relative_height_difference * svg_inner_height
199
+ svg_inner_offset = (embedding_text_relative_baseline_height - 1) * svg_inner_height
200
+ end
201
+
202
+ view_box[1] = svg_inner_offset
203
+ view_box[3] = svg_inner_height
204
+ svg_doc.root.attributes['viewBox'] = view_box.join(' ')
205
+ svg_doc.root.attributes['height'] = "#{svg_height / POINTS_PER_EX}ex"
206
+ svg_doc.root.attributes['width'] = "#{svg_width / POINTS_PER_EX}ex"
207
+ svg_doc.root.attributes.delete('style')
208
+
209
+ logger.debug "Adjusted SVG height: #{svg_height.round(2)}, width: #{svg_width.round(2)}, inner height: #{svg_inner_height.round(2)}, inner offset: #{svg_inner_offset.round(2)}"
210
+ svg_output = adjust_svg_color(svg_doc.to_s, font_color)
211
+
212
+ [svg_output, svg_width]
213
+ rescue => e
214
+ logger.warn "Failed to adjust SVG baseline: #{e.full_message}"
215
+ nil # Fallback to original if adjustment fails
216
+ end
217
+
218
+ def find_font_context(node)
219
+ while node
220
+ return node unless node.is_a?(Asciidoctor::Inline)
221
+ node = node.parent
222
+ end
223
+ node
224
+ end
225
+ end
metadata ADDED
@@ -0,0 +1,186 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: asciidoctor-pdf-mathjax
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Crown0815
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-03-10 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: asciidoctor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: asciidoctor-pdf
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.3'
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 2.3.19
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - "~>"
41
+ - !ruby/object:Gem::Version
42
+ version: '2.3'
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 2.3.19
46
+ - !ruby/object:Gem::Dependency
47
+ name: nokogiri
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '1.18'
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 1.18.3
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '1.18'
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 1.18.3
66
+ - !ruby/object:Gem::Dependency
67
+ name: asciimath
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '2.0'
73
+ type: :runtime
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '2.0'
80
+ - !ruby/object:Gem::Dependency
81
+ name: bigdecimal
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '3.0'
87
+ type: :runtime
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '3.0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: bundler
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '2.0'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '2.0'
108
+ - !ruby/object:Gem::Dependency
109
+ name: rake
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '13.0'
115
+ type: :development
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '13.0'
122
+ - !ruby/object:Gem::Dependency
123
+ name: minitest
124
+ requirement: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - "~>"
127
+ - !ruby/object:Gem::Version
128
+ version: '5.0'
129
+ type: :development
130
+ prerelease: false
131
+ version_requirements: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - "~>"
134
+ - !ruby/object:Gem::Version
135
+ version: '5.0'
136
+ - !ruby/object:Gem::Dependency
137
+ name: logger
138
+ requirement: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - "~>"
141
+ - !ruby/object:Gem::Version
142
+ version: '1.4'
143
+ type: :development
144
+ prerelease: false
145
+ version_requirements: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - "~>"
148
+ - !ruby/object:Gem::Version
149
+ version: '1.4'
150
+ description: Converts STEM blocks and inline macros to SVG images using MathJax-node
151
+ for AsciiDoctor PDF.
152
+ executables:
153
+ - render.js
154
+ extensions: []
155
+ extra_rdoc_files: []
156
+ files:
157
+ - bin/render.js
158
+ - lib/asciidoctor-pdf-mathjax.rb
159
+ homepage: https://github.com/Crown0815/asciidoctor-pdf-mathjax
160
+ licenses:
161
+ - MIT
162
+ metadata: {}
163
+ post_install_message: |
164
+ Thank you for installing asciidoctor-pdf-mathjax!
165
+ Note: This gem requires MathJax-Node for full functionality (e.g., LaTeX rendering).
166
+ If you haven't installed it, run:
167
+ npm install -g mathjax-node
168
+ See the README for details.
169
+ rdoc_options: []
170
+ require_paths:
171
+ - lib
172
+ required_ruby_version: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - ">="
175
+ - !ruby/object:Gem::Version
176
+ version: '2.7'
177
+ required_rubygems_version: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ requirements: []
183
+ rubygems_version: 3.6.2
184
+ specification_version: 4
185
+ summary: AsciiDoctor extension to render STEM fields using MathJax SVG
186
+ test_files: []