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 点だけ直す。
- 語の境界(空白)が行の後半にあるならそこで折る 「Markdown 執筆チュ/ートリアル」→「Markdown/執筆チュートリアル」
- 残りが 1 文字だけになるなら 1 文字手前で折る(泣き別れ回避。PDF 側の WORD JOINER と同じ意図で、こちらは Ruby が行分割を持つため直接調整する) 語の境界で折るかを決める閾値(行の使用率)。低すぎると 1 語だけの短い行が増え、 高すぎると「Markdown 執筆チュー/トリアル」のような語中折れが残る(epub_h1.png 実測)。
0.35- NO_LINE_START =
行頭に来てはいけない文字(行頭禁則)。小書き仮名・長音・約物・閉じ括弧。
/\A[ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶーゝゞヽヾ々‐–—、。,.・:;?!゛゜)]}」』〉》】〕〙〗”’]/- NO_LINE_END =
行末に来てはいけない文字(行末禁則)。開き括弧。
/[([{「『〈《【〔〘〖“‘]\z/
Class Method Summary collapse
- .build_raster_data_uri(path) ⇒ Object
-
.char_display_width(char) ⇒ Object
1 文字の表示幅。ASCII(半角)は約 0.55 全角相当として概算する。.
-
.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(中間表現)を生成する。.
-
.display_width(str) ⇒ Object
文字列の表示幅(全角=1.0・半角=0.55 の概算)。.
- .escape_attr(str) ⇒ Object
- .escape_text(str) ⇒ Object
-
.frontispiece_lead(lead, width, height, font_family, lead_ratio, lead_chars, lead_top = nil) ⇒ Object
リード段落の焼き込み。段落は "\n" 区切りで受け、各段落の先頭を全角 1 字下げする。 基準フォントで LEAD_BOTTOM_RATIO に収まらない長文だけ 8% ずつ縮小する(下限 LEAD_FONT_FLOOR)。.
-
.frontispiece_number(number, width, number_y, underline_y, size, font_family) ⇒ Object
扉絵の番号(中央寄せ+下線)。.
-
.frontispiece_svg(width, height, data_uri, number, title, lead, font_family, lead_font_family, lead_ratio, metrics) ⇒ Object
扉絵(portrait)全面の合成 SVG。PDF の縦長章扉(左上飾り→番号→タイトル→リード→ 右下飾り)を 1 枚に焼き込む。番号を上部・タイトルを中央・リードをその下へ縦に重ね、 原画の下側(右下飾り)まで含めて全高で出す。リフローでも千切れない完全な章扉ページを 実現するため、旧実装の上下分割(62% 帯+裾飾り注入)は廃止した(facsimile 仕様)。.
-
.frontispiece_title(lines, width, first_y, line_step, size, halo, font_family) ⇒ Object
扉絵のタイトル(複数行・中央寄せ・白ハロー付き)。.
-
.frontispiece_title_size(width, chars) ⇒ Object
扉絵タイトルの字面(px)。「タイトル領域の幅 ÷ 字数」を安全域に収める。.
-
.image_dimensions(path) ⇒ Object
飾り画像の寸法を取得する(magick identify)。失敗時は nil。.
-
.image_element(width, height, data_uri) ⇒ Object
飾り画像を全面に敷く
要素。 旧リーダー互換のため xlink:href を用いる(href 単独だと描画しない端末がある)。. -
.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" 等の語中折れを防ぐ)を必ず使う。.
-
.ornament_layer(data_uri, x, y, w, h, clip_id) ⇒ Object
飾り 1 層。旧リーダー互換のため xlink:href を用いる(image_element と同じ理由)。 width/height は元画像の縦横比どおりに与えるので meet でも歪まない。.
-
.ornament_layers(width, band_h, source_height, data_uri) ⇒ Object
飾りを左右に並べる 2 層。左は元画像の左上(=左上の飾り)、右は右下(=右下の飾り)を 見せる。帯は飾り 1 つ分より少し高いので、左が上寄せ・右が下寄せになる差分が そのまま右飾りの下がり量になる(CSS の left top / right bottom と同じ)。.
-
.ornament_layout(width, number, title, chars) ⇒ Array(Integer, Array<Hash>)
節絵のフォントサイズと行分割を決める。 字面は「節題領域の幅 ÷ 字数」。ORNAMENT_MAX_LINES 行に収まらない長い節題だけ 8% ずつ縮める(幅は一定なので 1 行の字数が増えて行数が減る)。.
-
.ornament_number_indent(lines, font_size) ⇒ Object
2 行目以降の字下げ量(px)。1 行目の節番号+区切りアキと同じ幅。 節番号は font-weight 900 の 1.0em 相当で組むため、表示幅換算で概算する。.
-
.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 版)。.
-
.pack_words(words, capacity) ⇒ Object
語を順に詰め、入らなければ改行する。1 語で幅を超える語だけ表示幅で割る。.
-
.raster_data_uri(path) ⇒ Object
飾り画像を縮小 JPEG へ変換し base64 data URI を返す(埋め込み用)。失敗時は nil。 webp 等の互換性懸念を避けるため JPEG に揃え、EMBED_MAX_EDGE まで縮小して軽量化する。 透過部分は白でフラット化する(JPEG は透過非対応。黒潰れを防ぎ、リーダーの白ページや PDF の白ページ表示と馴染ませる)。同一画像(章で共通)は使い回すためパスでメモ化する。.
-
.rasterize_to_jpeg(svg, width) ⇒ Object
合成 SVG をフラット JPEG(バイト列)へラスタライズする。 rsvg-convert で PNG 化 → magick で白フラット JPEG 化。ツール不在・失敗時は nil。.
- .refine_break(head, rest, avail) ⇒ Object
-
.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 を非対応のため、配る実体はラスター画像にする。.
-
.rsvg_available? ⇒ Boolean
rsvg-convert(librsvg)が使えるか。.
-
.split_by_display_width(text, avail) ⇒ Array(String, String)
表示幅(全角=1.0・半角=0.55)で先頭 chunk を切り出す。半角語の途中で切れる場合は、 直近の空白があればそこで折り返す(Latin 語の分断を避ける)。.
-
.svg_wrapper(width, height, label_segments, parts) ⇒ Object
SVG ルート要素で包む。aria-label に番号+タイトル(+リード)を入れて読み上げに資する。 width/height 属性(intrinsic size)を明示する——viewBox だけだと
で参照した ときに一部リーダーが縦横比を確定できず、レイアウト箱と描画サイズがずれて 後続コンテンツへのはみ出し(epub_h2 実測)を誘発する。.
- .wrap_by_display_width(text, capacity) ⇒ Object
-
.wrap_ornament_lines(number, title, capacity) ⇒ Object
番号+タイトルを表示幅ベースで行へ割り付ける。1 行目に番号(+区切り 0.5em)を置き、 タイトルは収まる位置で折り返す。半角文字は全角の約半分として数える(display_width)。.
-
.wrap_text_by_width(text, capacity) ⇒ Object
テキストを表示幅(全角換算 capacity)で折り返す。半角は 0.55 換算で数え、 Latin 語の途中では直近の空白で折る(split_by_display_width と同じ規則)。 語の境界が取れるなら語単位で詰める(PDF 側の .vs-nobr と同じ規則)。 取れない環境では従来どおり表示幅で切り、禁則等は refine_break が追い込む。.
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(中間表現)を生成する。
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('&', '&').gsub('<', '<').gsub('>', '>') |
.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
飾り画像を全面に敷く
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 行の字数が増えて行数が減る)。
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 行の字数が増えて行数が減る)。
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 を非対応のため、配る実体はラスター画像にする。
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)が使えるか。
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 語の分断を避ける)。
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 |