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.
- checksums.yaml +7 -0
- data/CITATION.cff +17 -0
- data/DESIGN.md +783 -0
- data/LICENSE +21 -0
- data/MANUAL.md +6265 -0
- data/README.md +267 -0
- data/bin/rcas +9 -0
- data/bin/rcas-app +9 -0
- data/bin/rcas-chat +9 -0
- data/lib/rcas/algebraic.rb +481 -0
- data/lib/rcas/analysis.rb +966 -0
- data/lib/rcas/app/launcher.rb +203 -0
- data/lib/rcas/app/public/app.css +402 -0
- data/lib/rcas/app/public/app.js +449 -0
- data/lib/rcas/app/public/index.html +46 -0
- data/lib/rcas/app/server.rb +220 -0
- data/lib/rcas/app/window.rb +94 -0
- data/lib/rcas/app/worksheet.rb +290 -0
- data/lib/rcas/app.rb +168 -0
- data/lib/rcas/background.rb +758 -0
- data/lib/rcas/chat/assistant.rb +199 -0
- data/lib/rcas/chat/picker.rb +164 -0
- data/lib/rcas/chat/repl.rb +583 -0
- data/lib/rcas/chat/session.rb +137 -0
- data/lib/rcas/chat/settings.rb +71 -0
- data/lib/rcas/chat/style.rb +30 -0
- data/lib/rcas/chat/tool.rb +53 -0
- data/lib/rcas/chat/ui.rb +316 -0
- data/lib/rcas/chat/usage.rb +62 -0
- data/lib/rcas/chat/workspace.rb +132 -0
- data/lib/rcas/chat.rb +54 -0
- data/lib/rcas/coefficients.rb +170 -0
- data/lib/rcas/combinatorics.rb +274 -0
- data/lib/rcas/complex_parts.rb +160 -0
- data/lib/rcas/constants.rb +129 -0
- data/lib/rcas/core_ext.rb +35 -0
- data/lib/rcas/decide.rb +501 -0
- data/lib/rcas/decompositions.rb +241 -0
- data/lib/rcas/differentiate.rb +144 -0
- data/lib/rcas/discussion.rb +558 -0
- data/lib/rcas/distributions.rb +980 -0
- data/lib/rcas/dixon.rb +95 -0
- data/lib/rcas/docs.rb +321 -0
- data/lib/rcas/domains.rb +728 -0
- data/lib/rcas/expand.rb +174 -0
- data/lib/rcas/expression.rb +613 -0
- data/lib/rcas/factor.rb +605 -0
- data/lib/rcas/finite_field.rb +577 -0
- data/lib/rcas/fourier.rb +118 -0
- data/lib/rcas/fps.rb +678 -0
- data/lib/rcas/fraction.rb +126 -0
- data/lib/rcas/functions.rb +1136 -0
- data/lib/rcas/gcd.rb +112 -0
- data/lib/rcas/geometry.rb +266 -0
- data/lib/rcas/groebner.rb +162 -0
- data/lib/rcas/hold.rb +277 -0
- data/lib/rcas/hypothesis.rb +364 -0
- data/lib/rcas/inequalities.rb +689 -0
- data/lib/rcas/integral_functions.rb +260 -0
- data/lib/rcas/integrate.rb +1589 -0
- data/lib/rcas/integrate_substitutions.rb +434 -0
- data/lib/rcas/interpolate.rb +40 -0
- data/lib/rcas/irb.rb +146 -0
- data/lib/rcas/laplace.rb +159 -0
- data/lib/rcas/latex.rb +556 -0
- data/lib/rcas/lattice.rb +172 -0
- data/lib/rcas/linear_algebra.rb +117 -0
- data/lib/rcas/linear_program.rb +416 -0
- data/lib/rcas/lint.rb +79 -0
- data/lib/rcas/matrix.rb +531 -0
- data/lib/rcas/matrix_multiply.rb +202 -0
- data/lib/rcas/multimodular.rb +286 -0
- data/lib/rcas/named_polynomials.rb +274 -0
- data/lib/rcas/number_theory.rb +443 -0
- data/lib/rcas/numerics.rb +825 -0
- data/lib/rcas/ode.rb +488 -0
- data/lib/rcas/openmath/objects.rb +364 -0
- data/lib/rcas/openmath/phrasebook.rb +551 -0
- data/lib/rcas/openmath/popcorn.rb +518 -0
- data/lib/rcas/openmath/xml.rb +309 -0
- data/lib/rcas/openmath.rb +49 -0
- data/lib/rcas/petkovsek.rb +165 -0
- data/lib/rcas/piecewise.rb +488 -0
- data/lib/rcas/plot.rb +763 -0
- data/lib/rcas/plot3d.rb +419 -0
- data/lib/rcas/poly_matrix.rb +318 -0
- data/lib/rcas/poly_recurrence.rb +117 -0
- data/lib/rcas/polynomial.rb +466 -0
- data/lib/rcas/precision.rb +925 -0
- data/lib/rcas/printer.rb +150 -0
- data/lib/rcas/product.rb +155 -0
- data/lib/rcas/q_difference.rb +296 -0
- data/lib/rcas/q_functions.rb +158 -0
- data/lib/rcas/q_summation.rb +308 -0
- data/lib/rcas/q_zeilberger.rb +199 -0
- data/lib/rcas/random.rb +506 -0
- data/lib/rcas/rational_function.rb +186 -0
- data/lib/rcas/recurrence.rb +323 -0
- data/lib/rcas/render.rb +431 -0
- data/lib/rcas/results.rb +192 -0
- data/lib/rcas/scalar.rb +219 -0
- data/lib/rcas/series.rb +726 -0
- data/lib/rcas/simplify.rb +649 -0
- data/lib/rcas/solve.rb +2002 -0
- data/lib/rcas/special.rb +163 -0
- data/lib/rcas/statistics.rb +175 -0
- data/lib/rcas/steps.rb +835 -0
- data/lib/rcas/summation.rb +532 -0
- data/lib/rcas/trig.rb +264 -0
- data/lib/rcas/van_hoeij.rb +241 -0
- data/lib/rcas/vector.rb +175 -0
- data/lib/rcas/vector_calculus.rb +411 -0
- data/lib/rcas/version.rb +5 -0
- data/lib/rcas/zeilberger.rb +358 -0
- data/lib/rcas.rb +91 -0
- data/package.json +8 -0
- metadata +206 -0
data/lib/rcas/render.rb
ADDED
|
@@ -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! }
|
data/lib/rcas/results.rb
ADDED
|
@@ -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
|