Module: VivlioStarter::CLI::PostProcessCommands::HeadingProcessor

Defined in:
lib/vivlio_starter/cli/post_process/heading_processor.rb

Overview

================================================================

Module: HeadingProcessor

【役割】

  • 見出し(h1..h6)にマーカーとメタデータを付与
  • 章番号・節番号のスパンを構築

【処理内容】

  1. 見出しマーカー付与

    • class: vs-h-marker
    • data-heading: 見出しテキスト
    • data-hN: レベル別見出しテキスト
    • data-chapter: 章トークン
  2. 見出し番号スパン構築

    • h1: 第N章タイトル
    • h2: N-Mタイトル
    • h3: タイトル

================================================================

Constant Summary collapse

MAIN_CHAPTER_RANGE =
(1..89)
ORPHAN_GUARD_KINDS =

泣き別れ防止の対象。折り返す前提で大きく組まれる章題・節題だけに適用する (h3 は block-size 固定の小見出しで、折返し位置を動かすと段が崩れる)。

%i[chapter section].freeze
WORD_JOINER =

見出しの末尾 1 文字だけが次の行へ落ちる「泣き別れ」を防ぐ。

日本語の見出しは 1 語で分割点を持たないことが多く(「コマンドラインオプション」)、 word-break: auto-phrase でも文節境界が無いため文字間で折れて末尾 1 文字が独り残る (pdf_h2_nakiwakare.png 実測)。末尾 2 文字を white-space: nowrap の span で括ると その 2 文字が分割できなくなり、折返しが 1 文字ぶん手前へ移って一緒に落ちる。

WORD JOINER(U+2060)を挟む方法は Vivliostyle が無視した(実測: HTML には 入るが組版結果は変わらない)。Vivliostyle は自前の行分割を持つため、制御文字より CSS で表現する方が確実。

見出し末尾は素のテキストとは限らない。索引語(<span class="index-term">)や 用語集リンクが末尾に来るため、最も深い末尾のテキストノードまで降りて括る (「コマンドラインオプション」のように、 末尾が要素で終わる見出しは実際に多い)。

""
NOBR_CLASS =
'vs-nobr'

Class Method Summary collapse

Class Method Details

.add_chapter_token(heading, chapter_token) ⇒ Object

章トークンを追加



143
144
145
146
147
148
149
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 143

def add_chapter_token(heading, chapter_token)
  return false unless chapter_token
  return false if heading['data-chapter'] == chapter_token

  heading['data-chapter'] = chapter_token
  true
end

.add_heading_data_attributes(heading, level) ⇒ Object

見出しテキストの data 属性を追加



115
116
117
118
119
120
121
122
123
124
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 115

def add_heading_data_attributes(heading, level)
  text = extract_heading_core_text(heading)
  return false if text.nil? || text.empty?

  modified = false
  modified |= set_attr_if_changed(heading, 'data-heading', text)
  modified |= set_attr_if_changed(heading, "data-h#{level}", text)
  modified |= set_h1_id(heading, text) if level == 1
  modified
end

.add_marker_class(heading) ⇒ Object

vs-h-marker クラスを追加



105
106
107
108
109
110
111
112
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 105

def add_marker_class(heading)
  classes = (heading['class'] || '').split
  return false if classes.include?('vs-h-marker')

  classes << 'vs-h-marker'
  heading['class'] = classes.join(' ').strip
  true
end

.add_number_span(node, number_class, number_text, doc) ⇒ Object

番号スパンを追加



317
318
319
320
321
322
323
324
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 317

def add_number_span(node, number_class, number_text, doc)
  return unless number_class && !number_text.empty?

  span = Nokogiri::XML::Node.new('span', doc)
  span['class'] = number_class
  span.content = number_text
  node.add_child(span)
end

.add_title_span(node, title_class, title_text, original_nodes, doc, kind = nil) ⇒ Object

タイトルスパンを追加



327
328
329
330
331
332
333
334
335
336
337
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 327

def add_title_span(node, title_class, title_text, original_nodes, doc, kind = nil)
  if title_class
    span = Nokogiri::XML::Node.new('span', doc)
    span['class'] = title_class
    original_nodes.empty? ? span.content = title_text : original_nodes.each { |c| span.add_child(c) }
    guard_last_character_orphan!(span, doc) if ORPHAN_GUARD_KINDS.include?(kind)
    node.add_child(span)
  elsif !title_text.empty?
    node.add_child(Nokogiri::XML::Text.new(title_text, doc))
  end
end

.apply_marker_to_heading(heading, level, chapter_token) ⇒ Object

見出し要素にマーカーを適用



84
85
86
87
88
89
90
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 84

def apply_marker_to_heading(heading, level, chapter_token)
  modified = add_marker_class(heading)
  modified |= add_heading_data_attributes(heading, level)
  modified |= add_chapter_token(heading, chapter_token)
  modified |= ensure_heading_id(heading, chapter_token)
  modified
end

.build_h1_number_text(context) ⇒ Object

h1 の番号テキストを構築



208
209
210
211
212
213
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 208

def build_h1_number_text(context)
  return "付録 #{context[:appendix_letter]}" if context[:file_type] == 'appendix' && context[:appendix_letter]
  return "#{context[:chapter_display_number]}" if context[:chapter_display_number]

  nil
end

.build_h2_number_text(context, section_index) ⇒ Object

h2 の番号テキストを構築



236
237
238
239
240
241
242
243
244
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 236

def build_h2_number_text(context, section_index)
  if context[:file_type] == 'appendix'
    context[:appendix_letter] ? "#{context[:appendix_letter]}-#{section_index}" : section_index.to_s
  elsif context[:chapter_display_number]
    "#{context[:chapter_display_number]}-#{section_index}"
  else
    section_index.to_s
  end
end

.build_heading_context(html_path, entry) ⇒ Object

見出し処理のコンテキストを構築

Parameters:

  • html_path (String)

    HTML ファイルパス

  • entry (TokenResolver::Entry)

    章情報を持つ Entry オブジェクト



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 178

def build_heading_context(html_path, entry)
  chapter_token = File.basename(html_path, File.extname(html_path))
  chapter_number_i = entry.number&.to_i

  {
    file_type: entry.kind.to_s,
    chapter_display_number: if chapter_number_i
                              resolve_main_chapter_display_number(chapter_token,
                                                                  chapter_number_i)
                            end,
    appendix_letter: if chapter_number_i&.between?(90, 98)
                       resolve_appendix_letter(chapter_token, chapter_number_i)
                     end,
    process_headings: %i[chapter appendix].include?(entry.kind)
  }
end

.chapter_tokens_overrideObject



48
49
50
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 48

def chapter_tokens_override
  @chapter_tokens_override || []
end

.chapter_tokens_override=(tokens) ⇒ Object

一時的な章トークンの並びを外部から指定するためのオーバーライド 例: vs build 54-56 のような単章/範囲ビルド時に、 そのビルド対象だけを 1,2,3... の順番で扱いたい場合に使用する。

  • nil または空配列の場合はオーバーライドなし(ワークスペースの HTML から自動検出)
  • 設定された場合は、その並びを優先的に main_chapter_order の候補として利用する


42
43
44
45
46
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 42

def chapter_tokens_override=(tokens)
  @chapter_tokens_override = Array(tokens).compact.map(&:to_s)
  # 章並びを再計算させるためキャッシュを無効化
  @main_chapter_order = nil
end

.deepest_last_text_node(node) ⇒ Object

文書順で最後のテキストノードまで降りる(末尾が要素なら中へ入る)



423
424
425
426
427
428
429
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 423

def deepest_last_text_node(node)
  child = node.children.last
  return nil if child.nil?
  return deepest_last_text_node(child) if child.element?

  child.text? ? child : nil
end

.discovered_main_chapter_tokensArray<String>

発見されたHTMLファイルから章トークンを取得 中間 HTML はワークスペースの html/ に置かれる(P4 §3.4-1)

Returns:

  • (Array<String>)

    章トークンの配列



515
516
517
518
519
520
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 515

def discovered_main_chapter_tokens
  resolver = TokenResolver::Resolver.new
  html_tokens = Dir.glob(File.join(Common::BUILD_HTML_DIR, '*.html'))
                   .map { |path| File.basename(path, '.html') }
  normalize_and_filter_tokens(html_tokens).sort_by { |token| resolver.resolve_file(token).number.to_i }
end

.ensure_heading_id(heading, chapter_token) ⇒ Object

見出しに非空の id を保証する。 ### $E = mc^2$ のように見出しが数式(→ SVG 画像)のみだと VFM が生成する slug が空になり、id="" が EPUB で epubcheck RSC-005(id 値が不正)を招くため、 内容のハッシュから一意な id を補う(テキスト見出しの既存 id はそのまま残す)。



96
97
98
99
100
101
102
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 96

def ensure_heading_id(heading, chapter_token)
  return false unless heading['id'].to_s.strip.empty?

  digest = Digest::SHA1.hexdigest(heading.inner_html.to_s)[0, 10]
  heading['id'] = "vsmath-#{chapter_token || 'h'}-#{digest}"
  true
end

.extract_chapter_token(path) ⇒ Object

ファイルパスから章トークンを抽出



152
153
154
155
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 152

def extract_chapter_token(path)
  token = File.basename(path, File.extname(path)).to_s.strip
  token.empty? ? nil : token
end

.extract_heading_core_text(node) ⇒ String

見出しのコアテキストを抽出

Parameters:

  • node (Nokogiri::XML::Element)

    見出し要素

Returns:

  • (String)

    見出しテキスト



434
435
436
437
438
439
440
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 434

def extract_heading_core_text(node)
  %w[chapter-title section-title subsection-title].each do |cls|
    span = node.at_css("span.#{cls}")
    return heading_plain_text(span) if span
  end
  heading_plain_text(node)
end

.extract_original_title_nodes(node, number_class, title_class) ⇒ Object

元のタイトルノードを抽出



305
306
307
308
309
310
311
312
313
314
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 305

def extract_original_title_nodes(node, number_class, title_class)
  title_span = title_class ? node.at_css("span.#{title_class}") : nil
  if title_span
    title_span.children.map(&:dup)
  else
    node.children.reject do |child|
      number_class && child.element? && child['class'].to_s.split.include?(number_class)
    end.map(&:dup)
  end
end

.guard_last_character_orphan!(span, doc) ⇒ Object



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 361

def guard_last_character_orphan!(span, doc)
  # 語の境界が取れるなら、各語を括って「語の途中では折らない」を保証する。
  # 取れない環境(MeCab 不在)では末尾 2 文字だけを守る従来の縮退へ。
  return if wrap_words_in_nobr!(span, doc)

  last = deepest_last_text_node(span)
  return if last.nil?
  return if last.parent['class'].to_s.split.include?(NOBR_CLASS) # 既に処理済み

  text = last.text
  return if text.strip.length < 2

  nobr = Nokogiri::XML::Node.new('span', doc)
  nobr['class'] = NOBR_CLASS
  nobr.content = text[-2..]
  last.content = text[0..-3]
  last.add_next_sibling(nobr)
end

.heading_plain_text(node) ⇒ Object

見出しの素のテキスト。data 属性(EPUB の合成見出し画像・目次の素材)に入れるため、 表示上の装飾を落とす:

- 用語集の † リンク … 見出しを画像化するとクリックできず記号だけが残る
- WORD JOINER      … 泣き別れ防止で挟んだ制御文字(見た目に影響しないが
                   比較・再構築の冪等性を壊すので比較前に落とす)


447
448
449
450
451
452
453
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 447

def heading_plain_text(node)
  return '' unless node

  copy = node.dup
  copy.css('a.glossary-link').each(&:remove)
  copy.text.to_s.delete(WORD_JOINER).strip
end

.heading_span_classes(kind) ⇒ Object

見出し種別に応じたクラス名を取得



284
285
286
287
288
289
290
291
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 284

def heading_span_classes(kind)
  case kind
  when :chapter then %w[chapter-number chapter-title]
  when :section then %w[section-number section-title]
  when :subsection then %w[subsection-marker subsection-title]
  else [nil, nil]
  end
end

.inject_heading_markers!(html_paths, max_level: 3) ⇒ Object

見出し(h1..hN)に本文参照用のマーカー(class と data 属性)を付与

Parameters:

  • html_paths (Array<String>)

    HTMLファイルパスの配列

  • max_level (Integer) (defaults to: 3)

    処理する見出しの最大レベル(デフォルト: 3)



55
56
57
58
59
60
61
62
63
64
65
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 55

def inject_heading_markers!(html_paths, max_level: 3)
  paths = Array(html_paths).select { |p| File.exist?(p) }
  return if paths.empty?

  max_l = max_level.to_i.clamp(1, 6)
  paths.each do |path|
    process_heading_markers_for_file(path, max_l)
  rescue StandardError => e
    Common.log_warn("見出しメタ付与に失敗: #{path} (#{e})")
  end
end

.inject_heading_number_spans!(html_path, entry) ⇒ Object

見出し番号スパンを構築

Parameters:

  • html_path (String)

    HTMLファイルパス

  • entry (TokenResolver::Entry)

    章情報を持つ Entry オブジェクト



160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 160

def inject_heading_number_spans!(html_path, entry)
  return unless File.exist?(html_path)

  html = File.read(html_path, encoding: 'utf-8')
  doc = parse_html_document(html)
  context = build_heading_context(html_path, entry)
  return unless context[:process_headings]

  modified = process_h1_spans(doc, context)
  modified |= process_h2_spans(doc, context)
  modified |= process_h3_spans(doc)

  save_html_document(html_path, doc) if modified
end

.main_chapter_orderArray<String>

メイン章の順序を取得

章構成の正典は config/catalog.yml であり、ここでは絞り込みを解釈しない。 単章/選択ビルドは chapter_tokens_override で与えられ、それ以外は ワークスペースに並んだ HTML から順序を起こす。

Returns:

  • (Array<String>)

    章トークンの配列



496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 496

def main_chapter_order
  return @main_chapter_order if @main_chapter_order

  # ビルドコマンド等から一時的な章リストが与えられている場合はそれを優先
  override = chapter_tokens_override
  if override && !override.empty?
    tokens = normalize_and_filter_tokens(override)
    if tokens && !tokens.empty?
      @main_chapter_order = tokens
      return @main_chapter_order
    end
  end

  @main_chapter_order = discovered_main_chapter_tokens
end

.main_chapter_token?(token) ⇒ Boolean

メイン章トークンかどうか判定

_ 始まりは章ではない——システムページ(_titlepage _part1 など)か、 著者向けの説明ファイル(contents/_README.md)である。 システムページは番号を持たないので従来も落ちていたが、_README は TokenResolver が番号 01 を割り当てるため通ってしまい、本文章の並びの 先頭に居座って図表番号の章プレフィックスを 1 つずらしていた (11-workflow が第 1 章ではなく第 2 章として採番される)。 綴りではなく接頭辞で弾く——_ の意味は「章として数えない」だからである。

Parameters:

  • token (String)

    トークン

Returns:

  • (Boolean)

    メイン章トークンの場合true



566
567
568
569
570
571
572
573
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 566

def main_chapter_token?(token)
  return false if token.to_s.start_with?('_')

  entry = TokenResolver::Resolver.new.resolve_file(token)
  return false unless entry.number

  MAIN_CHAPTER_RANGE.include?(entry.number.to_i)
end

.needs_heading_update?(node, number_text, title_text, number_class, title_class) ⇒ Boolean

見出しの更新が必要か判定

Returns:

  • (Boolean)


294
295
296
297
298
299
300
301
302
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 294

def needs_heading_update?(node, number_text, title_text, number_class, title_class)
  current_number = number_class ? node.at_css("span.#{number_class}")&.text&.strip : nil
  current_title_span = title_class ? node.at_css("span.#{title_class}") : nil
  current_title = current_title_span&.text&.strip || extract_heading_core_text(current_title_span || node)

  number_changed = number_text.empty? ? !current_number.to_s.empty? : current_number != number_text
  title_changed = current_title != title_text
  number_changed || title_changed
end

.normalize_and_filter_tokens(list) ⇒ Array<String>

トークンリストを正規化してフィルタ

Parameters:

  • list (Array)

    トークンリスト

Returns:

  • (Array<String>)

    正規化された章トークンの配列



525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 525

def normalize_and_filter_tokens(list)
  seen = {}
  Array(list).each_with_object([]) do |entry, acc|
    token = normalize_chapter_token(entry)
    next unless token
    next unless main_chapter_token?(token)
    next if seen[token]

    seen[token] = true
    acc << token
  end
end

.normalize_chapter_token(entry) ⇒ String?

章トークンを正規化

Parameters:

  • entry (String)

    エントリ

Returns:

  • (String, nil)

    正規化されたトークン



541
542
543
544
545
546
547
548
549
550
551
552
553
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 541

def normalize_chapter_token(entry)
  s = entry.to_s.strip
  return nil if s.empty?

  s = s.sub(%r{\A\./}, '')
  s = s.sub(%r{\A#{Regexp.escape(Common::CONTENTS_DIR)}/}i, '')
  s = s.sub(/\.(html|md)\z/i, '')
  s = s.sub(/\.(html|md)\z/i, '')
  s = s.strip
  return nil if s.empty?

  s
end

.parse_html_document(html) ⇒ Object

HTMLドキュメントをパース(HtmlParser に委譲)



259
260
261
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 259

def parse_html_document(html)
  HtmlParser.parse_html_document(html)
end

.process_h1_spans(doc, context) ⇒ Object

h1 のスパン処理



196
197
198
199
200
201
202
203
204
205
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 196

def process_h1_spans(doc, context)
  h1 = doc.at_css('h1')
  return false unless h1

  title_text = extract_heading_core_text(h1)
  number_text = build_h1_number_text(context)
  modified = rebuild_heading_with_spans(h1, number_text, title_text, :chapter, doc)
  update_h1_data_attributes(h1, number_text, title_text)
  modified
end

.process_h2_spans(doc, context) ⇒ Object

h2 のスパン処理



222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 222

def process_h2_spans(doc, context)
  modified = false
  doc.css('h2').each_with_index do |h2, idx|
    section_index = idx + 1
    title_text = extract_heading_core_text(h2)
    number_text = build_h2_number_text(context, section_index)
    modified |= rebuild_heading_with_spans(h2, number_text, title_text, :section, doc)
    h2['data-section-number-display'] = number_text if number_text
    h2['data-section-title'] = title_text if title_text
  end
  modified
end

.process_h3_spans(doc) ⇒ Object

h3 のスパン処理



247
248
249
250
251
252
253
254
255
256
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 247

def process_h3_spans(doc)
  marker = Common::CONFIG.theme.markers.h3 || ''
  modified = false
  doc.css('h3').each do |h3|
    title_text = extract_heading_core_text(h3)
    modified |= rebuild_heading_with_spans(h3, marker, title_text, :subsection, doc)
    h3['data-subsection-title'] = title_text if title_text
  end
  modified
end

.process_heading_markers_for_file(path, max_level) ⇒ Object

単一ファイルの見出しマーカー処理



68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 68

def process_heading_markers_for_file(path, max_level)
  html = File.read(path, encoding: 'utf-8')
  doc = parse_html_document(html)
  chapter_token = extract_chapter_token(path)

  modified = false
  (1..max_level).each do |lvl|
    doc.css("h#{lvl}").each do |h|
      modified |= apply_marker_to_heading(h, lvl, chapter_token)
    end
  end

  save_html_document(path, doc) if modified
end

.rebuild_heading_with_spans(node, number_text, title_text, kind, doc) ⇒ Object

見出しを番号スパンとタイトルスパンで再構築



269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 269

def rebuild_heading_with_spans(node, number_text, title_text, kind, doc)
  number_text = number_text.to_s.strip
  title_text = title_text.to_s.strip
  number_class, title_class = heading_span_classes(kind)

  return false unless needs_heading_update?(node, number_text, title_text, number_class, title_class)

  original_title_nodes = extract_original_title_nodes(node, number_class, title_class)
  node.children.remove
  add_number_span(node, number_class, number_text, doc)
  add_title_span(node, title_class, title_text, original_title_nodes, doc, kind)
  true
end

.replace_with_nobr_words!(node, words, doc) ⇒ Object

語ごとに nowrap の span を置く。語末の空白は span の外へ出す—— 中に入れると nowrap がその空白での折返しまで禁止し、「(create / delete / rename」のような半角混じりの見出しが 1 つの塊になって版面をはみ出す (pdf_h1_chapter6.png 実測)。外に出せば空白が折返し候補として残る。



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 406

def replace_with_nobr_words!(node, words, doc)
  fragment = Nokogiri::XML::Node.new('span', doc)
  words.each do |word|
    core = word.sub(/\s+\z/, '')
    unless core.empty?
      nobr = Nokogiri::XML::Node.new('span', doc)
      nobr['class'] = NOBR_CLASS
      nobr.content = core
      fragment.add_child(nobr)
    end
    trailing = word[core.length..]
    fragment.add_child(Nokogiri::XML::Text.new(trailing, doc)) unless trailing.empty?
  end
  node.replace(fragment.children)
end

.resolve_appendix_letter(chapter_token, chapter_number_i) ⇒ Object

付録のレター(A/B/C...)をビルド対象の付録の順番に基づいて解決する



474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 474

def resolve_appendix_letter(chapter_token, chapter_number_i)
  override = chapter_tokens_override
  if override && !override.empty?
    # 単章/選択ビルド: ビルド対象の付録トークンから Entry を構築
    resolver = TokenResolver::Resolver.new
    entries = override.filter_map do |token|
      entry = resolver.resolve_file(token)
      entry if entry&.kind == :appendix
    end
    Common.appendix_number_to_letter(chapter_number_i, entries: entries)&.upcase
  else
    # フルビルド: catalog.yml の全付録から順番を取得
    Common.appendix_number_to_letter(chapter_number_i)&.upcase
  end
end

.resolve_main_chapter_display_number(chapter_token, chapter_number_i = nil) ⇒ Integer?

メイン章の表示番号を解決

Parameters:

  • chapter_token (String)

    章トークン

  • chapter_number_i (Integer, nil) (defaults to: nil)

    章番号

Returns:

  • (Integer, nil)

    表示番号



459
460
461
462
463
464
465
466
467
468
469
470
471
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 459

def resolve_main_chapter_display_number(chapter_token, chapter_number_i = nil)
  return nil if chapter_token.nil? || chapter_token.empty?

  chapter_number_i ||= TokenResolver::Resolver.new.resolve_file(chapter_token).number&.to_i
  return nil unless chapter_number_i && MAIN_CHAPTER_RANGE.include?(chapter_number_i)

  order = main_chapter_order
  if (idx = order.index(chapter_token))
    return idx + 1
  end

  chapter_number_i
end

.save_html_document(path, doc) ⇒ Object

HTMLドキュメントを保存(HtmlParser に委譲)



264
265
266
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 264

def save_html_document(path, doc)
  HtmlParser.save_html_document(path, doc)
end

.set_attr_if_changed(node, attr, value) ⇒ Object

属性値が変わった場合のみ設定



127
128
129
130
131
132
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 127

def set_attr_if_changed(node, attr, value)
  return false if node[attr] == value

  node[attr] = value
  true
end

.set_h1_id(heading, text) ⇒ Object

h1 に id を設定



135
136
137
138
139
140
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 135

def set_h1_id(heading, text)
  return false unless heading['id'].to_s.strip.empty?

  heading['id'] = text
  true
end

.update_h1_data_attributes(h1, number_text, title_text) ⇒ Object

h1 の data 属性を更新



216
217
218
219
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 216

def update_h1_data_attributes(h1, number_text, title_text)
  number_text ? h1['data-chapter-number-display'] = number_text : h1.delete('data-chapter-number-display')
  title_text ? h1['data-chapter-title'] = title_text : h1.delete('data-chapter-title')
end

.wrap_words_in_nobr!(span, doc) ⇒ Boolean

見出し中の各テキストノードを語へ分割し、語ごとに nowrap の span で括る。 折返しは語と語の境目だけで起きるので「コマンドラインオプ/ション」のような 語中の分割が構造的に起きなくなる。

テキストノード単位で処理するのは、索引語や用語集リンクの要素構造を壊さないため (要素の境目はもともと折返し候補なので、分割の質は落ちない)。

Returns:

  • (Boolean)

    1 語でも括れたか(false なら呼び出し側が従来の縮退へ)



387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/vivlio_starter/cli/post_process/heading_processor.rb', line 387

def wrap_words_in_nobr!(span, doc)
  wrapped = false
  span.xpath('.//text()').to_a.each do |node|
    next if node.parent['class'].to_s.split.include?(NOBR_CLASS) # 既に処理済み
    next if node.text.strip.empty?

    words = HeadingSegmenter.segment(node.text)
    next if words.size < 2 && node.text.strip.length < 2

    replace_with_nobr_words!(node, words, doc)
    wrapped = true
  end
  wrapped
end