Module: VivlioStarter::CLI::PostProcessCommands::FootnoteConverter

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

Overview

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

Module: FootnoteConverter

【役割】

  • 章末脚注(endnotes)をページ脚注(page footnotes)に変換

【処理の流れ】

  1. section.footnotes 内の
  2. を収集
  3. 戻りリンク/空段落を除去した内側HTMLを定義として保持
  4. footnotes セクションを削除
  5. 本文の参照アンカー直後に脚注を挿入
    • 画面用:
    • 印刷用:
  6. 未使用の定義は 末尾に追加
  7. 定義のない参照は前方リンクから推測して補完

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

Class Method Summary collapse

Class Method Details

.adjust_following_whitespace(node) ⇒ Object

インライン脚注後の空白をノーブレークスペースへ変換する

Parameters:

  • node (Nokogiri::XML::Element)

    脚注ノード



366
367
368
369
370
371
372
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 366

def adjust_following_whitespace(node)
  following = node.next_sibling
  return unless following&.text?

  text = following.text
  following.content = " #{text.lstrip}" if text.start_with?(' ')
end

.append_unused_footnotes_to_body!(doc, definitions) ⇒ Object

残った脚注定義を本文末尾の aside として追加する 未使用の定義は sideimage 内の脚注(footnote-anchor 経由)であり、 後段の process_sideimage_footnotes! が参照を生成した後、 move_body_asides_near_references! が参照の近くへ移動する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • definitions (Hash<String, String>)

    未使用の脚注定義



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

def append_unused_footnotes_to_body!(doc, definitions)
  return if definitions.empty?

  body_el = doc.at_css('body') || doc
  definitions.each do |fid, body|
    aside = build_print_footnote_node(doc, fid, body)
    body_el.add_child("\n")
    body_el.add_child(aside)
  end
end

.bare_definition_url(body) ⇒ String?

URL」だけで構成された定義から URL を取り出す。 自動生成されたURL脚注の判定に使い、それ以外(手書きの脚注本文)は nil を返して修復対象から除外する。VFM の設定によっては定義が

で包まれることがあるため、単一の

ラッパーは許容する。

Parameters:

  • body (String)

    脚注定義のHTML

Returns:

  • (String, nil)

    URL



278
279
280
281
282
283
284
285
286
287
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 278

def bare_definition_url(body)
  html = body.to_s.strip
  if (wrapped = html.match(%r{\A<p>(.*)</p>\z}m))
    html = wrapped[1].strip
  end
  m = html.match(%r{\A<a href="(https?://[^"]+)"[^>]*>([^<]+)</a>\z})
  return nil unless m && m[1] == m[2]

  m[1]
end

.build_inline_footnote_node(doc, fid, body) ⇒ Nokogiri::XML::Element

インライン脚注用の span ノードを生成する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML

Returns:

  • (Nokogiri::XML::Element)

    span要素



331
332
333
334
335
336
337
338
339
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 331

def build_inline_footnote_node(doc, fid, body)
  span = Nokogiri::XML::Node.new('span', doc)
  span['role'] = 'doc-footnote'
  span['class'] = 'page-footnote page-footnote-inline'
  span['id'] = fid
  span['data-footnote-number'] = fid.sub(/^fn/, '')
  span.inner_html = body
  span
end

.build_print_footnote_node(doc, fid, body, anchored: false) ⇒ Nokogiri::XML::Element

印刷用脚注の aside ノードを生成する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML

  • anchored (Boolean) (defaults to: false)

    参照位置に同内容の span#fnN が既にあるか

Returns:

  • (Nokogiri::XML::Element)

    aside要素



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 347

def build_print_footnote_node(doc, fid, body, anchored: false)
  aside = Nokogiri::XML::Node.new('aside', doc)
  aside['role'] = 'doc-footnote'
  aside['class'] = 'page-footnote page-footnote-print'
  aside['id'] = fid
  # IDから脚注番号を抽出(例: fn5 -> 5, fnurl1 -> url1)
  footnote_number = fid.sub(/^fn/, '')
  aside['data-footnote-number'] = footnote_number
  # 参照位置に同内容の span#fnN があるなら、脚注フロートはそちらが担う。
  # この aside は控えなので PDF では描画しない(CSS が属性で拾う。クラスでなく
  # data 属性にするのは img[data-vs-raster] と同じフック方式に揃えるため)。
  # DOM に残すのは後処理(sideimage 変換・出現順の再番号付け)が aside を辿るため。
  aside['data-footnote-anchored'] = '1' if anchored
  aside.inner_html = body
  aside
end

.convert_endnotes_to_page_footnotes!(html) ⇒ String

章末脚注をページ脚注へ変換

Parameters:

  • html (String)

    HTML文字列

Returns:

  • (String)

    変換後のHTML文字列



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 31

def convert_endnotes_to_page_footnotes!(html)
  doc = HtmlParser.parse_html_document(html)
  footnotes = doc.at_css('section.footnotes')
  return html unless footnotes

  definitions = extract_footnote_definitions(footnotes)
  footnotes.remove

  # footnote-anchor span 内の参照を使って、VFM が割り当てた実際のIDに
  # definitions のキーを正規化する(例: fn-url3 → fn4)
  normalize_definition_ids!(doc, definitions)

  # VFM 2.x はテーブルセル内の脚注について定義本文を逆順に入れ替えるため、
  # 参照直前のリンクURLと照合して修復する
  repair_table_footnote_definitions!(doc, definitions)

  insert_footnotes_for_references!(doc, definitions)
  append_unused_footnotes_to_body!(doc, definitions)
  fill_missing_footnote_references!(doc)

  HtmlParser.render_html_document(doc)
end

.extract_footnote_definitions(footnotes_section) ⇒ Hash<String, String>

section.footnotes 内の脚注定義を id => HTML として抽出する

Parameters:

  • footnotes_section (Nokogiri::XML::Element)

    footnotes section要素

Returns:

  • (Hash<String, String>)

    脚注ID => 内容のハッシュ



57
58
59
60
61
62
63
64
65
66
67
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 57

def extract_footnote_definitions(footnotes_section)
  footnotes_section.css('li[id]').each_with_object({}) do |li, memo|
    fid = li['id']
    cleaned = li.dup
    # 戻りリンクを削除
    cleaned.css('a.footnote-back, a.footnote-backref').each(&:remove)
    # 空段落を削除
    cleaned.css('p').select { |p| p.text.strip.empty? }.each(&:remove)
    memo[fid] = cleaned.children.map(&:to_html).join.strip
  end
end

.fill_missing_footnote_references!(doc) ⇒ Object

定義が存在しない脚注参照を前方リンクから推測して補完する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 291

def fill_missing_footnote_references!(doc)
  doc.css('a.footnote-ref[href^="#fn"]').each do |anchor|
    # expose_container_footnotes! が生成した非表示参照はスキップする
    next if anchor.ancestors('span.footnote-anchor').any?

    fid = anchor['href']&.delete_prefix('#')
    next unless fid
    next if doc.at_css(%(##{fid}))

    body = inferred_body_from_previous_link(anchor)
    next unless body

    if anchor.ancestors('p').any?
      insert_inline_footnote!(doc, anchor, fid, body)
      insert_print_footnote_after_paragraph!(doc, anchor, fid, body)
    else
      insert_print_footnote_after_anchor!(doc, anchor, fid, body)
    end
  end
end

.find_last_print_footnote_sibling(node) ⇒ Object



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

def find_last_print_footnote_sibling(node)
  current = node
  while (sibling = current.next_sibling)
    break unless print_footnote_related_node?(sibling)

    current = sibling
  end
  current
end

.find_sideimage_container(node) ⇒ Nokogiri::XML::Element?

sideimage のトップレベルコンテナ(div.sideimage-right 等)を探す sideimage-body は除外し、sideimage / sideimage-right / sideimage-left のみ対象

Parameters:

  • node (Nokogiri::XML::Element)

    起点ノード

Returns:

  • (Nokogiri::XML::Element, nil)

    sideimage コンテナ、見つからなければ nil



186
187
188
189
190
191
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 186

def find_sideimage_container(node)
  node.ancestors('div').find do |d|
    classes = d['class'].to_s.split
    (classes & %w[sideimage sideimage-right sideimage-left]).any?
  end
end

脚注参照直前のリンク要素から本文 HTML を推定する。 内部リンク(#fn... など)は脚注本文として不適切なため除外する。

Parameters:

  • anchor (Nokogiri::XML::Element)

    脚注参照アンカー

Returns:

  • (String, nil)

    推定された脚注本文HTML



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

def inferred_body_from_previous_link(anchor)
  prev_link = anchor.previous_element
  prev_link = prev_link.previous_element while prev_link && prev_link.name != 'a'
  url = prev_link&.[]('href')
  # http(s):// で始まる外部URLのみを対象とし、内部リンク(#fn...)は除外する
  return unless url&.match?(/\Ahttps?:\/\//)

  %(<a href="#{url}">#{url}</a>)
end

.insert_footnote_for_anchor!(doc, anchor, fid, body) ⇒ Object

アンカー位置に応じてインライン/印刷脚注を挿入する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • anchor (Nokogiri::XML::Element)

    脚注参照アンカー

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML



94
95
96
97
98
99
100
101
102
103
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 94

def insert_footnote_for_anchor!(doc, anchor, fid, body)
  if anchor.ancestors('p').any?
    # 段落内の参照の場合
    insert_inline_footnote!(doc, anchor, fid, body)
    insert_print_footnote_after_paragraph!(doc, anchor, fid, body)
  else
    # 段落外の参照の場合
    insert_print_footnote_after_anchor!(doc, anchor, fid, body)
  end
end

.insert_footnotes_for_references!(doc, definitions) ⇒ Object

本文中の脚注参照アンカーへ定義を差し込む

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • definitions (Hash<String, String>)

    脚注定義



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 72

def insert_footnotes_for_references!(doc, definitions)
  # DOM順序で脚注参照を処理するため、本文中の全参照アンカーを取得
  # expose_container_footnotes! が生成した非表示の footnote-anchor span 内の参照は
  # process_sideimage_footnotes! が別途処理するためスキップする
  doc.css('a.footnote-ref[href^="#fn"]').each do |anchor|
    next if anchor.ancestors('span.footnote-anchor').any?

    fid = anchor['href']&.delete_prefix('#')
    next unless fid
    next unless definitions.key?(fid)

    body = definitions[fid]
    insert_footnote_for_anchor!(doc, anchor, fid, body)
    definitions.delete(fid)
  end
end

.insert_inline_footnote!(doc, anchor, fid, body) ⇒ Object

インライン脚注 span を参照アンカー直後に挿入する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • anchor (Nokogiri::XML::Element)

    脚注参照アンカー

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML



110
111
112
113
114
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 110

def insert_inline_footnote!(doc, anchor, fid, body)
  span = build_inline_footnote_node(doc, fid, body)
  anchor.add_next_sibling(span)
  adjust_following_whitespace(span)
end

.insert_print_footnote_after_anchor!(doc, anchor, fid, body) ⇒ Object

段落外参照(テーブルセル内など)の場合、参照直後に隠しインライン脚注と 印刷用脚注を配置する。 参照リンク(href="#fnN")の解決先が aside 自体になると Vivliostyle が 同じ脚注を複数回描画するため、必ず手前に span#fnN を置いて解決先にする。 sideimage コンテナ内の場合、aside はコンテナの直後に配置する。

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • anchor (Nokogiri::XML::Element)

    脚注参照アンカー

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML



168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 168

def insert_print_footnote_after_anchor!(doc, anchor, fid, body)
  span = build_inline_footnote_node(doc, fid, body)
  anchor.add_next_sibling(span)

  aside = build_print_footnote_node(doc, fid, body, anchored: true)
  sideimage = find_sideimage_container(anchor)
  if sideimage
    sideimage.add_next_sibling(aside)
  else
    span.add_next_sibling(aside)
  end
  aside.add_next_sibling("\n")
end

.insert_print_footnote_after_paragraph!(doc, anchor, fid, body) ⇒ Object

段落内参照の場合、段落直後に印刷用脚注 aside を差し込む sideimage コンテナ内の場合はコンテナの直後に配置する (aside を sideimage 内に置くと CSS Grid レイアウトが壊れるため。 参照リンクの解決先は insert_inline_footnote! が挿入する span#fnN に なるので、float:footnote でも重複描画は起きない)

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • anchor (Nokogiri::XML::Element)

    脚注参照アンカー

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 125

def insert_print_footnote_after_paragraph!(doc, anchor, fid, body)
  sideimage = find_sideimage_container(anchor)
  aside = build_print_footnote_node(doc, fid, body, anchored: true)
  if sideimage
    sideimage.add_next_sibling(aside)
  else
    para = anchor.ancestors('p').first
    if para
      insertion_point = find_last_print_footnote_sibling(para)
      insertion_point.add_next_sibling(aside)
    else
      anchor.add_next_sibling(aside)
    end
  end
  aside.add_next_sibling("\n")
end

Returns:

  • (Boolean)


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

def print_footnote_related_node?(node)
  return false unless node

  (node.text? && node.text.strip.empty?) ||
    (node.element? && node['class'].to_s.split.any? { |c| c == 'page-footnote' })
end

.repair_table_footnote_definitions!(doc, definitions) ⇒ Object

VFM 2.x(remark-footnotes)は、テーブルセル内から参照される脚注について 参照ID(fnref→fn の対応)は正しいまま、定義の「本文」だけを参照と 逆順に並べ替えてしまう(例: fn1 の本文に fn3 の URL が入る)。 自動生成されたURL脚注は「参照直前の外部リンク」が本来の本文なので、 テーブル内の各参照について定義本文を照合し、入れ替わっていれば修復する。 誤爆を防ぐため、(1) 定義本文が「URLそのものへのリンク」だけで構成され、 (2) 期待されるURLが他の定義に実在する(=入れ替わりの証拠がある) 場合のみ書き換える。

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • definitions (Hash<String, String>)

    脚注定義(破壊的に変更)



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/vivlio_starter/cli/post_process/footnote_converter.rb', line 246

def repair_table_footnote_definitions!(doc, definitions)
  known_urls = definitions.values.filter_map { bare_definition_url(it) }
  repaired = {}

  doc.css('table a.footnote-ref[href^="#fn"]').each do |anchor|
    next if anchor.ancestors('span.footnote-anchor').any?

    fid = anchor['href']&.delete_prefix('#')
    next unless fid && definitions.key?(fid)
    next if repaired.key?(fid)

    expected = inferred_body_from_previous_link(anchor)
    next unless expected

    expected_url = bare_definition_url(expected)
    current_url  = bare_definition_url(definitions[fid])
    next unless expected_url && current_url
    next if current_url == expected_url
    next unless known_urls.include?(expected_url)

    repaired[fid] = expected
  end

  definitions.merge!(repaired)
end