Module: Vivlio::Starter::CLI::PostProcessCommands::FootnoteConverter

Defined in:
lib/vivlio/starter/cli/post_process/footnote_converter.rb

Overview

Module: FootnoteConverter


【役割】

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

【処理の流れ】

  1. section.footnotes 内の <li id=“fnN”> を収集

  2. 戻りリンク/空段落を除去した内側HTMLを定義として保持

  3. footnotes セクションを削除

  4. 本文の参照アンカー直後に脚注を挿入

    • 画面用: <span class=“page-footnote page-footnote-inline”>

    • 印刷用: <aside class=“page-footnote page-footnote-print”>

  5. 未使用の定義は <body> 末尾に追加

  6. 定義のない参照は前方リンクから推測して補完

Class Method Summary collapse

Class Method Details

.adjust_following_whitespace(node) ⇒ Object

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

Parameters:

  • node (Nokogiri::XML::Element)

    脚注ノード



318
319
320
321
322
323
324
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 318

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 経由)であるためendnote クラスを付与して float:footnote を無効化する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • definitions (Hash<String, String>)

    未使用の脚注定義



203
204
205
206
207
208
209
210
211
212
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 203

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, endnote: true)
    body_el.add_child("\n")
    body_el.add_child(aside)
  end
end

.build_anchor_ref_map(doc, definitions) ⇒ Object

footnote-anchor 内の参照と未使用定義を対応付けるマップを構築する(廃止予定)



241
242
243
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 241

def build_anchor_ref_map(doc, definitions)
  {}
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要素



287
288
289
290
291
292
293
294
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 287

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.inner_html = body
  span
end

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

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

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML

  • endnote (Boolean) (defaults to: false)

    true の場合 page-footnote-endnote クラスを追加

Returns:

  • (Nokogiri::XML::Element)

    aside要素



302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 302

def build_print_footnote_node(doc, fid, body, endnote: false)
  aside = Nokogiri::XML::Node.new('aside', doc)
  aside['role'] = 'doc-footnote'
  classes = 'page-footnote page-footnote-print'
  classes += ' page-footnote-endnote' if endnote
  aside['class'] = classes
  aside['id'] = fid
  # IDから脚注番号を抽出(例: fn5 -> 5, fnurl1 -> url1)
  footnote_number = fid.sub(/^fn/, '')
  aside['data-footnote-number'] = footnote_number
  aside.inner_html = body
  aside
end

.convert_endnotes_to_page_footnotes!(html) ⇒ String

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

Parameters:

  • html (String)

    HTML文字列

Returns:

  • (String)

    変換後のHTML文字列



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

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)

  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 => 内容のハッシュ



54
55
56
57
58
59
60
61
62
63
64
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 54

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ドキュメント



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

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



146
147
148
149
150
151
152
153
154
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 146

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



191
192
193
194
195
196
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 191

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



272
273
274
275
276
277
278
279
280
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 272

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



91
92
93
94
95
96
97
98
99
100
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 91

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>)

    脚注定義



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 69

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



107
108
109
110
111
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 107

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

段落外参照の場合、アンカーの直後に印刷用脚注を配置するsideimage コンテナ内の場合は section 末尾に配置する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • anchor (Nokogiri::XML::Element)

    脚注参照アンカー

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 169

def insert_print_footnote_after_anchor!(doc, anchor, fid, body)
  sideimage = find_sideimage_container(anchor)
  aside = build_print_footnote_node(doc, fid, body, endnote: !!sideimage)
  if sideimage
    section = anchor.ancestors('section').first
    if section
      section.add_child("\n")
      section.add_child(aside)
    else
      sideimage.add_next_sibling("\n")
      sideimage.add_next_sibling(aside)
    end
  else
    anchor.add_next_sibling("\n")
    anchor.add_next_sibling(aside)
  end
end

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

段落内参照の場合、段落直後に印刷用脚注 aside を差し込むsideimage コンテナ内の場合はコンテナの外に配置する(Vivliostyle の float:footnote が sideimage 内で正しく動作しないため)

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • anchor (Nokogiri::XML::Element)

    脚注参照アンカー

  • fid (String)

    脚注ID

  • body (String)

    脚注本文HTML



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
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 120

def insert_print_footnote_after_paragraph!(doc, anchor, fid, body)
  sideimage = find_sideimage_container(anchor)
  aside = build_print_footnote_node(doc, fid, body, endnote: !!sideimage)
  if sideimage
    # sideimage 内の脚注は所属する section の末尾に配置する
    # (Vivliostyle の float:footnote がページ境界で重複表示されるのを防ぐ)
    section = anchor.ancestors('section').first
    if section
      section.add_child("\n")
      section.add_child(aside)
    else
      sideimage.add_next_sibling("\n")
      sideimage.add_next_sibling(aside)
    end
  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

.normalize_definition_ids!(doc, definitions) ⇒ Object

footnote-anchor span 内の参照を使って、definitions のキーをVFM が割り当てた実際のIDに正規化する例: section.footnotes 内の fnurl3 は、footnote-anchor span 内の

<a href="#fn4"> に対応するため、fnurl3 → fn4 に変換する

Parameters:

  • doc (Nokogiri::HTML::Document)

    Nokogiriドキュメント

  • definitions (Hash<String, String>)

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



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/footnote_converter.rb', line 220

def normalize_definition_ids!(doc, definitions)
  # footnote-anchor span 内の参照を DOM 順で収集
  anchor_refs = doc.css('span.footnote-anchor a.footnote-ref[href^="#fn"]')
                   .map { |a| a['href']&.delete_prefix('#') }
                   .compact
  return if anchor_refs.empty?

  # footnote-anchor span 内の参照に対応する定義IDのみを対象とする
  # (fn-urlN または fnurlN 形式のキーのみ)
  url_keys = definitions.keys.select { |k| k.match?(/\Afn-?url\d+\z/) }
  return if url_keys.empty?
  return if url_keys.size != anchor_refs.size

  url_keys.zip(anchor_refs).each do |old_id, new_id|
    next if old_id == new_id || new_id.nil?

    definitions[new_id] = definitions.delete(old_id)
  end
end

Returns:

  • (Boolean)


156
157
158
159
160
161
# File 'lib/vivlio/starter/cli/post_process/footnote_converter.rb', line 156

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