Module: VivlioStarter::CLI::PreProcessCommands::PlainMathTranspiler

Defined in:
lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb

Overview

素の表記 → TeX 変換器(plain-math-notation-spec.md §5)

Constant Summary collapse

SUPERSCRIPT =

Unicode 上付き文字 → 通常文字。¹²³ だけ Latin-1 に居て他と離れている。

{
  '' => '0', '¹' => '1', '²' => '2', '³' => '3', '' => '4',
  '' => '5', '' => '6', '' => '7', '' => '8', '' => '9',
  '' => '+', '' => '-', '' => '=', '' => '(', '' => ')',
  '' => 'n', '' => 'i', 'ˣ' => 'x' # ˣ は U+02E3(`eˣ` で実在)
}.freeze
SUBSCRIPT =

Unicode 下付き文字 → 通常文字。Hₙ の ₙ は U+2099。

{
  '' => '0', '' => '1', '' => '2', '' => '3', '' => '4',
  '' => '5', '' => '6', '' => '7', '' => '8', '' => '9',
  '' => '+', '' => '-', '' => '=', '' => '(', '' => ')',
  '' => 'n', '' => 'k', '' => 'i', '' => 'j'
}.freeze
SUPERSCRIPT_CHARS =
Regexp.union(SUPERSCRIPT.keys).freeze
SUBSCRIPT_CHARS =
Regexp.union(SUBSCRIPT.keys).freeze
SUPERSCRIPT_RUN =
/#{SUPERSCRIPT_CHARS}+/o
SUBSCRIPT_RUN =
/#{SUBSCRIPT_CHARS}+/o
FUNCTIONS =

TeX に用意されている演算子名(38 種)。長いものから並べるarctanarc + tan に、liminflim + inf に割らないため。 正規表現の交替は先頭一致優先)。MathJax で全種が通ることを実測済み。

%w[
  varinjlim varliminf varlimsup varprojlim arccos arcsin arctan liminf
  limsup injlim projlim sinh cosh tanh coth sin
  cos tan sec csc cot arg deg det
  dim exp gcd hom inf ker lim log
  max min sup Pr lg ln
].freeze
FUNCTION_NAMES =

関数名の綴り。前後が英数字・バックスラッシュのときは拾わない (\sin は変換済み、expressionexp は関数ではない)。

/(?<![\\\p{Alnum}])(?:#{FUNCTIONS.join('|')})(?![A-Za-z0-9])/o
PLAIN_MARKERS =

素の表記のしるし(ゲート)。仕様 §5.1。

既に正しい TeX を 1 文字も変えないための保険である。\sqrt{} は無く、 ^{2}² は無く、f^{(n)}^ の次が { であって ( ではない。

^ _ の扱いに注意: a^2 は TeX としてそのまま正しいので、しるしにしない。 壊れるのは引数が 2 文字以上のとき(a^560a^5 の後ろに 60 が並ぶ)と、 丸括弧を引数にしたとき(a^(p-1))だけなので、その形だけを拾う。

Regexp.union(
  SUPERSCRIPT_CHARS,
  SUBSCRIPT_CHARS,
  /[√Σ・]/,                        # 根号・総和(U+03A3)・中黒(U+30FB)
  /[\u{FF01}-\u{FF5E}\u{3000}]/,    # 全角英数字・全角記号・全角空白
  /[A-Za-z]̂/,                 # 合成アクセント(p̂)
  /[\^_]\(/,                        # 丸括弧を引数にした上下付き
  /[\^_]\w{2,}/,                    # 引数が 2 文字以上の上下付き
  /[\^_][-−+]/,                     # 符号つきの上下付き(`2^-52` は `2^{-}` に 52 が続く誤り)
  /<=|>=|!=/,
  /(?<!\\)[%#&]/,                  # TeX の特殊文字(素のままだと消える・エラーになる)
  /[〜~]/,
  /(?<!\\)\bmod\b/,
  /∫\s*\[/,                         # 上下限つきの積分
  /(?<!\\)\blim\s*\(/,
  /(?<!\\)\b(?:#{FUNCTIONS.join('|')})\b/o
).freeze
BASE_TAIL =

上下付きの「基底」になれる文字で終わっているか。 の x や (a+b)² の ) は基底になるが、行頭や + の直後は基底が無い。

/[\p{Alnum}\p{Greek})\]}]\z/
TEX_SPECIAL =

TeX が特別な意味を与える字。素で書かれると黙って壊れるので必ず退避する。 50% + 150 % から後ろがコメント扱いで消える a#b a&b TeX エラー(merror)になる 既に \% と書いてある場合は command() が先に食うので、ここへは来ない。

/[%#&$]/
WAVE_DASH =

波ダッシュ。半角 ~ にしてはならない——TeX の ~ は改行しない空白で、 範囲を表す g^1〜g^(p-1)g^1 g^(p-1) に化ける。

/[〜~]/

Class Method Summary collapse

Class Method Details

.atom(scanner, out) ⇒ Object

1 トークンを消費して TeX 片を返す。out は「直前に何を出したか」の判定にだけ使う。



143
144
145
146
147
148
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 143

def atom(scanner, out)
  command(scanner) || group(scanner) || script(scanner, out) ||
    radical(scanner) || big_operator(scanner) || limit(scanner) ||
    modulo(scanner) || named_function(scanner) || symbol(scanner) ||
    scanner.getch # 解釈できない字は原文のまま通す
end

.balanced(scanner, pair = ['(', ')']) ⇒ Object

対応する括弧まで読み、中身を再帰変換して返す(括弧自体は含めない)。 閉じ括弧が無ければ位置を戻して nil——壊れた入力で式全体を諦めないため。



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 328

def balanced(scanner, pair = ['(', ')'])
  open, close = pair
  saved = scanner.pos
  return nil unless scanner.scan(/#{Regexp.escape(open)}/)

  out = +''
  depth = 1
  until scanner.eos?
    if scanner.check(/#{Regexp.escape(open)}/)
      depth += 1
    elsif scanner.check(/#{Regexp.escape(close)}/)
      depth -= 1
      break if depth.zero?
    end
    out << atom(scanner, out)
  end
  unless scanner.scan(/#{Regexp.escape(close)}/)
    scanner.pos = saved
    return nil
  end

  out
end

.big_operator(scanner) ⇒ Object

Σ(U+03A3)は斜体の変数として描かれてしまうので \sum へ。 Σ(k=0 to ∞) のような著者が書いた上下限の記法もここで拾う。 (U+2211)は既に総和記号なので触らない。



225
226
227
228
229
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 225

def big_operator(scanner)
  if scanner.scan(/Σ/) then bounds(scanner, '\\sum')
  elsif scanner.scan(//) then integral_bounds(scanner)
  end
end

.bounds(scanner, name) ⇒ Object

(k=0 to ∞)_{k=0}^{∞}to が無ければ上下限なしの演算子だけを返す。



232
233
234
235
236
237
238
239
240
241
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 232

def bounds(scanner, name)
  saved = scanner.pos
  scanner.skip(/\s*/)
  if scanner.check(/\(/) && (inner = balanced(scanner)) && (parts = inner.split(/\s+to\s+/, 2)).size == 2
    return "#{name}_{#{parts[0].strip}}^{#{parts[1].strip}}"
  end

  scanner.pos = saved
  macro(scanner, name)
end

.command(scanner) ⇒ Object

\command は中身を見ずにそのまま通す(既存 TeX を壊さない)。



151
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 151

def command(scanner) = scanner.scan(/\\(?:[A-Za-z]+|.)/m)

.escape_tex(scanner, char) ⇒ Object

TeX の特殊文字なら退避する(50%50\%、全角の も同じ)。

ただし \begin{aligned} のような環境の中では、& は桁揃えの記号として、 # はマクロ引数として正しい——環境を使う式は LaTeX を知っている著者が 書いたものなので、そこは触らない(本書 96-sample.md の九九の表で実際に踏んだ)。 % だけは例外で常に退避する。素のままだと後ろが丸ごとコメントとして消えるため。



319
320
321
322
323
324
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 319

def escape_tex(scanner, char)
  return char unless char.match?(TEX_SPECIAL)
  return char if char != '%' && scanner.string.include?('\\begin{')

  "\\#{char}"
end

.group(scanner) ⇒ Object

{…} は透過。中は再帰する。



154
155
156
157
158
159
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 154

def group(scanner)
  return nil unless scanner.scan(/\{/)

  inner = run(scanner, stop: /\}/)
  "{#{inner}#{scanner.scan(/\}/) || ''}"
end

.integral_bounds(scanner) ⇒ Object

∫[0, π/2]\int_{0}^{π/2}。著者が考えた記法なので TeX 側に対応が無い。



244
245
246
247
248
249
250
251
252
253
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 244

def integral_bounds(scanner)
  saved = scanner.pos
  scanner.skip(/\s*/)
  if scanner.check(/\[/) && (inner = balanced(scanner, ['[', ']'])) && (parts = inner.split(',', 2)).size == 2
    return "\\int_{#{parts[0].strip}}^{#{parts[1].strip}}"
  end

  scanner.pos = saved
  macro(scanner, '\\int')
end

.limit(scanner) ⇒ Object

lim(n→∞)\lim_{n→∞} はそのままで正しく描かれる。



256
257
258
259
260
261
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 256

def limit(scanner)
  return nil unless scanner.scan(/(?<!\\)\blim(?=\s*\()/)

  scanner.skip(/\s*/)
  "\\lim_{#{balanced(scanner).to_s.strip}}"
end

.macro(scanner, name) ⇒ Object

マクロの直後が英字・ギリシャ文字だと \tanθ のように 1 つの命令として読まれるので、 そのときだけ空白を足す。\sin(x)\sin^{2}θ には足さない——余計な空きが出るうえ、 上付き・下付きの基底が空白で隠れてしまう。



287
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 287

def macro(scanner, name) = scanner.check(/[[:alpha:]]/) ? "#{name} " : name

.modulo(scanner) ⇒ Object

(mod n)\pmod{n}、単独の mod\bmodmod を素のまま渡すと 𝑚𝑜𝑑 と斜体 3 文字で並ぶ。



265
266
267
268
269
270
271
272
273
274
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 265

def modulo(scanner)
  saved = scanner.pos
  if scanner.scan(/\(\s*mod\b/)
    inner = run(scanner, stop: /\)/)
    return "\\pmod{#{inner.strip}}" if scanner.scan(/\)/)

    scanner.pos = saved
  end
  scanner.scan(/(?<!\\)\bmod\b/) ? macro(scanner, '\\bmod') : nil
end

.named_function(scanner) ⇒ Object

sin(x)\sin(x)tanθ\tan θ(ギリシャ文字自体は素通し)。 素のままだと 𝑡𝑎𝑛𝜃 と 1 文字ずつ斜体になる。 log₂ の下付きは続く script() が拾う——そのためにここで空白を足してはならない (空白があると基底が無いと判定され、₂ が変換されずに残る)。



280
281
282
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 280

def named_function(scanner)
  (name = scanner.scan(FUNCTION_NAMES)) && macro(scanner, "\\#{name}")
end

.plain?(src) ⇒ Boolean

素の表記のしるしを含むか(ゲート)。

Parameters:

  • src (String)

    デリミタを剥いだ数式本文

Returns:

  • (Boolean)


119
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 119

def plain?(src) = !src.nil? && src.match?(PLAIN_MARKERS)

.radical(scanner) ⇒ Object

は直後の 1 トークンだけに掛かる。全体に掛けたければ √(…) と括る。 √x+1\sqrt{x}+1 になるのはこの規則による(仕様 §5.2)。



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 202

def radical(scanner)
  saved = scanner.pos
  return nil unless scanner.scan(//)

  scanner.skip(/[ \t]+/)
  body = if scanner.check(/\(/)
           balanced(scanner)
         elsif scanner.scan(/\{/)
           run(scanner, stop: /\}/).tap { scanner.scan(/\}/) }
         else
           # 数は丸ごと 1 トークン。1 文字ずつ取ると `√163` が `\sqrt{1}63` になる
           scanner.scan(/\d+(?:\.\d+)?|[[:alpha:]]/)
         end
  return "\\sqrt{#{body.strip}}" if body && !body.strip.empty?

  # 閉じ括弧が無い `√(x` や裸の `√`。原文のまま通し、式全体を諦めない
  scanner.pos = saved
  scanner.getch
end

.run(scanner, stop: nil) ⇒ Object

トークン列を消費して TeX を組む。stop を見つけたら消費せずに戻る。



136
137
138
139
140
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 136

def run(scanner, stop: nil)
  out = +''
  out << atom(scanner, out) until scanner.eos? || (stop && scanner.check(stop))
  out
end

.script(scanner, out) ⇒ Object

上付き・下付き。Unicode の上下付き文字と、^/_ の両方をここで正規化する。



162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 162

def script(scanner, out)
  if (raw = scanner.scan(SUPERSCRIPT_RUN))
    return unicode_script(raw, SUPERSCRIPT, '^', out)
  end
  if (raw = scanner.scan(SUBSCRIPT_RUN))
    return unicode_script(raw, SUBSCRIPT, '_', out)
  end
  return nil unless (mark = scanner.scan(/[\^_]/))

  argument = script_argument(scanner)
  # 引数を読めなければ `^` をそのまま通す(`2^(` を `2^{}(` にしない)
  argument.nil? || argument.empty? ? mark : "#{mark}{#{argument}}"
end

.script_argument(scanner) ⇒ Object

^ / _ の引数。(…) は外して中身を、{…} はそのまま、 それ以外は 1 かたまりを取る。

数字と英字は混ぜて取らない。 H_2O は LaTeX では「H の添字 2」に続く「O」で、 混ぜて取ると H_{2O} になり O まで添字へ落ちる(実測: 本書の早見表で H₂OH₂ₒ と出た)。一方 a^560 は 560 全体が指数でなければ a^5 の後ろに 60 が並んでしまう。数字の連なり/英字の連なりを それぞれ 1 かたまりと見れば、どちらも正しくなる。



193
194
195
196
197
198
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 193

def script_argument(scanner)
  return balanced(scanner) if scanner.check(/\(/)
  return run(scanner, stop: /\}/).tap { scanner.scan(/\}/) } if scanner.scan(/\{/)

  scanner.scan(/[-−+]?(?:\d+(?:\.\d+)?|[\p{Alpha}]+)/) || ''
end

.symbol(scanner) ⇒ Object

1 文字の置き換え。全角は半角へ、合成アクセントは \hat{} へ、 (U+30FB) は \cdot へ(消えるのはこちらで、·(U+00B7) は無事)。



300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 300

def symbol(scanner)
  if (accented = scanner.scan(/[A-Za-z]̂/)) then "\\hat{#{accented[0]}}"
  elsif scanner.scan(//) then macro(scanner, '\\cdot')
  elsif scanner.scan(WAVE_DASH) then macro(scanner, '\\sim')
  elsif scanner.scan(/<=/) then macro(scanner, '\\leq')
  elsif scanner.scan(/>=/) then macro(scanner, '\\geq')
  elsif scanner.scan(/!=/) then macro(scanner, '\\neq')
  elsif (special = scanner.scan(TEX_SPECIAL)) then escape_tex(scanner, special)
  elsif (wide = scanner.scan(/[\u{FF01}-\u{FF5D}]/)) then escape_tex(scanner, (wide.ord - 0xFEE0).chr(Encoding::UTF_8))
  elsif scanner.scan(/ /) then ' '
  end
end

.to_tex(src, force: false) ⇒ String

素の表記を TeX へ起こす。しるしが無ければ src をそのまま返す。

Parameters:

  • src (String)

    デリミタを剥いだ数式本文(素の表記 / TeX / 混在)

  • force (Boolean) (defaults to: false)

    ゲートを飛ばす(判定器が数式と決めた式に使う)

Returns:

  • (String)

    TeX 記法



125
126
127
128
129
130
131
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 125

def to_tex(src, force: false)
  text = src.to_s
  return src if text.empty?
  return src unless force || plain?(text)

  run(StringScanner.new(text))
end

.unicode_script(raw, table, mark, out) ⇒ Object

連続する Unicode 上下付き文字を 1 つの ^{…} / _{…} にまとめる。 基底が無いとき(式頭の ²x)は変換せず原文のまま残す——基底の無い msup になり、 著者の意図と無関係な組版になるため。



179
180
181
182
183
# File 'lib/vivlio_starter/cli/pre_process/plain_math_transpiler.rb', line 179

def unicode_script(raw, table, mark, out)
  return raw unless out.match?(BASE_TAIL)

  "#{mark}{#{raw.each_char.map { table[it] }.join}}"
end