Module: VivlioStarter::CLI::Build::OutlineExtractor

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

Overview

HTML 見出し抽出・PDF アウトライン生成モジュール

Constant Summary collapse

APPX_RANGE =

章レンジ(定数)

(90..98)

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.last_outline_debug_infoObject

Returns the value of attribute last_outline_debug_info.



35
36
37
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 35

def last_outline_debug_info
  @last_outline_debug_info
end

Class Method Details

.add_outline_from_headings!(pdf_path, html_files, max_level: 3, start_page: 1) ⇒ Object

PDFにアウトラインを付与



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

def add_outline_from_headings!(pdf_path, html_files, max_level: 3, start_page: 1)
  require 'vivlio_starter/cli/pdf/provider'

  entries = heading_page_entries(pdf_path, html_files, max_level: max_level, start_page: start_page)
  return if entries.empty?

  VivlioStarter::Pdf.provider.add_outline!(pdf_path, entries, max_level: max_level)
end

.add_toc_entry(items, chapter_ranges, chapter_order, search_helpers) ⇒ Object



602
603
604
605
606
607
608
609
610
611
612
613
614
615
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 602

def add_toc_entry(items, chapter_ranges, chapter_order, search_helpers)
  return items unless chapter_ranges['_toc']

  toc_range = chapter_ranges['_toc']
  toc_page = search_helpers[:search_markers].call(['目次'], toc_range[0], toc_range[1]) || toc_range[0]

  return items if items.any? { |it| it[:chapter] == '_toc' }

  insert_index = items.index do |it|
    chapter_order.index(it[:chapter])&.> chapter_order.index('_toc')
  end || items.length
  items.insert(insert_index, { level: 1, text: '目次', page: toc_page, chapter: '_toc', id: nil })
  items
end

.appendix_label_for_basename(basename) ⇒ Object

付録ラベル取得



42
43
44
45
46
47
48
49
50
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 42

def appendix_label_for_basename(basename)
  entry = TokenResolver::Resolver.new.resolve_file(basename)
  return nil unless entry.number && APPX_RANGE.include?(entry.number.to_i)

  letter = Common.appendix_number_to_letter(entry.number)
  return nil unless letter

  "付録#{letter.upcase}"
end

.build_chapter_display_text(basename, heading) ⇒ Object



575
576
577
578
579
580
581
582
583
584
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 575

def build_chapter_display_text(basename, heading)
  display_text = heading[:text]

  if heading[:appendix_label]
    prepend_label_if_needed(display_text, heading[:appendix_label].to_s.strip)
  else
    number_display = resolve_chapter_number_display(basename, heading)
    prepend_label_if_needed(display_text, number_display)
  end
end

.build_chapter_order(html_basenames) ⇒ Object



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 207

def build_chapter_order(html_basenames)
  chapter_order = Build::SectionBuilder.chapter_order_from(html_basenames)
  # 新仕様: _titlepage, _legalpage を使用
  frontmatter_sequence = %w[_titlepage _legalpage 00-preface _toc]
  # 巻末の順序: 用語集 → 終わりに → 索引
  # 索引・用語集が無効の場合は除外
  backmatter_sequence = if IndexCommands.index_enabled?
                          %w[_glossarypage 99-postface _indexpage]
                        else
                          %w[99-postface]
                        end

  # 巻末ページを除外した章順序
  main_chapters = chapter_order.reject { |bn| backmatter_sequence.include?(bn) }

  (frontmatter_sequence + main_chapters + backmatter_sequence).uniq
end

.build_chapter_paths(html_paths) ⇒ Object



198
199
200
201
202
203
204
205
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 198

def build_chapter_paths(html_paths)
  chapter_paths = {}
  html_paths.each do |path|
    bn = File.basename(path, '.html')
    chapter_paths[bn] = path
  end
  chapter_paths
end

.build_display_text(basename, heading) ⇒ Object



564
565
566
567
568
569
570
571
572
573
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 564

def build_display_text(basename, heading)
  return heading[:text] unless heading[:level].to_i == 1

  case basename
  when '99-colophon'
    '奥付'
  else
    build_chapter_display_text(basename, heading)
  end
end

.build_outline_items(headings_by_chapter, chapter_ranges, _chapter_order, search_helpers, total_pages) ⇒ Object



529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 529

def build_outline_items(headings_by_chapter, chapter_ranges, _chapter_order, search_helpers, total_pages)
  search_markers = search_helpers[:search_markers]
  items = []
  fallback_items = []

  headings_by_chapter.each do |bn, headings|
    range = chapter_ranges[bn]
    next unless range

    headings.each do |heading|
      page = if bn == '_toc'
               range[0]
             else
               search_terms = (Array(heading[:search_terms]) + [heading[:text],
                                                                heading[:appendix_label]]).compact.map do |s|
                 s.to_s.strip
               end.reject(&:empty?).uniq
               found_page = search_markers.call(search_terms, range[0], range[1])
               found_page ||= search_markers.call(search_terms, range[0], total_pages)
               unless found_page
                 fallback_items << { chapter: bn, text: heading[:text], target_page: range[0],
                                     search_terms: search_terms }
                 found_page = range[0]
               end
               found_page
             end

      display_text = build_display_text(bn, heading)
      items << { level: heading[:level], text: display_text, page: page, chapter: bn, id: heading[:id] }
    end
  end

  [items, fallback_items]
end

.build_page_range_context(chapter_ranges, chapter_starts, chapter_markers, search_helpers, from_base, total_pages, preface_pages, toc_pages) ⇒ Object

ページ範囲計算用のコンテキストハッシュを構築



415
416
417
418
419
420
421
422
423
424
425
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 415

def build_page_range_context(chapter_ranges, chapter_starts, chapter_markers,
                             search_helpers, from_base, total_pages, preface_pages, toc_pages)
  {
    chapter_ranges: chapter_ranges, chapter_starts: chapter_starts,
    chapter_markers: chapter_markers,
    search_markers: search_helpers[:search_markers],
    find_page_by_first_line: search_helpers[:find_page_by_first_line],
    from_base: from_base, total_pages: total_pages,
    preface_pages: preface_pages, toc_pages: toc_pages
  }
end

.build_search_helpers(pdf_path, total_pages) ⇒ Object



249
250
251
252
253
254
255
256
257
258
259
260
261
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
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 249

def build_search_helpers(pdf_path, total_pages)
  page_cache = {}
  normalized_cache = {}
  normalize = ->(str) { str.to_s.gsub(/[[:space:]\u00A0\u2000-\u200B\u202F\u205F\u3000]+/, '') }

  fetch_page_text = lambda do |page|
    page = page.to_i.clamp(1, total_pages)
    page_cache[page] ||= `pdftotext -f #{page} -l #{page} "#{pdf_path}" - 2>/dev/null`
  end

  find_page_in_pdf = lambda do |term, from_page, to_page|
    term = term.to_s.strip
    return nil if term.empty?

    normalized_term = normalize.call(term)
    from_page = from_page.to_i.clamp(1, total_pages)
    to_page = to_page.to_i.clamp(from_page, total_pages)
    return nil if from_page > to_page

    (from_page..to_page).each do |page|
      text = fetch_page_text.call(page)
      next if text.nil? || text.empty?
      return page if text.include?(term)

      normalized_text = normalized_cache[page] ||= normalize.call(text)
      return page if !normalized_term.empty? && normalized_text.include?(normalized_term)
    end
    nil
  end

  search_markers = lambda do |markers, from_page, to_page|
    Array(markers).each do |term|
      page = find_page_in_pdf.call(term, from_page, to_page)
      return page if page
    end
    nil
  end

  # ページ「先頭付近の見出し」として term が現れる最初のページを返す。
  # 本文中の偶発的な語(例: 前書き本文に出てくる「目次」)に誤マッチせず、
  # そのセクションが実際に始まるページを特定するために用いる。
  # 直前に柱(ランニングヘッダ)が入る可能性を考慮し、先頭の有意行 2 行を見る。
  find_page_by_first_line = lambda do |term, from_page, to_page|
    normalized_term = normalize.call(term)
    return nil if normalized_term.empty?

    from_page = from_page.to_i.clamp(1, total_pages)
    to_page = to_page.to_i.clamp(from_page, total_pages)
    return nil if from_page > to_page

    (from_page..to_page).each do |page|
      lead = fetch_page_text.call(page).to_s.lines.map(&:strip).reject(&:empty?).first(2)
      return page if lead.any? { |line| normalize.call(line) == normalized_term }
    end
    nil
  end

  { search_markers: search_markers, find_page_by_first_line: find_page_by_first_line }
end

.build_search_terms(number_text, title_text, heading_attr, text, appendix_label) ⇒ Object



165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 165

def build_search_terms(number_text, title_text, heading_attr, text, appendix_label)
  search_terms = []
  if number_text && !number_text.empty?
    search_terms << "#{number_text}#{title_text}" unless title_text.empty?
    search_terms << "#{number_text} #{title_text}" unless title_text.empty?
    search_terms << number_text
  end
  search_terms << heading_attr unless heading_attr.empty?
  search_terms << text
  search_terms << appendix_label.to_s unless appendix_label.to_s.empty?
  search_terms.compact.map { |t| t.to_s.strip }.reject(&:empty?).uniq
end

.calculate_chapter_ranges(chapter_order, chapter_markers, search_helpers, from_base, total_pages) ⇒ Object



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 325

def calculate_chapter_ranges(chapter_order, chapter_markers, search_helpers, from_base, total_pages)
  chapter_starts = {}
  chapter_ranges = {}
  # 本文章(01-89)の最初の章を特定(_titlepage, _legalpage, 00-preface, _toc は除外)
  resolver = TokenResolver::Resolver.new
  first_chapter_bn = chapter_order.find do |token|
    entry = resolver.resolve_file(token)
    entry.number&.to_i&.between?(1, 89)
  end

  # 前付(preface)・目次(toc)のページ数を、注釈対象の結合 PDF から直接算出する。
  preface_pages, toc_pages = detect_front_matter_spans(
    chapter_markers, search_helpers, from_base, total_pages, first_chapter_bn
  )

  ctx = build_page_range_context(
    chapter_ranges, chapter_starts, chapter_markers,
    search_helpers, from_base, total_pages, preface_pages, toc_pages
  )

  prev_bn = nil
  chapter_order.each do |bn|
    start_page, end_page = calculate_page_range(bn, ctx, first_chapter_bn, prev_bn)
    update_previous_chapter_end(chapter_ranges, prev_bn, start_page, total_pages)

    chapter_starts[bn] = start_page
    chapter_ranges[bn] = [start_page, end_page]
    prev_bn = bn
  end

  clamp_all_ranges(chapter_ranges, from_base, total_pages)
end

.calculate_page_range(basename, ctx, first_chapter_bn, prev_bn) ⇒ Object



427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 427

def calculate_page_range(basename, ctx, first_chapter_bn, prev_bn)
  case basename
  when '_titlepage' then page_range_titlepage(ctx)
  when '_legalpage' then page_range_legalpage(ctx)
  when '00-preface' then page_range_preface(ctx)
  when '_toc'       then page_range_toc(ctx)
  when '99-colophon' then [ctx[:total_pages], ctx[:total_pages]]
  when '_glossarypage' then page_range_backmatter(basename, ctx, prev_bn, ['用語集'])
  when '99-postface' then page_range_backmatter(basename, ctx, prev_bn, ['終わりに'])
  when '_indexpage' then page_range_backmatter(basename, ctx, prev_bn, ['索引'])
  when first_chapter_bn then page_range_first_chapter(ctx, prev_bn)
  else page_range_default(basename, ctx, prev_bn)
  end
end

.clamp_all_ranges(chapter_ranges, from_base, total_pages) ⇒ Object



403
404
405
406
407
408
409
410
411
412
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 403

def clamp_all_ranges(chapter_ranges, from_base, total_pages)
  chapter_ranges.each_value do |rng|
    next unless rng

    rng[0] = rng[0].clamp(from_base, total_pages)
    rng[1] = rng[1].clamp(rng[0], total_pages)
  end

  chapter_ranges
end

.detect_front_matter_spans(chapter_markers, search_helpers, from_base, total_pages, first_chapter_bn) ⇒ Array(Integer, Integer)

前付(preface)・目次(toc)のページ数を、注釈対象の結合 PDF (output.pdf / output_print.pdf)から「ページ先頭の見出し」テキスト検出で算出する。

旧実装は補助 PDF 00-preface.pdf / _toc.pdf のページ数に依存していたが、これらは 閲覧用パイプライン専用の副産物で、print_pdf 単独ビルドでは生成されない。その結果、 入稿用 PDF のしおりが目次ページへ集中する不具合があった。注釈対象の PDF そのものから 算出することで pdf / print_pdf を同一ロジックに統一し、前書きを書かない書籍 (00-preface 不在)でも破綻しないようにする。

目次・最初の本文章は「ページ先頭が見出し」のページとして特定する(前書き本文や 目次一覧に同じ語が現れても誤マッチしない)。最初の本文章の目次項目は目次の先頭ページに あり、探索は toc_start+1 以降に限るため、目次一覧へ吸い寄せられることはない。

Returns:

  • (Array(Integer, Integer))

    [preface のページ数, toc のページ数]



372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 372

def detect_front_matter_spans(chapter_markers, search_helpers, from_base, total_pages, first_chapter_bn)
  find_first_line = search_helpers[:find_page_by_first_line]
  # タイトル・権利の 2 ページ後が前付/目次の開始候補(表紙ぶんは from_base に含まれる)。
  front_start = (from_base + 2).clamp(1, total_pages)

  # 目次ページを特定。前書きが無ければ front_start 自体が目次になり preface_pages=0。
  toc_start = find_first_line.call(toc_heading_title, front_start, total_pages)
  return [0, 0] unless toc_start

  preface_pages = toc_start > front_start ? toc_start - front_start : 0

  # 最初の本文章のページを特定し、目次のページ数を確定する。
  # 章扉ページではページ先頭に「第1章」だけが出て、フルタイトルは画像化・改行などで
  # ページ先頭一致しないことがある(フルタイトルは柱として後続ページに現れる)。
  # マーカーごとの一致ページの最小値を採り、最も早い=実際の章開始ページを選ぶ。
  first_chapter_start = Array(chapter_markers[first_chapter_bn]).filter_map do |term|
    find_first_line.call(term, toc_start + 1, total_pages)
  end.min
  toc_pages = first_chapter_start && first_chapter_start > toc_start ? first_chapter_start - toc_start : 1

  [preface_pages, toc_pages]
end

.extract_all_headings(chapter_order, chapter_paths, max_level) ⇒ Object



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 225

def extract_all_headings(chapter_order, chapter_paths, max_level)
  headings_by_chapter = Hash.new { |h, k| h[k] = [] }
  chapter_markers = {}

  chapter_order.each do |bn|
    path = chapter_paths[bn]
    headings = if path
                 extract_headings_from_html_file(path, max_level: max_level,
                                                       include_appendix_label: true)
               else
                 []
               end
    headings_by_chapter[bn].concat(headings) if headings.any?

    primary = headings.find { |h| h[:level] == 1 } || headings.first
    if primary
      markers = Array(primary[:search_terms]) + [primary[:text]]
      chapter_markers[bn] = markers.compact.map { |s| s.to_s.strip }.reject(&:empty?).uniq
    end
  end

  [headings_by_chapter, chapter_markers]
end

.extract_headings_from_html_file(path, max_level:, include_appendix_label: true) ⇒ Object

HTMLファイルから見出しを抽出



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 53

def extract_headings_from_html_file(path, max_level:, include_appendix_label: true)
  html = File.read(path, encoding: 'utf-8')
  doc  = Nokogiri::HTML.parse(html)
  basename = File.basename(path, '.html')
  headings = []
  selector = (1..max_level).map { |lvl| "h#{lvl}" }.join(',')
  doc.css(selector).each do |node|
    lvl = node.name.delete_prefix('h').to_i
    next unless lvl.positive? && lvl <= max_level

    text = node["data-h#{lvl}"].to_s.strip
    text = node['data-heading'].to_s.strip if text.empty?
    text = node.text.to_s.strip if text.empty?
    next if text.empty?

    appendix_label = nil
    appendix_label = appendix_label_for_basename(basename) if include_appendix_label && lvl == 1

    chapter_token = node['data-chapter'].to_s.strip
    chapter_token = basename if chapter_token.empty?
    heading_attr = node['data-heading'].to_s.strip

    number_text = extract_number_text(node, lvl)
    title_text = extract_title_text(node, lvl, text)

    search_terms = build_search_terms(number_text, title_text, heading_attr, text, appendix_label)

    headings << {
      level: lvl,
      text: text,
      chapter: chapter_token,
      id: node['id'].to_s.strip,
      appendix_label: appendix_label,
      search_terms: search_terms,
      number_display: number_text
    }
  end
  headings
end

.extract_number_text(node, lvl) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 138

def extract_number_text(node, lvl)
  case lvl
  when 1
    val = node['data-chapter-number-display'].to_s.strip
    val = node.at_css('span.chapter-number')&.text&.strip if val.empty?
    val
  when 2
    val = node['data-section-number-display'].to_s.strip
    val = node.at_css('span.section-number')&.text&.strip if val.empty?
    val
  when 3
    val = node['data-subsection-number-display'].to_s.strip
    val = node.at_css('span.subsection-marker')&.text&.strip if val.empty?
    val
  end
end

.extract_title_text(node, lvl, fallback) ⇒ Object



155
156
157
158
159
160
161
162
163
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 155

def extract_title_text(node, lvl, fallback)
  title_text = case lvl
               when 1 then node['data-chapter-title'].to_s.strip
               when 2 then node['data-section-title'].to_s.strip
               when 3 then node['data-subsection-title'].to_s.strip
               else ''
               end
  title_text.empty? ? fallback : title_text
end

.find_section_start_by_first_line(ctx, markers, search_from) ⇒ Object

markers のいずれかが「ページ先頭付近の見出し」として現れる最初のページを返す。



503
504
505
506
507
508
509
510
511
512
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 503

def find_section_start_by_first_line(ctx, markers, search_from)
  finder = ctx[:find_page_by_first_line]
  return nil unless finder

  Array(markers).each do |term|
    page = finder.call(term, search_from, ctx[:total_pages])
    return page if page
  end
  nil
end

.heading_page_entries(pdf_path, html_paths, max_level: 3, start_page: 1) ⇒ Object

見出しとページの対応を取得



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
119
120
121
122
123
124
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 94

def heading_page_entries(pdf_path, html_paths, max_level: 3, start_page: 1)
  @last_outline_debug_info = nil
  return [] unless validate_inputs(pdf_path, html_paths)

  total_pages = (Build::Utilities.page_count(pdf_path) || '0').to_i
  return [] if total_pages <= 0

  from_base = start_page.to_i.clamp(1, total_pages)
  max_level = max_level.to_i.clamp(1, 6)

  chapter_paths = build_chapter_paths(html_paths)
  chapter_order = build_chapter_order(chapter_paths.keys)
  headings_by_chapter, chapter_markers = extract_all_headings(chapter_order, chapter_paths, max_level)
  search_helpers = build_search_helpers(pdf_path, total_pages)
  chapter_ranges = calculate_chapter_ranges(chapter_order, chapter_markers, search_helpers, from_base,
                                            total_pages)
  items, fallback_items = build_outline_items(headings_by_chapter, chapter_ranges, chapter_order,
                                              search_helpers, total_pages)
  items = add_toc_entry(items, chapter_ranges, chapter_order, search_helpers)

  log_fallback_items(fallback_items) if fallback_items.any?

  @last_outline_debug_info = {
    chapter_order: chapter_order.dup,
    chapter_starts: chapter_ranges.transform_values { |r| r[0] },
    chapter_ranges: chapter_ranges.transform_values(&:dup),
    items: items.map(&:dup)
  }

  items
end

.log_fallback_items(fallback_items) ⇒ Object



617
618
619
620
621
622
623
624
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 617

def log_fallback_items(fallback_items)
  return unless Common.current_log_level >= 3

  Common.log_warn('[Outline] 以下の見出しはページ検出に失敗したため章先頭へフォールバックしました:')
  fallback_items.each do |fb|
    Common.log_warn("  - #{fb[:chapter]} ##{fb[:text]} (fallback page=#{fb[:target_page]})")
  end
end

.page_range_backmatter(basename, ctx, prev_bn, default_markers) ⇒ Object

巻末ページ(用語集、終わりに、索引)のページ範囲を計算 これらのタイトル(用語集/終わりに/索引)は前書き本文や目次の一覧にも 文字列として現れるため、単純な全文検索では誤マッチする。 「ページ先頭の見出し」として現れるページを優先的に特定し、 見つからない場合のみ従来の全文検索へフォールバックする。



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

def page_range_backmatter(basename, ctx, prev_bn, default_markers)
  search_from = [ctx[:chapter_starts][prev_bn] || ctx[:from_base], ctx[:from_base]].max.clamp(1,
                                                                                              ctx[:total_pages])
  markers = ctx[:chapter_markers][basename] || default_markers
  start_page = find_section_start_by_first_line(ctx, markers, search_from)
  start_page ||= search_page_with_fallback(ctx[:search_markers], markers, search_from, ctx[:from_base],
                                           ctx[:total_pages])
  [start_page, ctx[:total_pages]]
end

.page_range_default(basename, ctx, prev_bn) ⇒ Object



514
515
516
517
518
519
520
521
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 514

def page_range_default(basename, ctx, prev_bn)
  search_from = [ctx[:chapter_starts][prev_bn] || ctx[:from_base], ctx[:from_base]].max.clamp(1,
                                                                                              ctx[:total_pages])
  markers = ctx[:chapter_markers][basename] || []
  start_page = search_page_with_fallback(ctx[:search_markers], markers, search_from, ctx[:from_base],
                                         ctx[:total_pages])
  [start_page, ctx[:total_pages]]
end

.page_range_first_chapter(ctx, prev_bn) ⇒ Object



481
482
483
484
485
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 481

def page_range_first_chapter(ctx, prev_bn)
  toc_end = ctx[:chapter_ranges]['_toc']&.[](1)
  start_candidate = toc_end ? toc_end + 1 : (ctx[:chapter_starts][prev_bn] || ctx[:from_base])
  [[start_candidate, ctx[:from_base]].max.clamp(1, ctx[:total_pages]), ctx[:total_pages]]
end

.page_range_legalpage(ctx) ⇒ Object

権利表記ページ = タイトルページの次(from_base + 1)。



450
451
452
453
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 450

def page_range_legalpage(ctx)
  page = (ctx[:from_base] + 1).clamp(1, ctx[:total_pages])
  [page, page]
end

.page_range_preface(ctx) ⇒ Object

前書き = タイトル・権利の 2 ページ後(from_base + 2)から開始。



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

def page_range_preface(ctx)
  start_page = (ctx[:from_base] + 2).clamp(1, ctx[:total_pages])
  end_page = if ctx[:preface_pages].positive?
               (start_page + ctx[:preface_pages] - 1).clamp(1,
                                                            ctx[:total_pages])
             else
               start_page
             end
  [start_page, end_page]
end

.page_range_titlepage(ctx) ⇒ Object

タイトルページ = 基点ページ(from_base)。 from_base には output.pdf 先頭に結合される表紙 PDF のページ数オフセットが含まれる。



444
445
446
447
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 444

def page_range_titlepage(ctx)
  page = ctx[:from_base].clamp(1, ctx[:total_pages])
  [page, page]
end

.page_range_toc(ctx) ⇒ Object



467
468
469
470
471
472
473
474
475
476
477
478
479
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 467

def page_range_toc(ctx)
  preface_start = ctx[:from_base] + 2
  preface_end = ctx[:chapter_ranges]['00-preface']&.[](1) || (preface_start + ctx[:preface_pages] - 1)
  start_candidate = ctx[:preface_pages].positive? ? preface_end + 1 : preface_start
  start_page = [start_candidate, ctx[:from_base]].max.clamp(1, ctx[:total_pages])
  end_page = if ctx[:toc_pages].positive?
               (start_page + ctx[:toc_pages] - 1).clamp(1,
                                                        ctx[:total_pages])
             else
               start_page
             end
  [start_page, end_page]
end

.prepend_cover_item(items) ⇒ Object



626
627
628
629
630
631
632
633
634
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 626

def prepend_cover_item(items)
  cover_title = Common::CONFIG.book.main_title.to_s.strip
  cover_title = '表紙' if cover_title.empty?

  return items if items.any? { |it| it[:page].to_i == 1 && it[:text] == cover_title }

  items.unshift({ level: 1, text: cover_title, page: 1, chapter: '_titlepage', id: nil })
  items
end

.prepend_label_if_needed(text, label) ⇒ Object



596
597
598
599
600
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 596

def prepend_label_if_needed(text, label)
  return text if label.to_s.empty? || text.start_with?(label)

  "#{label} #{text}".strip
end

.resolve_chapter_number_display(basename, heading) ⇒ Object



586
587
588
589
590
591
592
593
594
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 586

def resolve_chapter_number_display(basename, heading)
  number_display = heading[:number_display].to_s.strip
  return number_display unless number_display.empty?

  entry = TokenResolver::Resolver.new.resolve_file(basename)
  return '' unless entry.number&.to_i&.between?(11, 89)

  "#{entry.number.to_i - 10}"
end

.search_page_with_fallback(search_markers, markers, search_from, from_base, total_pages) ⇒ Object



523
524
525
526
527
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 523

def search_page_with_fallback(search_markers, markers, search_from, from_base, total_pages)
  start_page = search_markers.call(markers, search_from, total_pages)
  start_page ||= search_markers.call(markers, from_base, total_pages) if search_from > from_base
  start_page || search_from
end

.toc_heading_titleObject

目次見出し(例: 「目次」)を取得する。注釈対象 PDF 内で目次ページを 「ページ先頭テキスト」として特定するために用いる。_toc.html の見出しから取得し、 取得できない場合は既定値「目次」を返す(旧実装の _toc.pdf 依存を解消)。 _toc.html はワークスペースの html/ に置かれる(P4 §3.4-1)



313
314
315
316
317
318
319
320
321
322
323
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 313

def toc_heading_title
  toc_html = File.join(Common::BUILD_HTML_DIR, '_toc.html')
  return '目次' unless File.exist?(toc_html)

  doc = Nokogiri::HTML.parse(File.read(toc_html, encoding: 'utf-8'))
  h1 = doc.at_css('h1')
  title = h1 ? (h1['data-h1'].to_s.strip.empty? ? h1.text : h1['data-h1']).to_s.strip : ''
  title.empty? ? '目次' : title
rescue StandardError
  '目次'
end

.update_previous_chapter_end(chapter_ranges, prev_bn, start_page, total_pages) ⇒ Object



395
396
397
398
399
400
401
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 395

def update_previous_chapter_end(chapter_ranges, prev_bn, start_page, total_pages)
  return unless prev_bn && chapter_ranges[prev_bn]

  prev_end = [start_page - 1, total_pages].min
  prev_end = chapter_ranges[prev_bn][0] if prev_end < chapter_ranges[prev_bn][0]
  chapter_ranges[prev_bn][1] = prev_end
end

.validate_inputs(pdf_path, html_paths) ⇒ Object



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/vivlio_starter/cli/build/outline_extractor.rb', line 178

def validate_inputs(pdf_path, html_paths)
  unless File.exist?(pdf_path)
    Common.log_warn("[Outline] PDF が見つかりません: #{pdf_path}")
    return false
  end
  if html_paths.nil? || html_paths.empty?
    Common.log_warn('[Outline] HTML ファイルが指定されていません')
    return false
  end
  if html_paths.any? { |path| !File.exist?(path) }
    Common.log_warn('[Outline] HTML ファイルが存在しません')
    return false
  end
  unless system('which pdftotext >/dev/null 2>&1')
    Common.log_warn('[Outline] pdftotext が見つかりません。`brew install poppler` を実行してください。')
    return false
  end
  true
end