asciidoctor-pdf-mathjax 0.4.0 → 0.6.0
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/bin/render.js +34 -7
- data/lib/asciidoctor-pdf-mathjax.rb +136 -79
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fc66d0f0c7026c39d6363d44464ad9c5f430aed3202b344de302604679d3166
|
|
4
|
+
data.tar.gz: 62009314bb1e5e72e759e2251ce7eaa7ffc14bb123a365a9c0f88dcc58d04bac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5234f95491bf7ec6320942e21c009f77575567a4312b1651e13736f19b1cb85d8eeee8cd9b3e443e483628c0b1e7d18847384cb98e9b4cab5efafb539973bcc8
|
|
7
|
+
data.tar.gz: 7e89244f2911bc889e4f2a3cdd2d34fc31fde201bacac64a0d995ddcbfbd14afe367e98c20922c828ce80bed93e0e05877cd2dbfb81fd655adda92e5c396a6e0
|
data/bin/render.js
CHANGED
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
const mj = require('mathjax-node');
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
// Two modes:
|
|
6
|
+
// render.js <latex> <format> <pixels_per_ex> <font>
|
|
7
|
+
// renders one formula, prints the SVG and exits.
|
|
8
|
+
// render.js --server <font>
|
|
9
|
+
// starts MathJax once and renders one formula per line of stdin,
|
|
10
|
+
// {"math": "...", "format": "TeX", "ex": 6}, answering with one line
|
|
11
|
+
// per formula, {"svg": "..."} or {"error": "..."}.
|
|
12
|
+
// Starting MathJax takes seconds; the server mode pays that once per font
|
|
13
|
+
// instead of once per formula.
|
|
14
|
+
const server = process.argv[2] === '--server';
|
|
15
|
+
const font = server ? process.argv[3] : process.argv[5];
|
|
9
16
|
|
|
10
17
|
mj.config({
|
|
11
18
|
MathJax: {
|
|
@@ -26,6 +33,26 @@ async function convertToSvg(latex, format, pixels_per_ex) {
|
|
|
26
33
|
return data.svg;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}).
|
|
36
|
+
if (server) {
|
|
37
|
+
let queue = Promise.resolve();
|
|
38
|
+
require('readline').createInterface({ input: process.stdin }).on('line', line => {
|
|
39
|
+
queue = queue.then(async () => {
|
|
40
|
+
let answer;
|
|
41
|
+
try {
|
|
42
|
+
const request = JSON.parse(line);
|
|
43
|
+
answer = { svg: await convertToSvg(request.math, request.format, request.ex) };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
answer = { error: String(err) };
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write(JSON.stringify(answer) + '\n');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
} else {
|
|
51
|
+
const latex = process.argv[2];
|
|
52
|
+
const format = process.argv[3];
|
|
53
|
+
const pixels_per_ex = parseInt(process.argv[4]);
|
|
54
|
+
|
|
55
|
+
convertToSvg(latex, format, pixels_per_ex).then(svg => {
|
|
56
|
+
console.log(svg);
|
|
57
|
+
}).catch(err => console.error(err));
|
|
58
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'asciidoctor-pdf' unless Asciidoctor::Converter.for 'pdf'
|
|
2
2
|
require 'open3'
|
|
3
|
+
require 'json'
|
|
3
4
|
require 'tempfile'
|
|
4
5
|
require 'rexml/document'
|
|
5
6
|
require 'ttfunk'
|
|
@@ -16,6 +17,7 @@ FALLBACK_FONT_COLOR = '#000000'
|
|
|
16
17
|
|
|
17
18
|
class AsciidoctorPDFExtensions < (Asciidoctor::Converter.for 'pdf')
|
|
18
19
|
register_for 'pdf'
|
|
20
|
+
FontAttributes = Struct.new(:font_size, :font_color, :font_full_height, :font_baseline)
|
|
19
21
|
|
|
20
22
|
@tempfiles = []
|
|
21
23
|
class << self
|
|
@@ -81,13 +83,14 @@ class AsciidoctorPDFExtensions < (Asciidoctor::Converter.for 'pdf')
|
|
|
81
83
|
|
|
82
84
|
theme = (load_theme node.document)
|
|
83
85
|
math_font = node.document.attributes['math-font'] || MATHJAX_DEFAULT_FONT_FAMILY
|
|
86
|
+
font = analyze_embedding_font(node, theme)
|
|
84
87
|
|
|
85
88
|
svg_output, error = stem_to_svg(latex_content, true, math_font)
|
|
86
89
|
if svg_output.nil? || svg_output.empty?
|
|
87
90
|
logger.warn "Error processing stem: #{error || 'No SVG output'}"
|
|
88
91
|
return super
|
|
89
92
|
end
|
|
90
|
-
adjusted_svg, svg_width = adjust_svg_to_match_text(svg_output,
|
|
93
|
+
adjusted_svg, svg_width = adjust_svg_to_match_text(svg_output, font)
|
|
91
94
|
tmp_svg = Tempfile.new(%w[stem- .svg])
|
|
92
95
|
self.class.tempfiles << tmp_svg
|
|
93
96
|
begin
|
|
@@ -95,7 +98,7 @@ class AsciidoctorPDFExtensions < (Asciidoctor::Converter.for 'pdf')
|
|
|
95
98
|
tmp_svg.close
|
|
96
99
|
|
|
97
100
|
logger.debug "Successfully embedded stem inline #{node.text} with font #{math_font} as SVG image"
|
|
98
|
-
quoted_text = "<img src=\"#{tmp_svg.path}\" format=\"svg\" width=\"#{svg_width}\" alt=\"#{node.text}\">"
|
|
101
|
+
quoted_text = "<img src=\"#{tmp_svg.path}\" format=\"svg\" width=\"#{svg_width}\" alt=\"#{node.text.gsub('"', '"')}\">"
|
|
99
102
|
node.id ? %(<a id="#{node.id}">#{DummyText}</a>#{quoted_text}) : quoted_text
|
|
100
103
|
rescue => e
|
|
101
104
|
logger.warn "Failed to process SVG: #{e.message}"
|
|
@@ -122,22 +125,125 @@ class AsciidoctorPDFExtensions < (Asciidoctor::Converter.for 'pdf')
|
|
|
122
125
|
end
|
|
123
126
|
|
|
124
127
|
def stem_to_svg(latex_content, is_inline, math_font)
|
|
125
|
-
js_script = File.join(File.dirname(__FILE__), '../bin/render.js')
|
|
126
|
-
svg_output, error = nil, nil
|
|
127
128
|
format = is_inline ? 'inline-TeX' : 'TeX'
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
MathJaxServer.for(math_font).render(latex_content, format)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# One long-running render.js process per math font. Starting node and MathJax takes
|
|
133
|
+
# seconds, rendering a formula takes milliseconds, so a document pays the start once.
|
|
134
|
+
class MathJaxServer
|
|
135
|
+
JS_SCRIPT = File.join(File.dirname(__FILE__), '../bin/render.js')
|
|
136
|
+
|
|
137
|
+
@servers = {}
|
|
138
|
+
|
|
139
|
+
def self.for(math_font)
|
|
140
|
+
@servers[math_font] ||= new(math_font)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def initialize(math_font)
|
|
144
|
+
@math_font = math_font
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render(latex_content, format)
|
|
148
|
+
start unless @stdin
|
|
149
|
+
@stdin.puts JSON.generate('math' => latex_content, 'format' => format, 'ex' => POINTS_PER_EX)
|
|
150
|
+
@stdin.flush
|
|
151
|
+
answer = read_answer
|
|
152
|
+
[answer['svg'], answer['error']]
|
|
133
153
|
rescue Errno::ENOENT => e
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
[nil, "Node.js executable 'node' was not found. Please install Node.js and ensure 'node' is available on your PATH. Original error: #{e.message}"]
|
|
155
|
+
rescue IOError, SystemCallError => e
|
|
156
|
+
stop
|
|
157
|
+
[nil, "MathJax process for font #{@math_font} failed: #{e.message}"]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def start
|
|
163
|
+
@stdin, @stdout, @wait_thr = Open3.popen2('node', JS_SCRIPT, '--server', @math_font)
|
|
164
|
+
at_exit { stop }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Skips anything else a dependency may print to stdout.
|
|
168
|
+
def read_answer
|
|
169
|
+
while (line = @stdout.gets)
|
|
170
|
+
answer = JSON.parse(line) rescue nil
|
|
171
|
+
return answer if answer.is_a?(Hash) && (answer.key?('svg') || answer.key?('error'))
|
|
172
|
+
end
|
|
173
|
+
raise IOError, 'render.js ended without an answer'
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def stop
|
|
177
|
+
return unless @stdin
|
|
178
|
+
@stdin.close unless @stdin.closed?
|
|
179
|
+
Process.kill('KILL', @wait_thr.pid) unless @wait_thr.join(5)
|
|
180
|
+
@stdin = @stdout = @wait_thr = nil
|
|
181
|
+
rescue SystemCallError
|
|
182
|
+
@stdin = @stdout = @wait_thr = nil
|
|
136
183
|
end
|
|
137
|
-
[svg_output, error]
|
|
138
184
|
end
|
|
139
185
|
|
|
140
|
-
def adjust_svg_to_match_text(svg_content,
|
|
186
|
+
def adjust_svg_to_match_text(svg_content, font)
|
|
187
|
+
|
|
188
|
+
svg_doc = REXML::Document.new(svg_content)
|
|
189
|
+
svg_width = svg_doc.root.attributes['width'].to_f * POINTS_PER_EX || raise("No width found in SVG")
|
|
190
|
+
svg_height = svg_doc.root.attributes['height'].to_f * POINTS_PER_EX || raise("No height found in SVG")
|
|
191
|
+
view_box = svg_doc.root.attributes['viewBox']&.split(/\s+/)&.map(&:to_f) || raise("No viewBox found in SVG")
|
|
192
|
+
svg_inner_offset = view_box[1]
|
|
193
|
+
svg_inner_height = view_box[3]
|
|
194
|
+
|
|
195
|
+
svg_default_font_size = FALLBACK_FONT_SIZE
|
|
196
|
+
|
|
197
|
+
# Adjust SVG height and width so that math font matches embedding text
|
|
198
|
+
scaling_factor = font.font_size.to_f / svg_default_font_size
|
|
199
|
+
svg_width = svg_width * scaling_factor
|
|
200
|
+
svg_height = svg_height * scaling_factor
|
|
201
|
+
|
|
202
|
+
svg_height_difference = font.font_full_height - svg_height
|
|
203
|
+
svg_relative_height_difference = font.font_full_height / svg_height
|
|
204
|
+
embedding_text_relative_baseline_height = font.font_baseline / font.font_full_height
|
|
205
|
+
|
|
206
|
+
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)}"
|
|
207
|
+
if svg_height_difference < 0
|
|
208
|
+
svg_relative_portion_extending_embedding_text_below = (1 - svg_relative_height_difference) / 2
|
|
209
|
+
svg_relative_baseline_height = embedding_text_relative_baseline_height * svg_relative_height_difference
|
|
210
|
+
svg_inner_relative_offset = svg_relative_baseline_height + svg_relative_portion_extending_embedding_text_below - 1
|
|
211
|
+
|
|
212
|
+
svg_inner_offset_new = svg_inner_relative_offset * svg_inner_height
|
|
213
|
+
svg_inner_height_padding = (svg_inner_offset - svg_inner_offset_new) * 0.25 # 25% padding to handle fractions
|
|
214
|
+
svg_inner_height_difference = 2 * svg_inner_height_padding
|
|
215
|
+
svg_inner_height_new = svg_inner_height + svg_inner_height_difference
|
|
216
|
+
svg_inner_height_relative_difference = svg_inner_height_new / svg_inner_height
|
|
217
|
+
|
|
218
|
+
logger.debug("svg_inner_offset = #{svg_inner_offset}, svg_inner_height = #{svg_inner_height}, svg_inner_offset_new = #{svg_inner_offset_new}, svg_inner_height_new = #{svg_inner_height_new}")
|
|
219
|
+
logger.debug("svg_inner_offset_diff = #{svg_inner_offset - svg_inner_offset_new}, svg_inner_offset_diff_relative = #{(svg_inner_offset - svg_inner_offset_new) / svg_inner_height}")
|
|
220
|
+
|
|
221
|
+
svg_height = svg_height * svg_inner_height_relative_difference
|
|
222
|
+
svg_inner_height = svg_inner_height_new
|
|
223
|
+
svg_inner_offset = svg_inner_offset_new - svg_inner_height_padding
|
|
224
|
+
else
|
|
225
|
+
svg_height = font.font_full_height
|
|
226
|
+
svg_inner_height = svg_relative_height_difference * svg_inner_height
|
|
227
|
+
svg_inner_offset = (embedding_text_relative_baseline_height - 1) * svg_inner_height
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
view_box[1] = svg_inner_offset
|
|
231
|
+
view_box[3] = svg_inner_height
|
|
232
|
+
svg_doc.root.attributes['viewBox'] = view_box.join(' ')
|
|
233
|
+
svg_doc.root.attributes['height'] = "#{svg_height / POINTS_PER_EX}ex"
|
|
234
|
+
svg_doc.root.attributes['width'] = "#{svg_width / POINTS_PER_EX}ex"
|
|
235
|
+
svg_doc.root.attributes.delete('style')
|
|
236
|
+
|
|
237
|
+
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)}"
|
|
238
|
+
svg_output = adjust_svg_color(svg_doc.to_s, font.font_color)
|
|
239
|
+
|
|
240
|
+
[svg_output, svg_width]
|
|
241
|
+
rescue => e
|
|
242
|
+
logger.warn "Failed to adjust SVG baseline: #{e.full_message}"
|
|
243
|
+
nil # Fallback to the original if adjustment fails
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def analyze_embedding_font(node, theme)
|
|
141
247
|
node_context = find_font_context(node)
|
|
142
248
|
logger.debug "Found font context #{node_context} for node #{node}"
|
|
143
249
|
|
|
@@ -183,15 +289,21 @@ class AsciidoctorPDFExtensions < (Asciidoctor::Converter.for 'pdf')
|
|
|
183
289
|
end
|
|
184
290
|
|
|
185
291
|
font = TTFunk::File.open(font_file)
|
|
186
|
-
unless font
|
|
187
|
-
|
|
292
|
+
raise "Failed opening font file: #{font_file}" unless font
|
|
293
|
+
|
|
294
|
+
if font.os2 && font.os2.ascent != 0
|
|
295
|
+
ascender_height = font.os2.ascent.abs
|
|
296
|
+
descender_height = font.os2.descent.abs
|
|
297
|
+
line_gap = font.os2.line_gap
|
|
298
|
+
else
|
|
299
|
+
ascender_height = font.horizontal_header.ascent.abs
|
|
300
|
+
descender_height = font.horizontal_header.descent.abs
|
|
301
|
+
line_gap = font.horizontal_header.line_gap
|
|
188
302
|
end
|
|
189
303
|
|
|
190
|
-
|
|
191
|
-
ascender_height = font.horizontal_header.ascent.abs
|
|
192
|
-
x_height = font.os2.x_height
|
|
304
|
+
ex_height = font.os2&.x_height
|
|
193
305
|
|
|
194
|
-
unless
|
|
306
|
+
unless ex_height
|
|
195
307
|
logger.debug "'OS/2' table not found, falling back to estimating font x-height (ex) from glyph"
|
|
196
308
|
|
|
197
309
|
cmap_table = font.cmap.tables.find { |table| table.format == 4 && table.platform_id == 3 && table.encoding_id == 1 || table.encoding_id == 10 }
|
|
@@ -203,72 +315,17 @@ class AsciidoctorPDFExtensions < (Asciidoctor::Converter.for 'pdf')
|
|
|
203
315
|
glyph = font.glyph_outlines.for(glyph_id)
|
|
204
316
|
raise 'Glyph data not available' unless glyph
|
|
205
317
|
|
|
206
|
-
|
|
318
|
+
ex_height = glyph.y_max - glyph.y_min
|
|
207
319
|
end
|
|
208
|
-
logger.debug "Embedding Font: #{font_family} #{font_style}, x-height: #{
|
|
320
|
+
logger.debug "Embedding Font: #{font_family} #{font_style}, x-height: #{ex_height}, ascender: #{ascender_height}, descender: #{descender_height}, line gap: #{line_gap}"
|
|
209
321
|
|
|
210
322
|
units_per_em = font.header.units_per_em.to_f
|
|
211
|
-
total_height = (descender_height.to_f + ascender_height.to_f)
|
|
323
|
+
total_height = (descender_height.to_f + ascender_height.to_f + line_gap.to_f)
|
|
212
324
|
|
|
213
325
|
embedding_text_height = total_height / units_per_em * font_size
|
|
214
|
-
embedding_text_baseline_height = descender_height / units_per_em * font_size
|
|
215
|
-
|
|
216
|
-
svg_doc = REXML::Document.new(svg_content)
|
|
217
|
-
svg_width = svg_doc.root.attributes['width'].to_f * POINTS_PER_EX || raise("No width found in SVG")
|
|
218
|
-
svg_height = svg_doc.root.attributes['height'].to_f * POINTS_PER_EX || raise("No height found in SVG")
|
|
219
|
-
view_box = svg_doc.root.attributes['viewBox']&.split(/\s+/)&.map(&:to_f) || raise("No viewBox found in SVG")
|
|
220
|
-
svg_inner_offset = view_box[1]
|
|
221
|
-
svg_inner_height = view_box[3]
|
|
222
|
-
|
|
223
|
-
svg_default_font_size = FALLBACK_FONT_SIZE
|
|
224
|
-
|
|
225
|
-
# Adjust SVG height and width so that math font matches embedding text
|
|
226
|
-
scaling_factor = font_size.to_f / svg_default_font_size
|
|
227
|
-
svg_width = svg_width * scaling_factor
|
|
228
|
-
svg_height = svg_height * scaling_factor
|
|
229
|
-
|
|
230
|
-
svg_height_difference = embedding_text_height - svg_height
|
|
231
|
-
svg_relative_height_difference = embedding_text_height / svg_height
|
|
232
|
-
embedding_text_relative_baseline_height = embedding_text_baseline_height / embedding_text_height
|
|
233
|
-
|
|
234
|
-
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)}"
|
|
235
|
-
if svg_height_difference < 0
|
|
236
|
-
svg_relative_portion_extending_embedding_text_below = (1 - svg_relative_height_difference) / 2
|
|
237
|
-
svg_relative_baseline_height = embedding_text_relative_baseline_height * svg_relative_height_difference
|
|
238
|
-
svg_inner_relative_offset = svg_relative_baseline_height + svg_relative_portion_extending_embedding_text_below - 1
|
|
326
|
+
embedding_text_baseline_height = (descender_height.to_f + line_gap.to_f) / units_per_em * font_size
|
|
239
327
|
|
|
240
|
-
|
|
241
|
-
svg_inner_height_padding = (svg_inner_offset - svg_inner_offset_new) * 0.25 # 25% padding to handle fractions
|
|
242
|
-
svg_inner_height_difference = 2 * svg_inner_height_padding
|
|
243
|
-
svg_inner_height_new = svg_inner_height + svg_inner_height_difference
|
|
244
|
-
svg_inner_height_relative_difference = svg_inner_height_new / svg_inner_height
|
|
245
|
-
|
|
246
|
-
logger.debug("svg_inner_offset = #{svg_inner_offset}, svg_inner_height = #{svg_inner_height}, svg_inner_offset_new = #{svg_inner_offset_new}, svg_inner_height_new = #{svg_inner_height_new}")
|
|
247
|
-
logger.debug("svg_inner_offset_diff = #{svg_inner_offset - svg_inner_offset_new}, svg_inner_offset_diff_relative = #{(svg_inner_offset - svg_inner_offset_new) / svg_inner_height}")
|
|
248
|
-
|
|
249
|
-
svg_height = svg_height * svg_inner_height_relative_difference
|
|
250
|
-
svg_inner_height = svg_inner_height_new
|
|
251
|
-
svg_inner_offset = svg_inner_offset_new - svg_inner_height_padding
|
|
252
|
-
else
|
|
253
|
-
svg_height = embedding_text_height
|
|
254
|
-
svg_inner_height = svg_relative_height_difference * svg_inner_height
|
|
255
|
-
svg_inner_offset = (embedding_text_relative_baseline_height - 1) * svg_inner_height
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
view_box[1] = svg_inner_offset
|
|
259
|
-
view_box[3] = svg_inner_height
|
|
260
|
-
svg_doc.root.attributes['viewBox'] = view_box.join(' ')
|
|
261
|
-
svg_doc.root.attributes['height'] = "#{svg_height / POINTS_PER_EX}ex"
|
|
262
|
-
svg_doc.root.attributes['width'] = "#{svg_width / POINTS_PER_EX}ex"
|
|
263
|
-
svg_doc.root.attributes.delete('style')
|
|
264
|
-
|
|
265
|
-
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)}"
|
|
266
|
-
svg_output = adjust_svg_color(svg_doc.to_s, font_color)
|
|
267
|
-
|
|
268
|
-
[svg_output, svg_width]
|
|
269
|
-
rescue => e
|
|
270
|
-
logger.warn "Failed to adjust SVG baseline: #{e.full_message}"
|
|
271
|
-
nil # Fallback to the original if adjustment fails
|
|
328
|
+
FontAttributes.new(font_size, font_color, embedding_text_height, embedding_text_baseline_height)
|
|
272
329
|
end
|
|
273
330
|
|
|
274
331
|
def find_font_context(node)
|