rcas 0.2.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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CITATION.cff +17 -0
  3. data/DESIGN.md +783 -0
  4. data/LICENSE +21 -0
  5. data/MANUAL.md +6265 -0
  6. data/README.md +267 -0
  7. data/bin/rcas +9 -0
  8. data/bin/rcas-app +9 -0
  9. data/bin/rcas-chat +9 -0
  10. data/lib/rcas/algebraic.rb +481 -0
  11. data/lib/rcas/analysis.rb +966 -0
  12. data/lib/rcas/app/launcher.rb +203 -0
  13. data/lib/rcas/app/public/app.css +402 -0
  14. data/lib/rcas/app/public/app.js +449 -0
  15. data/lib/rcas/app/public/index.html +46 -0
  16. data/lib/rcas/app/server.rb +220 -0
  17. data/lib/rcas/app/window.rb +94 -0
  18. data/lib/rcas/app/worksheet.rb +290 -0
  19. data/lib/rcas/app.rb +168 -0
  20. data/lib/rcas/background.rb +758 -0
  21. data/lib/rcas/chat/assistant.rb +199 -0
  22. data/lib/rcas/chat/picker.rb +164 -0
  23. data/lib/rcas/chat/repl.rb +583 -0
  24. data/lib/rcas/chat/session.rb +137 -0
  25. data/lib/rcas/chat/settings.rb +71 -0
  26. data/lib/rcas/chat/style.rb +30 -0
  27. data/lib/rcas/chat/tool.rb +53 -0
  28. data/lib/rcas/chat/ui.rb +316 -0
  29. data/lib/rcas/chat/usage.rb +62 -0
  30. data/lib/rcas/chat/workspace.rb +132 -0
  31. data/lib/rcas/chat.rb +54 -0
  32. data/lib/rcas/coefficients.rb +170 -0
  33. data/lib/rcas/combinatorics.rb +274 -0
  34. data/lib/rcas/complex_parts.rb +160 -0
  35. data/lib/rcas/constants.rb +129 -0
  36. data/lib/rcas/core_ext.rb +35 -0
  37. data/lib/rcas/decide.rb +501 -0
  38. data/lib/rcas/decompositions.rb +241 -0
  39. data/lib/rcas/differentiate.rb +144 -0
  40. data/lib/rcas/discussion.rb +558 -0
  41. data/lib/rcas/distributions.rb +980 -0
  42. data/lib/rcas/dixon.rb +95 -0
  43. data/lib/rcas/docs.rb +321 -0
  44. data/lib/rcas/domains.rb +728 -0
  45. data/lib/rcas/expand.rb +174 -0
  46. data/lib/rcas/expression.rb +613 -0
  47. data/lib/rcas/factor.rb +605 -0
  48. data/lib/rcas/finite_field.rb +577 -0
  49. data/lib/rcas/fourier.rb +118 -0
  50. data/lib/rcas/fps.rb +678 -0
  51. data/lib/rcas/fraction.rb +126 -0
  52. data/lib/rcas/functions.rb +1136 -0
  53. data/lib/rcas/gcd.rb +112 -0
  54. data/lib/rcas/geometry.rb +266 -0
  55. data/lib/rcas/groebner.rb +162 -0
  56. data/lib/rcas/hold.rb +277 -0
  57. data/lib/rcas/hypothesis.rb +364 -0
  58. data/lib/rcas/inequalities.rb +689 -0
  59. data/lib/rcas/integral_functions.rb +260 -0
  60. data/lib/rcas/integrate.rb +1589 -0
  61. data/lib/rcas/integrate_substitutions.rb +434 -0
  62. data/lib/rcas/interpolate.rb +40 -0
  63. data/lib/rcas/irb.rb +146 -0
  64. data/lib/rcas/laplace.rb +159 -0
  65. data/lib/rcas/latex.rb +556 -0
  66. data/lib/rcas/lattice.rb +172 -0
  67. data/lib/rcas/linear_algebra.rb +117 -0
  68. data/lib/rcas/linear_program.rb +416 -0
  69. data/lib/rcas/lint.rb +79 -0
  70. data/lib/rcas/matrix.rb +531 -0
  71. data/lib/rcas/matrix_multiply.rb +202 -0
  72. data/lib/rcas/multimodular.rb +286 -0
  73. data/lib/rcas/named_polynomials.rb +274 -0
  74. data/lib/rcas/number_theory.rb +443 -0
  75. data/lib/rcas/numerics.rb +825 -0
  76. data/lib/rcas/ode.rb +488 -0
  77. data/lib/rcas/openmath/objects.rb +364 -0
  78. data/lib/rcas/openmath/phrasebook.rb +551 -0
  79. data/lib/rcas/openmath/popcorn.rb +518 -0
  80. data/lib/rcas/openmath/xml.rb +309 -0
  81. data/lib/rcas/openmath.rb +49 -0
  82. data/lib/rcas/petkovsek.rb +165 -0
  83. data/lib/rcas/piecewise.rb +488 -0
  84. data/lib/rcas/plot.rb +763 -0
  85. data/lib/rcas/plot3d.rb +419 -0
  86. data/lib/rcas/poly_matrix.rb +318 -0
  87. data/lib/rcas/poly_recurrence.rb +117 -0
  88. data/lib/rcas/polynomial.rb +466 -0
  89. data/lib/rcas/precision.rb +925 -0
  90. data/lib/rcas/printer.rb +150 -0
  91. data/lib/rcas/product.rb +155 -0
  92. data/lib/rcas/q_difference.rb +296 -0
  93. data/lib/rcas/q_functions.rb +158 -0
  94. data/lib/rcas/q_summation.rb +308 -0
  95. data/lib/rcas/q_zeilberger.rb +199 -0
  96. data/lib/rcas/random.rb +506 -0
  97. data/lib/rcas/rational_function.rb +186 -0
  98. data/lib/rcas/recurrence.rb +323 -0
  99. data/lib/rcas/render.rb +431 -0
  100. data/lib/rcas/results.rb +192 -0
  101. data/lib/rcas/scalar.rb +219 -0
  102. data/lib/rcas/series.rb +726 -0
  103. data/lib/rcas/simplify.rb +649 -0
  104. data/lib/rcas/solve.rb +2002 -0
  105. data/lib/rcas/special.rb +163 -0
  106. data/lib/rcas/statistics.rb +175 -0
  107. data/lib/rcas/steps.rb +835 -0
  108. data/lib/rcas/summation.rb +532 -0
  109. data/lib/rcas/trig.rb +264 -0
  110. data/lib/rcas/van_hoeij.rb +241 -0
  111. data/lib/rcas/vector.rb +175 -0
  112. data/lib/rcas/vector_calculus.rb +411 -0
  113. data/lib/rcas/version.rb +5 -0
  114. data/lib/rcas/zeilberger.rb +358 -0
  115. data/lib/rcas.rb +91 -0
  116. data/package.json +8 -0
  117. metadata +206 -0
@@ -0,0 +1,431 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "open3"
6
+ require "tmpdir"
7
+ require "shellwords"
8
+ require "base64"
9
+ require "json"
10
+
11
+ module RCAS
12
+ # Turns LaTeX into pictures and shows them inline in iTerm2.
13
+ #
14
+ # e = (:x + 1) / (:x - 1)
15
+ # e.show # inline image in iTerm2, LaTeX source elsewhere
16
+ # e.to_png("e.png") # write a PNG
17
+ # RCAS::Render.backend = :latex
18
+ #
19
+ # Two backends, tried in this order unless Render.backend picks one:
20
+ #
21
+ # * :katex - node + the katex npm package (https://katex.org) typeset the
22
+ # formula to HTML, headless Chrome rasterizes it. Needs `npm install`
23
+ # in the project (see package.json) and Google Chrome / Chromium.
24
+ # * :latex - `latex` + `dvipng` (or `pdflatex` + ImageMagick) from a TeX
25
+ # distribution.
26
+ #
27
+ # Results are cached on disk keyed by the LaTeX source and options.
28
+ # Text colour follows the terminal background (light text on a dark
29
+ # terminal); override with RCAS_TEX_THEME=dark|light.
30
+ module Render
31
+ class Error < StandardError; end
32
+
33
+ OSC = "\e]"
34
+ BEL = "\a"
35
+
36
+ class << self
37
+ # :auto, :katex or :latex
38
+ attr_writer :backend
39
+ # Zoom factor for the inline display (1.0 = natural size).
40
+ attr_writer :scale
41
+ # "dark" or "light": the terminal background the picture will sit on.
42
+ attr_writer :theme
43
+ # Directory for cached PNGs.
44
+ attr_writer :cache_dir
45
+ # PNG pixels per displayed point; 2 suits Retina displays.
46
+ attr_writer :device_scale
47
+ # true / false to force inline display on or off (nil = detect).
48
+ attr_writer :inline
49
+ attr_writer :wrap
50
+ def wrap = @wrap || (ENV["RCAS_TEX_WRAP"]&.to_i&.then { |w| w.positive? ? w : nil })
51
+
52
+ # Pictures written by this process; removed by cleanup! (at exit).
53
+ def created_files = (@created_files ||= [])
54
+
55
+ def cleanup!
56
+ created_files.each { |f| File.delete(f) if File.file?(f) }
57
+ created_files.clear
58
+ Dir.rmdir(cache_dir) if Dir.exist?(cache_dir) && Dir.empty?(cache_dir)
59
+ rescue SystemCallError
60
+ nil
61
+ end
62
+
63
+ def backend = @backend || ENV.fetch("RCAS_TEX_BACKEND", "auto").to_sym
64
+ def theme_override = @theme
65
+ def scale = @scale || ENV.fetch("RCAS_TEX_SCALE", "1").to_f
66
+ def device_scale = @device_scale || ENV.fetch("RCAS_TEX_DEVICE_SCALE", "2").to_f
67
+ def cache_dir = @cache_dir || ENV["RCAS_CACHE_DIR"] || File.join("/tmp", "rcas")
68
+ def reset! = @theme = @detected_theme = @selected = nil
69
+ end
70
+
71
+ module_function
72
+
73
+ # ---- public API ---------------------------------------------------------
74
+
75
+ # LaTeX source for any rcas value. +wrap+ is a line width in typeset
76
+ # characters (see LaTeX.wrapped), :auto to fit the terminal, nil for none.
77
+ def latex(obj, wrap: nil)
78
+ return obj if obj.is_a?(String)
79
+ wrap = wrap_width if wrap == :auto
80
+ LaTeX.of(obj, wrap: wrap)
81
+ end
82
+
83
+ # Typeset characters that fit one terminal line: a math glyph at the
84
+ # default size is about 1.8 columns wide, and the picture is indented.
85
+ def wrap_width(io = $stdout, scale: Render.scale)
86
+ cols = Render.wrap
87
+ return cols if cols&.positive?
88
+ columns = io.respond_to?(:winsize) && io.respond_to?(:tty?) && io.tty? ? io.winsize[1] : ENV.fetch("COLUMNS", "100").to_i
89
+ columns = 100 unless columns.positive?
90
+ (((columns - 4) * 0.55) / scale).floor.clamp(24, 200)
91
+ rescue StandardError
92
+ 60
93
+ end
94
+
95
+ # PNG bytes for +obj+ (an rcas value or a LaTeX string). With +path+ the
96
+ # bytes are also written there.
97
+ def png(obj, path = nil, display: true, theme: nil, scale: nil, wrap: :auto)
98
+ source = latex(obj, wrap: wrap)
99
+ theme ||= Render.theme
100
+ scale ||= Render.scale
101
+ key = Digest::SHA256.hexdigest([selected.name, source, display, theme, scale.round(3), device_scale].join("\0"))
102
+ file = File.join(cache_dir, "#{key}.png")
103
+ bytes = File.binread(file) if File.file?(file)
104
+ unless bytes
105
+ bytes = selected.render(source, display: display, theme: theme, scale: scale)
106
+ FileUtils.mkdir_p(cache_dir)
107
+ File.binwrite(file, bytes)
108
+ Render.created_files << file
109
+ end
110
+ File.binwrite(path, bytes) if path
111
+ bytes
112
+ end
113
+
114
+ # Show +obj+ typeset. In iTerm2 this prints an inline image; anywhere
115
+ # else (or when no backend is installed) the LaTeX source is printed.
116
+ # Returns nil so irb does not echo anything noisy.
117
+ def show(obj, io: $stdout, display: true, wrap: :auto, **options)
118
+ wrap = wrap_width(io) if wrap == :auto
119
+ source = latex(obj, wrap: wrap)
120
+ if inline?(io) && available?
121
+ begin
122
+ io.print(inline_image(png(source, display: display, **options)))
123
+ io.puts
124
+ rescue Error => e
125
+ io.puts " #{source}"
126
+ io.puts " (#{e.message.lines.first&.strip})"
127
+ end
128
+ else
129
+ io.puts source
130
+ end
131
+ nil
132
+ end
133
+
134
+ # KaTeX HTML fragment (needs node + katex), for embedding in web pages.
135
+ def html(obj, display: true)
136
+ KaTeX.html(latex(obj), display: display)
137
+ end
138
+
139
+ # The iTerm2 inline-image escape sequence for PNG bytes, sized so that
140
+ # the picture appears at its natural size on a Retina display.
141
+ def inline_image(bytes, name: "rcas.png")
142
+ w, h = png_dimensions(bytes)
143
+ args = ["inline=1", "size=#{bytes.bytesize}", "name=#{Base64.strict_encode64(name)}", "preserveAspectRatio=1"]
144
+ if w && h
145
+ args << "width=#{(w / device_scale).ceil}px"
146
+ args << "height=#{(h / device_scale).ceil}px"
147
+ end
148
+ body = "1337;File=#{args.join(';')}:#{Base64.strict_encode64(bytes)}"
149
+ tmux? ? "\ePtmux;\e#{OSC}#{body}#{BEL}\e\\" : "#{OSC}#{body}#{BEL}"
150
+ end
151
+
152
+ # ---- environment --------------------------------------------------------
153
+
154
+ def inline?(io = $stdout)
155
+ return Render.instance_variable_get(:@inline) unless Render.instance_variable_get(:@inline).nil?
156
+ return false if ENV["RCAS_TEX_INLINE"] == "0"
157
+ io.respond_to?(:tty?) && io.tty? && iterm?
158
+ end
159
+
160
+ def iterm?
161
+ ENV["TERM_PROGRAM"] == "iTerm.app" || ENV["LC_TERMINAL"] == "iTerm2" || !ENV["ITERM_SESSION_ID"].to_s.empty?
162
+ end
163
+
164
+ def tmux? = !ENV["TMUX"].to_s.empty? || ENV["TERM"].to_s.start_with?("screen", "tmux")
165
+
166
+ def available? = !selected(strict: false).nil?
167
+
168
+ def backends = [KaTeX, TeX]
169
+
170
+ def available_backends = backends.select(&:available?)
171
+
172
+ # The backend in use, chosen from Render.backend / RCAS_TEX_BACKEND.
173
+ def selected(strict: true)
174
+ @selected ||=
175
+ case Render.backend
176
+ when :auto then available_backends.first
177
+ else backends.find { |b| b.name == Render.backend } || raise(Error, "unknown backend #{Render.backend}; use :katex or :latex")
178
+ end
179
+ if @selected && !@selected.available?
180
+ raise Error, "#{@selected.name} backend is not available: #{@selected.missing}" if strict
181
+ return nil
182
+ end
183
+ raise Error, "no rendering backend available: #{backends.map { |b| "#{b.name} (#{b.missing})" }.join('; ')}" if strict && @selected.nil?
184
+ @selected
185
+ end
186
+
187
+ # "dark" or "light": Render.theme= wins, then detection.
188
+ def theme = Render.theme_override || detected_theme
189
+
190
+ # "dark" or "light". Asks iTerm2 for its background colour (OSC 11),
191
+ # falls back to COLORFGBG, then assumes dark.
192
+ def detected_theme
193
+ @detected_theme ||= ENV["RCAS_TEX_THEME"] || query_background_theme || colorfgbg_theme || "dark"
194
+ end
195
+
196
+ def colorfgbg_theme
197
+ bg = ENV["COLORFGBG"].to_s.split(";").last
198
+ return nil unless bg&.match?(/\A\d+\z/)
199
+ bg.to_i <= 6 || bg.to_i == 8 ? "dark" : "light"
200
+ end
201
+
202
+ # Query the terminal for its background colour; nil if it does not answer.
203
+ def query_background_theme(timeout: 0.25)
204
+ return nil unless $stdin.tty? && $stdout.tty? && iterm?
205
+ require "io/console"
206
+ reply = +""
207
+ $stdin.raw do |tty|
208
+ $stdout.print "#{OSC}11;?#{BEL}"
209
+ $stdout.flush
210
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
211
+ loop do
212
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
213
+ break if remaining <= 0 || !IO.select([tty], nil, nil, remaining)
214
+ reply << tty.read_nonblock(256)
215
+ break if reply.include?(BEL) || reply.include?("\e\\")
216
+ end
217
+ end
218
+ m = reply.match(%r{11;rgb:([0-9a-f]+)/([0-9a-f]+)/([0-9a-f]+)}i) or return nil
219
+ r, g, b = m.captures.map { |c| c[0, 2].to_i(16) }
220
+ (0.299 * r + 0.587 * g + 0.114 * b) < 128 ? "dark" : "light"
221
+ rescue StandardError
222
+ nil
223
+ end
224
+
225
+ def text_color(theme) = theme.to_s == "light" ? "#000000" : "#ffffff"
226
+
227
+ # ---- helpers ------------------------------------------------------------
228
+
229
+ def png_dimensions(bytes)
230
+ return nil unless bytes[0, 8] == "\x89PNG\r\n\x1a\n".b
231
+ bytes[16, 8].unpack("NN")
232
+ end
233
+
234
+ def which(*names)
235
+ names.each do |n|
236
+ next if n.to_s.empty?
237
+ return n if n.include?("/") && File.executable?(n)
238
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
239
+ f = File.join(dir, n)
240
+ return f if File.executable?(f) && !File.directory?(f)
241
+ end
242
+ end
243
+ nil
244
+ end
245
+
246
+ def run(*cmd, chdir: nil, input: nil)
247
+ out, err, status = Open3.capture3(*cmd, chdir: chdir || Dir.pwd, stdin_data: input)
248
+ raise Error, "#{File.basename(cmd.first)} failed: #{(err + out).strip.lines.last(8).join}" unless status.success?
249
+ out
250
+ end
251
+
252
+ # Crop transparent margins, leaving +pad+ pixels. ImageMagick when
253
+ # present, otherwise chunky_png, otherwise the picture is left as is.
254
+ def trim(bytes, pad: 6)
255
+ if (magick = which("magick"))
256
+ run(magick, "png:-", "-trim", "+repage", "-bordercolor", "none", "-border", pad.to_s, "png:-", input: bytes).b
257
+ elsif (convert = which("convert"))
258
+ run(convert, "png:-", "-trim", "+repage", "-bordercolor", "none", "-border", pad.to_s, "png:-", input: bytes).b
259
+ else
260
+ chunky_trim(bytes, pad)
261
+ end
262
+ end
263
+
264
+ def chunky_trim(bytes, pad)
265
+ require "chunky_png"
266
+ img = ChunkyPNG::Image.from_blob(bytes)
267
+ x0, y0, x1, y1 = img.width, img.height, -1, -1
268
+ img.height.times do |y|
269
+ row = img.row(y)
270
+ row.each_with_index do |px, x|
271
+ next if ChunkyPNG::Color.a(px).zero?
272
+ x0 = x if x < x0
273
+ x1 = x if x > x1
274
+ y0 = y if y < y0
275
+ y1 = y
276
+ end
277
+ end
278
+ return bytes if x1 < 0
279
+ x0 = [x0 - pad, 0].max
280
+ y0 = [y0 - pad, 0].max
281
+ x1 = [x1 + pad, img.width - 1].min
282
+ y1 = [y1 + pad, img.height - 1].min
283
+ img.crop(x0, y0, x1 - x0 + 1, y1 - y0 + 1).to_blob
284
+ rescue LoadError
285
+ bytes
286
+ end
287
+
288
+ def project_root = File.expand_path("../..", __dir__)
289
+
290
+ # ---- backends -----------------------------------------------------------
291
+
292
+ # KaTeX in node produces HTML, headless Chrome takes the picture.
293
+ module KaTeX
294
+ module_function
295
+
296
+ def name = :katex
297
+
298
+ CHROME_CANDIDATES = [
299
+ ENV["RCAS_CHROME"],
300
+ "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "brave-browser", "microsoft-edge",
301
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
302
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
303
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
304
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
305
+ "/Applications/Arc.app/Contents/MacOS/Arc"
306
+ ].compact.freeze
307
+
308
+ def node = @node ||= Render.which(ENV["RCAS_NODE"], "node")
309
+ def chrome = @chrome ||= Render.which(*CHROME_CANDIDATES)
310
+
311
+ # Directory of the katex npm package: RCAS_KATEX_DIR, the project's
312
+ # node_modules, or wherever node resolves it from the project root.
313
+ def katex_dir
314
+ return @katex_dir if defined?(@katex_dir)
315
+ @katex_dir = [ENV["RCAS_KATEX_DIR"], File.join(Render.project_root, "node_modules", "katex")].compact.find { |d| File.file?(File.join(d, "package.json")) }
316
+ if @katex_dir.nil? && node
317
+ out, _err, status = Open3.capture3(node, "-p", "require('path').dirname(require.resolve('katex/package.json'))", chdir: Render.project_root)
318
+ @katex_dir = out.strip if status.success? && File.directory?(out.strip)
319
+ end
320
+ @katex_dir
321
+ end
322
+
323
+ def available? = !(node.nil? || katex_dir.nil? || chrome.nil?)
324
+
325
+ def missing
326
+ [node ? nil : "node", katex_dir ? nil : "katex npm package (run `npm install`)", chrome ? nil : "Google Chrome / Chromium"].compact.join(", ")
327
+ end
328
+
329
+ def html(source, display: true)
330
+ raise Error, "KaTeX needs node and the katex package: #{missing}" if node.nil? || katex_dir.nil?
331
+ script = <<~JS
332
+ const katex = require(process.argv[1]);
333
+ const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
334
+ process.stdout.write(katex.renderToString(input.tex, {displayMode: input.display, throwOnError: true, output: 'html', strict: 'ignore'}));
335
+ JS
336
+ Render.run(node, "-e", script, katex_dir, input: JSON.generate(tex: source, display: display))
337
+ end
338
+
339
+ def page(source, display:, color:, font_size:)
340
+ css = File.join(katex_dir, "dist", "katex.min.css")
341
+ <<~HTML
342
+ <!doctype html><html><head><meta charset="utf-8">
343
+ <link rel="stylesheet" href="file://#{css}">
344
+ <style>
345
+ html, body { margin: 0; background: transparent; }
346
+ body { display: inline-block; padding: 8px 10px; color: #{color}; white-space: nowrap; }
347
+ .katex { font-size: #{font_size}em; }
348
+ .katex-display { margin: 0; }
349
+ </style></head><body>#{html(source, display: display)}</body></html>
350
+ HTML
351
+ end
352
+
353
+ def render(source, display:, theme:, scale:)
354
+ raise Error, "KaTeX backend is not available: #{missing}" unless available?
355
+ Dir.mktmpdir("rcas-katex") do |dir|
356
+ file = File.join(dir, "formula.html")
357
+ File.write(file, page(source, display: display, color: Render.text_color(theme), font_size: 1.4 * scale))
358
+ out = File.join(dir, "formula.png")
359
+ Render.run(chrome, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--no-first-run",
360
+ "--default-background-color=00000000", "--force-device-scale-factor=#{Render.device_scale}",
361
+ "--window-size=#{(3000 * scale).ceil},#{(1200 * scale).ceil}", "--screenshot=#{out}", "file://#{file}")
362
+ raise Error, "Chrome produced no screenshot" unless File.file?(out)
363
+ Render.trim(File.binread(out))
364
+ end
365
+ end
366
+ end
367
+
368
+ # A TeX distribution: latex + dvipng, or pdflatex + ImageMagick.
369
+ module TeX
370
+ module_function
371
+
372
+ def name = :latex
373
+
374
+ def latex_bin = @latex_bin ||= Render.which("latex")
375
+ def dvipng = @dvipng ||= Render.which("dvipng")
376
+ def pdflatex = @pdflatex ||= Render.which("pdflatex")
377
+ def magick = @magick ||= Render.which("magick", "convert")
378
+
379
+ def available? = (latex_bin && dvipng) || (pdflatex && magick) ? true : false
380
+
381
+ def missing = "latex+dvipng or pdflatex+ImageMagick"
382
+
383
+ def document(source, display:, color:)
384
+ math = display ? "\\[#{source}\\]" : "$#{source}$"
385
+ <<~TEX
386
+ \\documentclass[preview,border=4pt,varwidth=true]{standalone}
387
+ \\usepackage{amsmath,amssymb,xcolor}
388
+ \\pagecolor{white}\\nopagecolor
389
+ \\begin{document}
390
+ \\color[HTML]{#{color.delete('#')}}
391
+ #{math}
392
+ \\end{document}
393
+ TEX
394
+ end
395
+
396
+ def render(source, display:, theme:, scale:)
397
+ raise Error, "LaTeX backend is not available: install #{missing}" unless available?
398
+ dpi = (220 * scale * Render.device_scale / 2).round
399
+ Dir.mktmpdir("rcas-tex") do |dir|
400
+ File.write(File.join(dir, "f.tex"), document(source, display: display, color: Render.text_color(theme)))
401
+ if latex_bin && dvipng
402
+ compile(latex_bin, dir)
403
+ Render.run(dvipng, "-q", "-D", dpi.to_s, "-T", "tight", "-bg", "Transparent", "-o", "f.png", "f.dvi", chdir: dir)
404
+ else
405
+ compile(pdflatex, dir)
406
+ Render.run(magick, "-density", dpi.to_s, "-background", "none", "f.pdf", "-trim", "+repage", "f.png", chdir: dir)
407
+ end
408
+ Render.trim(File.binread(File.join(dir, "f.png")))
409
+ end
410
+ end
411
+
412
+ def compile(bin, dir)
413
+ _out, _err, status = Open3.capture3(bin, "-interaction=batchmode", "-halt-on-error", "f.tex", chdir: dir)
414
+ return if status.success?
415
+ log = File.exist?(File.join(dir, "f.log")) ? File.read(File.join(dir, "f.log")) : ""
416
+ error = log.lines.find { |l| l.start_with?("!") }&.strip || "compilation failed"
417
+ raise Error, "LaTeX: #{error}"
418
+ end
419
+ end
420
+ end
421
+
422
+ module Functions
423
+ # show(expr) typesets any value inline; show(a, b) shows several.
424
+ def show(*objects, **options)
425
+ objects.each { |o| Render.show(o, **options) }
426
+ nil
427
+ end
428
+ end
429
+ end
430
+
431
+ at_exit { RCAS::Render.cleanup! }
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # The session, numbered line by line the way Mathematica numbers it: the
5
+ # third line of the session is `In[3]`, its result `Out[3]`.
6
+ #
7
+ # rcas[1]> (x + 1)*(x - 1)
8
+ # => (x + 1)*(x - 1)
9
+ # rcas[2]> expand(Out[1])
10
+ # => -1 + x**2
11
+ # rcas[3]> In[1]
12
+ # => (x + 1)*(x - 1)
13
+ #
14
+ # Both tables hand back what was there, *held*: `Out[n]` is the value as it
15
+ # was computed, never computed again, and `In[n]` is the input as it was
16
+ # typed, built with `hold` into an expression instead of being run (so
17
+ # `In[1]` above is the product, not the expansion). A negative number
18
+ # counts back: `Out[-1]` is the previous result, `In[-1]` the previous
19
+ # input. The line being typed is not in the tables yet.
20
+ #
21
+ # Recording is always on; `RCAS.numbered` only decides whether the prompt
22
+ # carries the number of the line to come (`rcas[3]> `). It is on by
23
+ # default; `RCAS.numbered = false`, `RCAS_NUMBERED=0` or `/numbered off`
24
+ # in rcas-chat gives the plain prompt back (which is what the transcripts
25
+ # in the manual show).
26
+ module Results
27
+ # A table from line number to input or result. Hash except that a
28
+ # negative key counts back from the last finished line and that the line
29
+ # being read is not there yet.
30
+ class Store < Hash
31
+ alias erase clear # Hash#clear, without restarting the numbering
32
+
33
+ def initialize(kind)
34
+ @kind = kind
35
+ super()
36
+ end
37
+
38
+ def [](key)
39
+ n = number(key)
40
+ n.nil? ? nil : super(n)
41
+ end
42
+
43
+ # The numbers of the finished lines, oldest first.
44
+ def numbers = keys - [Results.pending]
45
+
46
+ # The number a key stands for, or nil for one that is not there. A
47
+ # held line hands over its numbers as Num nodes (`In[2]` inside
48
+ # `In[7]`), so those count too.
49
+ def number(key)
50
+ key = key.value if key.is_a?(Num) && key.value.is_a?(Integer)
51
+ return numbers[key] if key.is_a?(Integer) && key.negative?
52
+ key == Results.pending ? nil : key
53
+ end
54
+
55
+ def to_s = text { |value| value.to_s }
56
+ def inspect = text { |value| value.inspect }
57
+ # irb prints with pp by default; keep the table one line per line.
58
+ def pretty_print(q) = q.text(inspect)
59
+
60
+ # Forgetting one table forgets the whole session and starts the
61
+ # numbering over.
62
+ def clear
63
+ Results.clear
64
+ self
65
+ end
66
+
67
+ private
68
+
69
+ def text
70
+ return "(no #{@kind} yet)" if numbers.empty?
71
+ numbers.map { |i| "[#{i}] #{yield fetch(i)}" }.join("\n")
72
+ end
73
+ end
74
+
75
+ # `In`: the inputs. The table prints the lines as they were typed;
76
+ # `In[3]` builds the expression of the third line with `hold`.
77
+ class Inputs < Store
78
+ def initialize = super("inputs")
79
+ def [](key) = (n = number(key)) && Results.held(n)
80
+ def inspect = to_s # the lines as typed, not as Ruby strings
81
+ end
82
+
83
+ module_function
84
+
85
+ def inputs = @inputs ||= Inputs.new
86
+ def outputs = @outputs ||= Store.new("results")
87
+ # The number of the line being read, or 0 before the first one.
88
+ def index = @index || 0
89
+ # The number the next input will get: what the prompt shows.
90
+ def line = index + 1
91
+ # The line that has been read but has no result yet, if any.
92
+ def pending = @pending
93
+ def last = outputs[-1]
94
+
95
+ # Remember the source of one input line and give it the next number.
96
+ # +context+ is the binding it was evaluated in, for In[n].
97
+ def record_input(source, context = nil)
98
+ @index = index + 1
99
+ @pending = @index
100
+ inputs[@index] = source.to_s.rstrip
101
+ bindings[@index] = context if context
102
+ @index
103
+ end
104
+
105
+ # Remember the result of the line being read and return its number. A
106
+ # host that records results only (test/manual_test.rb) numbers them here.
107
+ def record(value)
108
+ @bare = value.equal?(outputs) || value.equal?(inputs) # a table prints itself, unprefixed
109
+ @index = index + 1 if @pending.nil? && !@bare
110
+ @pending = nil
111
+ return index if @bare
112
+ outputs[@index] = value
113
+ @index
114
+ end
115
+
116
+ # The input of line +n+ held: the expression the line builds, not its
117
+ # value. A line `hold` cannot keep (an assignment, a command, a string)
118
+ # comes back as the text that was typed, and so does a line that reads
119
+ # itself.
120
+ def held(n)
121
+ source = inputs.fetch(n, nil)
122
+ return nil if source.nil?
123
+ return source if holding.include?(n)
124
+ holding << n
125
+ begin
126
+ Hold.source(source, bindings[n]) || source
127
+ rescue StandardError, ScriptError # user code: a NameError is an answer here
128
+ source
129
+ ensure
130
+ holding.delete(n)
131
+ end
132
+ end
133
+
134
+ def bindings = @bindings ||= {}
135
+ def holding = @holding ||= []
136
+
137
+ # Forget the session: both tables and the numbering.
138
+ def clear
139
+ inputs.erase
140
+ outputs.erase
141
+ bindings.clear
142
+ restart
143
+ self
144
+ end
145
+
146
+ # Numbering starts at one again.
147
+ def restart
148
+ @index = 0
149
+ @pending = nil
150
+ @bare = false
151
+ end
152
+
153
+ # On unless the environment or the session says otherwise.
154
+ def numbered? = @numbered.nil? ? ENV["RCAS_NUMBERED"] != "0" : @numbered
155
+
156
+ def numbered=(value)
157
+ @numbered = value.nil? ? nil : !(value == false || value.to_s == "off" || value.to_s == "false")
158
+ end
159
+
160
+ # The prompt with the number of the input to come: "rcas> " becomes
161
+ # "rcas[3]> ", "> " becomes "[3]> ".
162
+ def prompt(plain)
163
+ return plain if plain.nil? || !numbered? # no prompt without a terminal
164
+ plain.sub(/([^\w\s]*\s*)\z/) { "[#{line}]#{::Regexp.last_match(1)}" }
165
+ end
166
+
167
+ # What goes in front of a result; nil for a table printing itself.
168
+ def mark = @bare ? nil : "=>"
169
+
170
+ def return_format(plain) = @bare ? "%s\n" : plain
171
+ end
172
+
173
+ # In[3] is the third input of the session, held: the expression that line
174
+ # builds, not its value. `In` on its own prints the whole table.
175
+ In = Results.inputs
176
+ # Out[3] is the result of the session's third line, Out[-1] the previous
177
+ # result. `Out` on its own prints the whole table.
178
+ Out = Results.outputs
179
+
180
+ # `include RCAS::Constants` brings them into scope with PI, E and I.
181
+ Constants.const_set(:In, In)
182
+ Constants.const_set(:Out, Out)
183
+
184
+ class << self
185
+ def numbered? = Results.numbered?
186
+ def numbered=(value)
187
+ Results.numbered = value
188
+ end
189
+ def inputs = Results.inputs
190
+ def outputs = Results.outputs
191
+ end
192
+ end