Module: VivlioStarter::CLI::PostProcessCommands

Defined in:
lib/vivlio_starter/cli/post_process.rb,
lib/vivlio_starter/cli/post_process/html_parser.rb,
lib/vivlio_starter/cli/post_process/html_replacer.rb,
lib/vivlio_starter/cli/post_process/section_wrapper.rb,
lib/vivlio_starter/cli/post_process/heading_processor.rb,
lib/vivlio_starter/cli/post_process/replacement_rules.rb,
lib/vivlio_starter/cli/post_process/footnote_converter.rb,
lib/vivlio_starter/cli/post_process/body_class_injector.rb,
lib/vivlio_starter/cli/post_process/page_break_normalizer.rb,
lib/vivlio_starter/cli/post_process/terminal_block_converter.rb

Overview

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

Module: PostProcessCommands

HTML後処理のThorコマンド群とヘルパーメソッドを提供

Defined Under Namespace

Modules: BodyClassInjector, FootnoteConverter, HeadingProcessor, HtmlParser, HtmlReplacer, PageBreakNormalizer, ReplacementRules, SectionWrapper, TerminalBlockConverter

Constant Summary collapse

POST_PROCESS_DESC =
{
  short: 'HTMLファイルのポスト置換処理を行います',
  long: <<~DESC
    指定した HTML ファイルの後処理を行います。指定が無い場合はプロジェクトルートの全 .html を対象にします。

    処理内容:
    - <body> タグにファイルタイプクラスを付与
    - 組み込み置換ルールによる置換処理
    - 章末脚注をページ脚注に変換
    - ソースコードに行番号を追加(Prism.js対応)

    例:
      vs post_process 11-install
      vs post_process 11-install.html 12-tutorial
  DESC
}.freeze

Class Method Summary collapse

Class Method Details

.build_renumber_map(footnote_refs) ⇒ Object



794
795
796
797
798
799
800
801
# File 'lib/vivlio_starter/cli/post_process.rb', line 794

def build_renumber_map(footnote_refs)
  renumber_map = {}
  footnote_refs.each_with_index do |ref, idx|
    old_fn_id = ref['href'].sub('#', '')
    renumber_map[old_fn_id] = idx + 1
  end
  renumber_map
end

.collect_footnote_refs(doc) ⇒ Object



779
780
781
782
783
784
785
786
787
788
789
790
791
# File 'lib/vivlio_starter/cli/post_process.rb', line 779

def collect_footnote_refs(doc)
  refs = []
  doc.traverse do |node|
    next unless node.element?
    next unless node.name == 'a' && node['class']&.include?('footnote-ref') && node['href']&.start_with?('#fn')

    parent = node.parent
    next if parent&.name == 'span' && parent['class']&.include?('footnote-anchor')

    refs << node
  end
  refs
end

.execute_post_process(context_or_options, entries = []) ⇒ Object

Samovar/直接呼び出し用エントリポイント

Parameters:

  • context_or_options (Hash, Object)

    コマンドコンテキスト

  • entries (Array<TokenResolver::Entry>) (defaults to: [])

    Entry オブジェクトの配列



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/vivlio_starter/cli/post_process.rb', line 79

def execute_post_process(context_or_options, entries = [])
  opts = normalize_options(context_or_options)
  ENV['VERBOSE'] = '1' if opts[:verbose]

  entry_map = resolve_entry_map(entries)

  entry_map.each do |html_file, entry|
    BodyClassInjector.inject_body_class(html_file, entry)
    # HtmlReplacer より前に <pre> 化しておく。以降 <pre> は退避対象となり、
    # 端末の逐語出力に含まれる `---` 等が置換ルール(<hr class="pagebreak">)に食われない。
    TerminalBlockConverter.convert_terminal_blocks!(html_file)
  end

  total_replacements = 0

  entry_map.each do |html_file, entry|
    Common.log_action("処理中: #{html_file}")

    result = ReplacementRules.apply_builtin!(html_file)
    if result[:changed]
      total_replacements += result[:replacements]
      Common.log_success("#{html_file}: #{result[:replacements]}個の置換を反映")
    else
      Common.log_info("#{html_file}: 変更なし")
    end

    begin
      content_before = File.read(html_file, encoding: 'utf-8')
      content_after  = SectionWrapper.wrap_h2_with_article_if_image_style!(content_before)
      if content_after != content_before
        File.write(html_file, content_after, encoding: 'utf-8')
        Common.log_success("#{html_file}: h2を<article.section-topic>でラップ(theme.style=image)")
        result2 = ReplacementRules.apply_builtin!(html_file)
        if result2[:changed]
          Common.log_success("#{html_file}: ラップ後の不要な空段落をクリーンアップ (#{result2[:replacements]}件)")
        end
      end
    rescue StandardError => e
      Common.log_error("#{html_file}: section-topicラップ中にエラー: #{e.message}")
    end

    begin
      wrap_sideimage_blocks!(html_file)
    rescue StandardError => e
      Common.log_error("#{html_file}: sideimage ラップ中にエラー: #{e.message}")
    end

    begin
      process_image_groups!(html_file)
    rescue StandardError => e
      Common.log_error("#{html_file}: image-group 処理中にエラー: #{e.message}")
    end

    begin
      wrap_img_text_blocks!(html_file)
    rescue StandardError => e
      Common.log_error("#{html_file}: img-text ラップ中にエラー: #{e.message}")
    end

    content = File.read(html_file, encoding: 'utf-8')
    converted = FootnoteConverter.convert_endnotes_to_page_footnotes!(content)
    if converted != content
      File.write(html_file, converted, encoding: 'utf-8')
      Common.log_success("#{html_file}: 章末脚注をページ脚注に変換")
    end

    begin
      process_sideimage_footnotes!(html_file)
    rescue StandardError => e
      Common.log_error("#{html_file}: sideimage 脚注処理中にエラー: #{e.message}")
    end

    begin
      renumber_footnotes_by_document_order!(html_file)
    rescue StandardError => e
      Common.log_error("#{html_file}: 脚注再番号付け中にエラー: #{e.message}")
    end

    # Prism.js 行番号付与(直接呼び出し)
    PrismLinesCommands.execute_prism_lines(html_file)

    wrap_cross_ref_code_blocks!(html_file)

    begin
      HeadingProcessor.inject_heading_markers!([html_file], max_level: 3)
      Common.log_info("#{html_file}: 見出しメタを付与 (class=data)")
    rescue StandardError => e
      Common.log_warn("#{html_file}: 見出しメタ付与に失敗: #{e}")
    end

    begin
      HeadingProcessor.inject_heading_number_spans!(html_file, entry)
      Common.log_info("#{html_file}: 見出し番号スパンを構築")
    rescue StandardError => e
      Common.log_warn("#{html_file}: 見出し番号スパン構築に失敗: #{e}")
    end

    # 最終クリーンアップ: Nokogiri 系のステップが <p><div>...</div></p> の
    # ねじれを正すときに残してしまう空の <p></p> などを除去する。
    final_result = ReplacementRules.apply_builtin!(html_file)
    if final_result[:changed]
      total_replacements += final_result[:replacements]
      Common.log_info("#{html_file}: 最終クリーンアップで #{final_result[:replacements]} 件を整理")
    end

    # 二重改ページの正規化は最後に行う。改ページマーカー(ReplacementRules)と
    # article.section-topic ラップ(SectionWrapper)が揃った DOM でなければ
    # 「マーカーの直後が h2 か」を正しく判定できない(page-break-control-spec §2.1-5)。
    begin
      PageBreakNormalizer.normalize!(html_file)
    rescue StandardError => e
      Common.log_error("#{html_file}: 改ページ正規化中にエラー: #{e.message}")
    end
  end

  Common.log_success("ポスト置換処理完了 (合計: #{total_replacements}個の置換)")
end

.extract_image_group_width_fraction(figure, img) ⇒ Object

image-group 内の先頭画像から width 指定(%)を取り出し、 0.0〜1.0 の範囲の比率として返す



602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
# File 'lib/vivlio_starter/cli/post_process.rb', line 602

def extract_image_group_width_fraction(figure, img)
  width_attr = ''
  width_attr = figure['width'].to_s if figure
  width_attr = img['width'].to_s if width_attr.empty? && img

  if (m = width_attr.match(/([0-9]+(?:\.[0-9]+)?)\s*%/))
    return sanitize_image_group_fraction(m[1].to_f / 100.0)
  end

  style_sources = []
  style_sources << figure['style'].to_s if figure
  style_sources << img['style'].to_s if img

  style_sources.compact.each do |style|
    if (m = style.match(/width\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*%/))
      return sanitize_image_group_fraction(m[1].to_f / 100.0)
    end
  end

  nil
end

.extract_sideimage_width_fraction(figure) ⇒ Object

sideimage コンテナ内の figure/img から width 指定(%)を取り出し、 0.0〜1.0 の範囲の比率として返す



956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
# File 'lib/vivlio_starter/cli/post_process.rb', line 956

def extract_sideimage_width_fraction(figure)
  # figure 要素・子 img 要素の両方を対象とする
  img = figure.at_css('img')

  width_attr = figure['width'].to_s
  width_attr = img['width'].to_s if width_attr.empty? && img

  if (m = width_attr.match(/([0-9]+(?:\.[0-9]+)?)\s*%/))
    return sanitize_sideimage_fraction(m[1].to_f / 100.0)
  end

  style_sources = []
  style_sources << figure['style'].to_s
  style_sources << img['style'].to_s if img

  style_sources.compact.each do |style|
    if (m = style.match(/width\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*%/))
      return sanitize_sideimage_fraction(m[1].to_f / 100.0)
    end
  end

  nil
end

.included(base) ⇒ Object



74
# File 'lib/vivlio_starter/cli/post_process.rb', line 74

def included(base); end

.move_body_asides_near_references!(doc) ⇒ Object

body 直下の aside.page-footnote-print を対応する参照の近くに移動する append_unused_footnotes_to_body! が body 末尾に追加した aside を 参照と同じページに float:footnote で表示されるよう配置し直す



919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
# File 'lib/vivlio_starter/cli/post_process.rb', line 919

def move_body_asides_near_references!(doc)
  body = doc.at_css('body')
  return unless body

  body_asides = body.css('> aside.page-footnote-print').to_a
  return if body_asides.empty?

  body_asides.each do |aside|
    fn_id = aside['id']
    next unless fn_id

    # この aside を参照している <a class="footnote-ref"> を探す
    ref = doc.at_css("a.footnote-ref[href='##{fn_id}']")

    # sideimage 内の参照は、コンテナの直後に配置する
    # (sideimage 内に aside を置くと CSS Grid レイアウトが壊れるため)
    sideimage = ref && FootnoteConverter.find_sideimage_container(ref)
    if sideimage
      aside.remove
      sideimage.add_next_sibling(aside)
      next
    end

    # 参照が見つからない場合は最後の section 末尾に移動
    target_section = ref&.ancestors('section')&.first
    target_section ||= body.css('section').to_a.last
    next unless target_section

    aside.remove
    target_section.add_child("\n")
    target_section.add_child(aside)
  end
end

.needs_renumbering?(renumber_map) ⇒ Object



804
805
806
# File 'lib/vivlio_starter/cli/post_process.rb', line 804

def needs_renumbering?(renumber_map)
  renumber_map.any? { |old_id, new_num| old_id.sub('fn', '').to_i != new_num }
end

.normalize_image_group_width!(figure, img) ⇒ Object

列幅でレイアウトを制御するため、figure/img 側の width 指定は取り除く



635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
# File 'lib/vivlio_starter/cli/post_process.rb', line 635

def normalize_image_group_width!(figure, img)
  [figure, img].compact.each do |node|
    node.remove_attribute('width')

    style = node['style'].to_s
    next if style.empty?

    cleaned = style.gsub(/width\s*:\s*[^;]+;?/, '').strip
    if cleaned.empty?
      node.remove_attribute('style')
    else
      node['style'] = cleaned
    end
  end
end

.normalize_options(context_or_options) ⇒ Object

オプションを正規化



199
200
201
202
203
204
205
206
207
# File 'lib/vivlio_starter/cli/post_process.rb', line 199

def normalize_options(context_or_options)
  if context_or_options.is_a?(Hash)
    context_or_options[:options] || context_or_options
  elsif context_or_options.respond_to?(:options)
    context_or_options.options || {}
  else
    {}
  end
end

.normalize_sideimage_figure_width!(figure) ⇒ Object

列幅でレイアウトを制御するため、figure/img 側の width 指定は取り除く



989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
# File 'lib/vivlio_starter/cli/post_process.rb', line 989

def normalize_sideimage_figure_width!(figure)
  img = figure.at_css('img')

  [figure, img].compact.each do |node|
    node.remove_attribute('width')

    style = node['style'].to_s
    next if style.empty?

    cleaned = style.gsub(/width\s*:\s*[^;]+;?/, '').strip
    if cleaned.empty?
      node.remove_attribute('style')
    else
      node['style'] = cleaned
    end
  end
end

.process_image_groups!(html_file) ⇒ Object

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

image-group コンテナの列比率設定

VFM が出力する

...
...
のような構造で、先頭画像の width 指定を検出し、 CSS 変数 --image-group-col1, --image-group-col2 として コンテナの style 属性に埋め込む。 ================================================================


494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
# File 'lib/vivlio_starter/cli/post_process.rb', line 494

def process_image_groups!(html_file)
  content = File.read(html_file, encoding: 'utf-8')
  doc = HtmlParser.parse_html_document(content)
  changed = false

  doc.css('div.image-group').each do |container|
    # 先頭の figure または img を取得
    first_figure = container.at_css('> figure')
    first_img = first_figure&.at_css('img') || container.at_css('> img')

    next unless first_img || first_figure

    # 先頭画像の width 指定を取得
    fraction = extract_image_group_width_fraction(first_figure, first_img)
    next unless fraction

    col1_fr = fraction.round(3)
    col2_fr = (1.0 - fraction).round(3)

    existing_style = container['style'].to_s
    # 既存の image-group 用変数定義を一度取り除く
    cleaned_style = existing_style
                    .gsub(/--image-group-col1:[^;]+;?/, '')
                    .gsub(/--image-group-col2:[^;]+;?/, '')
                    .strip

    var_style = "--image-group-col1: #{col1_fr}fr; --image-group-col2: #{col2_fr}fr"

    container['style'] = if cleaned_style.empty?
                           var_style
                         else
                           "#{cleaned_style.chomp(';')}; #{var_style}"
                         end

    # 画像側の width 指定を削除(グリッド列幅で制御)
    normalize_image_group_width!(first_figure, first_img)

    changed = true
  end

  return unless changed

  HtmlParser.save_html_document(html_file, doc)
  Common.log_success("#{html_file}: image-group コンテナの列比率を設定しました")
end

.process_sideimage_footnotes!(html_file) ⇒ Object

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

sideimage 内の脚注参照を処理

Step 4 で page-footnote-print が生成された後に呼び出され、 sideimage-body 内の [^urlN] を N に変換する。 印刷読者のために、リンクのURLを脚注として表示する。



659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
# File 'lib/vivlio_starter/cli/post_process.rb', line 659

def process_sideimage_footnotes!(html_file)
  content = File.read(html_file, encoding: 'utf-8')
  doc = HtmlParser.parse_html_document(content)

  changed = false

  # URLと脚注 aside のマッピングを構築
  url_to_footnote = {}
  doc.css('aside.page-footnote-print').each do |aside|
    next unless aside['data-footnote-number']

    link = aside.at_css('a')
    url_to_footnote[link['href']] = aside if link && link['href']
  end

  return if url_to_footnote.empty?

  # sideimage-body 内の <a> タグの直後にある [^urlN] を処理
  doc.css('div.sideimage-body').each do |body|
    body.css('a').each do |link_elem|
      next_node = link_elem.next_sibling
      next unless next_node&.text?

      text = next_node.text
      # [^urlN] または [^N] パターンを検出
      next unless text.match?(/^\s*\[\^(?:url)?\d+\]/)

      # リンクのURLから対応する脚注 aside を取得
      target_url = link_elem['href']
      footnote_aside = url_to_footnote[target_url]

      if footnote_aside
        fn_num = footnote_aside['data-footnote-number']
        # 脚注参照をスーパースクリプトリンクに置換
        modified = text.sub(/^\s*\[\^(?:url)?\d+\]/, '')

        # スーパースクリプトリンクを生成
        sup = Nokogiri::XML::Node.new('sup', doc)
        anchor = Nokogiri::XML::Node.new('a', doc)
        anchor['href'] = "#fn#{fn_num}"
        anchor['class'] = 'footnote-ref'
        anchor['role'] = 'doc-noteref'
        anchor.content = fn_num
        sup.add_child(anchor)

        # <a>タグの直後にスーパースクリプトを挿入
        link_elem.add_next_sibling(sup)

        # 参照リンクの解決先となる隠しインライン脚注を直後に挿入する
        # (解決先が aside 自体だと Vivliostyle が脚注を重複描画するため)
        inline_span = FootnoteConverter.build_inline_footnote_node(
          doc, "fn#{fn_num}", footnote_aside.inner_html
        )
        sup.add_next_sibling(inline_span)

        # 脚注フロートは挿入した span が担う。控えの aside にも印を付けないと
        # CSS(aside.page-footnote[data-footnote-anchored] の非表示)が効かず、
        # 同じ脚注がページ下部へ二重に降りる。FootnoteConverter が自前で
        # span を挿すときに anchored: true を渡しているのと同じ意味付け。
        footnote_aside['data-footnote-anchored'] = '1'

        # 残りのテキストを更新
        if modified.strip.empty?
          next_node.remove
        else
          next_node.content = modified
        end

      else
        # 対応する脚注が見つからない場合は参照を削除
        cleaned = text.gsub(/\s*\[\^(?:url)?\d+\]/, '')
        if cleaned.empty?
          next_node.remove
        else
          next_node.content = cleaned
        end
      end
      changed = true
    end
  end

  return unless changed

  HtmlParser.save_html_document(html_file, doc)
  Common.log_success("#{html_file}: sideimage 脚注参照を変換しました")
end

.remove_footnote_anchors(doc) ⇒ Object



877
878
879
880
881
882
883
884
885
886
# File 'lib/vivlio_starter/cli/post_process.rb', line 877

def remove_footnote_anchors(doc)
  # 旧形式: <span class="footnote-anchor"> を削除
  doc.css('span.footnote-anchor').each do |anchor|
    parent = anchor.parent
    anchor.remove
    parent.remove if parent&.name == 'p' && parent.content.strip.empty?
  end
  # 新形式: <p class="footnote-anchor"> を削除(VFM が {.footnote-anchor} から生成)
  doc.css('p.footnote-anchor').each(&:remove)
end

.renumber_footnotes_by_document_order!(html_file) ⇒ Object

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

脚注をドキュメント出現順に再番号付け

sideimage 内の脚注と本文の脚注が混在する場合、処理順序により 番号が出現順にならないことがある。この関数で修正する。



753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
# File 'lib/vivlio_starter/cli/post_process.rb', line 753

def renumber_footnotes_by_document_order!(html_file)
  content = File.read(html_file, encoding: 'utf-8')
  doc = HtmlParser.parse_html_document(content)

  footnote_refs = collect_footnote_refs(doc)
  return if footnote_refs.empty?

  renumber_map = build_renumber_map(footnote_refs)

  if needs_renumbering?(renumber_map)
    update_footnote_refs(footnote_refs)
    update_footnote_definitions(doc, renumber_map)
    sort_footnotes_in_sections(doc)
  end

  # footnote-anchor span は再番号付けの有無に関わらず常に削除する
  remove_footnote_anchors(doc)

  # body 直下の aside.page-footnote-print を参照の近くに移動する
  move_body_asides_near_references!(doc)

  HtmlParser.save_html_document(html_file, doc)
  Common.log_success("#{html_file}: 脚注を出現順に再番号付けしました")
end

.reorder_asides(doc, asides, sorted_asides) ⇒ Object



907
908
909
910
911
912
913
# File 'lib/vivlio_starter/cli/post_process.rb', line 907

def reorder_asides(doc, asides, sorted_asides)
  marker = Nokogiri::XML::Comment.new(doc, 'footnote-sort-marker')
  asides.first.add_previous_sibling(marker)
  asides.each(&:remove)
  sorted_asides.reverse_each { |aside| marker.add_next_sibling(aside) }
  marker.remove
end

.resolve_entry_map(entries) ⇒ Hash{String => TokenResolver::Entry}

Entry 配列を解決し、HTML パス => Entry の Hash を返す 中間 HTML はワークスペースの html/ に置かれる(P4 §3.4-1)

Parameters:

Returns:



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
# File 'lib/vivlio_starter/cli/post_process.rb', line 214

def resolve_entry_map(entries)
  raw = Array(entries).compact
  resolver = TokenResolver::Resolver.new

  if raw.empty?
    # 全 HTML ファイルを TokenResolver で解決
    Dir.glob(File.join(Common::BUILD_HTML_DIR, '*.html')).each_with_object({}) do |html_file, map|
      entry = resolver.resolve_file(html_file)
      map[html_file] = entry
    end
  elsif raw.first.respond_to?(:kind)
    # Entry 配列: HTML パスと紐付け
    raw.each_with_object({}) do |entry, map|
      html_file = File.join(Common::BUILD_HTML_DIR, "#{entry.basename}.html")
      map[html_file] = entry
    end
  else
    # basename/パスの場合は TokenResolver で解決
    raw.each_with_object({}) do |bn, map|
      entry = resolver.resolve_file(bn)
      html_file = File.join(Common::BUILD_HTML_DIR, "#{entry.basename}.html")
      map[html_file] = entry
    end
  end
end

.sanitize_image_group_fraction(raw) ⇒ Object

パーセンテージから得た比率を安全な範囲に丸める



626
627
628
629
630
631
# File 'lib/vivlio_starter/cli/post_process.rb', line 626

def sanitize_image_group_fraction(raw)
  return nil unless raw.positive?

  # 極端な比率を避けるため、5%〜95% にクランプ
  raw.clamp(0.05, 0.95)
end

.sanitize_sideimage_fraction(raw) ⇒ Object

パーセンテージから得た比率を安全な範囲に丸める



981
982
983
984
985
986
# File 'lib/vivlio_starter/cli/post_process.rb', line 981

def sanitize_sideimage_fraction(raw)
  return nil unless raw.positive?

  # 極端な比率を避けるため、5%〜95% にクランプ
  raw.clamp(0.05, 0.95)
end

.sort_footnotes_in_sections(doc) ⇒ Object



889
890
891
892
893
# File 'lib/vivlio_starter/cli/post_process.rb', line 889

def sort_footnotes_in_sections(doc)
  doc.css('section').each do |section|
    sort_section_footnotes(doc, section)
  end
end

.sort_section_footnotes(doc, section) ⇒ Object



896
897
898
899
900
901
902
903
904
# File 'lib/vivlio_starter/cli/post_process.rb', line 896

def sort_section_footnotes(doc, section)
  asides = section.css('> aside.page-footnote-print').to_a
  return if asides.size < 2

  sorted_asides = asides.sort_by { |a| a['data-footnote-number'].to_i }
  return if asides.map { |a| a['data-footnote-number'] } == sorted_asides.map { |a| a['data-footnote-number'] }

  reorder_asides(doc, asides, sorted_asides)
end

.update_aside_footnote(doc, old_fn_id, new_number) ⇒ Object



848
849
850
851
852
853
854
# File 'lib/vivlio_starter/cli/post_process.rb', line 848

def update_aside_footnote(doc, old_fn_id, new_number)
  aside = doc.at_css("aside##{old_fn_id}")
  return unless aside

  aside['id'] = "fn#{new_number}"
  aside['data-footnote-number'] = new_number.to_s
end


870
871
872
873
874
# File 'lib/vivlio_starter/cli/post_process.rb', line 870

def update_fnref_link(doc, old_fn_id, new_number)
  old_fnref_id = old_fn_id.sub('fn', 'fnref')
  fnref = doc.at_css("a##{old_fnref_id}")
  fnref['id'] = "fnref#{new_number}" if fnref
end

.update_footnote_definitions(doc, renumber_map) ⇒ Object



830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
# File 'lib/vivlio_starter/cli/post_process.rb', line 830

def update_footnote_definitions(doc, renumber_map)
  # IDの衝突を避けるため、2段階で更新する
  # Phase 1: 全定義を一時ID(tmp_fnN)に変更
  # ※ update_footnote_refs が先に fnref ID を更新済みのため fnref は対象外
  renumber_map.each_key do |old_fn_id|
    tmp_id = "tmp_#{old_fn_id}"
    doc.at_css("aside##{old_fn_id}")&.tap { |n| n['id'] = tmp_id }
    doc.at_css("span##{old_fn_id}")&.tap { |n| n['id'] = tmp_id }
  end
  # Phase 2: 一時IDから最終IDへ変更
  renumber_map.each do |old_fn_id, new_number|
    tmp_id = "tmp_#{old_fn_id}"
    update_aside_footnote(doc, tmp_id, new_number)
    update_inline_footnote(doc, tmp_id, new_number)
  end
end

.update_footnote_ref_text(ref, new_number) ⇒ Object



819
820
821
822
823
824
825
826
827
# File 'lib/vivlio_starter/cli/post_process.rb', line 819

def update_footnote_ref_text(ref, new_number)
  if ref.parent&.name == 'sup'
    ref.content = new_number.to_s
  elsif ref.at_css('sup')
    ref.at_css('sup').content = new_number.to_s
  else
    ref.content = new_number.to_s
  end
end

.update_footnote_refs(footnote_refs) ⇒ Object



809
810
811
812
813
814
815
816
# File 'lib/vivlio_starter/cli/post_process.rb', line 809

def update_footnote_refs(footnote_refs)
  footnote_refs.each_with_index do |ref, idx|
    new_number = idx + 1
    ref['href'] = "#fn#{new_number}"
    ref['id'] = "fnref#{new_number}" if ref['id']
    update_footnote_ref_text(ref, new_number)
  end
end

.update_inline_footnote(doc, old_fn_id, new_number) ⇒ Object

脚注番号は data-footnote-number から ::before で描かれる(components.css)。 PDF で脚注本体を担うのは参照直後の span なので、id だけ振り直して番号を 据え置くと、本体には再番号付け前の番号が出たままになる。 aside 側(update_aside_footnote)と同じく両方を更新する。



861
862
863
864
865
866
867
# File 'lib/vivlio_starter/cli/post_process.rb', line 861

def update_inline_footnote(doc, old_fn_id, new_number)
  inline = doc.at_css("span##{old_fn_id}")
  return unless inline

  inline['id'] = "fn#{new_number}"
  inline['data-footnote-number'] = new_number.to_s
end

.wrap_cross_ref_code_blocks!(html_file) ⇒ Object

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

クロスリファレンス用コードブロックのラップ

pre_process で挿入した "" コメントを基準に、 直前の

(キャプション)と直後の

(Prism済みコード)を

で包みます。 旧スタイルの

にも対応します。 行番号付与 (prism_lines) 後に実行します。



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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/vivlio_starter/cli/post_process.rb', line 250

def wrap_cross_ref_code_blocks!(html_file)
  content = File.read(html_file, encoding: 'utf-8')
  doc = HtmlParser.parse_html_document(content)
  changed = false

  # パターン1: <!--xref:ID--> コメントを基準にラップ
  doc.xpath('//comment()').each do |comment|
    text = comment.text.to_s.strip
    next unless text.start_with?('xref:')

    id = text.sub(/\Axref:/, '')
    next if id.empty?

    caption = comment.previous_element
    block = comment.next_element

    next unless caption&.name == 'p'
    next unless block

    # 直後が <pre> 単体、または <figure> 内に <pre> を含むパターンの両方に対応
    if block.name == 'pre'
      code_container = block
    elsif block.name == 'figure' && block.at_css('pre')
      code_container = block
    else
      next
    end

    # キャプションに code-caption クラスを付与(既存クラスは保持)
    existing_classes = caption['class'].to_s.split(/\s+/).reject(&:empty?)
    unless existing_classes.include?('code-caption')
      caption['class'] = (existing_classes + ['code-caption']).uniq.join(' ')
    end

    wrapper = Nokogiri::XML::Node.new('div', doc)
    wrapper['id'] = id
    wrapper['class'] = 'cross-ref-list'

    # wrapper を caption の直前に挿入し、caption とコードブロック要素を移動
    caption.add_previous_sibling(wrapper)
    wrapper.add_child(caption)
    wrapper.add_child(code_container)

    # マーカーコメントは削除
    comment.remove

    changed = true
  end

  # パターン2(後方互換): <p class="code-caption" data-xref-id> + <pre>
  doc.css('p.code-caption[data-xref-id]').each do |p|
    id = p['data-xref-id'].to_s
    next if id.empty?

    node = p.next_sibling
    pre = nil
    while node
      if node.element?
        pre = node if node.name == 'pre'
        break
      end
      node = node.next_sibling
    end
    next unless pre

    wrapper = Nokogiri::XML::Node.new('div', doc)
    wrapper['id'] = id
    wrapper['class'] = 'cross-ref-list'

    p.remove_attribute('data-xref-id')

    p.add_previous_sibling(wrapper)
    wrapper.add_child(p)
    wrapper.add_child(pre)

    changed = true
  end

  return unless changed

  HtmlParser.save_html_document(html_file, doc)
  Common.log_success("#{html_file}: cross-ref list code blocks wrapped")
end

.wrap_img_text_blocks!(html_file) ⇒ Object

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

img-text / text-img 系コンテナの正規化

VFM が出力する

テキスト…
のような構造では、テキストノードや が独立したグリッドセルに なり、レイアウトが崩れる。figure 以外の子ノードを
でまとめて包み、常に
テキスト…
という 2 要素構造に正規化する。 ================================================================


558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
# File 'lib/vivlio_starter/cli/post_process.rb', line 558

def wrap_img_text_blocks!(html_file)
  content = File.read(html_file, encoding: 'utf-8')
  doc = HtmlParser.parse_html_document(content)
  changed = false

  selectors = %w[img-text img-text2 img-text3 text-img text2-img text3-img]
  css_selector = selectors.map { "div.#{it}" }.join(', ')

  doc.css(css_selector).each do |container|
    figure = container.at_css('> figure')
    next unless figure

    children = container.children
    body_nodes = children.reject do |node|
      node == figure || (node.text? && node.text.strip.empty?)
    end

    # 既に img-text-body でラップ済みならスキップ
    if body_nodes.size == 1 && body_nodes.first.element? &&
       body_nodes.first['class'].to_s.include?('img-text-body')
      next
    end

    next if body_nodes.empty?

    body_wrapper = Nokogiri::XML::Node.new('div', doc)
    body_wrapper['class'] = 'img-text-body'

    first_body = body_nodes.first
    first_body.add_previous_sibling(body_wrapper)
    body_nodes.each { body_wrapper.add_child(it) }

    changed = true
  end

  return unless changed

  HtmlParser.save_html_document(html_file, doc)
  Common.log_success("#{html_file}: img-text コンテナを正規化しました")
end

.wrap_sideimage_blocks!(html_file) ⇒ Object

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

sideimage コンテナ内テキストのラップ

Vivliostyle/VFM が出力する

本文テキスト…
のような構造では、figure 以外がテキストノードとなり CSS Grid の .sideimage-right > :not(figure) セレクタでは拾えない。 そこで、figure 以外の子ノードを
で まとめて包み、常に
本文…
という 2 要素構造に正規化する。 ================================================================


352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
# File 'lib/vivlio_starter/cli/post_process.rb', line 352

def wrap_sideimage_blocks!(html_file)
  content = File.read(html_file, encoding: 'utf-8')
  doc = HtmlParser.parse_html_document(content)
  changed = false

  doc.css('div.sideimage-right, div.sideimage-left, div.sideimage').each do |container|
    figure = container.at_css('> figure')
    next unless figure

    # 画像幅指定(width=50% など)から列比率を推定し、CSS変数として埋め込む
    if (fraction = extract_sideimage_width_fraction(figure))
      text_fr = (1.0 - fraction).round(3)
      img_fr  = fraction.round(3)

      existing_style = container['style'].to_s
      # 既存の sideimage 用変数定義を一度取り除く
      cleaned_style = existing_style
                      .gsub(/--sideimage-text-fr:[^;]+;?/, '')
                      .gsub(/--sideimage-img-fr:[^;]+;?/, '')
                      .strip

      var_style = "--sideimage-text-fr: #{text_fr}fr; --sideimage-img-fr: #{img_fr}fr"

      container['style'] = if cleaned_style.empty?
                             var_style
                           else
                             "#{cleaned_style.chomp(';')}; #{var_style}"
                           end

      normalize_sideimage_figure_width!(figure)

      changed = true
    end

    children = container.children

    body_nodes = children.reject do |node|
      node == figure || (node.text? && node.text.strip.empty?)
    end

    # すでに sideimage-body がひとつだけある場合は何もしない
    if body_nodes.size == 1 && body_nodes.first.element? &&
       body_nodes.first['class'].to_s.split.include?('sideimage-body')
      next
    end

    next if body_nodes.empty?

    body_wrapper = Nokogiri::XML::Node.new('div', doc)
    body_wrapper['class'] = 'sideimage-body'

    first_body = body_nodes.first
    first_body.add_previous_sibling(body_wrapper)

    body_nodes.each do |node|
      body_wrapper.add_child(node)
    end

    changed = true
  end

  # sideimage-body 内に残っているバッククォート付きコードを <code> 要素に変換
  doc.css('div.sideimage-body').each do |body|
    body.traverse do |node|
      next unless node.text?

      text = node.text
      next unless text.include?('`')

      segments = text.split(/(`[^`]*`)/)
      next if segments.length == 1

      # 元のテキストノードの直前に、新しいノードを順番に挿入していく
      segments.each do |seg|
        if seg.start_with?('`') && seg.end_with?('`') && seg.length >= 2
          inner = seg[1..-2]
          code = Nokogiri::XML::Node.new('code', doc)
          code.content = inner
          node.add_previous_sibling(code)
        else
          node.add_previous_sibling(Nokogiri::XML::Text.new(seg, doc))
        end
      end

      # 役目を終えた元のテキストノードは削除する
      node.remove
      changed = true
    end

    # sideimage-body 内のマークダウンリンクを <a> タグに変換
    # [テキスト](URL) → <a href="URL">テキスト</a>
    # 脚注参照 [^urlN] は一旦そのまま残す(後で process_sideimage_footnotes! で処理)
    body.traverse do |node|
      next unless node.text?

      text = node.text
      next unless text.match?(/\[.+?\]\(.+?\)/)

      # マークダウンリンクと脚注参照を分割(脚注参照は後で処理するので残す)
      pattern = /(\[[^\]]+\]\([^)]+\))/
      segments = text.split(pattern)
      next if segments.length == 1

      segments.each do |seg|
        if (m = seg.match(/^\[([^\]]+)\]\(([^)]+)\)$/))
          # マークダウンリンク: [text](url) → <a>text</a>
          link_text = m[1]
          link_url = m[2]
          anchor = Nokogiri::XML::Node.new('a', doc)
          anchor['href'] = link_url
          anchor.content = link_text
          node.add_previous_sibling(anchor)
        else
          # 通常テキスト(脚注参照 [^urlN] も含む)
          node.add_previous_sibling(Nokogiri::XML::Text.new(seg, doc))
        end
      end

      node.remove
      changed = true
    end
  end

  return unless changed

  HtmlParser.save_html_document(html_file, doc)
  Common.log_success("#{html_file}: sideimage コンテナを正規化しました")
end