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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4129aed907ee9beb5d7a2c523a0ed1ffb783502aed7184f687305a051ec60afe
4
- data.tar.gz: 5dcf3ee6fb448dd253b3fbafbc0ecdf879035744d490e192779f3cb0c0180015
3
+ metadata.gz: 3fc66d0f0c7026c39d6363d44464ad9c5f430aed3202b344de302604679d3166
4
+ data.tar.gz: 62009314bb1e5e72e759e2251ce7eaa7ffc14bb123a365a9c0f88dcc58d04bac
5
5
  SHA512:
6
- metadata.gz: 208a3e49a730fabb7eddce46b1ab711abd1765716c5647bfce354865b89e5553289d9b00c610f952daefbd9b6152b3718ceb94f90055aa44517867e948c6c09e
7
- data.tar.gz: e018fbd87bd4e13675aaef9803c6e41505abdb80c952d07185d1a26d78a4cc56d59016a343836c4d437783fea07a299653de9a3d999c22652a95cb0e8d62b707
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
- const latex = process.argv[2];
6
- const format = process.argv[3];
7
- const pixels_per_ex = parseInt(process.argv[4]);
8
- const font = process.argv[5];
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
- convertToSvg(latex, format, pixels_per_ex).then(svg => {
30
- console.log(svg);
31
- }).catch(err => console.error(err));
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, node, theme)
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('"', '&quot;')}\">"
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
- begin
129
- Open3.popen3('node', js_script, latex_content, format, POINTS_PER_EX.to_s, math_font) do |_, stdout, stderr, wait_thr|
130
- svg_output = stdout.read
131
- error = stderr.read unless wait_thr.value.success?
132
- end
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
- error = "Node.js executable 'node' was not found. Please install Node.js and ensure 'node' is available on your PATH. Original error: #{e.message}"
135
- svg_output = nil
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, node, theme)
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
- raise "Failed opening font file: #{font_file}"
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
- descender_height = font.horizontal_header.descent.abs
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 x_height
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
- x_height = glyph.y_max - glyph.y_min
318
+ ex_height = glyph.y_max - glyph.y_min
207
319
  end
208
- logger.debug "Embedding Font: #{font_family} #{font_style}, x-height: #{x_height}, ascender: #{ascender_height}, descender: #{descender_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
- svg_inner_offset_new = svg_inner_relative_offset * svg_inner_height
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asciidoctor-pdf-mathjax
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Crown0815