Module: VivlioStarter::CLI::Build::DerivedImage
- Defined in:
- lib/vivlio_starter/cli/build/derived_image.rb
Overview
DerivedImage: PDF へ渡す画像の派生を作る
PDF は WebP を格納できない(ISO 32000 に該当するフィルタが無い)。渡された
Chromium はデコードして Flate へ入れ直すが、可逆圧縮では写真がほとんど縮まない
ため、素材 280KB が PDF 内で 1,963KB になる。JPEG なら DCTDecode でストリームが
そのまま入り、素材のサイズがそのまま PDF のサイズになる
(image-format-per-target-spec.md §1.2)。
著者の images/ には触れない。 派生は .cache/vs/derived/pdf/ にだけ作り、 PDF 枝がステージングする pdf/ の HTML だけがそこを指す(同 §3.1)。EPUB は html/ の原本を読むので、WebP が最適な EPUB 側は素材のまま変換ゼロで済む。
Defined Under Namespace
Classes: Derivative
Constant Summary collapse
- DERIVED_ROOT =
clean が消すのは .cache/vs/build/ だけなので、ここはビルドをまたいで残る (theme-images / covers と同じ流儀)。素材より新しければ作り直さない。
"#{Common::CACHE_DIR}/derived".freeze
- PDF_DERIVED_DIR =
"#{DERIVED_ROOT}/pdf".freeze
- METRICS_FILE =
組み上がった PDF から測った、画素数ごとの「要る画素数」と「版面に対する表示幅の 割合」の対応表。ビルドをまたいで残す——索引を使わない本では Step 8 の再レンダが 無いため、次回のビルドのステージングで初めて効く(§3.6)。
割合は EPUB / Kindle が使う。 あちらはリフローなのでページが確定せず、 「組んでから測る」ができない。しかし版面に対する相対幅は同じなので——
width=30%の指定も 2 列に並べた表も、PDF 側の測定に既に現れている——読者の画面幅に掛ければ 必要画素数が出る。 "#{DERIVED_ROOT}/image-metrics.yml".freeze
- JPEG_QUALITY =
素材の多くが既に WebP(非可逆)で、派生はその二段目になる。ここで落とすと 劣化が重なるため、resize の「高精細」と同じ 90 を採る。
90- TARGET_PPI =
印刷に必要な解像度。これを下回らせない。
350- SIZE_STEPS =
派生の画素数の段階。必要画素数はここへ切り上げる——足りない解像度は後から 取り戻せず、印刷では粗さとして残るためである。実寸ちょうどで作れば数 % 小さく なるが、同じ素材が似た大きさで何箇所にも置かれるとき段階なら 1 つを使い回せる (§3.6)。
[480, 800, 1280, 2048].freeze
- PASSTHROUGH_EXTENSIONS =
派生を作らない拡張子。SVG はベクタのまま PDF へ入るのが最善で、ラスタ化が 要る場合は Techbook モードの Type 3 対策(
ResizeCommands.convert_svg_to_webp) が別途受け持つ。 %w[.svg].freeze
- DERIVED_EXTENSIONS =
派生の拡張子。既存キャッシュを探すときも、この順で見る。
%w[.jpg .png].freeze
- FLATTEN_OPTIONS =
透過を白へ落とす指定。PDF に限って透過を捨てる根拠は、地が紙の白だから である(
@pageに背景色の指定は無い。2026-08-17 確認)——透過部分はどのみち 白く出るので、フラット化しても 1 ピクセルも変わらない。代わりに JPEG が使えて、 本書の花の見本 12 枚は PDF 内 37.22 MB → 6.31 MB になる。EPUB / Kindle では捨てない。 あちらは html/ の原本(素材そのもの)を読む。 Kindle 端末のダークモードは背景を黒にするが画像は反転しないため、白で塗って あると矩形が浮く。テーマ素材(
stylesheets/images/)も CSS 背景で入るので<img>を経由せず、この差し替えの対象に最初から入らない——章扉の生成では-trimが透過を「絵の範囲」の手掛かりに使っており、潰してはならない。 %w[-background white -alpha remove -alpha off].freeze
Class Method Summary collapse
- .derivable?(source) ⇒ Boolean
-
.derived_base(source, shrink_to = nil, alpha: false) ⇒ Object
素材のパス構造を派生側にも残す。ハッシュ名にすると、PDF が大きいときに 「どの絵が効いているか」を人が追えなくなる。縮小したものは画素数を接尾辞に 持たせ、等倍と共存させる(1 回目は等倍、Step 8 以降は縮小版を使うため)。.
-
.fresh_derivative(base, source) ⇒ Object
既存の派生が使えるか。JPEG / PNG のどちらで作られたかは前回の判定次第なので 両方を見る。素材が更新されていれば作り直す。.
-
.measure!(pdf_path) ⇒ Integer
組み上がった PDF から実効解像度を測り、画素数ごとの必要画素数を記録する。.
-
.metrics ⇒ Object
測定結果。ビルドをまたいで残るので、索引を使わない本では次回から効く。.
-
.normalize_requests(sources) ⇒ Object
=> 透過保持 を [パス, 透過保持] の一意な配列へ。同じ絵が地色のブロックの 中と外の両方に出ることがあるので、両方を作れるようキーに透過保持を含める。.
-
.prepare(source, keep_alpha: false) ⇒ Object
1 件ぶんの派生を用意し、Derivative を返す(作れなければ nil)。.
-
.prepare_all(sources) ⇒ Hash{Array(String, Boolean) => Derivative}
素材群の派生をまとめて用意し、=> Derivative を返す。.
-
.probe(path) ⇒ Object
画素数と「実際に透明な画素があるか」を 1 回の identify で取る。.
-
.reset_cache! ⇒ Object
プロセス内キャッシュを捨てる(テスト用)。.
-
.run_magick(source, dest, *options) ⇒ Object
変換して成功したパスを返す。失敗したら nil(呼び出し側は素材のまま使う)。.
-
.shrink_target(width, height) ⇒ Object
縮小先の画素数。縮める必要が無ければ nil(素材の画素数のまま渡す)。.
-
.smaller_of(jpg, png) ⇒ Object
勝ったほうを残し、負けたほうは消す。両方失敗していれば nil。.
-
.text_area_width_mm ⇒ Object
版面幅(mm)。同じ計算を 2 度持たないよう
BookSettingsCssへ委ねる。 設定が無い直接ビルドや、pre_process が読み込まれていない文脈では nil。. -
.viewport_target(width, height, viewport_px) ⇒ Integer
読者の画面幅(EPUB 2048px / Kindle 1024px)に対して要る画素数。.
Class Method Details
.derivable?(source) ⇒ Boolean
297 298 299 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 297 def derivable?(source) File.file?(source) && !PASSTHROUGH_EXTENSIONS.include?(File.extname(source).downcase) end |
.derived_base(source, shrink_to = nil, alpha: false) ⇒ Object
素材のパス構造を派生側にも残す。ハッシュ名にすると、PDF が大きいときに 「どの絵が効いているか」を人が追えなくなる。縮小したものは画素数を接尾辞に 持たせ、等倍と共存させる(1 回目は等倍、Step 8 以降は縮小版を使うため)。
246 247 248 249 250 251 252 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 246 def derived_base(source, shrink_to = nil, alpha: false) relative = source.sub(%r{\A\./}, '').delete_prefix('/') stem = relative.sub(/\.[^.]+\z/, '') stem = "#{stem}_#{shrink_to}" if shrink_to stem = "#{stem}_alpha" if alpha File.join(PDF_DERIVED_DIR, stem) end |
.fresh_derivative(base, source) ⇒ Object
既存の派生が使えるか。JPEG / PNG のどちらで作られたかは前回の判定次第なので 両方を見る。素材が更新されていれば作り直す。
256 257 258 259 260 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 256 def fresh_derivative(base, source) src_mtime = File.mtime(source) DERIVED_EXTENSIONS.map { "#{base}#{it}" } .find { File.file?(it) && File.mtime(it) >= src_mtime } end |
.measure!(pdf_path) ⇒ Integer
組み上がった PDF から実効解像度を測り、画素数ごとの必要画素数を記録する。
pdfimages はどのファイルから来たかを教えないが、必要なのは「画素数 W×H の
素材には何 px あれば足りるか」だけなので、画素数をキーに ppi の最小値
(=最も大きく表示されている場面)を採れば決まる(§3.6)。同じ画素数の素材が
複数あっても、最も大きく表示されているものに合わせるので 350 ppi を下回らない。
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 188 189 190 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 157 def measure!(pdf_path) return 0 unless File.file?(pdf_path) out, status = Open3.capture2('pdfimages', '-list', pdf_path, err: File::NULL) return 0 unless status.success? text_width = text_area_width_mm # **既存を引き継ぐ。** 2 回目以降のビルドでは 1 回目から縮小版が組まれるため、 # 素材の画素数のエントリ(初回にしか現れない)が測定から消える。EPUB / Kindle は # 素材から派生を作るので、そちらのキーが引けなくなると縮小が効かなくなる。 # 素材が差し替わればキー自体が変わるので、古い値が悪さをすることはない。 measured = metrics.dup out.each_line do |line| cols = line.split next unless cols.size >= 15 && cols[2] == 'image' width = cols[3].to_i ppi = cols[12].to_i next unless width.positive? && ppi.positive? entry = measured["#{width}x#{cols[4]}"] ||= { 'px' => 0, 'ratio' => 0.0 } entry['px'] = [entry['px'], (width * TARGET_PPI.to_f / ppi).ceil].max # 版面幅が引けないときは 1.0(版面いっぱい)に倒す——縮めすぎるより素材のまま運ぶ display_mm = width / ppi.to_f * 25.4 ratio = text_width ? (display_mm / text_width) : 1.0 entry['ratio'] = [entry['ratio'], ratio.clamp(0.0, 1.0)].max end return 0 if measured.empty? FileUtils.mkdir_p(File.dirname(METRICS_FILE)) File.write(METRICS_FILE, measured.to_yaml) @metrics = measured measured.size end |
.metrics ⇒ Object
測定結果。ビルドをまたいで残るので、索引を使わない本では次回から効く。
232 233 234 235 236 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 232 def metrics @metrics ||= (YAML.safe_load_file(METRICS_FILE) if File.file?(METRICS_FILE)) || {} rescue StandardError @metrics = {} end |
.normalize_requests(sources) ⇒ Object
=> 透過保持 を [パス, 透過保持] の一意な配列へ。同じ絵が地色のブロックの 中と外の両方に出ることがあるので、両方を作れるようキーに透過保持を含める。
101 102 103 104 105 106 107 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 101 def normalize_requests(sources) pairs = case sources when Hash then sources.to_a else Array(sources).map { it.is_a?(Array) ? it : [it, false] } end pairs.uniq.select { derivable?(it[0]) } end |
.prepare(source, keep_alpha: false) ⇒ Object
1 件ぶんの派生を用意し、Derivative を返す(作れなければ nil)。
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 143 144 145 146 147 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 114 def prepare(source, keep_alpha: false) width, height, opaque = probe(source) return nil unless width.positive? flatten = !opaque && !keep_alpha # --- Phase: 使えるキャッシュがあれば作り直さない --- # フラット化したものと透過を残したものは**別のファイル**として持つ。同じ絵が # 地色のブロックの中と外の両方に出ることがあり、片方で上書きしてはならない。 shrink_to = shrink_target(width, height) base = derived_base(source, shrink_to, alpha: !flatten && !opaque) cached = fresh_derivative(base, source) return Derivative.new(path: cached, width:, height:) if cached # --- Phase: 変換の指定を組み立てる --- # 過剰な画素数は段階へ切り上げて縮める。透過の扱いは FLATTEN_OPTIONS 参照。 = flatten ? FLATTEN_OPTIONS.dup : [] += ['-resize', "#{shrink_to}x#{shrink_to}>"] if shrink_to # --- Phase: JPEG と PNG を作って小さいほうを採る --- # 色数では分けられないことを実測で確かめた(2026-08-17・本書 78 件。色数 2,701 の # 写真は JPEG が、2,731 の図は PNG が勝つ)。サイズの勝敗は「写真か図か」と # ほぼ一致するので、この比較は画質の判定も兼ねている——写真は PNG で膨らみ、 # 文字入りの図は PNG で縮むためである。 FileUtils.mkdir_p(File.dirname(base)) png = run_magick(source, "#{base}.png", *) # 透過を残すなら JPEG は選べない(持てない)。サイズの比較をせず PNG で決める。 return png && Derivative.new(path: png, width:, height:) if !opaque && !flatten jpg = run_magick(source, "#{base}.jpg", *, '-quality', JPEG_QUALITY.to_s) winner = smaller_of(jpg, png) winner && Derivative.new(path: winner, width:, height:) end |
.prepare_all(sources) ⇒ Hash{Array(String, Boolean) => Derivative}
素材群の派生をまとめて用意し、=> Derivative を返す。
派生が要らないもの(SVG)と作れなかったものは戻り値に含めない——呼び出し側は 「マップに無ければ素材のまま」と読めばよく、失敗が組版を止めない。
86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 86 def prepare_all(sources) requests = normalize_requests(sources) return {} if requests.empty? mapping = {} lock = Mutex.new ResizeCommands.each_in_parallel(requests) do |(src, keep_alpha)| derived = prepare(src, keep_alpha:) lock.synchronize { mapping[[src, keep_alpha]] = derived } if derived end mapping end |
.probe(path) ⇒ Object
画素数と「実際に透明な画素があるか」を 1 回の identify で取る。
透過の判定にアルファチャンネルの有無(%A)ではなく %[opaque] を使うのは、
WebP がチャンネルを持ちながら全画素が不透明なことがあるためである。%A だけでは
透過を必要としない絵まで別扱いしてしまう。
291 292 293 294 295 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 291 def probe(path) out = `magick identify -format '%w %h %[opaque]' #{Shellwords.escape(path)} 2>/dev/null` width, height, opaque = out.to_s.split [width.to_i, height.to_i, opaque == 'True'] end |
.reset_cache! ⇒ Object
プロセス内キャッシュを捨てる(テスト用)。
239 240 241 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 239 def reset_cache! @metrics = nil end |
.run_magick(source, dest, *options) ⇒ Object
変換して成功したパスを返す。失敗したら nil(呼び出し側は素材のまま使う)。
277 278 279 280 281 282 283 284 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 277 def run_magick(source, dest, *) cmd = ['magick', source, '-strip', *, dest] return dest if system(*cmd, out: File::NULL, err: File::NULL) && File.size?(dest) FileUtils.rm_f(dest) Common.log_warn("PDF 向け画像の変換に失敗しました: #{source}") nil end |
.shrink_target(width, height) ⇒ Object
縮小先の画素数。縮める必要が無ければ nil(素材の画素数のまま渡す)。
219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 219 def shrink_target(width, height) needed = metrics.dig("#{width}x#{height}", 'px') return nil unless needed&.positive? return nil if needed >= width # 段階へ切り上げる。**段階を超えるときは必要画素数そのものへ丸める**——版面 # 全幅に置かれた 4K 素材がこれに当たり、2048px で打ち止めにすると素材のまま # 運ばれてしまう。段階の利点(使い回し)は 2048px 以下で効き、それを超える # 大きな絵は稀なので実寸で作ってよい。 SIZE_STEPS.find { it >= needed } || needed end |
.smaller_of(jpg, png) ⇒ Object
勝ったほうを残し、負けたほうは消す。両方失敗していれば nil。
263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 263 def smaller_of(jpg, png) return png unless jpg return jpg unless png if File.size(jpg) <= File.size(png) FileUtils.rm_f(png) jpg else FileUtils.rm_f(jpg) png end end |
.text_area_width_mm ⇒ Object
版面幅(mm)。同じ計算を 2 度持たないよう BookSettingsCss へ委ねる。
設定が無い直接ビルドや、pre_process が読み込まれていない文脈では nil。
209 210 211 212 213 214 215 216 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 209 def text_area_width_mm return nil unless Common.configured? page_cfg = PreProcessCommands::BookSettingsCss.build_page_cfg(Common::CONFIG) PreProcessCommands::BookSettingsCss.text_area_width_mm(page_cfg) rescue StandardError nil end |
.viewport_target(width, height, viewport_px) ⇒ Integer
読者の画面幅(EPUB 2048px / Kindle 1024px)に対して要る画素数。
PDF で測った版面に対する割合を掛けるだけでよい。リフローではページが確定せず 「組んでから測る」ができないが、相対幅は組版系によらないからである——版面の半分に 並べた見本は EPUB でも画面の半分を占める。測っていなければ画面幅そのもの(=安全側)。
200 201 202 203 204 205 |
# File 'lib/vivlio_starter/cli/build/derived_image.rb', line 200 def (width, height, ) ratio = metrics.dig("#{width}x#{height}", 'ratio') return unless ratio&.positive? [( * ratio).ceil, ].min end |