Module: VivlioStarter::CLI::Build::PdfMerger

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

Defined Under Namespace

Classes: Segment

Class Method Summary collapse

Class Method Details

.add_outline_to_output_pdf!(entries_or_keep = nil) ⇒ Object

================================================================ 5. アウトライン付与 (Step 11) ================================================================



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 276

def add_outline_to_output_pdf!(entries_or_keep = nil)
  return false unless File.exist?(merged_output_pdf)

  keep_numbers = Build::Utilities.chapter_numbers_for_outline(entries_or_keep)

  # 抽出対象HTMLの絞り込み(dedup 済み HTML はワークスペース pdf/ 内・P4 §5.1)
  special_pages = %w[_toc]
  special_pages.push('_glossarypage', '_indexpage') if IndexCommands.index_enabled?

  chapter_htmls = Dir.glob(File.join(Common::BUILD_PDF_DIR, '*.html')).select do |path|
    bn = File.basename(path, '.html')
    num = bn[/\A(\d+)-/, 1]&.to_i

    (num && (keep_numbers.nil? || keep_numbers.include?(num))) ||
      special_pages.include?(bn)
  end

  if chapter_htmls.empty?
    Common.log_info('[Step 11] 本文HTMLなし。スキップします')
    return false
  end

  Common.log_action('[Step 11] PDF ブックマークを付与します…')
  # 表紙 PDF のページ数を基点に加味して、前付・本文・巻末のページ範囲を正しく算出する。
  OutlineExtractor.add_outline_from_headings!(merged_output_pdf, chapter_htmls, max_level: 3,
                                                                                start_page: front_matter_offset + 1)
  true
end

.base_pdf_for(segments) ⇒ Object

メタデータ・しおりの引き継ぎ元。本文を優先する(ベース PDF の情報が 出力へ受け継がれるため、表紙 1 枚の情報で上書きされないようにする)。



170
171
172
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 170

def base_pdf_for(segments)
  segments.find { it.role == :body }&.path || segments.first.path
end

.body_and_matter_segmentsObject

本文・前付・奥付の 3 区間。中間 PDF はワークスペース pdf/ 内に置かれる。

相乗り経路では 1 本の _sections.pdf から 3 範囲を切り出す。分割して 別ファイルにすると各ファイルがフォントを丸ごと抱え込み、実測で最終 PDF が 約 1.9MB 太る(front-back-matter-single-render-spec.md §3.1)。



70
71
72
73
74
75
76
77
78
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 70

def body_and_matter_segments
  sections = File.join(Common::BUILD_PDF_DIR, '_sections.pdf')
  ranges = Build::PdfBuilder.embedded_special_page_ranges(sections)

  return embedded_segments(sections, ranges) if ranges

  separate_segments(front_matter: '_titlepage_legalpage.pdf', body: '_sections.pdf',
                    colophon: '_colophon.pdf')
end

.chapter_pagebreak_any?Boolean

値の正規化と不正値の警告は BookSettingsCss が唯一の実装。ここは判定だけ借りる。

Returns:

  • (Boolean)


242
243
244
245
246
247
248
249
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 242

def chapter_pagebreak_any?
  return false unless Common.configured?

  PreProcessCommands::BookSettingsCss
    .chapter_pagebreak_value(Common::CONFIG.page) == 'any'
rescue StandardError
  false
end

.compute_front_matter_offset(segments) ⇒ Object

区間列のうち、前付より前のページ数を合算する。 前付が見つからない場合は 0 を返す(従来挙動と互換)。



266
267
268
269
270
271
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 266

def compute_front_matter_offset(segments)
  idx = segments.index { it.role == :front_matter }
  return 0 unless idx

  segments[0...idx].sum(&:pages)
end

.cover_enhanced_segmentsObject

結合順の区間列。前付・奥付が本文 PDF に相乗りしていれば同じファイルの 別範囲として、していなければ個別ファイルとして並べる。どちらも同じ Segment の列になるので、以降の工程は区別しなくてよい。



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 31

def cover_enhanced_segments
  segments = body_and_matter_segments
  cfg = Common::CONFIG

  # ターゲット判定
  targets = extract_targets(cfg.output.targets)
  pdf_selected = targets.empty? || targets.any? { it.include?('pdf') }

  return segments unless pdf_selected
  return segments unless Common.pdf_combined?

  begin
    page_use = resolve_page_use(cfg.page)

    # カバー資産は共通前段の `prepare cover assets` が作り済み。ここでは並べるだけ。
    # かつては本メソッドが生成も担っていたが、EPUB 枝・入稿用も同じ関数を呼んで
    # おり、枝を並列に走らせると同じパスへ同時に書く
    #(build-target-parallelization-spec.md §3.2)。
    theme = Common.cover_theme
    size = extract_size_from_preset(page_use)

    # パス生成(生成物は cover_cache_dir に出ている)
    front = File.join(Common.cover_cache_dir, "frontcover_#{theme}_#{size}_rgb.pdf")
    back  = File.join(Common.cover_cache_dir, "backcover_#{theme}_#{size}_rgb.pdf")

    segments.unshift(whole_file_segment(:cover_front, front)) if File.exist?(front)
    segments.push(whole_file_segment(:cover_back, back))      if File.exist?(back)
  rescue StandardError => e
    Common.log_warn("[Step 10] カバー結合設定の処理中にエラー: #{e.message}")
  end

  segments
end

.embedded_segments(path, ranges) ⇒ Object

1 本の PDF に相乗りした前付・奥付を 3 区間へ割る(閲覧用・入稿用で共用)



81
82
83
84
85
86
87
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 81

def embedded_segments(path, ranges)
  [
    ranged_segment(:front_matter, path, ranges[:front]),
    ranged_segment(:body,         path, ranges[:body]),
    ranged_segment(:colophon,     path, ranges[:colophon])
  ]
end

.extract_size_from_preset(preset_name) ⇒ Object



128
129
130
131
132
133
134
135
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 128

def extract_size_from_preset(preset_name)
  case preset_name.to_s
  when /a4/ then 'a4'
  when /a5/ then 'a5'
  when /b5/ then 'b5'
  else 'a4' # デフォルト
  end
end

.extract_targets(raw) ⇒ Object

================================================================ 2. 補助メソッド (Data / Pattern Matching 活用) ================================================================



111
112
113
114
115
116
117
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 111

def extract_targets(raw)
  case raw
  in String => s then s.split(',').map(&:strip).reject(&:empty?)
  in Array  => a then a.map(&:to_s).map(&:strip).reject(&:empty?)
  else []
  end
end

.front_matter_offsetObject

output.pdf 先頭に結合される表紙 PDF など、前付より前に並ぶページ数を返す。 アウトラインのページ位置計算の基点(本扉の実ページ番号 = offset + 1)を 補正するために用いる。merge_all_pdfs! 実行時に算出される。 未算出時は 0(表紙なし相当)。



262
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 262

def front_matter_offset = @front_matter_offset || 0

.insert_blank_page_before_colophon(segments) ⇒ Array<Segment>

奥付が偶数ページ(左ページ)始まりになるよう空白ページを挿入する。 閲覧用・入稿用のどちらの区間列にも使う。

page.chapter_pagebreak: any(面を問わない)では挿入しない。奥付を左ページに 置くのは改丁とは別の慣習だが、「どちら側でもよい」と宣言した本で白紙だけが 残るのは一貫しない(chapter-pagebreak-spec.md §2.3)。

Parameters:

Returns:



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 209

def insert_blank_page_before_colophon(segments)
  if chapter_pagebreak_any?
    Common.log_debug('[Step 10] page.chapter_pagebreak: any のため奥付前の空白ページを挿入しません')
    return segments
  end

  colophon_idx = segments.index { it.role == :colophon }
  return segments unless colophon_idx

  # カバーはページ番号体系に含まれないため parity 計算から除外
  preceding = segments[0...colophon_idx].reject { it.role == :cover_front }
  preceding.each { Common.log_debug("[Step 10] ページ数: #{it.path} #{it.range} = #{it.pages}p") }
  total = preceding.sum(&:pages)
  Common.log_debug("[Step 10] 奥付前の合計ページ数(カバー除外): #{total}")

  if total.zero?
    Common.log_debug('[Step 10] 奥付より前のPDFページ数を取得できませんでした')
    return segments
  end

  # total が偶数 → 次ページは奇数(右) → 空白ページを挿入して偶数に
  # total が奇数 → 次ページは偶数(左) → そのままでOK
  if total.even?
    blank = Build::Utilities.ensure_blank_page_pdf(File.join(Common::BUILD_PDF_DIR, '_blank_before_colophon.pdf'))
    Common.log_debug("[Step 10] 奥付を偶数ページに配置するため空白ページを挿入します(前方 #{total} ページ)")
    segments.dup.insert(colophon_idx, Segment.new(role: :blank, path: blank, range: '1-z', pages: 1))
  else
    Common.log_debug("[Step 10] 奥付は偶数ページに配置されます(前方 #{total} ページ、空白挿入なし)")
    segments
  end
end

.merge_all_pdfs!(_entries_or_keep = nil) ⇒ Object

================================================================ 3. PDF 結合実行 (Step 10) ================================================================



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
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 140

def merge_all_pdfs!(_entries_or_keep = nil)
  Common.log_action('[Step 10] 表紙、本文、奥付を結合します…')

  segments = cover_enhanced_segments.select { File.exist?(it.path) }

  if segments.empty?
    Common.log_error('[Step 10] 結合対象PDFがありません')
    return false
  end

  return false unless qpdf_available?

  # 奥付を偶数ページ(左ページ)に配置するため、必要なら空白ページを挿入
  segments = insert_blank_page_before_colophon(segments)

  # アウトライン付与の基点補正用に、前付より前に結合される表紙 PDF の
  # ページ数を記録しておく(Step 11 で参照)。
  @front_matter_offset = compute_front_matter_offset(segments)

  if merge_pdfs_with_qpdf!(segments, output: merged_output_pdf, base_pdf: base_pdf_for(segments))
    Common.log_success('[Step 10] output.pdf を生成しました')
    true
  else
    Common.log_error('[Step 10] PDF結合に失敗しました')
    false
  end
end

.merge_pdfs_with_qpdf!(segments, output:, base_pdf: nil) ⇒ Boolean

区間列を qpdf で 1 本の PDF に結合する(閲覧用・入稿用ビルドの共通基盤)。

base_pdf を「結合のベース」として qpdf に渡すと、その PDF の メタデータが出力へ引き継がれる。指定がなければ先頭区間のファイルを使う。

同じファイルが複数区間に現れてよい(相乗り経路では 3 区間が同一ファイル)。 qpdf は入力ごとに 1 度だけ読み込むため、フォントなどの共有資源は重複しない。

Parameters:

  • segments (Array<Segment>)

    結合順の区間(存在確認済みであること)

  • output (String)

    出力 PDF パス(既存ファイルは上書き)

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

    メタデータ引き継ぎ元の PDF

Returns:

  • (Boolean)

    結合に成功し出力ファイルが存在すれば true



189
190
191
192
193
194
195
196
197
198
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 189

def merge_pdfs_with_qpdf!(segments, output:, base_pdf: nil)
  return false if segments.empty?

  base_pdf ||= segments.first.path
  FileUtils.rm_f(output)

  pages = segments.map { %("#{it.path}" #{it.range}) }.join(' ')
  success = system(%(qpdf "#{base_pdf}" --pages #{pages} -- "#{output}" > /dev/null))
  success && File.exist?(output)
end

.merged_output_pdfObject

結合済み PDF のパス(ワークスペース pdf/ 内。最終リネームでルートへ出る)



175
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 175

def merged_output_pdf = File.join(Common::BUILD_PDF_DIR, 'output.pdf')

.qpdf_available?Boolean

Returns:

  • (Boolean)


251
252
253
254
255
256
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 251

def qpdf_available?
  return true if system('command -v qpdf >/dev/null 2>&1')

  Common.log_warn('[Step 10] qpdf が見つかりません。')
  false
end

.ranged_segment(role, path, range) ⇒ Object

ページ範囲を 1 区間として扱う



103
104
105
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 103

def ranged_segment(role, path, range)
  Segment.new(role:, path:, range: "#{range.first}-#{range.last}", pages: range.size)
end

.resolve_page_use(page_cfg) ⇒ Object



119
120
121
122
123
124
125
126
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 119

def resolve_page_use(page_cfg)
  # Data オブジェクトからプリセット名を優先順位付きで取得
  %i[use preset preset_name size].each do |key|
    val = page_cfg&.[](key)
    return val.to_s if val && !val.to_s.strip.empty?
  end
  'b5_standard'
end

.separate_segments(front_matter:, body:, colophon:) ⇒ Object

前付・奥付を個別レンダしたときの 3 区間(フォールバック経路・閲覧用と入稿用で ファイル名が違うため basename を受け取る)



91
92
93
94
95
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 91

def separate_segments(front_matter:, body:, colophon:)
  { front_matter:, body:, colophon: }.map do |role, name|
    whole_file_segment(role, File.join(Common::BUILD_PDF_DIR, name))
  end
end

.whole_file_segment(role, path) ⇒ Object

ファイル全体を 1 区間として扱う



98
99
100
# File 'lib/vivlio_starter/cli/build/pdf_merger.rb', line 98

def whole_file_segment(role, path)
  Segment.new(role:, path:, range: '1-z', pages: Build::Utilities.page_count(path).to_i)
end