Module: Vivlio::Starter::CLI::PreProcessCommands::ImagePathNormalizer

Defined in:
lib/vivlio/starter/cli/pre_process/image_path_normalizer.rb

Overview

画像パス正規化・プレースホルダー生成モジュール

Constant Summary collapse

NO_IMAGE_PLACEHOLDER_SVG =
<<~SVG.freeze
  <svg width="600" height="400" viewBox="0 0 600 400" fill="none" xmlns="http://www.w3.org/2000/svg">
    <defs>
          <linearGradient id="vivlioTextGradient" x1="0%" y1="0%" x2="100%" y2="0%">
        <stop offset="0%" style="stop-color:#4a86e8;stop-opacity:1" />
        <stop offset="100%" style="stop-color:#1c4587;stop-opacity:1" />
      </linearGradient>
      <linearGradient id="starterTextGradient" x1="0%" y1="0%" x2="100%" y2="0%">
        <stop offset="0%" style="stop-color:#6aa84f;stop-opacity:1" />
        <stop offset="100%" style="stop-color:#38761d;stop-opacity:1" />
      </linearGradient>
      <linearGradient id="backgroundGradient" x1="0%" y1="0%" x2="0%" y2="100%">
        <stop offset="0%" style="stop-color:#E0F2F7;stop-opacity:1" />
        <stop offset="100%" style="stop-color:#E8F5E9;stop-opacity:1" />
      </linearGradient>
    </defs>

    <rect x="0" y="0" width="600" height="400" fill="url(#backgroundGradient)" />

    <text#{' '}
      x="300"#{' '}
      y="140"#{' '}
      font-family="Arial, sans-serif"#{' '}
      font-size="72"#{' '}
      font-weight="bold"
      text-anchor="middle"
      dominant-baseline="middle"
    >
      <tspan fill="url(#vivlioTextGradient)">filename.webp</tspan>
    </text>

    <text#{' '}
      x="300"#{' '}
      y="260"#{' '}
      font-family="Arial, sans-serif"#{' '}
      font-size="72"#{' '}
      font-weight="bold"
      text-anchor="middle"
      dominant-baseline="middle"
    >
      <tspan fill="url(#starterTextGradient)">No Image</tspan>
    </text>
  </svg>
SVG

Class Method Summary collapse

Class Method Details

.build_source_image_line_map(source_path) ⇒ Object

元ファイルから画像名 → 行番号のマップを構築する。pre_process で行数が変わる前の正しい行番号を取得するため。



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

def build_source_image_line_map(source_path)
  return {} unless source_path && File.exist?(source_path)

  map = {}
  in_code_block = false
  File.readlines(source_path, encoding: 'utf-8').each_with_index do |line, idx|
    stripped = line.lstrip
    if stripped.start_with?('```')
      in_code_block = !in_code_block
      next
    end
    next if in_code_block

    line.scan(/!\[[^\]]*\]\(([^)]+)\)/) do
      image_name = File.basename(::Regexp.last_match(1))
      map[image_name] ||= idx + 1
    end
  end
  map
end

.fix_image_paths(content, filename, source_path: nil) ⇒ Object

Markdown 内の画像リンクを生成規約に合わせて正規化するコードブロック・インラインコード内は MarkdownUtils.extract_code_spans で退避し、変換対象から除外する。

Parameters:

  • source_path (String, nil) (defaults to: nil)

    元ファイルのパス(行番号補正用)



80
81
82
83
84
85
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
# File 'lib/vivlio/starter/cli/pre_process/image_path_normalizer.rb', line 80

def fix_image_paths(content, filename, source_path: nil)
  chapter_dir = filename.sub(/\.md$/, '')

  # 元ファイルの行番号マップを構築(画像名 → 元ファイルでの行番号)
  source_line_map = build_source_image_line_map(source_path)

  # コードブロック・インラインコードを退避して変換対象から除外する
  protected_text, spans = MarkdownUtils.extract_code_spans(content)

  # 行番号を保持しながら画像記法を正規化する
  # プレースホルダーは改行を含まないため、行番号は元のコンテンツと一致する
  lines = protected_text.lines
  transformed_lines = lines.each_with_index.map do |line, idx|
    line_number = idx + 1

    line.gsub(%r{!\[(.*?)\]\((?!https?://)([^)]+)\)}) do
      alt_text = ::Regexp.last_match(1)
      image_path = ::Regexp.last_match(2)

      # すでに images/ から始まる場合はそのまま。相対パスは images/<章ディレクトリ>/ に正規化
      normalized = if image_path.start_with?('images/')
                     image_path
                   else
                     "images/#{chapter_dir}/#{image_path}"
                   end

      # 生成物ポリシーに合わせて拡張子を .webp に寄せる(png/jpg のみ対象)
      normalized = normalized.sub(/\.(png|jpe?g)\z/i, '.webp')

      original_image_name = File.basename(image_path)
      # 元ファイルの行番号があればそちらを使う
      source_ln = source_line_map[original_image_name] || line_number
      resolved_placeholder_or_path(alt_text, normalized, filename, source_ln, original_image_name)
    end
  end

  # 退避したコードブロック・インラインコードを復元する
  MarkdownUtils.restore_code_spans(transformed_lines.join, spans)
end

.image_exists_for?(normalized_path) ⇒ Boolean

画像ディレクトリ内の拡張子違いを含めて存在を確認する

Returns:

  • (Boolean)


168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/vivlio/starter/cli/pre_process/image_path_normalizer.rb', line 168

def image_exists_for?(normalized_path)
  relative_path = normalized_path.sub(%r{\Aimages/}, '')
  base_path = File.expand_path(relative_path, Common::IMAGES_DIR)

  # SVGの場合は直接チェック
  return File.exist?(base_path) if base_path.end_with?('.svg')

  # その他の画像形式は拡張子違いをチェック
  base_without_ext = base_path.sub(/\.webp\z/i, '')
  %w[.webp .png .jpg .jpeg].any? do |ext|
    File.exist?("#{base_without_ext}#{ext}")
  end
end

.placeholder_image_path(missing_image_path = nil) ⇒ Object

プレースホルダーSVGを使用してデータURIを生成する



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/vivlio/starter/cli/pre_process/image_path_normalizer.rb', line 183

def placeholder_image_path(missing_image_path = nil)
  unless missing_image_path
    return 'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E'
  end

  begin
    filename = File.basename(missing_image_path)
    replacement = sanitize_placeholder_text(filename)
    svg_with_filename = NO_IMAGE_PLACEHOLDER_SVG.gsub('filename.webp', replacement)
    svg_to_data_uri(svg_with_filename)
  rescue StandardError => e
    Common.log_warn("プレースホルダー画像の生成に失敗しました: #{e.class}: #{e.message}")
    'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E'
  end
end

.resolved_placeholder_or_path(alt_text, normalized_path, source_filename = nil, line_number = nil, original_image_name = nil) ⇒ Object

既存画像なら元のパスを、無い場合はプレースホルダーを返す



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/image_path_normalizer.rb', line 121

def resolved_placeholder_or_path(alt_text, normalized_path, source_filename = nil, line_number = nil,
                                 original_image_name = nil)
  return "![#{alt_text}](#{normalized_path})" if image_exists_for?(normalized_path)

  image_name = original_image_name || File.basename(normalized_path)

  if source_filename && line_number
    Common.log_error(
      "#{source_filename}:#{line_number} - 画像 '#{image_name}' が見つかりません(代替画像を使用します)",
      detail: "画像の場所: #{normalized_path}"
    )
  else
    # 位置情報が取れない場合のフォールバック
    Common.log_error(
      "画像 '#{image_name}' が見つかりません(代替画像を使用します)",
      detail: "画像の場所: #{normalized_path}"
    )
  end

  placeholder_path = placeholder_image_path(normalized_path)
  "![#{alt_text}](#{placeholder_path})"
end

.sanitize_placeholder_text(filename) ⇒ Object

プレースホルダーに差し込むファイル名をサニタイズする



200
201
202
203
204
# File 'lib/vivlio/starter/cli/pre_process/image_path_normalizer.rb', line 200

def sanitize_placeholder_text(filename)
  text = filename.to_s.strip
  text = 'missing image' if text.empty?
  CGI.escapeHTML(text)
end

.svg_to_data_uri(svg_content) ⇒ Object

SVGコンテンツをURLエンコードした data URI に変換する



207
208
209
210
211
# File 'lib/vivlio/starter/cli/pre_process/image_path_normalizer.rb', line 207

def svg_to_data_uri(svg_content)
  escaped = CGI.escape(svg_content.encode('utf-8'))
  escaped = escaped.gsub('+', '%20')
  "data:image/svg+xml;charset=utf-8,#{escaped}"
end