Module: VivlioStarter::CLI::PreProcessCommands::ThemeImageResolver

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

Overview

テーマ画像パス解決モジュール

Constant Summary collapse

THEME_IMAGE_EXTENSIONS =
%w[.webp .png .jpg .jpeg].freeze
FALLBACK_THEME_IMAGE_SLUG =

扉絵・飾り画像の既定画像スラッグ。未指定時も無効な指定時もこの画像に寄せる。 (無効な color が yellow へフォールバックするのと揃えた挙動。バンドルの桜を使う)

'sakura'
FRONTISPIECE_DEFAULT_PATH =

万一バリアント解決に失敗したときの最終フォールバックパス(通常は到達しない)。 バリアントは生成キャッシュに出るため theme-images/ 形で指す(移設仕様 §3.1)。

'theme-images/bundled/sakura_portrait.webp'
ORNAMENT_DEFAULT_PATH =
'theme-images/bundled/sakura_landscape.webp'
DEFAULT_PAGE_WIDTH_MM =
210.0
DEFAULT_PAGE_HEIGHT_MM =
297.0
MIN_BINDING_RATIO =
1.35
MAX_BINDING_RATIO =
2.2
FRONTISPIECE_RATIO_TOLERANCE =
0.05
FRONTISPIECE_PLACEHOLDER_SVG =
<<~SVG
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 297" width="210" height="297">
    <rect width="210" height="297" fill="#e3e3e3"/>
    <text x="105" y="150" font-family="monospace" font-size="14" fill="#666" text-anchor="middle">filename.webp</text>
  </svg>
SVG
ORNAMENT_PLACEHOLDER_SVG =
<<~SVG
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 297 210" width="297" height="210">
    <rect width="297" height="210" fill="#e3e3e3"/>
    <text x="148.5" y="110" font-family="monospace" font-size="14" fill="#666" text-anchor="middle">filename.webp</text>
  </svg>
SVG

Class Method Summary collapse

Class Method Details

.binding_safe_portrait_ratioObject



358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 358

def binding_safe_portrait_ratio
  # page の版面キー(width 等)は page_presets 由来で存在保証がないため [] で参照する
  page_cfg = Common::CONFIG.page
  width_mm = Units.length_to_mm(page_cfg[:width]) || DEFAULT_PAGE_WIDTH_MM
  height_mm = Units.length_to_mm(page_cfg[:height]) || DEFAULT_PAGE_HEIGHT_MM
  margin_inner_mm = Units.length_to_mm(page_cfg[:margin_inner]) || 0
  margin_outer_mm = Units.length_to_mm(page_cfg[:margin_outer]) || 0

  binding_delta = [margin_inner_mm - margin_outer_mm, 0].max
  effective_width = width_mm - binding_delta
  effective_width = width_mm * 0.4 if effective_width <= width_mm * 0.4
  ratio = height_mm / [effective_width, 1.0].max

  ratio.clamp(MIN_BINDING_RATIO, MAX_BINDING_RATIO)
rescue StandardError
  1.414
end

.find_cached_theme_variant(base_slug, variant) ⇒ Object

生成キャッシュ内のバリアントを探索する。 ImageGenerator は images root からの相対サブパスを保って生成するため、 bundled 由来は theme-images/bundled/ に、ユーザー画像由来はルート直下に出る。



260
261
262
263
264
265
266
267
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 260

def find_cached_theme_variant(base_slug, variant)
  %W[#{base_slug}_#{variant}.webp bundled/#{File.basename(base_slug)}_#{variant}.webp].uniq.each do |candidate|
    path = File.join(theme_images_cache_root, candidate)
    return path if File.exist?(path)
  end

  nil
end

.find_existing_theme_image(slug, location_order: %i[user bundled],, allowed_extensions: THEME_IMAGE_EXTENSIONS) ⇒ Object

既存テーマ画像を探索



289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 289

def find_existing_theme_image(slug, location_order: %i[user bundled],
                              allowed_extensions: THEME_IMAGE_EXTENSIONS)
  base = normalize_theme_image_slug(slug)
  ext = File.extname(base)
  stem = ext.empty? ? base : base.sub(/\.[^.]+\z/, '')
  candidates = if ext.empty?
                 allowed_extensions.map { |e| "#{stem}#{e}" }
               else
                 ["#{stem}#{ext}"]
               end

  location_order.each do |loc|
    dir = loc == :user ? theme_images_root : File.join(theme_images_root, 'bundled')
    candidates.each do |candidate|
      path = File.join(dir, candidate)
      return path if File.exist?(path)
    end
  end

  nil
end

.find_existing_theme_variant(base_slug, variant) ⇒ Object

バリアント画像を探索。 ユーザーが stylesheets/images/ に手置きしたバリアント(意図的な上書き)を優先し、 次に生成キャッシュ(.cache/vs/theme-images/。ImageGenerator の出力先)を見る。



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

def find_existing_theme_variant(base_slug, variant)
  find_existing_theme_image("#{base_slug}_#{variant}", location_order: %i[user bundled],
                                                       allowed_extensions: ['.webp']) ||
    find_cached_theme_variant(base_slug, variant)
end

.frontispiece_allowed_ratiosObject



354
355
356
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 354

def frontispiece_allowed_ratios
  [binding_safe_portrait_ratio, 1.414].uniq
end

.image_ratio(path) ⇒ Object

画像のアスペクト比を取得



331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 331

def image_ratio(path)
  out, status = Open3.capture2('magick', 'identify', '-format', '%w %h', path)
  return nil unless status.success?

  width_str, height_str = out.strip.split
  width = width_str.to_f
  height = height_str.to_f
  return nil if width <= 0 || height <= 0

  height / width
rescue StandardError
  nil
end

.normalize_theme_image_slug(value) ⇒ Object

テーマ画像スラッグを正規化



270
271
272
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 270

def normalize_theme_image_slug(value)
  value.to_s.strip.sub(%r{\Aimages/}, '').sub(%r{\A/+}, '')
end

.placeholder_uri(base_slug, placeholder_svg) ⇒ Object

プレースホルダーURIを生成



377
378
379
380
381
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 377

def placeholder_uri(base_slug, placeholder_svg)
  base_name = base_slug.to_s.strip.empty? ? 'missing' : File.basename(base_slug)
  filename = "#{base_name}.webp"
  svg_placeholder_uri(placeholder_svg, filename)
end

.ratio_accepted_for_frontispiece?(ratio) ⇒ Boolean

frontispiece 用の許容アスペクト比かチェック

Returns:

  • (Boolean)


346
347
348
349
350
351
352
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 346

def ratio_accepted_for_frontispiece?(ratio)
  frontispiece_allowed_ratios.any? do |allowed|
    next false if allowed.zero?

    ((ratio - allowed).abs / allowed) <= FRONTISPIECE_RATIO_TOLERANCE
  end
end

.resolve_frontispiece_path(raw, allow_generation: false) ⇒ Object

frontispiece (扉絵) の解決(未指定時は既定画像 sakura を生成して使う)



72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 72

def resolve_frontispiece_path(raw, allow_generation: false)
  source = raw.nil? || raw.to_s.strip.empty? ? FALLBACK_THEME_IMAGE_SLUG : raw
  resolve_theme_image_path(
    source,
    variant: :portrait,
    default_path: FRONTISPIECE_DEFAULT_PATH,
    placeholder_svg: FRONTISPIECE_PLACEHOLDER_SVG,
    allow_generation: allow_generation,
    fallback_slug: FALLBACK_THEME_IMAGE_SLUG,
    slug_transform: lambda do |value|
      value =~ /^door[1-7](?:_portrait)?(?:\.[^.]+)?$/i ? value.downcase : value
    end
  )
end

.resolve_image_path(raw, default_when_nil:, downcase_if: nil) ⇒ Object

汎用: 画像ライクな指定を解決して CSS 用相対パス/URL を返す



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 133

def resolve_image_path(raw, default_when_nil:, downcase_if: nil)
  return default_when_nil if raw.nil? || raw.to_s.strip.empty?

  s = raw.to_s.strip
  return s if s =~ /^url\(/i || s =~ %r{^https?://}i

  path = s
  path = path.downcase if downcase_if && path =~ downcase_if
  path = "images/#{path}" unless path.include?('/')

  styles_dir = Common::STYLESHEETS_DIR
  abs_path   = File.join(styles_dir, path)
  base_noext = File.extname(abs_path).empty? ? abs_path : abs_path.sub(/\.[^.]+\z/, '')
  webp_abs   = "#{base_noext}.webp"

  unless File.exist?(webp_abs)
    candidates = ["#{base_noext}.png", "#{base_noext}.jpg", "#{base_noext}.jpeg"]
    src = candidates.find { |p| File.exist?(p) }
    if src
      dir = File.dirname(src)
      Common.log_action("WebP を生成します: #{File.basename(src)}#{File.basename(webp_abs)}")
      # かつて `system("vs resize:high …")` と書いていたが、**そんなコマンドは無い**
      # (`vs resize --high` であって `resize:high` は登録されていない)。存在しない
      # サブコマンドを渡すと `vs` はヘルプを出して終わるので、WebP は 1 枚も
      # 生成されていなかった(2026-08-17 に実測して発覚)。素材が既に WebP の
      # プロジェクトでは通らない経路のため、長く露見しなかった。
      # 直接呼べばプロセスの起動も要らない。
      ResizeCommands.execute_resize_high(dir)
    end
  end

  rel = base_noext.sub(%r{\A#{Regexp.escape(styles_dir)}/}, '')
  rel += '.webp'
  rel
end

.resolve_ornament_path(raw, allow_generation: false) ⇒ Object

ornament (装飾画像) の解決(未指定時は既定画像 sakura を生成して使う)



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
123
124
125
126
127
128
129
130
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 88

def resolve_ornament_path(raw, allow_generation: false)
  raw = FALLBACK_THEME_IMAGE_SLUG if raw.nil? || raw.to_s.strip.empty?

  value = raw.to_s.strip
  return value if value =~ /^url\(/i || value =~ %r{^https?://}i

  slug_value = value =~ /^frame-[a-z0-9_-]+(?:_landscape)?(?:\.[^.]+)?$/i ? value.downcase : value
  slug = normalize_theme_image_slug(slug_value)
  base_slug, requested_variant, ext = split_slug_and_variant(slug)

  # ornament用に landscape バリアント(2.39:1)を使用
  if requested_variant == :landscape
    if (direct = find_existing_theme_image(slug, location_order: %i[user bundled]))
      return theme_relative_path(direct)
    end
  elsif (variant_specific = find_existing_theme_image("#{base_slug}_landscape",
                                                      location_order: %i[user bundled]))
    return theme_relative_path(variant_specific)
  end

  base_query = ext.empty? ? base_slug : "#{base_slug}#{ext}"

  if (direct = find_existing_theme_image(base_query, location_order: %i[user bundled]))
    if allow_generation
      require_relative 'image_generator'
      # ornamentはlandscapeバリアントを生成
      if (generated = ImageGenerator.ensure_variant_generated(direct, :landscape))
        return theme_relative_path(generated)
      end
    end

    return theme_relative_path(direct)
  end

  resolve_theme_image_path(
    slug,
    variant: :landscape,
    default_path: ORNAMENT_DEFAULT_PATH,
    placeholder_svg: ORNAMENT_PLACEHOLDER_SVG,
    allow_generation: allow_generation,
    fallback_slug: FALLBACK_THEME_IMAGE_SLUG
  )
end

.resolve_theme_image_path(raw, variant:, default_path:, placeholder_svg:, allow_generation: false, slug_transform: nil, fallback_slug: nil) ⇒ Object

テーマ画像パスの解決



170
171
172
173
174
175
176
177
178
179
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
215
216
217
218
219
220
221
222
223
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 170

def resolve_theme_image_path(raw, variant:, default_path:, placeholder_svg:, allow_generation: false,
                             slug_transform: nil, fallback_slug: nil)
  return default_path if raw.nil? || raw.to_s.strip.empty?

  value = raw.to_s.strip
  return value if value =~ /^url\(/i || value =~ %r{^https?://}i

  slug_value = slug_transform ? slug_transform.call(value) : value
  slug = normalize_theme_image_slug(slug_value)
  base_slug, requested_variant, ext = split_slug_and_variant(slug)

  if (requested_variant == variant) && (direct = find_existing_theme_image(slug,
                                                                           location_order: %i[user bundled]))
    return theme_relative_path(direct)
  end

  if (variant_specific = find_existing_theme_variant(base_slug, variant))
    return theme_relative_path(variant_specific)
  end

  base_query = ext.empty? ? base_slug : "#{base_slug}#{ext}"

  if (user_source = find_existing_theme_image(base_query, location_order: [:user]))
    ratio = image_ratio(user_source)
    return theme_relative_path(user_source) if ratio && ratio_accepted_for_frontispiece?(ratio)

    if allow_generation
      require_relative 'image_generator'
      if (generated = ImageGenerator.ensure_variant_generated(user_source, variant))
        return theme_relative_path(generated)
      end
    end
  end

  if allow_generation && (bundled_source = find_existing_theme_image(base_query, location_order: [:bundled],
                                                                                 allowed_extensions: ['.webp']))
    require_relative 'image_generator'
    if (generated = ImageGenerator.ensure_variant_generated(bundled_source, variant))
      return theme_relative_path(generated)
    end
  end

  # 指定名が解決できない場合は既定画像(sakura 等)へフォールバックする。
  # フォールバック自身も無ければ(fallback_slug: nil の再帰)プレースホルダーを返す。
  if fallback_slug && normalize_theme_image_slug(fallback_slug) != base_slug
    fallback = resolve_theme_image_path(
      fallback_slug, variant: variant, default_path: default_path,
      placeholder_svg: placeholder_svg, allow_generation: allow_generation
    )
    return fallback unless fallback.start_with?('data:')
  end

  placeholder_uri(base_slug, placeholder_svg)
end

.split_slug_and_variant(slug) ⇒ Object

スラッグをベース名、バリアント、拡張子に分割



275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 275

def split_slug_and_variant(slug)
  ext = File.extname(slug)
  without_ext = ext.empty? ? slug : slug.sub(/\.[^.]+\z/, '')
  case without_ext.downcase
  when /_portrait\z/
    [without_ext.sub(/_portrait\z/i, ''), :portrait, ext]
  when /_landscape\z/
    [without_ext.sub(/_landscape\z/i, ''), :landscape, ext]
  else
    [without_ext, nil, ext]
  end
end

.svg_placeholder_uri(svg_template, filename) ⇒ Object

SVGプレースホルダーをdata URIに変換



384
385
386
387
388
389
390
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 384

def svg_placeholder_uri(svg_template, filename)
  replaced = svg_template.gsub('filename.webp', CGI.escapeHTML(filename))
  svg_to_data_uri(replaced)
rescue StandardError => e
  Common.log_warn("プレースホルダー生成に失敗しました: #{e.message}")
  'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E'
end

.svg_to_data_uri(svg_content) ⇒ Object

SVGをdata URIに変換



393
394
395
396
397
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 393

def svg_to_data_uri(svg_content)
  # シンプルにURL encoding
  encoded = URI.encode_www_form_component(svg_content)
  "data:image/svg+xml;charset=utf-8,#{encoded}"
end

.theme_image_available?(raw, variant:) ⇒ Boolean

指定されたテーマ画像名が実在する(またはバリアント生成の元になる画像が存在する)かを返す。 resolve_* が allow_generation: true でプレースホルダーではなく実画像を返せるかと一致する。 未指定・URL/url() 指定は検証対象外として true(既定画像・外部指定のため)。

Parameters:

  • raw (String, nil)

    book.yml の frontispiece/ornament 指定値

  • variant (:portrait, :landscape)

    判定するバリアント

Returns:

  • (Boolean)


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

def theme_image_available?(raw, variant:)
  return true if raw.nil? || raw.to_s.strip.empty?

  value = raw.to_s.strip
  return true if value =~ /^url\(/i || value =~ %r{^https?://}i

  slug = normalize_theme_image_slug(value)
  base_slug, requested_variant, ext = split_slug_and_variant(slug)
  base_query = ext.empty? ? base_slug : "#{base_slug}#{ext}"

  # 既に該当バリアントがある / バリアント名を直接指定している / 生成元の base 画像がある、のいずれか
  return true if find_existing_theme_variant(base_slug, variant)
  return true if requested_variant && find_existing_theme_image(slug)

  !find_existing_theme_image(base_query).nil?
end

.theme_images_cache_rootObject

生成バリアントのキャッシュルート(.cache/vs/theme-images/)。 theme_images_root と同様に ivar でメモ化し、テストが一時 dir へ差し替えられるようにする。



318
319
320
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 318

def theme_images_cache_root
  @theme_images_cache_root ||= Common.theme_images_cache_dir
end

.theme_images_rootObject

テーマ画像のルートディレクトリ(ソース置き場: stylesheets/images/)



312
313
314
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 312

def theme_images_root
  @theme_images_root ||= File.join(Common::STYLESHEETS_DIR, 'images')
end

.theme_relative_path(path) ⇒ Object

テーマ画像の CSS 用相対パスを取得(返却 2 形はファイル冒頭コメント参照)



323
324
325
326
327
328
# File 'lib/vivlio_starter/cli/pre_process/theme_image_resolver.rb', line 323

def theme_relative_path(path)
  cache_prefix = "#{theme_images_cache_root}/"
  return "theme-images/#{path.delete_prefix(cache_prefix)}" if path.start_with?(cache_prefix)

  path.sub(%r{\A#{Regexp.escape(theme_images_root)}/}, 'images/')
end