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__)
PLACEHOLDER_PREFIX =

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

"VS_MATH_"
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)


74
75
76
77
78
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 74

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 のみで解像度非依存になり、リーダーでぼやけない)。



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 169

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}"#{style}>)
  end
end

.default_rendererObject

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



192
193
194
195
196
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 192

def default_renderer
  return nil unless available?

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

.enqueue_formulas(text, queue) ⇒ Object

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



126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 126

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?

      placeholder = "#{PLACEHOLDER_PREFIX}#{queue.size}"
      queue << { key: digest(display, latex), latex:, display:, original:, placeholder: }
      placeholder
    end
  end
  text
end

.mathjax_rootObject

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



209
210
211
212
213
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 209

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

  @mathjax_root = resolve_mathjax_root
end

.node_commandObject

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



199
200
201
202
203
204
205
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 199

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 回しか描かれない。



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 146

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"), 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)

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



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/vivlio_starter/cli/pre_process/math_transformer.rb', line 86

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