Module: VivlioStarter::CLI::PreProcessCommands::ShowcaseTransformer

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

Overview

:::showcase ブロックを合成画像へ変換するモジュール

Defined Under Namespace

Classes: ImageTools

Constant Summary collapse

REL_BASE =

生成物の出力先(images/ 配下)。著者画像(images/<章>/…)とは別系統に置く。

'showcase'
BLOCK_PATTERN =

ブロック全体(開きフェンス〜閉じフェンス)。中身を丸ごと消費する。

/^:::\s*\{\s*\.showcase\s*\}[ \t]*\n(.*?)^:::[ \t]*\n?/m
EMBED_MAX_EDGE =

SVG へ埋め込む元画像の最大長辺(スクリーンショットの文字が潰れない上限)。

2000
RASTER_SCALE =

ラスタライズ倍率と上限幅(リーダー側の縮小表示で鮮明に見せるため 2 倍で焼く)。

2
RASTER_MAX_WIDTH =
2600
PHOTO_COLOR_THRESHOLD =

元画像を「写真」とみなすユニーク色数の下限(冒頭コメントの実測値に基づく。 ロゴ 5,648 と写真 17,122 の谷に置いた)。これ以上なら JPEG、未満なら PNG。

8192

Class Method Summary collapse

Class Method Details

.cache_key(source, block) ⇒ Object

画像内容をキーに含めるため、著者がスクリーンショットを撮り直せば再生成される。 v2: ラベル書体をキーに含める。SVG はサブセットを自身に抱くので、著者が typography.heading.font を変えたら作り直さないと古い書体のまま残る (mermaid 側は最初から font_family をキーに含めていた)。



189
190
191
192
193
194
195
196
197
198
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 189

def cache_key(source, block)
  payload = [
    'v2',
    Digest::SHA256.file(source).hexdigest,
    block.crop.join(','),
    JSON.generate(block.annotations.map(&:to_h)),
    SvgFontEmbedder.configured_heading_font
  ].join('|')
  Digest::SHA256.hexdigest(payload)[0, 16]
end

.cached_raster_ext(key) ⇒ Object

永続キャッシュに残るラスターの拡張子(png/jpg)。無ければ nil(=形式判定から決める)。



181
182
183
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 181

def cached_raster_ext(key)
  %w[png jpg].find { File.exist?(File.join(GeneratedAssetCache.dir(REL_BASE), "#{key}.#{it}")) }
end

.compose(image, lines, chapter_slug:, source_filename:, tools:) ⇒ Array(ShowcaseSvgBuilder::ShowcaseBlock, String, String)?

合成 SVG とラスターを(必要なら)生成し、参照パスを返す。縮退すべき場合は nil。

Returns:



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
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 111

def compose(image, lines, chapter_slug:, source_filename:, tools:)
  source = resolve_image_file(image.path, source_filename)
  return nil unless source

  unless tools.available?
    warn_tools_missing(source_filename)
    return nil
  end

  dims = tools.image_dimensions(source)
  unless dims
    warn_tools_missing(source_filename)
    return nil
  end

  orig_w, orig_h = dims
  block = ShowcaseSvgBuilder.parse(lines, orig_w:, orig_h:,
                                          on_warn: ->(line) { warn_unparsable(line, source_filename) })
  key = cache_key(source, block)
  out_dir = File.join(Common::BUILD_HTML_DIR, 'images', REL_BASE, chapter_slug)
  # 元画像の素性でラスター形式を選ぶ(冒頭コメント参照)。同じ画像なら判定も同じに
  # なるため、キャッシュキーに形式を含める必要はない——ゆえに永続キャッシュに残る
  # 拡張子をそのまま信じてよく、ヒット時は magick identify(0.2〜0.35 秒)ごと省ける。
  ext = cached_raster_ext(key) || (tools.photographic?(source) ? 'jpg' : 'png')
  return nil unless write_assets!(block, key, out_dir, ext, source:, orig_w:, orig_h:, tools:)

  # <img> の参照は消費者 dir 相対(asset_prefix 無し)。数式 SVG と同じ理由で、
  # ビルド生成物は workspace 内実体のため EPUB の prefix 剥がしを素通りし、
  # PDF は pdf/ ミラーで解決する(math_transformer.rb P4b §2.1 コメント参照)。
  rel_dir = "images/#{REL_BASE}/#{chapter_slug}"
  [block, "#{rel_dir}/#{key}.svg", "#{rel_dir}/#{key}.#{ext}"]
end

.default_toolsObject

既定の外部ツール(magick + rsvg-convert)。



280
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 280

def default_tools = (@default_tools ||= ImageTools.new)

.escape_attr(str) ⇒ Object



238
239
240
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 238

def escape_attr(str)
  str.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
end

.figure(svg_rel, raster_rel, alt, width) ⇒ Object

置換後の HTML。前後に空行を補い独立段落として組ませる。 ラスターの参照を data-vs-raster に明示して持たせる——形式が png / jpg のどちらにも なりうるため、EpubBuilder 側で拡張子を推測させない(推測させると --no-clean ビルドで 前回形式の残骸を拾いうる)。EpubBuilder は使用後にこの属性を取り除く。



228
229
230
231
232
233
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 228

def figure(svg_rel, raster_rel, alt, width)
  "\n\n<figure class=\"vs-showcase\">\n" \
    "<img class=\"vs-showcase\" src=\"#{svg_rel}\" data-vs-raster=\"#{raster_rel}\" " \
    "alt=\"#{escape_attr(alt)}\" style=\"width: #{escape_attr(width)};\">\n" \
    "</figure>\n\n"
end

.label_font_subset(block) ⇒ String?

注釈ラベルに出る字だけへ絞った見出し書体(Bold)を返す。

合成 SVG は <img> から参照される独立文書で、本文 HTML の @font-face が届かない。 素の sans-serif に任せると OS 既定の和文フォントへ落ち、Chromium がそれを Type 3 で埋め込んでしまう(入稿で不可)。字を SVG 自身に持たせて断ち切る。 サブセットなので 8 文字で 3.3KB 程度、SVG は実質太らない。 経緯と実測は type3-font-embedding-notes.md

フォントが見つからない・解析できない場合は nil を返し、SVG は従来どおり sans-serif で組まれる(Type 3 は残るが、ビルドは止めない)。

Returns:

  • (String, nil)

    サブセット済み TTF のバイト列



175
176
177
178
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 175

def label_font_subset(block)
  SvgFontEmbedder.subset(ShowcaseSvgBuilder.label_characters(block),
                         SvgFontEmbedder.heading_font_path)
end

.plain_image(image) ⇒ Object

縮退時の出力。注釈を捨て、画像行だけを通常の画像記法として残す。



236
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 236

def plain_image(image) = "\n\n![#{image.alt}](#{image.path})\n\n"

.raster_width(block, orig_w:, orig_h:) ⇒ Object



200
201
202
203
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 200

def raster_width(block, orig_w:, orig_h:)
  width, = ShowcaseSvgBuilder.cropped_size(block, orig_w:, orig_h:)
  [(width * RASTER_SCALE).round, RASTER_MAX_WIDTH].min
end

.render_block(lines, chapter_slug:, source_filename:, tools:) ⇒ Object

ブロック 1 つ分を HTML(または縮退した画像記法)へ変換する。



93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 93

def render_block(lines, chapter_slug:, source_filename:, tools:)
  image = ShowcaseSvgBuilder.scan_image(lines)
  unless image
    warn_no_image(source_filename)
    return ''
  end
  warn_extra_images(source_filename) if ShowcaseSvgBuilder.image_line_count(lines) > 1

  composed = compose(image, lines, chapter_slug:, source_filename:, tools:)
  return plain_image(image) unless composed

  block, svg_rel, raster_rel = composed
  figure(svg_rel, raster_rel, ShowcaseSvgBuilder.alt_text(block), block.width)
end

.resolve_image_file(src, source_filename) ⇒ Object

正規化済みの画像参照(asset_prefix + images/…)から実ファイルパスを解決する。 ImagePathNormalizer が既に .webp 寄せ・存在チェックを済ませているため、 ここでは prefix を剥がして Common::IMAGES_DIR 基準で引き当てるだけでよい。



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 208

def resolve_image_file(src, source_filename)
  # 画像不在時に normalizer が差し込む data URI プレースホルダ(既に 🔴 で報告済み)
  return nil if src.start_with?('data:')

  if src.start_with?('http://', 'https://')
    warn_remote_image(source_filename, src)
    return nil
  end

  rel = src.delete_prefix(Common.asset_prefix)
  return nil unless rel.start_with?('images/')

  path = File.expand_path(rel.delete_prefix('images/'), Common::IMAGES_DIR)
  File.exist?(path) ? path : nil
end

.transform(content, chapter_slug:, source_filename:, tools: default_tools) ⇒ String

本文中の showcase ブロックをすべて

へ置換する。

コードスパンを退避してから走査する——記法そのものを解説する原稿(拡張記法 リファレンス)では ```markdown フェンスの中に showcase ブロックの書き方の例が 書かれており、退避しないと作例が変換に食われて消える。

Parameters:

  • content (String)

    処理対象の Markdown 本文

  • chapter_slug (String)

    生成物の出力先章ディレクトリ名(例: "10-intro")

  • source_filename (String)

    警告に出す原稿ファイル名

  • tools (#available?, #image_dimensions, #data_uri, #rasterize) (defaults to: default_tools)

    外部ツール (テスト差し替え用。既定は magick + rsvg-convert)

Returns:

  • (String)

    置換後の本文



82
83
84
85
86
87
88
89
90
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 82

def transform(content, chapter_slug:, source_filename:, tools: default_tools)
  return content unless content.match?(BLOCK_PATTERN)

  text, spans = MarkdownUtils.extract_code_spans(content)
  text = text.gsub(BLOCK_PATTERN) do
    render_block(::Regexp.last_match(1).lines, chapter_slug:, source_filename:, tools:)
  end
  MarkdownUtils.restore_code_spans(text, spans)
end

.warn_extra_images(source_filename) ⇒ Object



251
252
253
254
255
256
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 251

def warn_extra_images(source_filename)
  Common.log_warn(
    "[showcase] #{source_filename}: showcase ブロック内に画像が 2 枚あります。2 枚目以降は無視します",
    detail: '→ 1 ブロック 1 画像です。別の :::{.showcase} ブロックに分けてください'
  )
end

.warn_no_image(source_filename) ⇒ Object

--- 警告(§7.10: 修正例と出現位置を必ず添える) ---



244
245
246
247
248
249
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 244

def warn_no_image(source_filename)
  Common.log_warn(
    "[showcase] #{source_filename}: showcase ブロックに画像がありません。ブロックを出力しません",
    detail: '→ 先頭に ![説明](screenshot.png) を置いてください'
  )
end

.warn_remote_image(source_filename, src) ⇒ Object



272
273
274
275
276
277
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 272

def warn_remote_image(source_filename, src)
  Common.log_warn(
    "[showcase] #{source_filename}: 外部 URL の画像(#{src})は注釈できません。通常の画像として出力します",
    detail: '→ 画像を images/ 配下へ置き ![説明](screenshot.png) と書いてください'
  )
end

.warn_tools_missing(source_filename) ⇒ Object



258
259
260
261
262
263
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 258

def warn_tools_missing(source_filename)
  Common.log_warn(
    "[showcase] #{source_filename}: ImageMagick / librsvg が見つからないため注釈なしの画像に縮退します",
    detail: '→ `vs doctor --fix` で導入できます(brew install imagemagick librsvg)'
  )
end

.warn_unparsable(line, source_filename) ⇒ Object



265
266
267
268
269
270
# File 'lib/vivlio_starter/cli/pre_process/showcase_transformer.rb', line 265

def warn_unparsable(line, source_filename)
  Common.log_warn(
    %([showcase] #{source_filename} 内の記法を解釈できません: "#{line}"),
    detail: '→ 座標はカンマ区切りです。例: rect:1 190, 30, 360, 90 {pos=right} コメント'
  )
end

.write_assets!(block, key, out_dir, ext, source:, orig_w:, orig_h:, tools:) ⇒ Boolean

SVG とラスターを対でワークスペースへ用意する。合成は magick+rsvg で 1 枚 0.5〜1 秒級 のため、生成物は GeneratedAssetCache(.cache/vs/showcase/)に永続キャッシュされ、 元画像・注釈が変わらない限りクリーンビルドを跨いで再合成しない(キーは内容アドレス)。

Returns:

  • (Boolean)

    参照可能な生成物が揃ったか



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

def write_assets!(block, key, out_dir, ext, source:, orig_w:, orig_h:, tools:)
  GeneratedAssetCache.fetch(REL_BASE, ["#{key}.svg", "#{key}.#{ext}"], out_dir:) do |cache_dir|
    data_uri = tools.data_uri(source)
    next false unless data_uri

    svg = ShowcaseSvgBuilder.build(block, orig_w:, orig_h:, data_uri:,
                                   font_data: label_font_subset(block))
    raster = tools.rasterize(svg, raster_width(block, orig_w:, orig_h:), format: ext.to_sym)
    next false unless raster

    File.write(File.join(cache_dir, "#{key}.svg"), svg, encoding: 'utf-8')
    File.binwrite(File.join(cache_dir, "#{key}.#{ext}"), raster)
    true
  end
end