Module: VivlioStarter::CLI::Build::MathTextRenderer

Defined in:
lib/vivlio_starter/cli/build/math_text_renderer.rb

Overview

LaTeX 単純サブセット → HTML テキスト変換器(Kindle 数式テキスト化)

Constant Summary collapse

SYMBOLS =

名前付き記号コマンド(\times 等)→ Unicode。§3.1 の記号行。

{
  'times' => '×', 'cdot' => '', 'pm' => '±', 'mp' => '',
  'approx' => '', 'neq' => '', 'leq' => '', 'geq' => '',
  'le' => '', 'ge' => '', 'sim' => '', 'propto' => '',
  'infty' => '', 'll' => '', 'gg' => '',
  'langle' => '', 'rangle' => '',
  'ldots' => '', 'cdots' => '', 'dots' => '',
  # 矢印と集合。`\to` が無いだけで `\lim_{n \to \infty}` が式ごと拒否され、
  # SVG のまま Kindle へ残って横に潰れていた(2026-08-20 の実機で発覚)。
  'to' => '', 'rightarrow' => '', 'leftarrow' => '', 'mapsto' => '',
  'Rightarrow' => '', 'Leftarrow' => '', 'Leftrightarrow' => '',
  'in' => '', 'notin' => '', 'subset' => '', 'supset' => '',
  'cup' => '', 'cap' => '', 'emptyset' => '',
  'partial' => '', 'nabla' => '', 'circ' => '', 'perp' => '',
  'forall' => '', 'exists' => '', 'equiv' => '',
  # 大型演算子。直後の `_{…}^{…}` は既存の上下付き処理がそのまま拾うので、
  # `\sum_{i=1}^{n}` は `∑` + `<sub>i=1</sub><sup>n</sup>` になる。
  # SVG 画像より読みやすく、かつ本文のフォントサイズに追従する。
  'sum' => '', 'prod' => '', 'int' => '', 'oint' => ''
}.freeze
GREEK =

ギリシャ文字コマンド → Unicode(立体=イタリックにしない・§3.1)。

{
  'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ',
  'epsilon' => 'ε', 'varepsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η',
  'theta' => 'θ', 'vartheta' => 'ϑ', 'iota' => 'ι', 'kappa' => 'κ',
  'lambda' => 'λ', 'mu' => 'μ', 'nu' => 'ν', 'xi' => 'ξ',
  'omicron' => 'ο', 'pi' => 'π', 'varpi' => 'ϖ', 'rho' => 'ρ',
  'varrho' => 'ϱ', 'sigma' => 'σ', 'varsigma' => 'ς', 'tau' => 'τ',
  'upsilon' => 'υ', 'phi' => 'φ', 'varphi' => 'ϕ', 'chi' => 'χ',
  'psi' => 'ψ', 'omega' => 'ω',
  'Gamma' => 'Γ', 'Delta' => 'Δ', 'Theta' => 'Θ', 'Lambda' => 'Λ',
  'Xi' => 'Ξ', 'Pi' => 'Π', 'Sigma' => 'Σ', 'Upsilon' => 'Υ',
  'Phi' => 'Φ', 'Psi' => 'Ψ', 'Omega' => 'Ω'
}.freeze
OPERATORS =

そのまま通す演算子・区切り(§3.1)。< > & は出力時にエスケープする。 ' は導関数のプライム(f'(x)f''(0))。数式で普通に出る。

%r{[+\-=/<>()\[\]|,.:;!']}
PASSTHROUGH =

Unicode で直接書かれた記号・ギリシャ文字・日本語をそのまま通す。

なぜ後から足したか: この変換器は 2026-07-19 に、LaTeX で書かれた原稿だけを見て 作られた(当時の原稿の数式は全件 \times \pi の形だった)。素の表記を組む機能が 入ってからは ×θ が直接届くようになり、受理集合が実態に追いつかなくなった。 上の SYMBOLS / GREEK は「コマンド名 → Unicode」の対応表なので、Unicode をそのまま 受けて出すのは同じ字を返すだけで済む。 これを足すと a^(p-1) mod psin²θ + cos²θ真の上付き付きの HTMLになり、 原文テキストへ落とさずに済む(Kindle での見栄えが一段良くなる)。 日本語は ぁ-ゖ ァ-ヺ の範囲だけでは足りない——長音符 (U+30FC)が外にあり、 「ハッシュ値 mod サーバー台数」が丸ごと拒否されていた。文字プロパティで書く。

/[×÷±∓⋅・·≈≒≡≠≤≥≦≧∼∝∞…°−→∈]|[α-ωΑ-Ω]|[\p{Hiragana}\p{Katakana}\p{Han}ー々〆]/
FUNCTIONS =

TeX に用意されている演算子名(38 種)。立体(イタリックにしない)で出す。 変換器(PlainMathTranspiler::FUNCTIONS)と同じ集合にしておく——あちらが \sup を出すのにこちらが知らないと、式ごと拒否されて Kindle が SVG へ落ちる。

%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
BIG_OPERATOR_WITH_LIMITS =

大型演算子と lim は、上下限のあとに被演算子が続くと詰まって読みにくい。 数式の空白は TeX 流に落としているので(consume_atom)、ここで細空白を補う。

%r{((?:[∑∏∫∮]|\blim)(?:<su[bp]>.*?</su[bp]>)*)(?=[^\s])}m

Class Method Summary collapse

Class Method Details

.consume_atom(scanner, upright:, allow_script:, allow_frac:) ⇒ Object

1 アトムを消費して HTML 片を返す(空白は '' か ' ')。未知トークンは nil。



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 135

def consume_atom(scanner, upright:, allow_script:, allow_frac:)
  if scanner.scan(/\s+/)
    upright ? ' ' : '' # 数式空白は無視、\text 等の立体テキスト内は保持
  elsif (letters = scanner.scan(/[A-Za-z]+/))
    upright ? escape_html(letters) : "<i>#{escape_html(letters)}</i>"
  elsif (digits = scanner.scan(/[0-9]+/))
    digits
  elsif scanner.scan(/\^/)
    wrap_script(scanner, 'sup', upright:, allow_script:, allow_frac:)
  elsif scanner.scan(/_/)
    wrap_script(scanner, 'sub', upright:, allow_script:, allow_frac:)
  elsif scanner.check(/\{/)
    consume_brace_group(scanner, upright:, allow_script:, allow_frac:) # 透過グループ
  elsif scanner.scan(/\\/)
    consume_command(scanner, upright:, allow_frac:)
  elsif (op = scanner.scan(OPERATORS))
    escape_html(op)
  elsif (raw = scanner.scan(PASSTHROUGH))
    raw # 記号・ギリシャ・日本語はそのままの字で出す(立体)
  end
end

.consume_brace_group(scanner, upright:, allow_script:, allow_frac:) ⇒ Object

'{ … }' を必須で読み、中身の run を返す(透過)。閉じ '}' が無ければ nil。



238
239
240
241
242
243
244
245
246
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 238

def consume_brace_group(scanner, upright:, allow_script:, allow_frac:)
  scanner.skip(/\s+/)
  return nil unless scanner.scan(/\{/)

  inner = consume_run(scanner, upright:, allow_script:, allow_frac:, stop_at_brace: true)
  return nil if inner.nil? || !scanner.scan(/\}/)

  inner
end

.consume_command(scanner, upright:, allow_frac:) ⇒ Object

\command を消費する。\ は消費済みで、続く名前/記号を読む。



166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 166

def consume_command(scanner, upright:, allow_frac:)
  if (name = scanner.scan(/[A-Za-z]+/))
    dispatch_named_command(name, scanner, allow_frac:)
  elsif scanner.scan(/,/)      # \,  細空白
    ""
  elsif scanner.scan(/[;:]/)   # \; \:  通常空白
    ' '
  elsif (esc = scanner.scan(/[%&#_{}$]/)) # \% \& \# \_ \{ \} \$  リテラル
    escape_html(esc)
  end
  # \\ やその他の記号は nil(拒否)
end

.consume_group_or_token(scanner, upright:, allow_script:, allow_frac:) ⇒ Object

上/下付き・\frac の引数('…' グループ or 単一アトム)。先行空白は読み飛ばす。



228
229
230
231
232
233
234
235
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 228

def consume_group_or_token(scanner, upright:, allow_script:, allow_frac:)
  scanner.skip(/\s+/)
  if scanner.check(/\{/)
    consume_brace_group(scanner, upright:, allow_script:, allow_frac:)
  else
    consume_atom(scanner, upright:, allow_script:, allow_frac:)
  end
end

.consume_run(scanner, upright:, allow_script:, allow_frac:, stop_at_brace:) ⇒ Object

トークン列を消費して HTML を組む。stop_at_brace 時はトップレベルの '}' で停止(消費しない)。 1 つでも解釈不能なトークンがあれば nil(=式全体を拒否)。



121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 121

def consume_run(scanner, upright:, allow_script:, allow_frac:, stop_at_brace:)
  buf = +''
  until scanner.eos?
    break if stop_at_brace && scanner.check(/\}/)

    piece = consume_atom(scanner, upright:, allow_script:, allow_frac:)
    return nil if piece.nil?

    buf << piece
  end
  buf
end

.dispatch_named_command(name, scanner, allow_frac:) ⇒ Object

英字コマンドの振り分け(\text/\mathrm・\frac・空白語・記号/ギリシャ表)。未知は nil。



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 180

def dispatch_named_command(name, scanner, allow_frac:)
  case name
  when 'text', 'mathrm'
    consume_brace_group(scanner, upright: true, allow_script: false, allow_frac: false)
  when 'frac'
    return nil unless allow_frac

    numer = consume_brace_group(scanner, upright: false, allow_script: true, allow_frac: false)
    denom = numer && consume_brace_group(scanner, upright: false, allow_script: true, allow_frac: false)
    # **括弧が要る。** `\frac{a+b}{2}` を `a+b/2` と書くと `a + (b/2)` に読める
    # ——意味が変わる。`\frac{1}{2}` のような単項なら付けない。
    denom && "#{parenthesize(numer)}/#{parenthesize(denom)}"
  when 'hat'
    # `\hat{p}` → `p̂`(結合アクセント U+0302)。統計の推定値でよく出る。
    # HTML のテキストなので結合文字がそのまま乗る
    base = consume_brace_group(scanner, upright: false, allow_script: false, allow_frac: false)
    base && "#{base}\u0302"
  when 'sqrt'
    # 根号は横線を引けないので `√…` にする。中身が複合式なら括弧で範囲を示す
    # ——`√a+b` は「√a に b を足す」に読めてしまう。
    body = consume_brace_group(scanner, upright: false, allow_script: true, allow_frac: false)
    body && "#{parenthesize(body)}"
  when 'quad', 'qquad'
    ' '
  when 'bmod'
    ' mod '
  when 'pmod'
    inner = consume_brace_group(scanner, upright: true, allow_script: false, allow_frac: false)
    inner && " (mod #{inner})"
  when *FUNCTIONS
    name # 立体で出す(素のままだと 𝑠𝑖𝑛 と 1 文字ずつ斜体になる)
  else
    SYMBOLS[name] || GREEK[name]
  end
end

.escape_html(str) ⇒ Object

出力の HTML 予約文字をエスケープする(< > & のみ。" は属性化しないので不要)。



249
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 249

def escape_html(str) = str.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')

.parenthesize(html) ⇒ Object

複合式なら括弧で囲む。判定は描画済み HTML から上下付きの中身を除いたうえで、 加減算や除算が残るかを見る(10<sup>-34</sup>- は指数の符号であって 項の区切りではない)。生成するタグは // だけなので、 タグ自体に + - / が現れることはない。



220
221
222
223
224
225
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 220

def parenthesize(html)
  # 上下付きは中身ごと落とす(指数の符号を項の区切りと読まないため)。
  # 残りのタグも落とす——`</i>` の `/` を除算と読まないため。
  bare = html.gsub(%r{<su[bp]>.*?</su[bp]>}m, '').gsub(/<[^>]+>/, '')
  bare.match?(%r{[+\-−/]}) ? "(#{html})" : html
end

.render(latex) ⇒ String?

LaTeX(デリミタ除去済み)を HTML テキストへ変換する。

Parameters:

  • latex (String)

    $…$ / (…) を剥いだ LaTeX 本文

Returns:

  • (String, nil)

    HTML 文字列(変換可能時)/ nil(サブセット外・空)



100
101
102
103
104
105
106
107
108
109
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 100

def render(latex)
  src = latex.to_s
  return nil if src.strip.empty?

  scanner = StringScanner.new(src)
  html = consume_run(scanner, upright: false, allow_script: true, allow_frac: true, stop_at_brace: false)
  return nil if html.nil? || !scanner.eos? # 未消費が残る=解釈できないトークンがあった

  space_after_big_operators(html)
end

.space_after_big_operators(html) ⇒ Object



115
116
117
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 115

def space_after_big_operators(html)
  html.gsub(BIG_OPERATOR_WITH_LIMITS) { "#{::Regexp.last_match(1)}\u2009" }
end

.wrap_script(scanner, tag, upright:, allow_script:, allow_frac:) ⇒ Object

上/下付き(/)。2 段入れ子は許さない(引数を allow_script: false で読む)。



158
159
160
161
162
163
# File 'lib/vivlio_starter/cli/build/math_text_renderer.rb', line 158

def wrap_script(scanner, tag, upright:, allow_script:, allow_frac:)
  return nil unless allow_script

  arg = consume_group_or_token(scanner, upright:, allow_script: false, allow_frac:)
  arg && "<#{tag}>#{arg}</#{tag}>"
end