Module: VivlioStarter::CLI::Build::HeadingImageComposer

Defined in:
lib/vivlio_starter/cli/build/heading_image_composer.rb

Overview

扉絵・節絵の合成 SVG を生成するモジュール

Constant Summary collapse

EMBED_MAX_EDGE =

EPUB 用に埋め込む raster の最大長辺(印刷解像度は不要なので縮小して軽量化)。

1400
RENDER_WIDTH =

ラスタライズ後の出力幅(px)。viewBox 比からの縦は rsvg が自動算出する。 扉絵はリード文字を焼き込むため 1400 に引き上げる(Kindle 端末幅 1072px 以上での可読性)。

{ frontispiece: 1400, ornament: 1400 }.freeze
LAYOUT_VERSION =

合成レイアウトの版数。座標・方式を変えたら +1 する(EpubBuilder が生成キャッシュのキーに混ぜる)。 v2: 上下分割(62% 帯+裾飾り)を廃止し、リード文まで焼き込む縦長ファクシミリ 1 枚へ。 v3: 寸法を book.yml の文字数指定(heading_chars / lead_chars / ornament.heading_chars)から 導き、節絵を左右並びのコンパクト帯へ(heading-metrics-spec §5-2・§5-3)。 v4: リード列の左寄せ(右下飾り回避)を廃し、左右中央へ。

4
DEFAULT_METRICS =

文字数指定の既定。theme.css の既定と BookSettingsCss::DEFAULT_*_CHARS に一致させる (EpubBuilder が book.yml の値を渡すので、ここが使われるのは直接呼び出し時だけ)。

{ heading_chars: 8, lead_chars: 20, ornament_chars: 14,
heading_offset_ratio: 0.0 }.freeze
LEAD_FONT_FLOOR =

リード焼き込みの規格(画像 width / height 比・モック実証値)。 基準フォントは lead_chars から導くため LEAD_FONT_RATIO は廃止した。 下限は「合成画像を端末幅で見たときに読める大きさ」で決まる。字数指定が多いと 字面がいくらでも小さくなるため、ここで止める(epub_h1.png 実測で 0.018 では 読めなかった。Kindle 端末幅 1072px 換算でおよそ 8pt 相当を確保する)。

0.026
LEAD_LINE_HEIGHT =

縮小の下限(画像幅比)

1.70
LEAD_BOTTOM_RATIO =

行送り(フォントサイズ比)

0.88
FRONTISPIECE_TOP_RATIO =

章番号のベースライン(height 比)。ここから章番号 → 章題 → リードを下へ積む。 PDF の章扉と同じくページ上部から始める。中ほどから始めると、リードが長い章で 下の飾りへ食い込む余地が無くなる(epub_h1_chapter6.png 実測)。

0.17
FRONTISPIECE_TITLE_WIDTH =

扉絵タイトルが使ってよい幅(画像幅比)。この幅を heading_chars で割って字面を決める。

0.80
FRONTISPIECE_TITLE_SIZE_RANGE =

字面サイズの安全域(画像幅比)。極端な字数指定でも番号・リードとの階層を壊さない。

(0.045..0.115)
ORNAMENT_MAX_LINES =

節絵タイトルの最大行数。これを超える長さのときだけフォントを縮小する。

2
ORNAMENT_BAND_ASPECT =

--- コンパクト帯の規格(PDF の image-header.css と同じ比率・§5-3)--- 帯の縦横比。h2 の aspect-ratio: 480 / 100 に対応する。

4.8
ORNAMENT_LAYER_IMAGE_WIDTH =

各層に敷く飾り画像の幅(帯幅比)。CSS の background-size: 150% × 半幅 と等価。

0.75
ORNAMENT_PAD_START =

左右の飾り避け(帯幅比)。CSS の padding-inline 16mm / 18mm ÷ A4 版面 162mm。

16.0 / 162.0
ORNAMENT_PAD_END =
18.0 / 162.0
ORNAMENT_TEXT_WIDTH =

節題が使ってよい幅(帯幅比)。この幅を字数で割って字面を決める。

1.0 - ORNAMENT_PAD_START - ORNAMENT_PAD_END
ORNAMENT_FONT_FLOOR =

字面の下限(帯幅比)。長い節題でも本文と見分けが付く大きさを保つ。

0.030
WORD_BREAK_MIN_FILL =

折返し位置の追い込み。表示幅だけで切ると読みにくい位置で割れるため、2 点だけ直す。

  1. 語の境界(空白)が行の後半にあるならそこで折る 「Markdown 執筆チュ/ートリアル」→「Markdown/執筆チュートリアル」
  2. 残りが 1 文字だけになるなら 1 文字手前で折る(泣き別れ回避。PDF 側の WORD JOINER と同じ意図で、こちらは Ruby が行分割を持つため直接調整する) 語の境界で折るかを決める閾値(行の使用率)。低すぎると 1 語だけの短い行が増え、 高すぎると「Markdown 執筆チュー/トリアル」のような語中折れが残る(epub_h1.png 実測)。

Returns:

  • (Array(String, String))

    [確定した行, 残り]

0.35
NO_LINE_START =

行頭に来てはいけない文字(行頭禁則)。小書き仮名・長音・約物・閉じ括弧。

/\A[ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶーゝゞヽヾ々‐–—、。,.・:;?!゛゜)]}」』〉》】〕〙〗”’]/
NO_LINE_END =

行末に来てはいけない文字(行末禁則)。開き括弧。

/[([{「『〈《【〔〘〖“‘]\z/

Class Method Summary collapse

Class Method Details

.build_raster_data_uri(path) ⇒ Object



569
570
571
572
573
574
575
576
577
578
579
580
581
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 569

def build_raster_data_uri(path)
  jpg, status = Open3.capture2(
    'magick', path, '-background', 'white', '-flatten',
    '-resize', "#{EMBED_MAX_EDGE}x#{EMBED_MAX_EDGE}>", '-quality', '80', 'jpg:-',
    binmode: true
  )
  return nil unless status.success? && !jpg.empty?

  # base64 は Ruby 3.4+ で default gem 外のため Array#pack('m0')(改行なし base64)で代替
  "data:image/jpeg;base64,#{[jpg].pack('m0')}"
rescue StandardError
  nil
end

.char_display_width(char) ⇒ Object

1 文字の表示幅。ASCII(半角)は約 0.55 全角相当として概算する。



437
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 437

def char_display_width(char) = char.ascii_only? ? 0.55 : 1.0

.compose(image_path:, number:, title:, kind:, font_family:, lead: '', lead_font_family: nil, lead_ratio: 0.60, number_color: '#333333', metrics: DEFAULT_METRICS) ⇒ String?

飾り画像+見出しを焼き込んだ合成 SVG(中間表現)を生成する。

Parameters:

  • image_path (String)

    飾り画像の実ファイルパス(portrait/landscape webp 等)

  • number (String)

    見出し番号("第1章" / "1-1" 等。空可)

  • title (String)

    見出しタイトル

  • kind (Symbol)

    :frontispiece(扉絵・縦。リード込み)/ :ornament(節絵・横)

  • font_family (String)

    用フォントスタック(単一引用符で囲んだ名前の羅列)

  • lead (String) (defaults to: '')

    章リード文(:frontispiece のみ。段落は "\n" 区切り。空可)

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

    リード用フォント(nil なら font_family で代用)

  • lead_ratio (Float) (defaults to: 0.60)

    リード焼き込み幅の画像幅比(判型 lead_width÷page.width 由来)

  • number_color (String) (defaults to: '#333333')

    節絵の番号色(CSS 色。既定はダーク)

  • metrics (Hash) (defaults to: DEFAULT_METRICS)

    book.yml の文字数指定(heading_chars / lead_chars / ornament_chars)。 PDF 側(BookSettingsCss)と同じ値を渡すことで、同じ原稿の扉・節絵が両ターゲットで 同じ字数に組まれる(heading-metrics-spec §5-2)。

Returns:

  • (String, nil)

    SVG 文字列。画像が読めない/寸法不明時は nil(→ simple 縮退)



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 125

def compose(image_path:, number:, title:, kind:, font_family:,
            lead: '', lead_font_family: nil, lead_ratio: 0.60, number_color: '#333333',
            metrics: DEFAULT_METRICS)
  return nil unless image_path && File.exist?(image_path)

  dims = image_dimensions(image_path)
  data_uri = raster_data_uri(image_path)
  return nil unless dims && data_uri

  m = DEFAULT_METRICS.merge(metrics || {})
  width, height = dims
  case kind
  when :frontispiece
    frontispiece_svg(width, height, data_uri, number.to_s.strip, title.to_s.strip,
                     lead.to_s, font_family, lead_font_family, lead_ratio, m)
  when :ornament
    ornament_svg(width, height, data_uri, number.to_s.strip, title.to_s.strip,
                 font_family, number_color, m[:ornament_chars])
  end
end

.display_width(str) ⇒ Object

文字列の表示幅(全角=1.0・半角=0.55 の概算)。



434
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 434

def display_width(str) = str.each_char.sum { char_display_width(it) }

.escape_attr(str) ⇒ Object



584
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 584

def escape_attr(str) = escape_text(str).gsub('"', '"')

.escape_text(str) ⇒ Object



583
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 583

def escape_text(str) = str.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')

.frontispiece_lead(lead, width, height, font_family, lead_ratio, lead_chars, lead_top = nil) ⇒ Object

リード段落の焼き込み。段落は "\n" 区切りで受け、各段落の先頭を全角 1 字下げする。 基準フォントで LEAD_BOTTOM_RATIO に収まらない長文だけ 8% ずつ縮小する(下限 LEAD_FONT_FLOOR)。



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 199

def frontispiece_lead(lead, width, height, font_family, lead_ratio, lead_chars, lead_top = nil)
  first_y   = (lead_top || (height * 0.62)).round
  font_size, lines = lead_layout(width, height, lead, lead_ratio, lead_chars, first_y)
  halo      = [(font_size * 0.14).round, 1].max
  step      = (font_size * LEAD_LINE_HEIGHT).round
  # リード列は左右中央に置く。以前は右下の飾りを避けて左寄せ(余白の 45%)にして
  # いたが、扉を上部起点へ改めた(FRONTISPIECE_TOP_RATIO)ことでリードが飾りの高さ
  # まで下りてこなくなり、避ける必要が無くなった。左寄せのままだと右に 3 字ぶんの
  # 余白が残り、PDF の章扉(margin-inline: auto で中央)と揃わない(epub_h1_justify.png)。
  left_x    = (width * (1.0 - lead_ratio) * 0.5).round

  tspans = lines.each_with_index.map do |line, i|
    %(<tspan x="#{left_x}" y="#{first_y + (i * step)}">#{escape_text(line)}</tspan>)
  end.join
  %(<text text-anchor="start" font-family="#{font_family}" font-size="#{font_size}" ) +
    %(font-weight="400" fill="#1a1a1a" paint-order="stroke" stroke="#ffffff" ) +
    %(stroke-width="#{halo}" stroke-linejoin="round">#{tspans}</text>)
end

.frontispiece_number(number, width, number_y, underline_y, size, font_family) ⇒ Object

扉絵の番号(中央寄せ+下線)。



447
448
449
450
451
452
453
454
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 447

def frontispiece_number(number, width, number_y, underline_y, size, font_family)
  cx = (width / 2.0).round
  line_w = (width * 0.30).round
  %(<text x="#{cx}" y="#{number_y}" text-anchor="middle" font-family="#{font_family}" ) +
    %(font-size="#{size}" font-weight="600" letter-spacing="#{(size * 0.2).round}" fill="#333333">#{escape_text(number)}</text>) +
    %(<line x1="#{cx - line_w}" y1="#{underline_y}" x2="#{cx + line_w}" y2="#{underline_y}" ) +
    %(stroke="#000000" stroke-opacity="0.35" stroke-width="#{[(size * 0.05).round, 1].max}" stroke-linecap="round"/>)
end

.frontispiece_svg(width, height, data_uri, number, title, lead, font_family, lead_font_family, lead_ratio, metrics) ⇒ Object

扉絵(portrait)全面の合成 SVG。PDF の縦長章扉(左上飾り→番号→タイトル→リード→ 右下飾り)を 1 枚に焼き込む。番号を上部・タイトルを中央・リードをその下へ縦に重ね、 原画の下側(右下飾り)まで含めて全高で出す。リフローでも千切れない完全な章扉ページを 実現するため、旧実装の上下分割(62% 帯+裾飾り注入)は廃止した(facsimile 仕様)。



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 150

def frontispiece_svg(width, height, data_uri, number, title, lead, font_family, lead_font_family,
                     lead_ratio, metrics)
  number_size = (width * 0.052).round
  # 字面は「タイトル領域の幅 ÷ heading_chars」で決める。以前は 0.072 固定
  # (= 1 行 11 字)というマジックナンバーで、PDF の箱幅とは無関係だった。
  title_size  = frontispiece_title_size(width, metrics[:heading_chars])
  halo        = [(title_size * 0.14).round, 1].max

  # --- Phase: タイトルを段組み(表示幅ベース・半角 0.55 換算) ---
  lines      = wrap_text_by_width(title, width * FRONTISPIECE_TITLE_WIDTH / title_size)
  line_step  = (title_size * 1.4).round

  # --- Phase: 縦位置 ---
  # 章番号と章題は同じ見出しなので近づけ、章題とリードの間を空ける(近接の原則)。
  # 全体の下げ量は book.yml の theme.frontispiece.heading_offset(判型比)で追い込める。
  # 章番号 → 章題 → リードを**上から順に積む**(PDF の章扉と同じ流れ)。
  # 以前は章題を height の一定比に centering していたため、長い章題(4 行)が
  # 上へ伸びて章番号と重なっていた(epub_h1_kasanari.png 実測)。
  # 積み上げ方式なら行数がいくつでも重ならず、下へ伸びるだけになる。
  offset      = height * metrics[:heading_offset_ratio].to_f
  number_y    = (height * FRONTISPIECE_TOP_RATIO + offset).round
  underline_y = number_y + (number_size * 0.45).round
  # 章番号と章題は同じ見出しの一部なので近づける(近接の原則)
  first_y     = (underline_y + (title_size * 1.05)).round
  title_last_y = first_y + ((lines.size - 1) * line_step)
  # 章題とリードの間は空ける
  lead_top     = title_last_y + (title_size * 1.30)

  parts = [image_element(width, height, data_uri)]
  parts << frontispiece_number(number, width, number_y, underline_y, number_size, font_family) unless number.empty?
  parts << frontispiece_title(lines, width, first_y, line_step, title_size, halo, font_family) unless lines.empty?
  unless lead.empty?
    parts << frontispiece_lead(lead, width, height, lead_font_family || font_family,
                               lead_ratio, metrics[:lead_chars], lead_top)
  end

  svg_wrapper(width, height, [number, title, lead], parts)
end

.frontispiece_title(lines, width, first_y, line_step, size, halo, font_family) ⇒ Object

扉絵のタイトル(複数行・中央寄せ・白ハロー付き)。



457
458
459
460
461
462
463
464
465
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 457

def frontispiece_title(lines, width, first_y, line_step, size, halo, font_family)
  cx = (width / 2.0).round
  tspans = lines.each_with_index.map do |line, i|
    y = first_y + i * line_step
    %(<tspan x="#{cx}" y="#{y}">#{escape_text(line)}</tspan>)
  end.join
  %(<text text-anchor="middle" font-family="#{font_family}" font-size="#{size}" font-weight="800" ) +
    %(fill="#111111" paint-order="stroke" stroke="#ffffff" stroke-width="#{halo}" stroke-linejoin="round">#{tspans}</text>)
end

.frontispiece_title_size(width, chars) ⇒ Object

扉絵タイトルの字面(px)。「タイトル領域の幅 ÷ 字数」を安全域に収める。



190
191
192
193
194
195
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 190

def frontispiece_title_size(width, chars)
  chars = chars.to_i
  chars = DEFAULT_METRICS[:heading_chars] unless chars.positive?
  raw = (width * FRONTISPIECE_TITLE_WIDTH) / chars
  raw.clamp(width * FRONTISPIECE_TITLE_SIZE_RANGE.begin, width * FRONTISPIECE_TITLE_SIZE_RANGE.end).round
end

.image_dimensions(path) ⇒ Object

飾り画像の寸法を取得する(magick identify)。失敗時は nil。



546
547
548
549
550
551
552
553
554
555
556
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 546

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

  tokens = out.split
  w = tokens[0].to_i
  h = tokens[1].to_i
  (w.positive? && h.positive?) ? [w, h] : nil
rescue StandardError
  nil
end

.image_element(width, height, data_uri) ⇒ Object

飾り画像を全面に敷く 要素。 旧リーダー互換のため xlink:href を用いる(href 単独だと描画しない端末がある)。



441
442
443
444
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 441

def image_element(width, height, data_uri)
  %(<image xlink:href="#{data_uri}" x="0" y="0" width="#{width}" height="#{height}" ) +
    %(preserveAspectRatio="xMidYMid slice"/>)
end

.lead_layout(width, height, lead, lead_ratio, lead_chars, top_y = nil) ⇒ Array(Integer, Array<String>)

リードのフォントサイズと行分割。折返しは既存 wrap_text_by_width(半角 0.55 換算・ Latin 語は空白で折る——"--add-missing" 等の語中折れを防ぐ)を必ず使う。

基準の字面は「リード領域の幅 ÷ lead_chars」——リード幅比(lead_ratio)自体が lead_chars × 1 字の送り ÷ 判型幅なので、これで PDF と同じ字面比になる。 縦に収まらない長文だけ 8% ずつ縮める(幅は一定なので 1 行の字数が増えて行数が減る)。

Returns:

  • (Array(Integer, Array<String>))

    [フォントサイズ, 折り返し済み行の配列]



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 225

def lead_layout(width, height, lead, lead_ratio, lead_chars, top_y = nil)
  paragraphs = lead.split("\n")
  chars = lead_chars.to_i
  chars = DEFAULT_METRICS[:lead_chars] unless chars.positive?
  top       = top_y || (height * 0.62)
  floor     = (width * LEAD_FONT_FLOOR).round
  font_size = [((width * lead_ratio) / chars).round, floor].max
  loop do
    capacity = (width * lead_ratio) / font_size
    lines = paragraphs.flat_map { |p| wrap_text_by_width(" #{p}", capacity) }
    bottom = top + ((lines.size - 1) * font_size * LEAD_LINE_HEIGHT)
    return [font_size, lines] if bottom <= height * LEAD_BOTTOM_RATIO || font_size <= floor

    font_size = [(font_size * 0.92).round, floor].max
  end
end

.ornament_layer(data_uri, x, y, w, h, clip_id) ⇒ Object

飾り 1 層。旧リーダー互換のため xlink:href を用いる(image_element と同じ理由)。 width/height は元画像の縦横比どおりに与えるので meet でも歪まない。



322
323
324
325
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 322

def ornament_layer(data_uri, x, y, w, h, clip_id)
  %(<image xlink:href="#{data_uri}" x="#{x}" y="#{y}" width="#{w}" height="#{h}" ) +
    %(clip-path="url(##{clip_id})" preserveAspectRatio="xMidYMid meet"/>)
end

.ornament_layers(width, band_h, source_height, data_uri) ⇒ Object

飾りを左右に並べる 2 層。左は元画像の左上(=左上の飾り)、右は右下(=右下の飾り)を 見せる。帯は飾り 1 つ分より少し高いので、左が上寄せ・右が下寄せになる差分が そのまま右飾りの下がり量になる(CSS の left top / right bottom と同じ)。



307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 307

def ornament_layers(width, band_h, source_height, data_uri)
  img_w = (width * ORNAMENT_LAYER_IMAGE_WIDTH).round
  img_h = (img_w * source_height.to_f / width).round
  half  = (width / 2.0).round

  [
    %(<clipPath id="vs-orn-l"><rect x="0" y="0" width="#{half}" height="#{band_h}"/></clipPath>),
    %(<clipPath id="vs-orn-r"><rect x="#{half}" y="0" width="#{width - half}" height="#{band_h}"/></clipPath>),
    ornament_layer(data_uri, 0, 0, img_w, img_h, 'vs-orn-l'),
    ornament_layer(data_uri, width - img_w, band_h - img_h, img_w, img_h, 'vs-orn-r')
  ]
end

.ornament_layout(width, number, title, chars) ⇒ Array(Integer, Array<Hash>)

節絵のフォントサイズと行分割を決める。 字面は「節題領域の幅 ÷ 字数」。ORNAMENT_MAX_LINES 行に収まらない長い節題だけ 8% ずつ縮める(幅は一定なので 1 行の字数が増えて行数が減る)。

Returns:

  • (Array(Integer, Array<Hash>))

    [フォントサイズ, text: の行配列]



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

def ornament_layout(width, number, title, chars)
  count = chars.to_i
  count = DEFAULT_METRICS[:ornament_chars] unless count.positive?
  avail = width * ORNAMENT_TEXT_WIDTH
  floor = (width * ORNAMENT_FONT_FLOOR).round
  font_size = [(avail / count).round, floor].max
  loop do
    capacity = avail / font_size
    lines = wrap_ornament_lines(number, title, capacity)
    return [font_size, lines] if lines.size <= ORNAMENT_MAX_LINES || font_size <= floor

    font_size = [(font_size * 0.92).round, floor].max
  end
end

.ornament_number_indent(lines, font_size) ⇒ Object

2 行目以降の字下げ量(px)。1 行目の節番号+区切りアキと同じ幅。 節番号は font-weight 900 の 1.0em 相当で組むため、表示幅換算で概算する。



297
298
299
300
301
302
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 297

def ornament_number_indent(lines, font_size)
  number = lines.first&.dig(:number).to_s
  return 0 if number.empty?

  ((display_width(number) + 0.5) * font_size).round
end

.ornament_svg(width, source_height, data_uri, number, title, font_family, number_color, chars) ⇒ Object

節絵の合成 SVG。飾りを左右に並べたコンパクト帯(PDF と同じ 4.8:1)へ、 番号+節題を左寄せで重ねる。字面は ornament.heading_chars から導く。 元アセットは左上と右下に飾りを持つ 2.39:1 の 1 枚なので、clipPath で 左半分/右半分を切り出して同じ行に置き直す(CSS の 2 層スプライトの SVG 版)。



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 262

def ornament_svg(width, source_height, data_uri, number, title, font_family, number_color, chars)
  band_h = (width / ORNAMENT_BAND_ASPECT).round
  font_size, lines = ornament_layout(width, number, title, chars)
  halo       = [(font_size * 0.14).round, 1].max
  line_step  = (font_size * 1.35).round
  # 単行時は旧来のベースライン(中央+0.34em)。複数行は行ブロックごと中央へ寄せる。
  first_base = (band_h * 0.50 + font_size * 0.34 - (line_step * (lines.size - 1) / 2.0)).round
  text_x     = (width * ORNAMENT_PAD_START).round

  # 2 行目以降は節番号のぶんだけ字下げして、番号の右で節題が上下に揃うようにする
  # (PDF の flex レイアウトと同じ見え方。揃えないと 2 行目が番号の下に潜り込む)。
  number_indent = ornament_number_indent(lines, font_size)

  texts = lines.each_with_index.map do |line, i|
    tspans = +''
    if line[:number] && !line[:number].empty?
      tspans << %(<tspan fill="#{escape_attr(number_color)}" font-weight="900">#{escape_text(line[:number])}</tspan>)
    end
    unless line[:text].empty?
      dx = tspans.empty? ? '' : %( dx="#{(font_size * 0.5).round}")
      tspans << %(<tspan#{dx}>#{escape_text(line[:text])}</tspan>)
    end
    x = i.zero? ? text_x : text_x + number_indent
    %(<text x="#{x}" y="#{first_base + (line_step * i)}" text-anchor="start" ) +
      %(font-family="#{font_family}" font-size="#{font_size}" font-weight="800" fill="#1a1a1a" ) +
      %(paint-order="stroke" stroke="#ffffff" stroke-width="#{halo}" stroke-linejoin="round">#{tspans}</text>)
  end

  segments = [number, title].reject(&:empty?)
  layers = ornament_layers(width, band_h, source_height, data_uri)
  svg_wrapper(width, band_h, segments, [*layers, *texts])
end

.pack_words(words, capacity) ⇒ Object

語を順に詰め、入らなければ改行する。1 語で幅を超える語だけ表示幅で割る。



502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 502

def pack_words(words, capacity)
  lines = []
  current = +''
  words.each do |word|
    if current.empty? && display_width(word) > capacity
      wrap_by_display_width(word, capacity).each { lines << it }
      current = lines.pop.to_s
    elsif display_width(current + word) > capacity && !current.empty?
      lines << current.rstrip
      current = +word.dup
    else
      current << word
    end
  end
  lines << current.rstrip unless current.strip.empty?
  lines
end

.raster_data_uri(path) ⇒ Object

飾り画像を縮小 JPEG へ変換し base64 data URI を返す(埋め込み用)。失敗時は nil。 webp 等の互換性懸念を避けるため JPEG に揃え、EMBED_MAX_EDGE まで縮小して軽量化する。 透過部分は白でフラット化する(JPEG は透過非対応。黒潰れを防ぎ、リーダーの白ページや PDF の白ページ表示と馴染ませる)。同一画像(章で共通)は使い回すためパスでメモ化する。



562
563
564
565
566
567
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 562

def raster_data_uri(path)
  @data_uri_cache ||= {}
  return @data_uri_cache[path] if @data_uri_cache.key?(path)

  @data_uri_cache[path] = build_raster_data_uri(path)
end

.rasterize_to_jpeg(svg, width) ⇒ Object

合成 SVG をフラット JPEG(バイト列)へラスタライズする。 rsvg-convert で PNG 化 → magick で白フラット JPEG 化。ツール不在・失敗時は nil。



522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 522

def rasterize_to_jpeg(svg, width)
  return nil unless rsvg_available?

  png, s1 = Open3.capture2('rsvg-convert', '-w', width.to_s, '-f', 'png',
                           stdin_data: svg, binmode: true)
  return nil unless s1.success? && !png.empty?

  jpg, s2 = Open3.capture2('magick', 'png:-', '-background', 'white', '-flatten',
                           '-quality', '85', 'jpg:-', stdin_data: png, binmode: true)
  return nil unless s2.success? && !jpg.empty?

  jpg
rescue StandardError
  nil
end

.refine_break(head, rest, avail) ⇒ Object



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 399

def refine_break(head, rest, avail)
  if (sp = head.rstrip.rindex(' ')) && display_width(head[0...sp]) >= avail * WORD_BREAK_MIN_FILL
    rest = head[(sp + 1)..].to_s + rest
    head = head[0...sp]
  elsif rest.strip.length == 1 && head.strip.length >= 2
    # 末尾 1 文字の泣き別れ回避(PDF 側の .vs-nobr と同じ意図)
    rest = head[-1] + rest
    head = head[0..-2]
  end

  # 禁則。1 文字ずつ次行へ送る(送りすぎないよう 2 回まで)。
  2.times do
    break unless head.strip.length >= 2 && (rest.match?(NO_LINE_START) || head.match?(NO_LINE_END))

    rest = head[-1] + rest
    head = head[0..-2]
  end

  # 半角語(英数字の連なり)の途中では折らない。空白を持たない語(ID・API 名など)は
  # 語の先頭まで戻す(「手動I/D・」のような分断を防ぐ。戻しすぎないよう 8 文字まで)。
  # 禁則の後に置くのは、禁則の送りが語を割ることがあるため(「・」は行頭禁則なので
  # 1 文字送られ、その 1 文字が ID の D だった、という並びが実際に起きる)。
  if head[-1]&.match?(/[0-9A-Za-z]/) && rest[0]&.match?(/[0-9A-Za-z]/)
    8.times do
      break unless head.strip.length >= 2 && head[-1]&.match?(/[0-9A-Za-z]/)

      rest = head[-1] + rest
      head = head[0..-2]
    end
  end

  [head.rstrip, rest.lstrip]
end

.render(image_path:, number:, title:, kind:, font_family:, lead: '', lead_font_family: nil, lead_ratio: 0.60, number_color: '#333333', metrics: DEFAULT_METRICS) ⇒ String?

飾り画像+見出しを焼き込んだ JPEG 画像(バイト列) を生成する。 合成 SVG を組み、rsvg-convert + magick でフラット JPEG にラスタライズする。 Kindle は SVG 内 base64 を非対応のため、配る実体はラスター画像にする。

Parameters:

  • image_path (String)

    飾り画像の実ファイルパス(portrait/landscape webp 等)

  • number (String)

    見出し番号("第1章" / "1-1" 等。空可)

  • title (String)

    見出しタイトル

  • kind (Symbol)

    :frontispiece(扉絵・縦。リード込み)/ :ornament(節絵・横)

  • font_family (String)

    用フォントスタック(単一引用符で囲んだ名前の羅列)

  • lead (String) (defaults to: '')

    章リード文(:frontispiece のみ。段落は "\n" 区切り。空可)

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

    リード用フォント(nil なら font_family で代用)

  • lead_ratio (Float) (defaults to: 0.60)

    リード焼き込み幅の画像幅比(判型 lead_width÷page.width 由来)

  • number_color (String) (defaults to: '#333333')

    節絵の番号色(CSS 色。既定はダーク)

  • metrics (Hash) (defaults to: DEFAULT_METRICS)

    book.yml の文字数指定(heading_chars / lead_chars / ornament_chars)。 PDF 側(BookSettingsCss)と同じ値を渡すことで、同じ原稿の扉・節絵が両ターゲットで 同じ字数に組まれる(heading-metrics-spec §5-2)。

Returns:

  • (String, nil)

    JPEG バイト列。画像不読・ツール不在・失敗時は nil(→ simple 縮退)



100
101
102
103
104
105
106
107
108
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 100

def render(image_path:, number:, title:, kind:, font_family:,
           lead: '', lead_font_family: nil, lead_ratio: 0.60, number_color: '#333333',
           metrics: DEFAULT_METRICS)
  svg = compose(image_path:, number:, title:, kind:, font_family:,
                lead:, lead_font_family:, lead_ratio:, number_color:, metrics:)
  return nil unless svg

  rasterize_to_jpeg(svg, RENDER_WIDTH.fetch(kind, 1000))
end

.rsvg_available?Boolean

rsvg-convert(librsvg)が使えるか。

Returns:

  • (Boolean)


539
540
541
542
543
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 539

def rsvg_available?
  return @rsvg_available unless @rsvg_available.nil?

  @rsvg_available = system('rsvg-convert', '--version', out: File::NULL, err: File::NULL) || false
end

.split_by_display_width(text, avail) ⇒ Array(String, String)

表示幅(全角=1.0・半角=0.55)で先頭 chunk を切り出す。半角語の途中で切れる場合は、 直近の空白があればそこで折り返す(Latin 語の分断を避ける)。

Returns:

  • (Array(String, String))

    [切り出した行, 残り]



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 364

def split_by_display_width(text, avail)
  acc = +''
  used = 0.0
  text.each_char.with_index do |ch, i|
    w = char_display_width(ch)
    if used + w > avail && !acc.empty?
      rest = text[i..]
      # 半角語の途中なら、行内の最後の空白で折り返す
      if ch.match?(/[!-~]/) && acc[-1]&.match?(/[!-~]/) && (sp = acc.rindex(' '))
        rest = acc[(sp + 1)..] + rest
        acc = acc[0...sp]
      end
      return refine_break(acc, rest, avail)
    end
    acc << ch
    used += w
  end
  [acc.rstrip, '']
end

.svg_wrapper(width, height, label_segments, parts) ⇒ Object

SVG ルート要素で包む。aria-label に番号+タイトル(+リード)を入れて読み上げに資する。 width/height 属性(intrinsic size)を明示する——viewBox だけだと で参照した ときに一部リーダーが縦横比を確定できず、レイアウト箱と描画サイズがずれて 後続コンテンツへのはみ出し(epub_h2 実測)を誘発する。



471
472
473
474
475
476
477
478
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 471

def svg_wrapper(width, height, label_segments, parts)
  aria = escape_attr(label_segments.reject(&:empty?).join(' '))
  %(<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ) +
    %(width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}" ) +
    %(preserveAspectRatio="xMidYMid meet" role="img" aria-label="#{aria}">) +
    parts.join +
    '</svg>'
end

.wrap_by_display_width(text, capacity) ⇒ Object



491
492
493
494
495
496
497
498
499
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 491

def wrap_by_display_width(text, capacity)
  lines = []
  rest = text
  until rest.empty?
    head, rest = split_by_display_width(rest, capacity)
    lines << head
  end
  lines
end

.wrap_ornament_lines(number, title, capacity) ⇒ Object

番号+タイトルを表示幅ベースで行へ割り付ける。1 行目に番号(+区切り 0.5em)を置き、 タイトルは収まる位置で折り返す。半角文字は全角の約半分として数える(display_width)。



348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 348

def wrap_ornament_lines(number, title, capacity)
  first_avail = capacity - (number.empty? ? 0 : display_width(number) + 0.5)
  lines = []
  rest = title
  loop do
    avail = lines.empty? ? [first_avail, 1.0].max : capacity
    head, rest = split_by_display_width(rest, avail)
    lines << { number: lines.empty? ? number : nil, text: head }
    break if rest.empty?
  end
  lines
end

.wrap_text_by_width(text, capacity) ⇒ Object

テキストを表示幅(全角換算 capacity)で折り返す。半角は 0.55 換算で数え、 Latin 語の途中では直近の空白で折る(split_by_display_width と同じ規則)。 語の境界が取れるなら語単位で詰める(PDF 側の .vs-nobr と同じ規則)。 取れない環境では従来どおり表示幅で切り、禁則等は refine_break が追い込む。



484
485
486
487
488
489
# File 'lib/vivlio_starter/cli/build/heading_image_composer.rb', line 484

def wrap_text_by_width(text, capacity)
  return [] if text.empty?

  words = HeadingSegmenter.segment(text)
  words.size < 2 ? wrap_by_display_width(text, capacity) : pack_words(words, capacity)
end