Module: VivlioStarter::CLI::PreProcessCommands::MathTransformer

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

Overview

LaTeX 数式を SVG 画像へ変換するモジュール

Defined Under Namespace

Classes: NodeRenderer

Constant Summary collapse

REL_BASE =

SVG 出力先のベースディレクトリ(images/ 配下)。 章画像(images/<章>/…)とは別系統の math/ 配下に置き、混在を避ける。

'math'
SCRIPT_PATH =

MathJax で LaTeX→SVG する Node スクリプト。

File.expand_path('mathjax_to_svg.mjs', __dir__)
TEXT_FONT_FAMILY =

日本語などを含む式で MathJax が <text> へ落とす字。SVG は 参照の独立文書で @font-face が届かず OS フォントへ落ちるため、PDF に Type 3 が混入する (type3-font-embedding-notes.md §4・plain-math-notation-spec.md §3.1 で実測)。 showcase と同じく、汎用名のままでは @font-face を当てられないので専用名を与える。

'vs-math-cjk'
CJK_IN_FORMULA =

<text> を生む字(MathJax がグリフを持たない)。実測では CJK だけ。 キャッシュ鍵に書体を混ぜるかの判定に使う。

/[぀-ヿ一-鿿]/
PLACEHOLDER_PREFIX =

数式をプレースホルダに退避するための番兵(本文に現れない制御文字を使う)。

"VS_MATH_"
BIG_OPERATORS =

インラインの大型演算子。上下限が記号のへ寄って潰れる(実測: \sum_{i=1}^{n} i は インライン 2.563ex / ディスプレイ 6.354ex)。\limits を挿すと上下へ開いて 5.449ex になり、 数学書と同じ組み方になる。上下限が直後に付いている場合だけ挿す——\limits は 上下限を伴わないと TeX エラーになるため。

\int\lim は対象外。 積分は上下限を記号の横に置くのが組版の慣習で、display style でも変わらない (実測でも msubsup のまま)。\lim は上下へ開いても 3.171ex——本書の行送り 1.6em (≒3.5ex)に収まってしまうので行が押し開かれず、開く利点が無い。しかも lim は 文字列なので display style でも大きくならない(\displaystyle でも同じ 3.171ex)。 横に置いたほうが lim n→∞ aₙ と読めて素直。

/\\(?:sum|prod|coprod|bigcup|bigcap|bigoplus|bigotimes|bigvee|bigwedge)(?=[_^])/
DISPLAY_DOLLAR =

検出パターン([正規表現, ディスプレイか])。$$ を $ より先に処理する。

/(?<!\\)\$\$(.+?)(?<!\\)\$\$/m
DISPLAY_BRACKET =
/\\\[(.+?)\\\]/m
INLINE_DOLLAR =
/(?<!\\)\$(?!\s)((?:[^$\n\\]|\\.)+?)(?<!\s)(?<!\\)\$(?!\$)/
INLINE_PAREN =
/\\\((.+?)\\\)/m

Class Method Summary collapse

Class Method Details

.available?Boolean

数式変換が利用可能か(node と mathjax-full が解決できるか)。

Returns:

  • (Boolean)


99
100
101
102
103
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 99

def available?
  return @available unless @available.nil?

  @available = !node_command.nil? && !mathjax_root.nil?
end

.build_img(rel_path, svg, formula) ⇒ Object

SVG 1 つを (インライン)/

(ディスプレイ)へ整形する。 表示寸法は mathjax_to_svg.mjs が SVG ルートから外して data-vs-* に退避した ex 値を 読み、 の style に写す(本文フォント相対の正しいサイズ・整列。SVG 本体は viewBox のみで解像度非依存になり、リーダーでぼやけない)。



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 226

def build_img(rel_path, svg, formula)
  valign = svg[/data-vs-valign="([^"]*)"/, 1]
  width  = svg[/data-vs-width="([^"]*)"/, 1]
  height = svg[/data-vs-height="([^"]*)"/, 1]
  valign = nil if valign.to_s.empty?
  width  = nil if width.to_s.empty?
  height = nil if height.to_s.empty?
  alt    = escape(formula[:original])

  if formula[:display]
    style = [width && "width: #{width}", height && "height: #{height}"].compact.join('; ')
    style = style.empty? ? '' : %( style="#{style};")
    "\n\n<figure class=\"vs-math vs-math-display\">\n" \
      "<img src=\"#{rel_path}\" alt=\"#{alt}\"#{style}>\n" \
      "</figure>\n\n"
  else
    parts = [valign && "vertical-align: #{valign}", width && "width: #{width}", height && "height: #{height}"].compact
    style = parts.empty? ? '' : %( style="#{parts.join('; ')};")
    %(<img class="vs-math vs-math-inline" src="#{rel_path}" alt="#{alt}"#{tex_hint(formula)}#{style}>)
  end
end

.default_rendererObject

既定のレンダラ(Node + MathJax)。利用不可なら nil。



280
281
282
283
284
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 280

def default_renderer
  return nil unless available?

  @default_renderer ||= NodeRenderer.new(node_command, mathjax_root)
end

.embed_text_font(svg) ⇒ Object

<text> を含む SVG に、その字だけのサブセット書体を @font-face で抱かせる。 これで SVG が自己完結し、PDF の Type 3 混入が消える(実測: 対照と同数まで戻る)。 書体を解決できない環境では font_face_style が nil を返し、SVG は元のまま ——Type 3 は残るがビルドは止めない(notes §5 と同じ縮退)。



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

def embed_text_font(svg)
  return svg unless svg.include?('<text')

  # **本文書体を埋める。** 数式の中の日本語は本文の続きなので、見出し書体だと
  # `面積 = √(s(s−a)…)` の「面積」だけ周りと違う書体になる(本書なら明朝の中にゴシック)。
  style = SvgFontEmbedder.font_face_style(SvgFontEmbedder.characters_in(svg),
                                          family: TEXT_FONT_FAMILY,
                                          font_path: SvgFontEmbedder.body_font_path)
  # 書体を解決できないときは**名前も書き換えない**。埋め込めないのに専用名へ
  # 変えると、存在しないファミリを指すだけで従来(serif)より悪くなる。
  return svg if style.nil?

  # MathJax は font-family="serif" と汎用名を書く。汎用名には @font-face を
  # 当てられないので、専用名へ書き換えてから注ぐ(showcase と同じ手当て)。
  renamed = svg.gsub(/(<text[^>]*?)font-family="[^"]*"/) { "#{::Regexp.last_match(1)}font-family=\"#{TEXT_FONT_FAMILY}\"" }
  SvgFontEmbedder.inject(renamed, style)
end

.enqueue_formulas(text, queue) ⇒ Object

4 種の数式記法を順にプレースホルダ化し queue に積む。 $$ を $ より先に処理することで二重マッチを避ける。



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 151

def enqueue_formulas(text, queue)
  [[DISPLAY_DOLLAR, true], [DISPLAY_BRACKET, true], [INLINE_DOLLAR, false], [INLINE_PAREN, false]].each do |regex, display|
    text = text.gsub(regex) do
      latex    = ::Regexp.last_match(1).strip
      original = ::Regexp.last_match(0)
      next original if latex.empty?

      # 素の表記(`√n`・`x²`・`Σ`)を TeX へ起こしてから MathJax へ渡す
      # (plain-math-notation-spec.md §7.5)。既に TeX で書かれた式はゲートで
      # 素通りするので、既刊原稿の SVG キャッシュは割れない。
      tex = PlainMathTranspiler.to_tex(latex)
      tex = stack_operator_limits(tex) unless display

      placeholder = "#{PLACEHOLDER_PREFIX}#{queue.size}"
      # key と latex は起こした後の TeX。`$x²$` と `$x^{2}$` が同じ SVG を共有する。
      # original は素のまま——描画に失敗したとき本文へ戻すのは、
      # 著者が書いた文字列でなければならない。
      queue << { key: digest(display, tex), latex: tex, display:, original:,
                 plain: plain_source?(latex), placeholder: }
      placeholder
    end
  end
  text
end

.mathjax_rootObject

mathjax-full が入っている node_modules ディレクトリを解決する。 プロジェクト直下のローカルを優先し、無ければグローバル(npm root -g)。



297
298
299
300
301
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 297

def mathjax_root
  return @mathjax_root if defined?(@mathjax_root)

  @mathjax_root = resolve_mathjax_root
end

.node_commandObject

node / nodejs コマンドを解決する(無ければ nil)。



287
288
289
290
291
292
293
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 287

def node_command
  return @node_command if defined?(@node_command)

  @node_command = %w[node nodejs].find do |cmd|
    system(cmd, '--version', out: File::NULL, err: File::NULL)
  end
end

.render_uncached!(queue, renderer) ⇒ Object

まだ永続キャッシュに無い式だけを抽出し、まとめてレンダラへ渡してキャッシュへ書き出す。 LaTeX 原文+表示種別のハッシュをファイル名にし、同一式は再生成しない。 キャッシュ(.cache/vs/math/)は BUILD_DIR の外にあり final clean を生き延びるため、 式が変わらない限りクリーンビルドを跨いで Node+MathJax を起動しない。キーは章に 依存しないため、複数章に現れる同一式もビルド全体で 1 回しか描かれない。



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 181

def render_uncached!(queue, renderer)
  cache_dir = GeneratedAssetCache.dir(REL_BASE)
  pending = queue.reject { |f| File.exist?(File.join(cache_dir, "#{f[:key]}.svg")) }
                 .uniq { |f| f[:key] }
  return if pending.empty?

  items = pending.map { |f| { id: f[:key], latex: f[:latex], display: f[:display] } }
  svgs = renderer.render_batch(items) || {}

  FileUtils.mkdir_p(cache_dir)
  svgs.each do |key, svg|
    next unless svg.is_a?(String) && svg.start_with?('<svg')

    File.write(File.join(cache_dir, "#{key}.svg"), embed_text_font(svg), encoding: 'utf-8')
  end
rescue StandardError => e
  Common.log_warn("数式の SVG 化に失敗しました(元の記法を維持): #{e.message}")
end

.transform(content, chapter_slug:, renderer: default_renderer) ⇒ String

本文中の数式を SVG 化して へ置換する。

Parameters:

  • content (String)

    処理対象の Markdown 本文

  • chapter_slug (String)

    SVG 出力先の章ディレクトリ名(例: "94-sample")

  • renderer (#render_batch, nil) (defaults to: default_renderer)

    数式レンダラ(テスト差し替え用。既定は Node+MathJax)

Returns:

  • (String)

    数式を 化した本文(変換不能時は元の本文)



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 111

def transform(content, chapter_slug:, renderer: default_renderer)
  return content if renderer.nil?

  # --- Phase: コードスパン退避($ を含むコード例を誤変換しない) ---
  text, spans = MarkdownUtils.extract_code_spans(content)

  # --- Phase: 全数式をプレースホルダ化して収集(Node 呼び出しを 1 回に束ねる) ---
  queue = []
  text = enqueue_formulas(text, queue)
  return MarkdownUtils.restore_code_spans(text, spans) if queue.empty?

  # SVG はビルド生成物なのでワークスペースの html/images/math/ へ書き出す(P4b §2.1)。
  out_dir = File.join(Common::BUILD_HTML_DIR, 'images', REL_BASE, chapter_slug)
  # <img> の参照は消費者 dir 相対(asset_prefix 無し)。EPUB/Kindle の prefix 剥がし
  # (stage_consumer_htmls!)を素通りし、PDF は pdf/ へミラーした同 dir 内で解決する。
  # 著者画像は asset_prefix 付き(ルート実体)、数式はビルド生成物(workspace 内)で
  # 参照形が分かれるが、それは資産の出自の区別が正しく表れたもの(P4b §2.1)。
  rel_dir = "images/#{REL_BASE}/#{chapter_slug}"

  # --- Phase: 未キャッシュの式をまとめて SVG 化して永続キャッシュへ書き出す ---
  render_uncached!(queue, renderer)

  # --- Phase: プレースホルダを <img>/<figure> へ置換(失敗式は元の記法に戻す) ---
  # 生成物はキャッシュから章のワークスペース dir へ写して参照する(materialize)。
  queue.each do |formula|
    file = "#{formula[:key]}.svg"
    replacement = if GeneratedAssetCache.materialize(REL_BASE, [file], out_dir:)
                    build_img("#{rel_dir}/#{file}", File.read(File.join(out_dir, file), encoding: 'utf-8'),
                              formula)
                  else
                    formula[:original]
                  end
    text = text.sub(formula[:placeholder]) { replacement }
  end

  MarkdownUtils.restore_code_spans(text, spans)
end