jekyll-maths-latex-to-image 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40fa3a5d43a4d56e7665d77049e931cdbc0342ee31995eb108e28e1cc6ec8f60
4
+ data.tar.gz: ab6bb61286ad507646a13d467780d01f528451e7aa7f171e766ced590f1694e2
5
+ SHA512:
6
+ metadata.gz: 271edbc4ca16c37bfddf751215b6e078c4005751f2dddc52ebc13fa010f99a6bf53f50894679c2691f000b204d406c69be1c4519b1bd40b5a4b2e64cfa7e10e4
7
+ data.tar.gz: 0c84b3048409ad729c54a3d74cbdc8d3532fc3f82057e866aff827aa6f963dd6a8228cd2079886b32e225691c5d9a217ed2f027f50e8d3a979e29f0920934f8a
@@ -0,0 +1,5 @@
1
+ module Jekyll
2
+ module MathsLatexToImage
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,541 @@
1
+ require 'net/http'
2
+ require 'json'
3
+ require 'parallel'
4
+ require 'logger'
5
+ require 'digest'
6
+ require 'fileutils'
7
+ require 'open3'
8
+ require 'jimson'
9
+ begin
10
+ require 'mutex_m'
11
+ rescue LoadError
12
+ # For Ruby 3.4+ where mutex_m is not a default gem
13
+ gem 'mutex_m'
14
+ require 'mutex_m'
15
+ end
16
+
17
+ class NodeRendererClient
18
+ include Mutex_m # Mix in mutex capabilities
19
+
20
+ def initialize(logger)
21
+ super() # Initialize mutex
22
+ @logger = logger
23
+ @request_id = 0
24
+ start_renderer
25
+ rescue => e
26
+ @logger.error("Failed to initialize renderer: #{e.message}")
27
+ @logger.error(e.backtrace.join("\n"))
28
+ raise
29
+ end
30
+
31
+ def start_renderer
32
+ user_dir = Dir.pwd # Get user's current working directory
33
+ node_path = File.join(user_dir, 'node_modules')
34
+
35
+ # Set environment with node_path
36
+ env = ENV.to_h.merge({
37
+ 'NODE_PATH' => node_path
38
+ })
39
+
40
+ @logger.info("Node renderer NODE_PATH: #{node_path}")
41
+ @logger.info("Node renderer working directory: #{user_dir}")
42
+
43
+ # Use npx with updated command name
44
+ command = "npx jekyll-maths-tex2svg"
45
+
46
+ # Capture stderr separately for better error logging
47
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(env, command, chdir: user_dir)
48
+
49
+ # Rest of the start_renderer method remains the same
50
+ @stderr_thread = Thread.new do
51
+ while line = @stderr.gets
52
+ @logger.error("Node renderer stderr: #{line.strip}")
53
+ end
54
+ end
55
+
56
+ # Verify the renderer is working
57
+ begin
58
+ response = send_request('ping', nil)
59
+ unless response['result'] == 'pong'
60
+ raise "Renderer verification failed: #{response.inspect}"
61
+ end
62
+ @logger.info("Node renderer verified and ready")
63
+ rescue => e
64
+ @logger.error("Renderer startup failed: #{e.message}")
65
+ cleanup
66
+ raise
67
+ end
68
+ rescue => e
69
+ @logger.error("Failed to verify renderer connection: #{e.message}")
70
+ cleanup
71
+ raise
72
+ end
73
+
74
+ def get_svg(latex, display_mode: false, options: {})
75
+ synchronize do # Synchronize the entire request-response cycle
76
+ params = {
77
+ latex: latex.strip,
78
+ display: display_mode,
79
+ svgOptions: {
80
+ width: 'auto',
81
+ ex: 6,
82
+ em: 6,
83
+ containerWidth: display_mode ? 600 : 300
84
+ }.merge(options)
85
+ }
86
+
87
+ response = send_request('renderMath', params)
88
+
89
+ if response['error']
90
+ raise "Render error: #{response['error']['message']}"
91
+ end
92
+
93
+ if response['result'] && response['result']['svg']
94
+ Base64.decode64(response['result']['svg'])
95
+ else
96
+ raise "Invalid response format"
97
+ end
98
+ end
99
+ end
100
+
101
+ def cleanup
102
+ @stdin&.close
103
+ @stdout&.close
104
+ @stderr&.close
105
+ @stderr_thread&.kill
106
+ @stderr_thread&.join
107
+ Process.kill('TERM', @wait_thr.pid) if @wait_thr&.pid
108
+ @wait_thr&.join
109
+ rescue => e
110
+ @logger.error("Error during cleanup: #{e.message}")
111
+ end
112
+
113
+ private
114
+
115
+ def send_request(method, params)
116
+ request = {
117
+ jsonrpc: '2.0',
118
+ method: method,
119
+ params: params,
120
+ id: next_request_id
121
+ }
122
+
123
+ @logger.debug("→ #{method}: #{params.inspect}")
124
+ @stdin.puts(request.to_json)
125
+ @stdin.flush
126
+
127
+ response = @stdout.gets
128
+ raise "No response from renderer" if response.nil?
129
+
130
+ parsed = JSON.parse(response)
131
+ @logger.debug("← #{parsed.inspect}")
132
+
133
+ if parsed['error']
134
+ error = parsed['error']
135
+ @logger.error("Render error (#{error['code']}): #{error['message']}")
136
+ if error['data']
137
+ @logger.error("Stack trace: #{error['data']}")
138
+ end
139
+ end
140
+
141
+ parsed
142
+ end
143
+
144
+ def next_request_id
145
+ @request_id += 1
146
+ "req_#{@request_id}"
147
+ end
148
+ end
149
+
150
+ module Jekyll
151
+ module MathsLatexToImage
152
+ class Generator < Jekyll::Generator # Changed from MathRenderer to MathsLatexToImage
153
+ priority :highest
154
+
155
+ def initialize(config = nil)
156
+ super()
157
+ setup_logging
158
+ setup_debug_dir
159
+ @config = config
160
+ @svg_cache = {}
161
+
162
+ # Register the post_write hook during initialization
163
+ Jekyll::Hooks.register :site, :post_write do |site|
164
+ @console_logger.info("Post-write hook triggered")
165
+ begin
166
+ copy_svg_files(site) # First copy SVG files
167
+ convert_to_png(site) # Then do conversion to PNG
168
+ @console_logger.info("Post-write tasks completed successfully")
169
+ rescue => e
170
+ @console_logger.error("Post-write tasks failed: #{e.message}")
171
+ @console_logger.error(e.backtrace.join("\n"))
172
+ end
173
+ end
174
+
175
+ @@instance = self # Store instance for hook access
176
+
177
+ # Updated regex patterns
178
+ @display_math_pattern = /\$\$(.*?)\$\$/m
179
+ @inline_math_pattern = /(?<!\\)\$([^\$]+?)\$/ # Changed pattern
180
+
181
+ @renderer = NodeRendererClient.new(@console_logger)
182
+ rescue => e
183
+ @console_logger.error("Failed to initialize renderer: #{e.message}")
184
+ raise
185
+ end
186
+
187
+ def generate(site)
188
+ @site = site
189
+ setup_svg_dir
190
+ setup_temp_dir
191
+ start_time = Time.now
192
+
193
+ @svg_files = {} # Map of temp paths to final paths
194
+ process_documents(site)
195
+
196
+ @console_logger.info("Math rendering completed in #{Time.now - start_time.round(2)} seconds")
197
+ end
198
+
199
+ def convert_to_png(site)
200
+ build_dir = site.dest
201
+ user_dir = Dir.pwd
202
+ node_path = File.join(user_dir, 'node_modules')
203
+
204
+ @console_logger.info("Starting PNG conversion from: #{build_dir}")
205
+ @console_logger.info("PNG converter NODE_PATH: #{node_path}")
206
+ @console_logger.info("PNG converter working directory: #{user_dir}")
207
+
208
+ # Set environment with node_path
209
+ env = ENV.to_h.merge({
210
+ 'NODE_PATH' => node_path
211
+ })
212
+
213
+ # Use npx with updated command name
214
+ command = "npx jekyll-maths-svg2png \"#{build_dir}\""
215
+
216
+ @console_logger.debug("Running command: #{command}")
217
+ output, status = Open3.capture2e(env, command, chdir: user_dir)
218
+
219
+ if status.success?
220
+ @console_logger.info("PNG conversion complete")
221
+ @console_logger.debug(output) if ENV['DEBUG']
222
+ else
223
+ @console_logger.error("PNG conversion failed:")
224
+ @console_logger.error(output)
225
+ raise "PNG conversion failed: #{output}"
226
+ end
227
+ end
228
+
229
+ # New method to handle post-write tasks
230
+ def post_write_tasks(site)
231
+ begin
232
+ convert_to_png(site) # Do PNG conversion first
233
+ copy_svg_files(site) # Then copy both SVG and PNG files
234
+ rescue => e
235
+ @console_logger.error("Post-write tasks failed: #{e.message}")
236
+ @console_logger.error(e.backtrace.join("\n"))
237
+ end
238
+ end
239
+
240
+ private
241
+
242
+ def setup_logging
243
+ log_dir = File.join(Dir.pwd, '_logs', 'math')
244
+ FileUtils.mkdir_p(log_dir)
245
+
246
+ @rpc_logger = Logger.new(
247
+ File.open(File.join(log_dir, "math-rpc-#{Time.now.strftime('%Y-%m-%d')}.log"), 'a')
248
+ )
249
+ @rpc_logger.formatter = proc do |_, _, _, msg|
250
+ "#{msg}\n"
251
+ end
252
+
253
+ # Minimal console logging
254
+ @console_logger = Logger.new(STDOUT)
255
+ @console_logger.formatter = proc do |severity, _, _, msg|
256
+ next unless severity == "ERROR" || ENV["DEBUG"]
257
+ "[#{severity}] #{msg}\n"
258
+ end
259
+ @console_logger.level = ENV["DEBUG"] ? Logger::DEBUG : Logger::ERROR
260
+ end
261
+
262
+ def setup_debug_dir
263
+ @debug_dir = File.join(Dir.pwd, '_debug', 'math', 'ruby')
264
+ FileUtils.mkdir_p(@debug_dir)
265
+ @console_logger.info("Debug directory: #{@debug_dir}")
266
+ end
267
+
268
+ def setup_svg_dir
269
+ # Use configurable path from _config.yml or default
270
+ base_path = @site.config.dig('texsvg_math_renderer', 'path') || '/assets/img/math'
271
+ @svg_url_path = File.join(@site.config['baseurl'].to_s, base_path)
272
+ end
273
+
274
+ def setup_temp_dir
275
+ # Create temporary directory for SVGs during generation
276
+ @temp_svg_dir = File.join(Dir.tmpdir, "jekyll-math-#{Time.now.to_i}")
277
+ FileUtils.mkdir_p(@temp_svg_dir)
278
+ @console_logger.info("Created temp SVG directory: #{@temp_svg_dir}")
279
+ end
280
+
281
+ def has_math?(content)
282
+ content =~ @display_math_pattern || content =~ @inline_math_pattern
283
+ end
284
+
285
+ def convert_math(page)
286
+ return unless has_math?(page.content)
287
+
288
+ begin
289
+ setup_page_svg_dir(page)
290
+
291
+ # Add debug logging for inline math
292
+ page.content.scan(@inline_math_pattern) do |match|
293
+ @console_logger.debug("Found inline math: #{match[0]}")
294
+ end
295
+
296
+ # Process display math first
297
+ page.content = page.content.gsub(@display_math_pattern) do |match|
298
+ math = $1.strip
299
+ next match if math.empty?
300
+ render_math(math, true)
301
+ end
302
+
303
+ # Process inline math with debug logging
304
+ page.content = page.content.gsub(@inline_math_pattern) do |match|
305
+ math = $1.strip
306
+ @console_logger.debug("Processing inline math: #{math}")
307
+ next match if math.empty?
308
+ render_math(math, false)
309
+ end
310
+ rescue => e
311
+ @console_logger.error("Error processing #{page.path}: #{e.message}")
312
+ raise e
313
+ end
314
+ end
315
+
316
+ def setup_page_svg_dir(page)
317
+ # Get clean URL path for the page
318
+ rel_path = if page.url
319
+ # Remove leading/trailing slashes and .html
320
+ page.url.gsub(/^\/|\/$|\.html$/, '')
321
+ else
322
+ page.path.sub(/\.[^.]+$/, '').sub(/^\//, '')
323
+ end
324
+
325
+ # Create temp subdirectory for this page's SVGs
326
+ @current_temp_dir = File.join(@temp_svg_dir, rel_path)
327
+ FileUtils.mkdir_p(@current_temp_dir)
328
+
329
+ # Store the final URL path
330
+ @current_url_path = File.join(@svg_url_path, rel_path)
331
+
332
+ @console_logger.info("Page URL path: #{@current_url_path}")
333
+ @console_logger.info("Temp directory: #{@current_temp_dir}")
334
+ end
335
+
336
+ def escape_latex(latex)
337
+ # More comprehensive LaTeX escaping
338
+ latex.gsub(/\\/, '\\\\') # Escape backslashes first
339
+ .gsub(/\{/, '\\{') # Escape curly braces
340
+ .gsub(/\}/, '\\}') # Fixed closing brace
341
+ .gsub(/\$/, '\\$') # Escape dollar signs
342
+ .gsub(/&/, '\\&') # Escape ampersands
343
+ .gsub(/\#/, '\\#') # Escape hash
344
+ .gsub(/\^/, '\\^') # Escape carets
345
+ .gsub(/\_/, '\\_') # Escape underscores
346
+ .gsub(/\%/, '\\%') # Escape percent signs
347
+ .gsub(/~/, '\\~{}') # Escape tildes
348
+ end
349
+
350
+ def render_math(math, display_mode)
351
+ cache_key = "#{math}:#{display_mode}"
352
+ return @svg_cache[cache_key] if @svg_cache.key?(cache_key)
353
+
354
+ begin
355
+ cleaned_math = clean_math_input(math)
356
+ svg = @renderer.get_svg(cleaned_math, display_mode: display_mode)
357
+ svg_path = save_svg(math, svg, SecureRandom.uuid)
358
+ png_path = svg_path.sub(/\.svg$/, '.png')
359
+
360
+ # Get format preference from config
361
+ format = @site.config.dig('texsvg_math_renderer', 'format') || 'both'
362
+
363
+ # Build srcset based on format
364
+ srcset = case format
365
+ when 'svg'
366
+ "#{svg_path}"
367
+ when 'png'
368
+ "#{png_path}"
369
+ else # 'both'
370
+ "#{svg_path}, #{png_path}"
371
+ end
372
+
373
+ # Default source is PNG for better compatibility
374
+ src = format == 'svg' ? svg_path : png_path
375
+
376
+ # Build the common part of the img tag
377
+ img_class = "math-#{display_mode ? 'block' : 'inline'}"
378
+ img_tag = "<img src=\"#{src}\" "\
379
+ "srcset=\"#{srcset}\" "\
380
+ "alt=\"#{html_escape(math)}\" "\
381
+ "class=\"#{img_class}\""
382
+
383
+ # For inline math add a style override
384
+ unless display_mode
385
+ img_tag += " style=\"font-size:1.3em\""
386
+ end
387
+ img_tag += ">"
388
+
389
+ html = display_mode ?
390
+ "<div class=\"math-block\">#{img_tag}</div>" :
391
+ "<span class=\"math-inline\">#{img_tag}</span>"
392
+
393
+ html = html.html_safe if html.respond_to?(:html_safe)
394
+ @svg_cache[cache_key] = html
395
+ html
396
+ rescue => e
397
+ @console_logger.error("Math Processing Error:")
398
+ @console_logger.error(" Input: #{math.inspect}")
399
+ @console_logger.error(" Error: #{e.message}")
400
+ "<span class='math-error' title='#{html_escape(e.message)}'>#{html_escape(math)}</span>"
401
+ end
402
+ end
403
+
404
+ def save_svg(latex, svg, id)
405
+ begin
406
+ hash = Digest::MD5.hexdigest(latex)[0..7]
407
+ sanitized_latex = latex.gsub(/[^a-zA-Z0-9]/, '_')[0..19]
408
+ filename = "math_#{sanitized_latex}-#{hash}-#{id}.svg"
409
+
410
+ # Save to temp location
411
+ temp_path = File.join(@current_temp_dir, filename)
412
+ FileUtils.mkdir_p(@current_temp_dir)
413
+ File.write(temp_path, svg)
414
+
415
+ # Calculate final path
416
+ final_url = File.join(@current_url_path, filename)
417
+ final_path = File.join(@site.dest, final_url.sub(/^\//, ''))
418
+
419
+ # Store mapping for post-write hook
420
+ @svg_files[temp_path] = final_path
421
+
422
+ # Only log errors, not successes
423
+ @rpc_logger.debug("SVG: #{filename}")
424
+
425
+ # Return URL path for HTML
426
+ final_url
427
+ rescue => e
428
+ @console_logger.error("Error saving SVG: #{e.message}")
429
+ raise
430
+ end
431
+ end
432
+
433
+ def copy_svg_files(site)
434
+ @svg_files.each do |temp_path, final_path|
435
+ begin
436
+ FileUtils.mkdir_p(File.dirname(final_path))
437
+ FileUtils.cp(temp_path, final_path)
438
+ rescue => e
439
+ @console_logger.error("Failed to copy #{temp_path} to #{final_path}: #{e.message}")
440
+ end
441
+ end
442
+
443
+ FileUtils.rm_rf(@temp_svg_dir)
444
+ end
445
+
446
+ def cleanup
447
+ @renderer.cleanup
448
+ end
449
+
450
+ def clean_math_input(math)
451
+ original = math.dup
452
+ cleaned = math
453
+ # .gsub(/\\boxed\{/, '{\\boxed{') # Fix boxed command grouping
454
+ .gsub(/\s+/, ' ') # Normalize spaces
455
+
456
+ #.gsub(/[^\x00-\x7F]/, '') # Remove non-ASCII
457
+ #.strip
458
+
459
+ @console_logger.debug("Math cleaning steps:")
460
+ @console_logger.debug(" 1. Original: #{original.inspect}")
461
+ @console_logger.debug(" 2. Cleaned: #{cleaned.inspect}")
462
+ @console_logger.debug(" 3. Changed: #{original != cleaned}")
463
+
464
+ cleaned
465
+ end
466
+
467
+ def html_escape(str)
468
+ str.gsub(/[&<>"']/, {
469
+ '&' => '&amp;',
470
+ '<' => '&lt;',
471
+ '>' => '&gt;',
472
+ '"' => '&quot;',
473
+ "'" => '&#39;'
474
+ })
475
+ end
476
+
477
+ # Remove old logging methods
478
+ def log_error(message, data = {})
479
+ @console_logger.error("#{message} #{data.inspect unless data.empty()}")
480
+ end
481
+
482
+ def log_info(message, data = {})
483
+ @console_logger.info("#{message} #{data.inspect unless data.empty()}")
484
+ end
485
+
486
+ def log_debug(message, data = {})
487
+ @console_logger.debug("#{message} #{data.inspect unless data.empty()}")
488
+ end
489
+
490
+ def process_documents(site)
491
+ all_documents = site.pages + site.posts.docs
492
+ chunks = all_documents.each_slice(5).to_a
493
+
494
+ Parallel.each(chunks, in_threads: Parallel.processor_count) do |chunk|
495
+ chunk.each do |doc|
496
+ begin
497
+ convert_math(doc) if has_math?(doc.content)
498
+ rescue => e
499
+ @console_logger.error("Error processing #{doc.path}: #{e.message}")
500
+ end
501
+ end
502
+ end
503
+ end
504
+
505
+ def check_node_dependencies(required_modules)
506
+ missing = []
507
+ required_modules.each do |mod|
508
+ begin
509
+ require_cmd = "require.resolve('#{mod}')"
510
+ cmd = "node -e \"#{require_cmd}\""
511
+ _, status = Open3.capture2e(cmd)
512
+ missing << mod unless status.success?
513
+ rescue
514
+ missing << mod
515
+ end
516
+ end
517
+
518
+ unless missing.empty?
519
+ msg = "Missing required Node.js modules: #{missing.join(', ')}. "\
520
+ "Please run: npm install #{missing.join(' ')}"
521
+ raise msg
522
+ end
523
+ end
524
+ end
525
+ end
526
+ end
527
+
528
+ # Helper class for multiple IO logging
529
+ class MultiIO
530
+ def initialize(*targets)
531
+ @targets = targets
532
+ end
533
+
534
+ def write(*args)
535
+ @targets.each { |t| t.write(*args) }
536
+ end
537
+
538
+ def close
539
+ @targets.each(&:close)
540
+ end
541
+ end
@@ -0,0 +1,37 @@
1
+ const sharp = require('sharp');
2
+ const glob = require('glob');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ async function convertSvgToPng(buildDir) {
7
+ try {
8
+ // Find all SVG files recursively
9
+ const svgFiles = glob.sync('**/*.svg', { cwd: buildDir });
10
+
11
+ for (const svgFile of svgFiles) {
12
+ const svgPath = path.join(buildDir, svgFile);
13
+ const pngPath = svgPath.replace(/\.svg$/, '.png');
14
+
15
+ try {
16
+ await sharp(svgPath)
17
+ .png()
18
+ .toFile(pngPath);
19
+ console.log(`Converted: ${svgFile} -> ${path.basename(pngPath)}`);
20
+ } catch (err) {
21
+ console.error(`Error converting ${svgFile}:`, err);
22
+ }
23
+ }
24
+ } catch (err) {
25
+ console.error('Conversion error:', err);
26
+ process.exit(1);
27
+ }
28
+ }
29
+
30
+ // Get build directory from command line argument
31
+ const buildDir = process.argv[2];
32
+ if (!buildDir) {
33
+ console.error('Please provide the build directory path');
34
+ process.exit(1);
35
+ }
36
+
37
+ convertSvgToPng(buildDir);