] Formatted row strings
+ def format_table_rows(rows)
+ rows.map { |row| format_table_row(row) }
+ end
+
+ # Format a single table row
+ # @param row [RowNode] Row node to format
+ # @return [String] Tab-separated cell contents
+ def format_table_row(row)
+ row.children.map { |cell| render_cell_content(cell) }.join("\t")
+ end
+
+ # Render table cell content
+ # @param cell [CellNode] Cell node to render
+ # @return [String] Cell content or '.' for empty cells
+ def render_cell_content(cell)
+ content = cell.children.map { |child| visit(child) }.join
+ # Empty cells should be represented with a dot in Re:VIEW syntax
+ content.empty? ? '.' : content
+ end
+ end
+ end
+end
diff --git a/lib/review/ast/table_cell_node.rb b/lib/review/ast/table_cell_node.rb
new file mode 100644
index 000000000..d966cb98a
--- /dev/null
+++ b/lib/review/ast/table_cell_node.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require_relative 'node'
+
+module ReVIEW
+ module AST
+ # TableCellNode - Represents a cell in a table
+ #
+ # A table cell can contain text nodes and inline elements.
+ # Cells are separated by tabs in the original Re:VIEW syntax.
+ #
+ # The cell_type attribute determines whether this cell should be
+ # rendered as a header cell () or data cell ( ).
+ class TableCellNode < Node
+ attr_reader :children, :cell_type
+
+ def initialize(location:, cell_type: :td, **kwargs)
+ super
+ @children = []
+ @cell_type = cell_type # :th or :td
+ end
+
+ def self.deserialize_from_hash(hash)
+ node = new(location: ReVIEW::AST::JSONSerializer.restore_location(hash))
+ if hash['children']
+ hash['children'].each do |child_hash|
+ child = ReVIEW::AST::JSONSerializer.deserialize_from_hash(child_hash)
+ node.add_child(child) if child.is_a?(ReVIEW::AST::Node)
+ end
+ end
+ node
+ end
+
+ private
+
+ def serialize_properties(hash, options)
+ super
+ hash[:cell_type] = @cell_type if @cell_type != :td
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/review/ast/table_column_width_parser.rb b/lib/review/ast/table_column_width_parser.rb
new file mode 100644
index 000000000..e714338d0
--- /dev/null
+++ b/lib/review/ast/table_column_width_parser.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+module ReVIEW
+ module AST
+ # Parse tsize specification and generate column width information
+ # This class handles the logic from LATEXBuilder's tsize/separate_tsize methods
+ class TableColumnWidthParser
+ # Result struct for parse method
+ Result = Struct.new(:col_spec, :cellwidth)
+
+ # Check if cellwidth is a fixed-width specification (contains '{')
+ # @param cellwidth [String] column width specification (e.g., "p{10mm}", "l", "c")
+ # @return [Boolean] true if fixed-width (contains braces)
+ def self.fixed_width?(cellwidth)
+ cellwidth && cellwidth.include?('{')
+ end
+
+ # Initialize parser with tsize specification and column count
+ # @param tsize [String] tsize specification (e.g., "10,18,50" or "p{10mm}p{18mm}|p{50mm}")
+ # @param col_count [Integer] number of columns
+ def initialize(tsize, col_count)
+ raise ArgumentError, 'col_count must be positive' if col_count.nil? || col_count <= 0
+
+ @tsize = tsize
+ @col_count = col_count
+ end
+
+ # Parse tsize specification and return result as Struct
+ # @return [Result] Result struct with col_spec and cellwidth
+ def parse
+ if @tsize.nil? || @tsize.empty?
+ default_spec
+ elsif simple_format?
+ parse_simple_format
+ else
+ parse_complex_format
+ end
+ end
+
+ private
+
+ # Generate default column specification
+ # @return [Result] Result struct with default values
+ def default_spec
+ Result.new(
+ '|' + ('l|' * @col_count),
+ ['l'] * @col_count
+ )
+ end
+
+ # Check if tsize is in simple format (e.g., "10,18,50")
+ # @return [Boolean] true if simple format
+ def simple_format?
+ /\A[\d., ]+\Z/.match?(@tsize)
+ end
+
+ # Parse simple format tsize (e.g., "10,18,50" means p{10mm},p{18mm},p{50mm})
+ # @return [Result] Result struct with parsed values
+ def parse_simple_format
+ cellwidth = @tsize.split(/\s*,\s*/).map { |i| "p{#{i}mm}" }
+ col_spec = '|' + cellwidth.join('|') + '|'
+
+ Result.new(col_spec, cellwidth)
+ end
+
+ # Parse complex format tsize (e.g., "p{10mm}p{18mm}|p{50mm}")
+ # @return [Result] Result struct with parsed values
+ def parse_complex_format
+ cellwidth = separate_columns(@tsize)
+ Result.new(@tsize, cellwidth)
+ end
+
+ # Parse tsize string into array of column specifications
+ # Example: "p{10mm}p{18mm}|p{50mm}" -> ["p{10mm}", "p{18mm}", "p{50mm}"]
+ # @param size [String] tsize specification
+ # @return [Array] array of column specifications
+ def separate_columns(size)
+ columns = []
+ current = +''
+ in_brace = false
+
+ size.each_char do |ch|
+ case ch
+ when '|'
+ # Skip pipe characters (table borders)
+ next
+ when '{'
+ in_brace = true
+ current << ch
+ when '}'
+ in_brace = false
+ current << ch
+ columns << current
+ current = +''
+ else
+ if in_brace || current.empty?
+ current << ch
+ else
+ columns << current
+ current = ch.dup
+ end
+ end
+ end
+
+ columns << current unless current.empty?
+
+ columns
+ end
+ end
+ end
+end
diff --git a/lib/review/ast/table_node.rb b/lib/review/ast/table_node.rb
new file mode 100644
index 000000000..b2c69c600
--- /dev/null
+++ b/lib/review/ast/table_node.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require_relative 'node'
+require_relative 'caption_node'
+require_relative 'captionable'
+require_relative 'json_serializer'
+
+module ReVIEW
+ module AST
+ class TableNode < Node
+ include Captionable
+
+ attr_accessor :col_spec, :cellwidth
+ attr_reader :table_type, :metric
+
+ def initialize(location:, id: nil, caption_node: nil, table_type: :table, metric: nil, col_spec: nil, cellwidth: nil, **kwargs)
+ super(location: location, id: id, **kwargs)
+ @caption_node = caption_node
+ @table_type = table_type # :table, :emtable, :imgtable
+ @metric = metric
+ @col_spec = col_spec # Column specification string (e.g., "|l|c|r|")
+ @cellwidth = cellwidth # Array of column width specifications
+ @header_rows = []
+ @body_rows = []
+ end
+
+ def header_rows
+ @children.find_all do |node|
+ node.row_type == :header
+ end
+ end
+
+ def body_rows
+ @children.find_all do |node|
+ node.row_type == :body
+ end
+ end
+
+ def add_header_row(row_node)
+ unless row_node.row_type == :header
+ raise ArgumentError, "Expected header row (row_type: :header), got #{row_node.row_type}"
+ end
+
+ idx = @children.index { |child| child.row_type == :body }
+ if idx
+ insert_child(idx, row_node)
+ else
+ add_child(row_node)
+ end
+ end
+
+ def add_body_row(row_node)
+ unless row_node.row_type == :body
+ raise ArgumentError, "Expected body row (row_type: :body), got #{row_node.row_type}"
+ end
+
+ add_child(row_node)
+ end
+
+ # Get column count from table rows
+ def column_count
+ all_rows = header_rows + body_rows
+ all_rows.first&.children&.length || 1
+ end
+
+ # Get default column specification (left-aligned with borders)
+ def default_col_spec
+ '|' + ('l|' * column_count)
+ end
+
+ # Get default cellwidth array (all left-aligned)
+ def default_cellwidth
+ ['l'] * column_count
+ end
+
+ # Parse tsize value and set col_spec and cellwidth on this table
+ # @param tsize_value [String] tsize specification
+ def parse_and_set_tsize(tsize_value)
+ require_relative('table_column_width_parser')
+ parser = TableColumnWidthParser.new(tsize_value, column_count)
+ result = parser.parse
+ @col_spec = result.col_spec
+ @cellwidth = result.cellwidth
+ end
+
+ # Update table attributes after creation
+ # This is used by MarkdownAdapter to set id and caption from attribute blocks
+ # @param id [String, nil] Table ID
+ # @param caption_node [CaptionNode, nil] Caption node
+ def update_attributes(id: nil, caption_node: nil)
+ @id = id if id
+ @caption_node = caption_node if caption_node
+ end
+
+ def to_h
+ result = super.merge(
+ caption_node: caption_node&.to_h,
+ table_type: table_type,
+ header_rows: header_rows.map(&:to_h),
+ body_rows: body_rows.map(&:to_h)
+ )
+ result[:metric] = metric if metric
+ result[:col_spec] = col_spec if col_spec
+ result[:cellwidth] = cellwidth if cellwidth
+ result
+ end
+
+ # Override serialize_to_hash to use header_rows/body_rows instead of children
+ def serialize_to_hash(options = nil)
+ options ||= JSONSerializer::Options.new
+ hash = {
+ type: self.class.name.split('::').last
+ }
+
+ # Include location information
+ if options.include_location
+ hash[:location] = location&.to_h
+ end
+
+ # Add TableNode-specific properties (no children field)
+ hash[:id] = id if id && !id.empty?
+ hash[:table_type] = table_type
+ serialize_caption_to_hash(hash, options)
+ hash[:header_rows] = header_rows.map { |row| row.serialize_to_hash(options) }
+ hash[:body_rows] = body_rows.map { |row| row.serialize_to_hash(options) }
+ hash[:metric] = metric if metric
+ hash[:col_spec] = col_spec if col_spec
+ hash[:cellwidth] = cellwidth if cellwidth
+
+ hash
+ end
+
+ def self.deserialize_from_hash(hash)
+ node = new(
+ location: ReVIEW::AST::JSONSerializer.restore_location(hash),
+ id: hash['id'],
+ caption_node: deserialize_caption_from_hash(hash),
+ table_type: hash['table_type'] || :table,
+ metric: hash['metric']
+ )
+ # Process header and body rows
+ (hash['header_rows'] || []).each do |row_hash|
+ row = ReVIEW::AST::JSONSerializer.deserialize_from_hash(row_hash)
+ node.add_header_row(row) if row.is_a?(ReVIEW::AST::TableRowNode)
+ end
+ (hash['body_rows'] || []).each do |row_hash|
+ row = ReVIEW::AST::JSONSerializer.deserialize_from_hash(row_hash)
+ node.add_body_row(row) if row.is_a?(ReVIEW::AST::TableRowNode)
+ end
+
+ node
+ end
+ end
+ end
+end
diff --git a/lib/review/ast/table_row_node.rb b/lib/review/ast/table_row_node.rb
new file mode 100644
index 000000000..ec329802e
--- /dev/null
+++ b/lib/review/ast/table_row_node.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require_relative 'node'
+
+module ReVIEW
+ module AST
+ # TableRowNode - Represents a row in a table
+ #
+ # A table row contains multiple table cells (TableCellNode).
+ # Each cell can contain text and inline elements.
+ class TableRowNode < Node
+ ROW_TYPES = %i[header body]
+
+ def initialize(location:, row_type: :body, **kwargs)
+ super
+ @children = []
+ @row_type = row_type.to_sym
+
+ validate_row_type
+ end
+
+ attr_reader :children, :row_type
+
+ def self.deserialize_from_hash(hash)
+ row_type = hash['row_type']&.to_sym || :body
+ node = new(
+ location: ReVIEW::AST::JSONSerializer.restore_location(hash),
+ row_type: row_type
+ )
+ if hash['children']
+ hash['children'].each do |child_hash|
+ child = ReVIEW::AST::JSONSerializer.deserialize_from_hash(child_hash)
+ node.add_child(child) if child.is_a?(ReVIEW::AST::Node)
+ end
+ end
+ node
+ end
+
+ private
+
+ def serialize_properties(hash, options)
+ super
+ hash[:row_type] = @row_type.to_s
+ end
+
+ def validate_row_type
+ unless ROW_TYPES.include?(row_type)
+ raise ArgumentError, "invalid row_type in TableRowNode: `#{row_type}`"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/ast/tex_equation_node.rb b/lib/review/ast/tex_equation_node.rb
new file mode 100644
index 000000000..daae8cd4a
--- /dev/null
+++ b/lib/review/ast/tex_equation_node.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require_relative 'leaf_node'
+require_relative 'caption_node'
+require_relative 'captionable'
+
+module ReVIEW
+ module AST
+ # TexEquationNode - LaTeX mathematical equation block
+ #
+ # Represents LaTeX equation blocks like:
+ # //texequation{
+ # \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
+ # //}
+ #
+ # //texequation[eq1][Caption]{
+ # E = mc^2
+ # //}
+ class TexEquationNode < LeafNode
+ include Captionable
+
+ def initialize(location:, content:, id: nil, caption_node: nil)
+ super(location: location, id: id, content: content)
+ @caption_node = caption_node
+ end
+
+ def to_s
+ "TexEquationNode(id: #{@id.inspect}, caption_node: #{@caption_node.inspect})"
+ end
+
+ # Override to_h to include TexEquationNode-specific attributes
+ def to_h
+ result = super
+ result[:id] = id if id?
+ result[:caption_node] = caption_node&.to_h if caption_node
+ result
+ end
+
+ # Override serialize_to_hash to include TexEquationNode-specific attributes
+ def serialize_to_hash(options = nil)
+ options ||= ReVIEW::AST::JSONSerializer::Options.new
+
+ # Start with type
+ hash = {
+ type: self.class.name.split('::').last
+ }
+
+ # Include location information
+ if options.include_location
+ hash[:location] = location&.to_h
+ end
+
+ # Call node-specific serialization
+ serialize_properties(hash, options)
+
+ # LeafNode automatically excludes children
+ hash
+ end
+
+ def self.deserialize_from_hash(hash)
+ new(
+ location: ReVIEW::AST::JSONSerializer.restore_location(hash),
+ id: hash['id'],
+ caption_node: deserialize_caption_from_hash(hash),
+ content: hash['content'] || ''
+ )
+ end
+
+ private
+
+ def serialize_properties(hash, options)
+ hash[:id] = id if id?
+ serialize_caption_to_hash(hash, options)
+ hash[:content] = content if content && !content.empty?
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/review/ast/text_node.rb b/lib/review/ast/text_node.rb
new file mode 100644
index 000000000..d53f539ba
--- /dev/null
+++ b/lib/review/ast/text_node.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require_relative 'leaf_node'
+
+module ReVIEW
+ module AST
+ class TextNode < LeafNode
+ # Override to_h to exclude children array for TextNode
+ def to_h
+ result = {
+ type: self.class.name.split('::').last,
+ location: location_to_h
+ }
+ result[:content] = @content if @content && !@content.empty?
+ # TextNode is a leaf node - do not include children array
+ result
+ end
+
+ # Override serialize_to_hash to exclude children array for TextNode
+ def serialize_to_hash(options = nil)
+ options ||= ReVIEW::AST::JSONSerializer::Options.new
+
+ # Start with type
+ hash = {
+ type: self.class.name.split('::').last
+ }
+
+ # Include location information
+ if options.include_location
+ hash[:location] = location_to_h
+ end
+
+ # Call node-specific serialization (adds content)
+ serialize_properties(hash, options)
+
+ # TextNode is a leaf node - do not include children array
+ hash
+ end
+
+ def self.deserialize_from_hash(hash)
+ new(
+ location: ReVIEW::AST::JSONSerializer.restore_location(hash),
+ content: hash['content'] || ''
+ )
+ end
+
+ private
+
+ def serialize_properties(hash, _options)
+ # Add content property explicitly for TextNode
+ hash[:content] = content if content
+ hash
+ end
+
+ def location_to_h
+ return nil unless location
+
+ {
+ filename: location.filename,
+ lineno: location.lineno
+ }
+ end
+ end
+ end
+end
diff --git a/lib/review/ast/visitor.rb b/lib/review/ast/visitor.rb
new file mode 100644
index 000000000..d9854196d
--- /dev/null
+++ b/lib/review/ast/visitor.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+module ReVIEW
+ module AST
+ # Base visitor class for traversing AST nodes using the Visitor pattern.
+ # This class provides a generic way to walk through AST structures and
+ # perform operations on each node type.
+ #
+ # Usage:
+ # class MyVisitor < ReVIEW::AST::Visitor
+ # def visit_headline(node)
+ # # Process headline node
+ # end
+ #
+ # def visit_paragraph(node)
+ # # Process paragraph node
+ # end
+ # end
+ #
+ # visitor = MyVisitor.new
+ # result = visitor.visit(ast_root)
+ class Visitor
+ # Visit a node and dispatch to the appropriate visit method.
+ #
+ # @param node [Object] The AST node to visit
+ # @return [Object] The result of the visit method
+ def visit(node)
+ return nil if node.nil?
+
+ method_name = node.visit_method_name
+
+ if respond_to?(method_name, true)
+ send(method_name, node)
+ else
+ raise NotImplementedError, "Visitor #{self.class.name} does not implement #{method_name} for #{node.class.name}"
+ end
+ end
+
+ # Visit multiple nodes and return an array of results.
+ #
+ # @param nodes [Array] Array of AST nodes to visit
+ # @return [Array] Array of visit results
+ def visit_all(nodes)
+ return [] unless nodes
+
+ nodes.map { |node| visit(node) }
+ end
+ end
+ end
+end
diff --git a/lib/review/book/book_unit.rb b/lib/review/book/book_unit.rb
index db3e93fb8..357f7ecbc 100644
--- a/lib/review/book/book_unit.rb
+++ b/lib/review/book/book_unit.rb
@@ -68,6 +68,22 @@ def generate_indexes(use_bib: false)
end
end
+ # Set indexes using AST-based indexing
+ def ast_indexes=(indexes)
+ @footnote_index = indexes[:footnote_index] if indexes[:footnote_index]
+ @endnote_index = indexes[:endnote_index] if indexes[:endnote_index]
+ @list_index = indexes[:list_index] if indexes[:list_index]
+ @table_index = indexes[:table_index] if indexes[:table_index]
+ @equation_index = indexes[:equation_index] if indexes[:equation_index]
+ @image_index = indexes[:image_index] if indexes[:image_index]
+ @icon_index = indexes[:icon_index] if indexes[:icon_index]
+ @numberless_image_index = indexes[:numberless_image_index] if indexes[:numberless_image_index]
+ @indepimage_index = indexes[:indepimage_index] if indexes[:indepimage_index]
+ @headline_index = indexes[:headline_index] if indexes[:headline_index]
+ @column_index = indexes[:column_index] if indexes[:column_index]
+ @book.bibpaper_index = indexes[:bibpaper_index] if @book.present? && indexes[:bibpaper_index]
+ end
+
def dirname
@path && File.dirname(@path)
end
diff --git a/lib/review/book/chapter.rb b/lib/review/book/chapter.rb
index 9f6a9d23a..561284003 100644
--- a/lib/review/book/chapter.rb
+++ b/lib/review/book/chapter.rb
@@ -149,7 +149,9 @@ def on_postdef?
private
def on_file?(contents)
- contents.map(&:strip).include?("#{id}#{@book.ext}")
+ contents.map(&:strip).include?("#{id}#{@book.ext}") ||
+ contents.map(&:strip).include?("#{id}.re") ||
+ contents.map(&:strip).include?("#{id}.md")
end
# backward compatibility
diff --git a/lib/review/book/index/item.rb b/lib/review/book/index/item.rb
index 595a364af..c02f01ca6 100644
--- a/lib/review/book/index/item.rb
+++ b/lib/review/book/index/item.rb
@@ -18,10 +18,11 @@ module ReVIEW
module Book
class Index
class Item
- def initialize(id, number, caption = nil)
+ def initialize(id, number, caption = nil, caption_node: nil)
@id = id
@number = number
@caption = caption
+ @caption_node = caption_node
@path = nil
@index = nil
end
@@ -29,6 +30,7 @@ def initialize(id, number, caption = nil)
attr_reader :id
attr_reader :number
attr_reader :caption
+ attr_accessor :caption_node
attr_accessor :index # internal use only
alias_method :content, :caption
diff --git a/lib/review/html_escape_utils.rb b/lib/review/html_escape_utils.rb
new file mode 100644
index 000000000..beb3f6ea1
--- /dev/null
+++ b/lib/review/html_escape_utils.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'cgi'
+
+module ReVIEW
+ # HTML escape utility methods for AST/Renderer
+ # This module provides basic HTML escaping methods used by HTML Renderer classes.
+ # For Builder classes, use HTMLUtils or LaTeXUtils instead.
+ module HtmlEscapeUtils
+ # HTML content escaping using CGI.escapeHTML
+ def escape_content(str)
+ CGI.escapeHTML(str.to_s)
+ end
+
+ # URL escaping using CGI.escape
+ # Note: LaTeXUtils has its own escape_url implementation for LaTeX-specific needs
+ def escape_url(str)
+ CGI.escape(str.to_s)
+ end
+ end
+end
diff --git a/lib/review/renderer.rb b/lib/review/renderer.rb
new file mode 100644
index 000000000..da165257e
--- /dev/null
+++ b/lib/review/renderer.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+# Renderer module for converting AST nodes to various output formats.
+# This module provides a cleaner, more maintainable approach to output
+# generation compared to the traditional Builder pattern.
+#
+# The renderer approach separates concerns:
+# - AST generation (handled by Compiler)
+# - Format-specific rendering (handled by Renderer subclasses)
+#
+# Usage:
+# # HTML output
+# html_renderer = ReVIEW::Renderer::HtmlRenderer.new
+# html_output = html_renderer.render(ast_root)
+#
+# # JSON output is handled by ReVIEW::AST::JSONSerializer
+
+module ReVIEW
+ module Renderer
+ # Load renderer classes
+ autoload :Base, 'review/renderer/base'
+ autoload :HtmlRenderer, 'review/renderer/html_renderer'
+ autoload :LatexRenderer, 'review/renderer/latex_renderer'
+ autoload :PlaintextRenderer, 'review/renderer/plaintext_renderer'
+ # NOTE: JSONRenderer removed - use ReVIEW::AST::JSONSerializer instead
+ end
+end
diff --git a/lib/review/renderer/base.rb b/lib/review/renderer/base.rb
new file mode 100644
index 000000000..374b7d0f0
--- /dev/null
+++ b/lib/review/renderer/base.rb
@@ -0,0 +1,237 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/ast/visitor'
+require 'review/exception'
+require 'review/renderer/text_formatter'
+
+module ReVIEW
+ module Renderer
+ # Error class for renderer-specific errors
+ class RenderError < ReVIEW::ApplicationError; end
+
+ # Base class for all AST renderers.
+ # This class extends the Visitor pattern to provide rendering capabilities
+ # for converting AST nodes into various output formats.
+ #
+ # Subclasses should implement visit methods for specific node types:
+ # - visit_document(node)
+ # - visit_headline(node)
+ # - visit_paragraph(node)
+ # - visit_codeblock(node)
+ # - visit_table(node)
+ # - etc.
+ #
+ # Usage:
+ # class HtmlRenderer < ReVIEW::Renderer::Base
+ # def visit_headline(node)
+ # level = node.level
+ # caption = process_inline_content(node.caption)
+ # "#{caption} "
+ # end
+ # end
+ #
+ # renderer = HtmlRenderer.new
+ # html_output = renderer.render(ast_root)
+ class Base < ReVIEW::AST::Visitor
+ # Initialize the renderer with chapter context.
+ #
+ # @param chapter [ReVIEW::Book::Chapter] Chapter context
+ def initialize(chapter)
+ @chapter = chapter
+ @book = chapter&.book
+ @config = @book&.config || {}
+ super()
+ @text_formatter = ReVIEW::Renderer::TextFormatter.new(
+ config: @config,
+ chapter: @chapter
+ )
+ end
+
+ attr_reader :text_formatter
+
+ # Render an AST node to the target format.
+ #
+ # @param ast_root [Object] The root AST node to render
+ # @return [String] The rendered output
+ def render(ast_root)
+ result = visit(ast_root)
+ post_process(result)
+ end
+
+ # Check if caption should be positioned at top for given type
+ #
+ # @param type [String] Element type (e.g., 'image', 'table', 'list', 'equation')
+ # @return [Boolean] true if caption should be at top, false otherwise
+ def caption_top?(type)
+ config['caption_position'] && config['caption_position'][type] == 'top'
+ end
+
+ # Render all children of a node and join the results.
+ #
+ # @param node [Object] The parent node whose children should be rendered
+ # @return [String] The joined rendered output of all children
+ def render_children(node)
+ node.children.map { |child| visit(child) }.join
+ end
+
+ # Get the format type for this renderer.
+ # Subclasses must override this method to specify their format.
+ #
+ # @return [Symbol] Format type (:html, :latex, :idgxml, :text, :top)
+ def format_type
+ raise NotImplementedError, "#{self.class} must implement #format_type"
+ end
+
+ private
+
+ attr_reader :config
+
+ # Post-process the rendered result.
+ # Subclasses can override this to perform final formatting,
+ # cleanup, or validation.
+ #
+ # @param result [Object] The result from visiting the AST
+ # @return [String] The final rendered output
+ def post_process(result)
+ result.to_s
+ end
+
+ # Escape special characters for the target format.
+ #
+ # @param str [String] The string to escape
+ # @return [String] The escaped string
+ def escape(str)
+ str.to_s
+ end
+
+ # Default visit methods for common node types.
+ # These provide basic fallback behavior that subclasses can override.
+
+ def visit_text(node)
+ escape(node.content.to_s)
+ end
+
+ def visit_inline(node)
+ content = render_children(node)
+ render_inline_element(node.inline_type, content, node)
+ end
+
+ # Render a specific inline element.
+ #
+ # @param type [String] The inline element type (e.g., 'b', 'i', 'code')
+ # @param content [String] The content of the inline element
+ # @param node [Object] The original inline node (for additional attributes)
+ # @return [String] The rendered inline element
+ def render_inline_element(_type, content, _node = nil)
+ # Default implementation just returns the content
+ content
+ end
+
+ # Visit a code block node.
+ # This method uses dynamic method dispatch to call format-specific handlers.
+ # Subclasses should implement visit_code_block_ methods for each code block type.
+ #
+ # @param node [Object] The code block node
+ # @return [String] The rendered code block
+ def visit_code_block(node)
+ method_name = "visit_code_block_#{node.code_type}"
+ if respond_to?(method_name, true)
+ send(method_name, node)
+ else
+ raise NotImplementedError, "Unknown code block type: #{node.code_type}"
+ end
+ end
+
+ # Visit a block node.
+ # This method uses dynamic method dispatch to call format-specific handlers.
+ # Subclasses should implement visit_block_ methods for each block type.
+ #
+ # @param node [Object] The block node
+ # @return [String] The rendered block
+ def visit_block(node)
+ method_name = "visit_block_#{node.block_type}"
+ if respond_to?(method_name, true)
+ send(method_name, node)
+ else
+ raise NotImplementedError, "Unknown block type: #{node.block_type}"
+ end
+ end
+
+ # Parse metric option for images and tables
+ #
+ # @param type [String] Builder type (e.g., 'latex', 'html')
+ # @param metric [String] Metric string (e.g., 'latex::width=80mm,scale=0.5')
+ # @return [String] Processed metric string
+ #
+ # @example
+ # parse_metric('latex', 'latex::width=80mm') # => 'width=80mm'
+ # parse_metric('latex', 'scale=0.5') # => 'scale=0.5'
+ # parse_metric('html', 'latex::width=80mm') # => ''
+ def parse_metric(type, metric)
+ return '' if metric.nil? || metric.empty?
+
+ params = metric.split(/,\s*/)
+ results = []
+ params.each do |param|
+ # Check if param has builder prefix (e.g., "latex::")
+ if /\A.+?::/.match?(param)
+ # Skip if not for this builder type
+ next unless /\A#{type}::/.match?(param)
+
+ # Remove the builder prefix
+ param = param.sub(/\A#{type}::/, '')
+ end
+ # Handle metric transformations if needed
+ param2 = handle_metric(param)
+ results.push(param2)
+ end
+ result_metric(results)
+ end
+
+ # Handle individual metric transformations
+ #
+ # @param str [String] Metric string (e.g., 'scale=0.5')
+ # @return [String] Transformed metric string
+ def handle_metric(str)
+ str
+ end
+
+ # Combine metric results into final string
+ #
+ # @param array [Array] Array of metric strings
+ # @return [String] Combined metric string
+ def result_metric(array)
+ array.join(',')
+ end
+
+ # Extract text content from a node, handling various node types.
+ # This is useful for extracting plain text from caption nodes or
+ # inline content.
+ #
+ # @param node [Object] The node to extract text from
+ # @return [String] The extracted text content
+ def extract_text(node)
+ case node
+ when String
+ node
+ when nil
+ ''
+ else
+ if node.children&.any?
+ node.children.map { |child| extract_text(child) }.join
+ elsif node.leaf_node?
+ node.content.to_s
+ else
+ node.to_s
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/footnote_collector.rb b/lib/review/renderer/footnote_collector.rb
new file mode 100644
index 000000000..0a4f213ae
--- /dev/null
+++ b/lib/review/renderer/footnote_collector.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+module ReVIEW
+ module Renderer
+ # FootnoteCollector - Collects and manages footnotes within a rendering context
+ #
+ # This class handles the collection of footnotes that occur in contexts where
+ # they cannot be rendered immediately (e.g., within table captions, minicolumns).
+ # Instead of rendering \footnote{} directly, these contexts use \footnotemark
+ # and collect the footnotes for later output as \footnotetext{}.
+ #
+ # Key responsibilities:
+ # - Collect footnote nodes and their assigned numbers
+ # - Generate appropriate footnotetext output for LaTeX
+ # - Generate appropriate footnote output for HTML
+ # - Track footnote order and numbering
+ class FootnoteCollector
+ include Enumerable
+
+ # Footnote data structure
+ FootnoteEntry = Struct.new(:node, :number, :content, keyword_init: true)
+
+ def initialize
+ @footnotes = []
+ end
+
+ # Add a footnote to the collection
+ # @param footnote_node [AST::FootnoteNode] the footnote AST node
+ # @param footnote_number [Integer] the assigned footnote number
+ def add(footnote_node, footnote_number)
+ entry = FootnoteEntry.new(
+ node: footnote_node,
+ number: footnote_number,
+ content: nil # Content will be rendered when needed
+ )
+ @footnotes << entry
+ end
+
+ # Get the number of collected footnotes
+ # @return [Integer] number of footnotes
+ def size
+ @footnotes.size
+ end
+
+ # Clear all collected footnotes
+ def clear
+ @footnotes.clear
+ end
+
+ # Iterate over collected footnotes
+ # @yield [FootnoteEntry] each footnote entry
+ def each(&block)
+ @footnotes.each(&block)
+ end
+
+ # Get all footnote numbers in order
+ # @return [Array] array of footnote numbers
+ def numbers
+ @footnotes.map(&:number)
+ end
+
+ # Convert to hash for debugging/serialization
+ # @return [Hash] hash representation
+ def to_h
+ {
+ size: size,
+ numbers: numbers,
+ footnotes: @footnotes.map do |entry|
+ # Get text preview from footnote node children
+ preview_text = if entry.node.respond_to?(:to_inline_text)
+ entry.node.to_inline_text
+ else
+ ''
+ end
+ {
+ number: entry.number,
+ id: entry.node.id,
+ content_preview: preview_text.slice(0, 50)
+ }
+ end
+ }
+ end
+
+ # String representation for debugging
+ # @return [String] string representation
+ def to_s
+ if @footnotes.empty?
+ 'FootnoteCollector[empty]'
+ else
+ numbers_str = numbers.join(', ')
+ "FootnoteCollector[#{size} footnotes: #{numbers_str}]"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/html/inline_context.rb b/lib/review/renderer/html/inline_context.rb
new file mode 100644
index 000000000..021988f96
--- /dev/null
+++ b/lib/review/renderer/html/inline_context.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/htmlutils'
+require 'review/html_escape_utils'
+require_relative '../inline_render_proxy'
+
+module ReVIEW
+ module Renderer
+ module Html
+ # Context for inline element rendering with business logic
+ # Used by InlineElementHandler
+ class InlineContext
+ include ReVIEW::HTMLUtils
+ include ReVIEW::HtmlEscapeUtils
+
+ attr_reader :config, :book, :chapter, :img_math
+
+ def initialize(config:, book:, chapter:, renderer:, img_math: nil)
+ @config = config
+ @book = book
+ @chapter = chapter
+ # Automatically create proxy from renderer to limit access
+ @render_proxy = InlineRenderProxy.new(renderer)
+ @img_math = img_math
+ end
+
+ def extname
+ ".#{config['htmlext'] || 'html'}"
+ end
+
+ def epub3?
+ config['epubversion'].to_i == 3
+ end
+
+ def math_format
+ config['math_format'] || 'mathjax'
+ end
+
+ # === HTMLUtils and HtmlEscapeUtils methods are available via include ===
+ # From HTMLUtils:
+ # - escape(str) or h(str) - Basic HTML escaping
+ # - escape_comment(str) - HTML comment escaping (escapes '-' to '-')
+ # - normalize_id(id) - ID normalization for HTML elements
+ # From HtmlEscapeUtils:
+ # - escape_content(str) - Content escaping (same as escape)
+ # - escape_url(str) - URL escaping using CGI.escape
+
+ def chapter_number(chapter_id)
+ book.chapter_index.number(chapter_id)
+ end
+
+ def chapter_title(chapter_id)
+ book.chapter_index.title(chapter_id)
+ end
+
+ def chapter_display_string(chapter_id)
+ book.chapter_index.display_string(chapter_id)
+ end
+
+ def chapter_link_enabled?
+ config['chapterlink']
+ end
+
+ def footnote_number(fn_id)
+ chapter.footnote(fn_id).number
+ end
+
+ def build_icon_html(icon_id)
+ image_item = chapter.image(icon_id)
+ path = image_item.path.sub(%r{\A\./}, '')
+ %Q( )
+ end
+
+ def bibpaper_number(bib_id)
+ chapter.bibpaper(bib_id).number
+ end
+
+ def build_bib_reference_link(bib_id, number)
+ bib_file = book.bib_file.gsub(/\.re\Z/, extname)
+ %Q([#{number}] )
+ end
+
+ def over_secnolevel?(n)
+ secnolevel = config['secnolevel'] || 0
+ # Section level = chapter level (1) + n.size
+ # Only show numbers if secnolevel is >= section level
+ section_level = n.is_a?(::Array) ? (1 + n.size) : (1 + n.to_s.split('.').size)
+ secnolevel >= section_level
+ end
+
+ def render_children(node)
+ @render_proxy.render_children(node)
+ end
+
+ def text_formatter
+ @render_proxy.text_formatter
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/html/inline_element_handler.rb b/lib/review/renderer/html/inline_element_handler.rb
new file mode 100644
index 000000000..f7c4ea36f
--- /dev/null
+++ b/lib/review/renderer/html/inline_element_handler.rb
@@ -0,0 +1,606 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'digest'
+require 'review/htmlutils'
+require 'review/html_escape_utils'
+
+module ReVIEW
+ module Renderer
+ module Html
+ # Inline element handler for HTML rendering
+ # Uses InlineContext for shared logic
+ class InlineElementHandler
+ include ReVIEW::HTMLUtils
+ include ReVIEW::HtmlEscapeUtils
+ include ReVIEW::Loggable
+
+ def initialize(inline_context)
+ @ctx = inline_context
+ @img_math = @ctx.img_math
+ @logger = ReVIEW.logger
+ end
+
+ def render_inline_b(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_strong(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_i(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_em(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_code(_type, content, _node)
+ %Q(#{content})
+ end
+
+ def render_inline_tt(_type, content, _node)
+ %Q(#{content})
+ end
+
+ def render_inline_ttb(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_tti(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_kbd(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_samp(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_var(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_sup(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_sub(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_del(_type, content, _node)
+ %Q(#{content})
+ end
+
+ def render_inline_ins(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_u(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_br(_type, _content, _node)
+ ' '
+ end
+
+ def render_inline_bou(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_ami(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_big(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_small(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_balloon(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_cite(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_dfn(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_chap(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ chapter_num = @ctx.text_formatter.format_chapter_number_full(data.chapter_number, data.chapter_type)
+ build_chapter_link(data.item_id, chapter_num)
+ end
+
+ def render_inline_chapref(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ display_str = @ctx.text_formatter.format_reference(:chapter, data)
+ build_chapter_link(data.item_id, display_str)
+ end
+
+ def render_inline_title(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ title = data.chapter_title || ''
+ build_chapter_link(data.item_id, title)
+ end
+
+ def render_inline_fn(_type, _content, node)
+ # Footnote reference
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ build_footnote_link(data.item_id, data.item_number)
+ end
+
+ def render_inline_kw(_type, content, node)
+ if node.args.length >= 2
+ build_keyword_with_index(node.args[0], alt: node.args[1].strip)
+ elsif node.args.length == 1
+ build_keyword_with_index(node.args[0])
+ else
+ build_keyword_with_index(content)
+ end
+ end
+
+ def render_inline_idx(_type, content, node)
+ index_str = node.args.first || content
+ content + build_index_comment(index_str)
+ end
+
+ def render_inline_hidx(_type, _content, node)
+ index_str = node.args.first
+ build_index_comment(index_str)
+ end
+
+ def render_inline_href(_type, _content, node)
+ args = node.args
+ if args.length >= 2
+ url = args[0]
+ text = escape_content(args[1])
+ if url.start_with?('#')
+ build_anchor_link(url[1..-1], text)
+ else
+ build_external_link(url, text)
+ end
+ elsif args.length >= 1
+ url = args[0]
+ escaped_url = escape_content(url)
+ if url.start_with?('#')
+ build_anchor_link(url[1..-1], escaped_url)
+ else
+ build_external_link(url, escaped_url)
+ end
+ else
+ content
+ end
+ end
+
+ def render_inline_ruby(_type, content, node)
+ if node.args.length >= 2
+ build_ruby(node.args[0], node.args[1])
+ else
+ content
+ end
+ end
+
+ def render_inline_raw(_type, _content, node)
+ node.targeted_for?('html') ? (node.content || '') : ''
+ end
+
+ def render_inline_embed(_type, _content, node)
+ node.targeted_for?('html') ? (node.content || '') : ''
+ end
+
+ def render_inline_abbr(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_acronym(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_list(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text = @ctx.text_formatter.format_reference_text(:list, data)
+ wrap_reference_with_html(text, data, 'listref')
+ end
+
+ def render_inline_table(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text = @ctx.text_formatter.format_reference_text(:table, data)
+ wrap_reference_with_html(text, data, 'tableref')
+ end
+
+ def render_inline_img(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text = @ctx.text_formatter.format_reference_text(:image, data)
+ wrap_reference_with_html(text, data, 'imgref')
+ end
+
+ def render_inline_comment(_type, content, _node)
+ if @ctx.config['draft']
+ %Q()
+ else
+ ''
+ end
+ end
+
+ def render_inline_w(_type, content, _node)
+ # Content should already be resolved by ReferenceResolver
+ content
+ end
+
+ def render_inline_wb(_type, content, _node)
+ # Content should already be resolved by ReferenceResolver
+ %Q(#{content} )
+ end
+
+ def render_inline_dtp(_type, content, _node)
+ ""
+ end
+
+ def render_inline_recipe(_type, content, _node)
+ %Q(「#{content}」 )
+ end
+
+ def render_inline_uchar(_type, content, _node)
+ %Q(#{content};)
+ end
+
+ def render_inline_tcy(_type, content, _node)
+ style = 'tcy'
+ if content.size == 1 && content.match(/[[:ascii:]]/)
+ style = 'upright'
+ end
+ %Q(#{content} )
+ end
+
+ def render_inline_pageref(_type, content, _node)
+ # Page reference is unsupported in HTML
+ content
+ end
+
+ def render_inline_icon(_type, content, node)
+ # Icon is an image reference
+ id = node.args.first || content
+ begin
+ @ctx.build_icon_html(id)
+ rescue ReVIEW::KeyError, NoMethodError
+ warn "image not bound: #{id}"
+ %Q(missing image: #{id} )
+ end
+ end
+
+ def render_inline_bib(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ bib_id = data.item_id
+ bib_number = data.item_number
+ @ctx.build_bib_reference_link(bib_id, bib_number)
+ end
+
+ def render_inline_endnote(_type, _content, node)
+ # Endnote reference
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ build_endnote_link(data.item_id, data.item_number)
+ end
+
+ def render_inline_m(_type, content, node)
+ # Math/equation rendering
+ # Get raw string from node args (content is already escaped)
+ str = node.args.first || content
+
+ # Use 'equation' class like HTMLBuilder
+ case @ctx.config['math_format']
+ when 'mathml'
+ begin
+ require 'math_ml'
+ require 'math_ml/symbol/character_reference'
+ rescue LoadError
+ app_error 'not found math_ml'
+ return %Q(#{escape(str)} )
+ end
+ parser = MathML::LaTeX::Parser.new(symbol: MathML::Symbol::CharacterReference)
+ # parser.parse returns MathML::Math object, need to convert to string
+ %Q(#{parser.parse(str, nil)} )
+ when 'mathjax'
+ %Q(\\( #{str.gsub('<', '\lt{}').gsub('>', '\gt{}').gsub('&', '&')} \\) )
+ when 'imgmath'
+ unless @img_math
+ app_error 'ImgMath not initialized'
+ return %Q(#{escape(str)} )
+ end
+
+ math_str = '$' + str + '$'
+ key = Digest::SHA256.hexdigest(str)
+ img_path = @img_math.defer_math_image(math_str, key)
+ %Q( )
+ else
+ %Q(#{escape(str)} )
+ end
+ end
+
+ def render_inline_sec(_type, _content, node)
+ # Section number reference: @{id} or @{chapter|id}
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ n = data.headline_number
+ chapter_num = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+
+ # Build full section number including chapter number
+ full_number = if n.present? && chapter_num && !chapter_num.to_s.empty? && @ctx.over_secnolevel?(n)
+ ([chapter_num] + n).join('.')
+ else
+ ''
+ end
+
+ if @ctx.config['chapterlink'] && full_number.present?
+ # Get target chapter ID for link
+ chapter_id = data.chapter_id || @ctx.chapter.id
+ anchor = 'h' + full_number.tr('.', '-')
+ %Q(#{full_number} )
+ else
+ full_number
+ end
+ end
+
+ def render_inline_secref(type, content, node)
+ render_inline_hd(type, content, node)
+ end
+
+ def render_inline_labelref(_type, content, node)
+ # Label reference: @{id}
+ # This should match HTMLBuilder's inline_labelref behavior
+ idref = node.target_item_id || content
+ marker = @ctx.text_formatter.format_label_marker(idref)
+ %Q(「#{escape_content(marker)}」 )
+ end
+
+ def render_inline_ref(type, content, node)
+ render_inline_labelref(type, content, node)
+ end
+
+ def render_inline_eq(_type, _content, node)
+ # Equation reference
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text = @ctx.text_formatter.format_reference_text(:equation, data)
+ wrap_reference_with_html(text, data, 'eqref')
+ end
+
+ def render_inline_hd(_type, _content, node)
+ # Headline reference: @{id} or @{chapter|id}
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ n = data.headline_number
+ chapter_num = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+
+ # Render caption with inline markup
+ caption_html = if data.caption_node
+ @ctx.render_children(data.caption_node)
+ else
+ data.caption_text
+ end
+
+ # Build full section number including chapter number
+ full_number = if n.present? && chapter_num && !chapter_num.to_s.empty? && @ctx.over_secnolevel?(n)
+ ([chapter_num] + n).join('.')
+ end
+
+ str = @ctx.text_formatter.format_headline_quote(full_number, caption_html)
+
+ if @ctx.config['chapterlink'] && full_number
+ # Get target chapter ID for link
+ chapter_id = data.chapter_id || @ctx.chapter.id
+ anchor = 'h' + full_number.tr('.', '-')
+ %Q(#{str} )
+ else
+ str
+ end
+ end
+
+ def render_inline_column(_type, _content, node)
+ # Column reference: @{id} or @{chapter|id}
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+
+ # Render caption with inline markup
+ caption_html = if data.caption_node
+ @ctx.render_children(data.caption_node)
+ else
+ escape_content(data.caption_text)
+ end
+
+ anchor = "column-#{data.item_number}"
+ column_text = @ctx.text_formatter.format_column_label(caption_html)
+
+ if @ctx.config['chapterlink']
+ chapter_id = data.chapter_id || @ctx.chapter.id
+ %Q(#{column_text} )
+ else
+ column_text
+ end
+ end
+
+ def render_inline_sectitle(_type, _content, node)
+ # Section title reference
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+
+ # Render caption with inline markup
+ title_html = if data.caption_node
+ @ctx.render_children(data.caption_node)
+ else
+ escape_content(data.caption_text)
+ end
+
+ if @ctx.config['chapterlink']
+ n = data.headline_number
+ chapter_num = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+ full_number = ([chapter_num] + n).join('.')
+ anchor = 'h' + full_number.tr('.', '-')
+
+ # Get target chapter ID for link
+ chapter_id = data.chapter_id || @ctx.chapter.id
+ %Q(#{title_html} )
+ else
+ title_html
+ end
+ end
+
+ private
+
+ def target_format?(format_name)
+ format_name.to_s == 'html'
+ end
+
+ def build_index_comment(index_str)
+ %Q()
+ end
+
+ def build_keyword_with_index(word, alt: nil)
+ escaped_word = escape_content(word)
+
+ if alt && !alt.empty?
+ escaped_alt = escape_content(alt)
+ # Include alt text in visible content, but only word in IDX comment
+ text = "#{escaped_word} (#{escaped_alt})"
+ %Q(#{text} )
+ else
+ %Q(#{escaped_word} )
+ end
+ end
+
+ def build_ruby(base, ruby_text)
+ %Q(#{escape_content(base)}#{escape_content(ruby_text)} )
+ end
+
+ def build_anchor_link(anchor_id, content, css_class: 'link')
+ %Q(#{content} )
+ end
+
+ def build_external_link(url, content, css_class: 'link')
+ %Q(#{content} )
+ end
+
+ def build_footnote_link(fn_id, number)
+ if @ctx.epub3?
+ %Q(#{@ctx.text_formatter.format_footnote_mark(number)} )
+ else
+ %Q(*#{number} )
+ end
+ end
+
+ def build_chapter_link(chapter_id, content)
+ if @ctx.chapter_link_enabled?
+ %Q(#{content} )
+ else
+ content
+ end
+ end
+
+ def build_endnote_link(endnote_id, number)
+ if @ctx.epub3?
+ %Q(#{@ctx.text_formatter.format_endnote_mark(number)} )
+ else
+ %Q(#{number} )
+ end
+ end
+
+ # Wrap reference text with HTML decoration (span and optional link)
+ # @param text [String] Plain text reference (e.g., "図1.1")
+ # @param data [ResolvedData] Resolved reference data
+ # @param css_class [String] CSS class name (e.g., 'imgref', 'tableref', 'listref', 'eqref')
+ # @return [String] HTML with span and optional link
+ def wrap_reference_with_html(text, data, css_class)
+ escaped_text = escape_content(text)
+
+ return %Q(#{escaped_text} ) unless @ctx.config['chapterlink']
+
+ # Build link with chapter_id and item_id
+ chapter_id = data.chapter_id || @ctx.chapter&.id
+ extname = ".#{@ctx.config['htmlext'] || 'html'}"
+ %Q(#{escaped_text} )
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/html_renderer.rb b/lib/review/renderer/html_renderer.rb
new file mode 100644
index 000000000..6032d7304
--- /dev/null
+++ b/lib/review/renderer/html_renderer.rb
@@ -0,0 +1,1315 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/renderer/base'
+require 'review/ast/caption_node'
+require 'review/htmlutils'
+require 'review/textutils'
+require 'review/html_escape_utils'
+require 'review/highlighter'
+require 'review/sec_counter'
+require 'review/i18n'
+require 'review/loggable'
+require 'review/ast/indexer'
+require 'review/ast/compiler'
+require 'review/template'
+require 'review/img_math'
+require 'digest'
+require_relative 'rendering_context'
+require_relative 'html/inline_context'
+require_relative 'html/inline_element_handler'
+
+module ReVIEW
+ module Renderer
+ class HtmlRenderer < Base
+ include ReVIEW::HTMLUtils
+ include ReVIEW::TextUtils
+ include ReVIEW::HtmlEscapeUtils
+ include ReVIEW::Loggable
+
+ attr_reader :chapter, :book
+
+ def initialize(chapter, img_math: nil)
+ super(chapter)
+
+ # Initialize logger like HTMLBuilder for error handling
+ @logger = ReVIEW.logger
+
+ # Initialize section counter like HTMLBuilder (handle nil chapter)
+ @sec_counter = @chapter ? SecCounter.new(5, @chapter) : nil
+
+ # Initialize template variables like HTMLBuilder
+ @javascripts = []
+ @body_ext = ''
+
+ # Initialize ImgMath for equation image generation (like Builder)
+ # Accept shared instance or create new one
+ @img_math = img_math || ReVIEW::ImgMath.new(config)
+
+ # Initialize RenderingContext for cleaner state management
+ @rendering_context = RenderingContext.new(:document)
+
+ # Initialize HTML-specific inline context and inline element handler
+ @inline_context = Html::InlineContext.new(config: config, book: book, chapter: chapter, renderer: self, img_math: @img_math)
+ @inline_element_handler = Html::InlineElementHandler.new(@inline_context)
+ end
+
+ # Format type for this renderer
+ # @return [Symbol] Format type :html
+ def format_type
+ :html
+ end
+
+ def visit_document(node)
+ render_children(node)
+ end
+
+ def visit_headline(node)
+ level = node.level
+ caption = render_caption_inline(node.caption_node)
+
+ if node.nonum? || node.notoc? || node.nodisp?
+ # Use label if provided, otherwise use auto_id generated by Compiler
+ id = normalize_id(node.label || node.auto_id)
+
+ spacing_before = level > 1 ? "\n" : ''
+
+ if node.nodisp?
+ a_tag = %Q( )
+ %Q(#{spacing_before}#{a_tag}#{caption} \n)
+ elsif node.notoc?
+ %Q(#{spacing_before}#{caption} \n)
+ else
+ %Q(#{spacing_before}#{caption} \n)
+ end
+ else
+ prefix, anchor = headline_prefix(level)
+
+ anchor_html = anchor ? %Q( ) : ''
+ secno_html = prefix ? %Q(#{prefix} ) : ''
+ spacing_before = level > 1 ? "\n" : ''
+
+ if node.label
+ label_id = normalize_id(node.label)
+ %Q(#{spacing_before}#{anchor_html}#{secno_html}#{caption} \n)
+ else
+ "#{spacing_before}#{anchor_html}#{secno_html}#{caption} \n"
+ end
+ end
+ end
+
+ def visit_paragraph(node)
+ content = render_children(node)
+ content = join_paragraph_lines(content).strip
+
+ # Check for noindent attribute
+ if node.attribute?(:noindent)
+ %Q(#{content}
\n)
+ else
+ "#{content}
\n"
+ end
+ end
+
+ # Join paragraph lines according to join_lines_by_lang setting
+ # This matches HTMLBuilder's join_lines_to_paragraph behavior
+ #
+ # @param content [String] paragraph content with newlines
+ # @return [String] processed content with lines joined appropriately
+ def join_paragraph_lines(content)
+ if config['join_lines_by_lang']
+ # Split by newlines to get individual lines
+ lines = content.split("\n")
+
+ # Add spaces between lines based on language rules
+ lazy = true
+ lang = config['language'] || 'ja'
+ 0.upto(lines.size - 2) do |n|
+ if add_space?(lines[n], lines[n + 1], lang, lazy)
+ lines[n] += ' '
+ end
+ end
+ lines.join
+ else
+ # Default: just remove newlines (no space added)
+ content.gsub(/\n+/, '')
+ end
+ end
+
+ def visit_list(node)
+ tag = case node.list_type
+ when :ul
+ 'ul'
+ when :ol
+ 'ol'
+ when :dl
+ 'dl'
+ else
+ raise NotImplementedError, "HTMLRenderer does not support list_type #{node.list_type}."
+ end
+
+ # Check for start_number for ordered lists
+ # Only output start attribute if it's not the default value (1)
+ start_attr = ''
+ if node.list_type == :ol && node.start_number && node.start_number != 1
+ start_attr = %Q( start="#{node.start_number}")
+ end
+
+ content = render_children(node)
+ # Format list items with proper line breaks like HTMLBuilder
+ formatted_content = content.gsub(%r{(?=)}, " \n")
+ formatted_content = formatted_content.gsub(/([^<]*)/, "\\1\n")
+ formatted_content = formatted_content.gsub(' ', " \n ")
+ "<#{tag}#{start_attr}>\n#{formatted_content}\n#{tag}>\n"
+ end
+
+ def visit_list_item(node)
+ # Get parent list to determine list type
+ parent_list = node.parent
+ if parent_list && parent_list.list_type == :dl
+ # Definition list item - use term_children for term like LaTeXRenderer
+ term = if node.term_children&.any?
+ node.term_children.map { |child| visit(child) }.join
+ else
+ ''
+ end
+
+ # Children contain the definition content
+ # Join all children into a single dd like HTMLBuilder does with join_lines_to_paragraph
+ if node.children.empty?
+ # Only term, no definition - add empty dd like HTMLBuilder
+ "#{term} "
+ else
+ # Render all child content and join together
+ definition_parts = node.children.map { |child| visit(child) }
+ # Join multiple paragraphs/text into single dd content, removing tags
+ definition_content = definition_parts.map { |part| part.gsub(%r{?p[^>]*>}, '').strip }.join
+ "
#{term} #{definition_content} "
+ end
+ else
+ # Regular list item
+ content = render_children(node)
+ "#{content} "
+ end
+ end
+
+ def visit_text(node)
+ escape_content(node.content.to_s)
+ end
+
+ def visit_code_line(node)
+ # Process each line like HTMLBuilder - detab and preserve exact content
+ # Add newline like other renderers (LaTeX, Markdown, Top) do
+ line_content = render_children(node)
+ detab(line_content) + "\n"
+ end
+
+ def visit_table(node)
+ # Check if this is an imgtable - handle as image like HTMLBuilder
+ if node.table_type == :imgtable
+ return render_imgtable(node)
+ end
+
+ id_attr = node.id ? %Q( id="#{normalize_id(node.id)}") : ''
+
+ # Process caption with proper context management
+ caption_html = if node.caption_node
+ @rendering_context.with_child_context(:caption) do |caption_context|
+ caption_content = render_caption_with_context(node.caption_node, caption_context)
+ # Generate table number like HTMLBuilder using chapter table index
+ table_number = if node.id
+ generate_table_header(node.id, caption_content)
+ else
+ # No ID - just use caption without numbering
+ caption_content
+ end
+ %Q(#{table_number}
+)
+ end
+ else
+ ''
+ end
+
+ # Process table content with table context
+ table_html = @rendering_context.with_child_context(:table) do |table_context|
+ # Process all table rows using visitor pattern with table context
+ all_rows = node.header_rows + node.body_rows
+ rows_html = all_rows.map { |row| visit_with_context(row, table_context) }.join("\n")
+ rows_html += "\n" unless rows_html.empty?
+
+ %Q()
+ end
+
+ %Q(
+#{caption_html}#{table_html}
+
+)
+ end
+
+ def visit_table_row(node)
+ cells_html = render_children(node)
+ "#{cells_html} "
+ end
+
+ def visit_table_cell(node)
+ content = render_children(node)
+ tag = node.cell_type == :th ? 'th' : 'td'
+ "<#{tag}>#{content}#{tag}>"
+ end
+
+ def visit_column(node)
+ # Use auto_id generated by Compiler for anchor
+ id_attr = node.label ? %Q( id="#{normalize_id(node.label)}") : ''
+ anchor_id = %Q( )
+
+ # HTMLBuilder uses h4 tag for column headers
+ caption_content = render_caption_inline(node.caption_node)
+ caption_html = if caption_content.empty?
+ node.label ? anchor_id : ''
+ elsif node.label
+ %Q(#{anchor_id}#{caption_content} )
+ else
+ %Q(#{anchor_id}#{caption_content} )
+ end
+
+ content = render_children(node)
+
+ %Q(\n#{caption_html}#{content}
)
+ end
+
+ def visit_minicolumn(node)
+ type = node.minicolumn_type.to_s
+ id_attr = node.id ? %Q( id="#{normalize_id(node.id)}") : ''
+
+ caption_content = render_caption_inline(node.caption_node)
+ caption_html = caption_content.empty? ? '' : %Q(#{caption_content}
\n)
+
+ # Content already contains proper paragraph structure from ParagraphNode children
+ content_html = render_children(node)
+
+ %Q(\n#{caption_html}#{content_html}
\n)
+ end
+
+ def visit_image(node)
+ id_attr = node.id ? %Q( id="#{normalize_id(node.id)}") : ''
+
+ caption_node = node.caption_node
+
+ # Process image with caption context management
+ if caption_node
+ @rendering_context.with_child_context(:caption) do |caption_context|
+ # Check if image is bound like HTMLBuilder does
+ if @chapter&.image_bound?(node.id)
+ image_image_html_with_context(node.id, caption_node, id_attr, caption_context, node.image_type)
+ else
+ # For dummy images, ImageNode doesn't have lines, so use empty array
+ image_dummy_html_with_context(node.id, caption_node, [], id_attr, caption_context, node.image_type)
+ end
+ end
+ elsif @chapter&.image_bound?(node.id)
+ # No caption, no special context needed
+ image_image_html(node.id, caption_node, id_attr, node.image_type)
+ else
+ image_dummy_html(node.id, caption_node, [], id_attr, node.image_type)
+ end
+ end
+
+ # visit_block is now handled by Base renderer with dynamic method dispatch
+ # Individual visit_block_* methods delegate to existing render_*_block methods
+
+ def visit_block_note(node)
+ render_note_block(node)
+ end
+
+ def visit_block_memo(node)
+ render_memo_block(node)
+ end
+
+ def visit_block_tip(node)
+ render_tip_block(node)
+ end
+
+ def visit_block_info(node)
+ render_info_block(node)
+ end
+
+ def visit_block_warning(node)
+ render_warning_block(node)
+ end
+
+ def visit_block_important(node)
+ render_important_block(node)
+ end
+
+ def visit_block_caution(node)
+ render_caution_block(node)
+ end
+
+ def visit_block_notice(node)
+ render_notice_block(node)
+ end
+
+ def visit_block_quote(node)
+ render_quote_block(node)
+ end
+
+ def visit_block_blockquote(node)
+ render_quote_block(node)
+ end
+
+ def visit_block_lead(node)
+ render_lead_block(node)
+ end
+
+ def visit_block_comment(node)
+ render_comment_block(node)
+ end
+
+ def visit_block_firstlinenum(node)
+ render_firstlinenum_block(node)
+ end
+
+ def visit_block_blankline(_node)
+ '
'
+ end
+
+ def visit_block_pagebreak(_node)
+ %Q(
\n)
+ end
+
+ def visit_block_label(node)
+ render_label_block(node)
+ end
+
+ def visit_block_tsize(node)
+ render_tsize_block(node)
+ end
+
+ def visit_block_printendnotes(node)
+ render_printendnotes_block(node)
+ end
+
+ def visit_block_flushright(node)
+ render_flushright_block(node)
+ end
+
+ def visit_block_centering(node)
+ render_centering_block(node)
+ end
+
+ def visit_block_bibpaper(node)
+ render_bibpaper_block(node)
+ end
+
+ def visit_tex_equation(node)
+ content = node.content
+
+ math_format = config['math_format']
+
+ return render_texequation_body(content, math_format) unless node.id?
+
+ id_attr = %Q( id="#{normalize_id(node.id)}")
+ caption_content = render_caption_inline(node.caption_node)
+ caption_text = caption_content.empty? ? nil : caption_content
+ caption_html = %Q(#{text_formatter.format_caption('equation', get_chap, @chapter.equation(node.id).number, caption_text)}
\n)
+
+ caption_top_html = caption_top?('equation') ? caption_html : ''
+ caption_bottom_html = caption_top?('equation') ? '' : caption_html
+
+ equation_body_html = render_texequation_body(content, math_format)
+
+ %Q(\n#{caption_top_html}#{equation_body_html}#{caption_bottom_html}
\n)
+ end
+
+ # Render equation body with appropriate format (matches HTMLBuilder's texequation_body)
+ def render_texequation_body(content, math_format)
+ result = %Q(\n)
+
+ math_content = render_math_format(content, math_format)
+ # Check if error case returned complete div (early return from helper)
+ return math_content if math_content.include?('
')
+
+ result + math_content + "\n"
+ end
+
+ def render_math_format(content, math_format)
+ case math_format
+ when 'mathjax'
+ render_mathjax_format(content)
+ when 'mathml'
+ render_mathml_format(content)
+ when 'imgmath'
+ render_imgmath_format(content)
+ else
+ # Fallback: render as preformatted text
+ %Q(#{escape(content)}\n \n)
+ end
+ end
+
+ # Render math content using MathJax (display mode with $$)
+ def render_mathjax_format(content)
+ "$$#{content.gsub('<', '\lt{}').gsub('>', '\gt{}').gsub('&', '&')}$$\n"
+ end
+
+ def render_mathml_format(content)
+ begin
+ require 'math_ml'
+ require 'math_ml/symbol/character_reference'
+ rescue LoadError
+ app_error 'not found math_ml'
+ return %Q(\n)
+ end
+ parser = MathML::LaTeX::Parser.new(symbol: MathML::Symbol::CharacterReference)
+ # Add newline to content like HTMLBuilder does
+ # parser.parse returns MathML::Math object, need to convert to string
+ parser.parse(content + "\n", true).to_s
+ end
+
+ def render_imgmath_format(content)
+ unless @img_math
+ app_error 'ImgMath not initialized'
+ return %Q(\n)
+ end
+
+ fontsize = config['imgmath_options']['fontsize'].to_f
+ lineheight = config['imgmath_options']['lineheight'].to_f
+ math_str = "\\begin{equation*}\n\\fontsize{#{fontsize}}{#{lineheight}}\\selectfont\n#{content}\n\\end{equation*}\n"
+ key = Digest::SHA256.hexdigest(math_str)
+
+ img_path = @img_math.defer_math_image(math_str, key)
+ %Q( \n)
+ end
+
+ # Render AST to HTML body content only (without template).
+ #
+ # @param ast_root [Object] The root AST node to render
+ # @return [String] HTML body content only
+ def render_body(ast_root)
+ visit(ast_root)
+ end
+
+ def layoutfile
+ # Determine layout file like HTMLBuilder
+ if config.maker == 'webmaker'
+ htmldir = 'web/html'
+ localfilename = 'layout-web.html.erb'
+ else
+ htmldir = 'html'
+ localfilename = 'layout.html.erb'
+ end
+
+ htmlfilename = if config['htmlversion'] == 5 || config['htmlversion'].nil?
+ File.join(htmldir, 'layout-html5.html.erb')
+ else
+ File.join(htmldir, 'layout-xhtml1.html.erb')
+ end
+
+ layout_file = File.join(@book.basedir || '.', 'layouts', localfilename)
+
+ # Check for custom layout file
+ if File.exist?(layout_file)
+ # Respect safe mode like HTMLBuilder
+ if ENV['REVIEW_SAFE_MODE'].to_i & 4 > 0
+ warn 'user\'s layout is prohibited in safe mode. ignored.'
+ layout_file = File.expand_path(htmlfilename, ReVIEW::Template::TEMPLATE_DIR)
+ end
+ else
+ # Use default template
+ layout_file = File.expand_path(htmlfilename, ReVIEW::Template::TEMPLATE_DIR)
+ end
+
+ layout_file
+ end
+
+ # Helper methods for references
+ def get_chap(chapter = @chapter)
+ if config['secnolevel'] && config['secnolevel'] > 0 &&
+ !chapter.number.nil? && !chapter.number.to_s.empty?
+ if chapter.is_a?(ReVIEW::Book::Part)
+ return text_formatter.format_part_short(chapter)
+ else
+ return chapter.format_number(nil)
+ end
+ end
+ nil
+ end
+
+ private
+
+ # Generate a complete HTML document with template.
+ def post_process(result)
+ @body = result
+
+ # Set up template variables like HTMLBuilder
+ # Chapter title is already plain text (markup removed), just escape it
+ @title = escape_content(@chapter&.title || '')
+ @language = config['language'] || 'ja'
+ @stylesheets = config['stylesheet'] || []
+ @next = @chapter&.next_chapter
+ @prev = @chapter&.prev_chapter
+ @next_title = @next ? escape_content(@next.title) : ''
+ @prev_title = @prev ? escape_content(@prev.title) : ''
+
+ # Handle MathJax configuration like HTMLBuilder
+ if config['math_format'] == 'mathjax'
+ @javascripts.push(%Q())
+ @javascripts.push(%Q())
+ end
+
+ # Render template
+ ReVIEW::Template.load(layoutfile).result(binding)
+ end
+
+ def visit_code_block_emlist(node)
+ processed_content = format_code_content(node)
+
+ code_block_wrapper(
+ node,
+ div_class: 'emlist-code',
+ pre_class: build_pre_class('emlist', node.lang),
+ content: processed_content,
+ caption_style: :top_bottom
+ )
+ end
+
+ def visit_code_block_emlistnum(node)
+ numbered_lines = format_emlistnum_content(node)
+
+ code_block_wrapper(
+ node,
+ div_class: 'emlistnum-code',
+ pre_class: build_pre_class('emlist', node.lang),
+ content: numbered_lines,
+ caption_style: :top_bottom
+ )
+ end
+
+ def visit_code_block_list(node)
+ processed_content = format_code_content(node)
+
+ code_block_wrapper(
+ node,
+ div_class: 'caption-code',
+ pre_class: build_pre_class('list', node.lang),
+ content: processed_content,
+ caption_style: :numbered
+ )
+ end
+
+ def visit_code_block_listnum(node)
+ numbered_lines = format_listnum_content(node)
+
+ code_block_wrapper(
+ node,
+ div_class: 'code',
+ pre_class: build_pre_class('list', node.lang, with_highlight: false),
+ content: numbered_lines,
+ caption_style: :numbered
+ )
+ end
+
+ def visit_code_block_source(node)
+ processed_content = format_code_content(node)
+
+ code_block_wrapper(
+ node,
+ div_class: 'source-code',
+ pre_class: 'source',
+ content: processed_content,
+ caption_style: :top_bottom
+ )
+ end
+
+ def visit_code_block_cmd(node)
+ processed_content = format_code_content(node, default_lang: 'shell-session')
+
+ code_block_wrapper(
+ node,
+ div_class: 'cmd-code',
+ pre_class: 'cmd',
+ content: processed_content,
+ caption_style: :top_bottom
+ )
+ end
+
+ def code_block_wrapper(node, div_class:, pre_class:, content:, caption_style:)
+ id_attr = node.id ? %Q( id="#{normalize_id(node.id)}") : ''
+
+ caption_top = render_code_caption(node, caption_style, :top)
+ caption_bottom = render_code_caption(node, caption_style, :bottom)
+
+ %Q(
+#{caption_top}
#{content}
+#{caption_bottom}
+)
+ end
+
+ def render_code_caption(node, style, position)
+ caption_node = node.caption_node
+ return '' unless caption_node
+
+ caption_content = render_caption_inline(caption_node)
+ return '' if caption_content.empty?
+
+ case style
+ when :top_bottom
+ return '' unless position == :top ? caption_top?('list') : !caption_top?('list')
+
+ %Q(#{caption_content}
+)
+ when :numbered
+ return '' unless position == :top
+
+ list_number = generate_list_header(node.id, caption_content)
+ %Q(#{list_number}
+)
+ else
+ ''
+ end
+ end
+
+ def build_pre_class(base_class, lang, with_highlight: true)
+ classes = [base_class]
+ classes << "language-#{lang}" if lang
+ classes << 'highlight' if with_highlight && highlight?
+ classes.join(' ')
+ end
+
+ def format_code_content(node, default_lang: nil)
+ lang = node.lang || default_lang
+
+ # Disable highlighting if code block contains inline elements (e.g., @{})
+ # to allow proper rendering of inline markup
+ if highlight? && !node.contains_inline?
+ highlight(body: node.plain_text, lexer: lang, format: 'html')
+ else
+ # render_children already escapes text, no need to escape again
+ lines_content = render_children(node)
+ lines = lines_content.split("\n")
+ lines.inject('') { |i, j| i + detab(j) + "\n" }
+ end
+ end
+
+ def format_emlistnum_content(node)
+ lang = node.lang
+ first_line_number = node&.first_line_num || 1
+
+ # Disable highlighting if code block contains inline elements
+ if highlight? && !node.contains_inline?
+ highlight(body: node.plain_text, lexer: lang, format: 'html', linenum: true, options: { linenostart: first_line_number })
+ else
+ lines_content = render_children(node)
+ lines = lines_content.split("\n")
+ lines.pop if lines.last && lines.last.empty?
+ lines.map.with_index(first_line_number) do |line, i|
+ "#{i.to_s.rjust(2)}: #{detab(line)}"
+ end.join("\n") + "\n"
+ end
+ end
+
+ def format_listnum_content(node)
+ lang = node.lang
+ first_line_number = node&.first_line_num || 1
+
+ # Disable highlighting if code block contains inline elements
+ if highlight? && !node.contains_inline?
+ highlight(body: node.plain_text, lexer: lang, format: 'html', linenum: true, options: { linenostart: first_line_number })
+ else
+ lines_content = render_children(node)
+ lines = lines_content.split("\n")
+ lines.pop if lines.last && lines.last.empty?
+ lines.map.with_index(first_line_number) do |line, i|
+ "#{i.to_s.rjust(2)}: #{detab(line)}"
+ end.join("\n") + "\n"
+ end
+ end
+
+ def highlight?
+ highlighter.highlight?('html')
+ end
+
+ def highlight(body:, lexer: nil, format: 'html', linenum: false, options: {}, location: nil)
+ highlighter.highlight(
+ body: body,
+ lexer: lexer,
+ format: format,
+ linenum: linenum,
+ options: options,
+ location: location
+ )
+ end
+
+ def highlighter
+ @highlighter ||= ReVIEW::Highlighter.new(config)
+ end
+
+ def generate_list_header(id, caption)
+ list_item = @chapter&.list(id)
+ raise NotImplementedError, "no such list: #{id}" unless list_item
+
+ list_num = list_item.number
+ chapter_num = @chapter&.number
+
+ text_formatter.format_caption('list', chapter_num, list_num, caption)
+ end
+
+ def visit_reference(node)
+ if node.resolved?
+ format_resolved_reference(node.resolved_data)
+ else
+ # Reference resolution was skipped or disabled
+ # Return content as fallback
+ node.content || ''
+ end
+ end
+
+ # Format resolved reference based on ResolvedData
+ # Gets plain text from TextFormatter and wraps it with HTML markup
+ def format_resolved_reference(data)
+ # Get plain text from TextFormatter (no HTML markup)
+ plain_text = text_formatter.format_reference(data.reference_type, data)
+
+ # Wrap with HTML-specific markup based on reference type
+ case data.reference_type
+ when :image, :table, :list, :equation
+ # For image/table/list/equation, wrap with span and optional link
+ css_class = case data.reference_type # rubocop:disable Style/HashLikeCase
+ when :image then 'imgref'
+ when :table then 'tableref'
+ when :list then 'listref'
+ when :equation then 'eqref'
+ end
+ format_html_reference(plain_text, data, css_class)
+ when :bibpaper
+ # For bibliography, wrap with span class="bibref"
+ %Q(#{plain_text} )
+ when :chapter, :headline, :column, :word
+ # For chapter/headline/column/word, escape HTML entities
+ escape_html(plain_text)
+ else
+ # For other types (footnote, endnote), return plain text as-is
+ plain_text
+ end
+ end
+
+ # Format HTML reference with link support
+ # @param text [String] Plain text to wrap
+ # @param data [ResolvedData] Resolved reference data
+ # @param css_class [String] CSS class name
+ # @return [String] HTML markup
+ def format_html_reference(text, data, css_class)
+ return %Q(#{text} ) unless config['chapterlink']
+
+ # Use chapter_id from data, or fall back to current chapter's id
+ chapter_id = data.chapter_id || @chapter&.id
+ extname = ".#{config['htmlext'] || 'html'}"
+ %Q(#{text} )
+ end
+
+ def visit_footnote(node)
+ # Handle FootnoteNode - render as footnote or endnote definition
+ # Note: This renders the footnote/endnote definition block at document level.
+ # For inline footnote references (@{id}), see render_footnote method.
+ footnote_content = render_children(node)
+
+ # Check if this is a footnote or endnote based on footnote_type attribute
+ if node.footnote_type == :endnote
+ # Endnote - skip rendering here, will be rendered by printendnotes
+ return ''
+ end
+
+ # Match HTMLBuilder's footnote output format
+ footnote_number = @chapter&.footnote(node.id)&.number || '??'
+
+ # Check epubversion for consistent output with HTMLBuilder
+ if config['epubversion'].to_i == 3
+ # EPUB3 version with epub:type attributes
+ # Only add back link if epubmaker/back_footnote is configured (like HTMLBuilder)
+ back_link = ''
+ if config['epubmaker'] && config['epubmaker']['back_footnote']
+ back_link = %Q(#{text_formatter.format_footnote_backmark} )
+ end
+ %Q()
+ else
+ # Non-EPUB version
+ footnote_back_link = %Q(*#{footnote_number} )
+ %Q()
+ end
+ end
+
+ def visit_embed(node)
+ # All embed types now use unified processing
+ process_raw_embed(node)
+ end
+
+ def render_inline_element(type, content, node)
+ # Try delegating to inline element handler first
+ handler_method = "render_inline_#{type}"
+ if @inline_element_handler.respond_to?(handler_method, true)
+ return @inline_element_handler.send(handler_method, type, content, node)
+ end
+
+ # Fall back to renderer's own methods if handler returns nil
+ method_name = "render_inline_#{type}"
+ if respond_to?(method_name, true)
+ send(method_name, type, content, node)
+ else
+ raise NotImplementedError, "Unknown inline element: #{type}"
+ end
+ end
+
+ def render_note_block(node)
+ render_callout_block(node, 'note')
+ end
+
+ def render_memo_block(node)
+ render_callout_block(node, 'memo')
+ end
+
+ def render_tip_block(node)
+ render_callout_block(node, 'tip')
+ end
+
+ def render_info_block(node)
+ render_callout_block(node, 'info')
+ end
+
+ def render_warning_block(node)
+ render_callout_block(node, 'warning')
+ end
+
+ def render_important_block(node)
+ render_callout_block(node, 'important')
+ end
+
+ def render_caution_block(node)
+ render_callout_block(node, 'caution')
+ end
+
+ def render_notice_block(node)
+ render_callout_block(node, 'notice')
+ end
+
+ def render_quote_block(node)
+ id_attr = node.id ? %Q( id="#{normalize_id(node.id)}") : ''
+ content = render_children(node)
+ %Q(#{content} )
+ end
+
+ def render_lead_block(node)
+ id_attr = node.id ? %Q( id="#{normalize_id(node.id)}") : ''
+ content = render_children(node)
+ %Q(\n#{content}
\n)
+ end
+
+ def render_comment_block(node)
+ return '' unless config['draft']
+
+ content_lines = []
+
+ if node.args.first && !node.args.first.empty?
+ content_lines << escape(node.args.first)
+ end
+
+ if node.content && !node.content.empty?
+ body_content = render_children(node)
+ content_lines << body_content unless body_content.empty?
+ end
+
+ return '' if content_lines.empty?
+
+ content_str = content_lines.join(' ')
+ %Q()
+ end
+
+ def render_callout_block(node, type)
+ id_attr = node.id ? %Q( id="#{normalize_id(node.id)}") : ''
+
+ caption_content = render_caption_inline(node.caption_node)
+ caption_html = caption_content.empty? ? '' : %Q()
+
+ content = render_children(node)
+
+ %Q(\n#{caption_html}#{content}
)
+ end
+
+ def render_label_block(node)
+ # Extract label from args
+ label = node.args.first
+ return '' unless label
+
+ %Q( )
+ end
+
+ def render_tsize_block(_node)
+ # Table size control - HTMLBuilder outputs nothing for HTML
+ # tsize is only used for LaTeX/PDF output
+ ''
+ end
+
+ def render_printendnotes_block(_node)
+ # Render collected endnotes like HTMLBuilder's printendnotes method
+ return '' unless @chapter
+ return '' unless @chapter.endnotes
+
+ # Check if there are any endnotes using size
+ return '' if @chapter.endnotes.size == 0
+
+ # Mark that we've shown endnotes (like Builder base class)
+ @shown_endnotes = true
+
+ # Begin endnotes block
+ result = %Q(\n)
+
+ # Render each endnote like HTMLBuilder's endnote_item
+ @chapter.endnotes.each do |en|
+ back = ''
+ if config['epubmaker'] && config['epubmaker']['back_footnote']
+ back = %Q(
#{text_formatter.format_footnote_backmark} )
+ end
+ # Render endnote content from footnote_node
+ endnote_content = render_children(en.footnote_node)
+ result += %Q(
#{back}#{text_formatter.format_endnote_textmark(@chapter.endnote(en.id).number)}#{endnote_content}
\n)
+ end
+
+ # End endnotes block
+ result + %Q(
\n)
+ end
+
+ def render_flushright_block(node)
+ # Render children (which produces tags)
+ content = render_children(node)
+ # Replace
with
like HTMLBuilder
+ content.gsub('
', %Q(
))
+ end
+
+ def render_centering_block(node)
+ # Render children (which produces
tags)
+ content = render_children(node)
+ # Replace
with
like HTMLBuilder
+ content.gsub('
', %Q(
))
+ end
+
+ def render_bibpaper_block(node)
+ # For BlockNode, id and caption are in args array like HTMLBuilder's bibpaper(lines, id, caption)
+ id = node.args[0]
+ caption_text = node.args[1]
+
+ # Start div (puts in HTMLBuilder, so newline after)
+ result = %Q(
\n)
+
+ # Add anchor and number like HTMLBuilder's bibpaper_header
+ # bibpaper_header uses print for anchor, then puts for caption (with newline)
+ if id && @chapter
+ begin
+ bibpaper_number = @chapter.bibpaper(id).number
+ result += %Q(
[#{bibpaper_number}] )
+ rescue StandardError
+ # If bibpaper not found, use ?? like other references
+ result += %Q(
[??] )
+ end
+ end
+
+ # Add caption as plain text (BlockNode doesn't have caption_node)
+ # HTMLBuilder uses puts " #{compile_inline(caption)}", so space before caption and newline after
+ if caption_text && !caption_text.empty?
+ result += escape_content(caption_text) + "\n"
+ end
+
+ # Add content wrapped in
if present (like split_paragraph does)
+ # HTMLBuilder uses print for bibpaper_bibpaper, so no newline after
+ # Then puts '
' adds the closing tag with newline
+ content = render_children(node)
+ unless content.strip.empty?
+ # strip to remove paragraph newlines, match Builder's behavior
+ result += %Q(#{content.strip}
)
+ end
+
+ # Close div (puts in HTMLBuilder, so it's on the same line as )
+ result + "\n"
+ end
+
+ def escape(str)
+ # Use EscapeUtils for consistency
+ escape_content(str.to_s)
+ end
+
+ def headline_prefix(level)
+ return [nil, nil] unless @sec_counter
+
+ @sec_counter.inc(level)
+ anchor = @sec_counter.anchor(level)
+ prefix = @sec_counter.prefix(level, config['secnolevel'])
+ [prefix, anchor]
+ end
+
+ def image_image_html(id, caption_node, id_attr, image_type = :image)
+ caption_html = image_header_html(id, caption_node, image_type)
+ caption_present = !caption_html.empty?
+
+ begin
+ image_path = @chapter.image(id).path.sub(%r{\A\./}, '')
+ alt_text = escape(render_caption_inline(caption_node))
+
+ img_html = %Q( )
+
+ # Check caption positioning like HTMLBuilder
+ if caption_top?('image') && caption_present
+ %Q(\n#{caption_html}#{img_html}\n
\n)
+ else
+ %Q(\n#{img_html}\n#{caption_html}
\n)
+ end
+ rescue StandardError
+ # If image loading fails, fall back to dummy
+ image_dummy_html(id, caption_node, [], id_attr, image_type)
+ end
+ end
+
+ def image_image_html_with_context(id, caption_node, id_attr, caption_context, image_type = :image)
+ caption_html = image_header_html_with_context(id, caption_node, caption_context, image_type)
+ caption_present = !caption_html.empty?
+
+ begin
+ image_path = @chapter.image(id).path.sub(%r{\A\./}, '')
+ img_html = %Q( )
+
+ # Check caption positioning like HTMLBuilder
+ if caption_top?('image') && caption_present
+ %Q(\n#{caption_html}#{img_html}\n
\n)
+ else
+ %Q(\n#{img_html}\n#{caption_html}
\n)
+ end
+ rescue StandardError
+ # If image loading fails, fall back to dummy
+ image_dummy_html_with_context(id, caption_node, [], id_attr, caption_context, image_type)
+ end
+ end
+
+ def image_dummy_html(id, caption_node, lines, id_attr, image_type = :image)
+ caption_html = image_header_html(id, caption_node, image_type)
+ caption_present = !caption_html.empty?
+
+ # Generate dummy image content exactly like HTMLBuilder
+ # HTMLBuilder puts each line and adds newlines via 'puts'
+ lines_content = if lines.empty?
+ "\n" # Empty image block just has one newline
+ else
+ "\n" + lines.map { |line| escape(line) }.join("\n") + "\n"
+ end
+
+ # Check caption positioning like HTMLBuilder
+ if caption_top?('image') && caption_present
+ %Q(\n#{caption_html}
#{lines_content} \n
\n)
+ else
+ %Q(\n
#{lines_content} \n#{caption_html}
\n)
+ end
+ end
+
+ def image_dummy_html_with_context(id, caption_node, lines, id_attr, caption_context, image_type = :image)
+ caption_html = image_header_html_with_context(id, caption_node, caption_context, image_type)
+ caption_present = !caption_html.empty?
+
+ # Generate dummy image content exactly like HTMLBuilder
+ lines_content = if lines.empty?
+ "\n" # Empty image block just has one newline
+ else
+ "\n" + lines.map { |line| escape(line) }.join("\n") + "\n"
+ end
+
+ # Check caption positioning like HTMLBuilder
+ if caption_top?('image') && caption_present
+ %Q(\n#{caption_html}
#{lines_content} \n
\n)
+ else
+ %Q(\n
#{lines_content} \n#{caption_html}
\n)
+ end
+ end
+
+ def image_header_html(id, caption_node, image_type = :image)
+ caption_content = render_caption_inline(caption_node)
+ return '' if caption_content.empty?
+
+ # For indepimage (numberless image), use numberless_image label like HTMLBuilder
+ if image_type == :indepimage || image_type == :numberlessimage
+ image_number = text_formatter.format_numberless_image
+ caption_text = "#{image_number}#{text_formatter.format_caption_prefix}#{caption_content}"
+ else
+ # Generate image number like HTMLBuilder using chapter image index
+ image_item = @chapter&.image(id)
+ unless image_item && image_item.number
+ raise ReVIEW::KeyError, "image '#{id}' not found"
+ end
+
+ caption_text = text_formatter.format_caption('image', get_chap, image_item.number, caption_content)
+ end
+
+ %Q(\n#{caption_text}\n
\n)
+ end
+
+ def image_header_html_with_context(id, caption_node, caption_context, image_type = :image)
+ caption_content = render_caption_with_context(caption_node, caption_context)
+ return '' if caption_content.empty?
+
+ # For indepimage (numberless image), use numberless_image label like HTMLBuilder
+ if image_type == :indepimage || image_type == :numberlessimage
+ image_number = text_formatter.format_numberless_image
+ caption_text = "#{image_number}#{text_formatter.format_caption_prefix}#{caption_content}"
+ else
+ # Generate image number like HTMLBuilder using chapter image index
+ image_item = @chapter&.image(id)
+ unless image_item && image_item.number
+ raise ReVIEW::KeyError, "image '#{id}' not found"
+ end
+
+ caption_text = text_formatter.format_caption('image', get_chap, image_item.number, caption_content)
+ end
+
+ %Q(\n#{caption_text}\n
\n)
+ end
+
+ def generate_table_header(id, caption)
+ table_item = @chapter.table(id)
+ table_num = table_item.number
+ chapter_num = @chapter.number
+
+ text_formatter.format_caption('table', chapter_num, table_num, caption)
+ rescue ReVIEW::KeyError
+ raise NotImplementedError, "no such table: #{id}"
+ end
+
+ def render_imgtable(node)
+ id = node.id
+ caption_node = node.caption_node
+
+ # Check if image is bound like HTMLBuilder does
+ unless @chapter&.image_bound?(id)
+ warn "image not bound: #{id}"
+ # For dummy images, use empty array for lines (no lines in TableNode)
+ return render_imgtable_dummy(id, caption_node, [])
+ end
+
+ id_attr = id ? %Q( id="#{normalize_id(id)}") : ''
+
+ # Generate table caption HTML if caption exists
+ caption_content = render_caption_inline(caption_node)
+ caption_html = if caption_content.empty?
+ ''
+ else
+ table_caption = generate_table_header(id, caption_content)
+ %Q(#{table_caption}
\n)
+ end
+
+ # Render image tag
+ begin
+ image_path = @chapter.image(id).path.sub(%r{\A\./}, '')
+ alt_text = escape(node.caption_text)
+ img_html = %Q( \n)
+
+ # Check caption positioning like HTMLBuilder (uses 'table' type for imgtable)
+ if caption_top?('table') && !caption_content.empty?
+ %Q(\n#{caption_html}#{img_html}
\n)
+ else
+ %Q(\n#{img_html}#{caption_html}
\n)
+ end
+ rescue ReVIEW::KeyError
+ app_error "no such table: #{id}"
+ end
+ end
+
+ def render_imgtable_dummy(id, caption_node, lines)
+ id_attr = id ? %Q( id="#{normalize_id(id)}") : ''
+
+ # Generate table caption HTML if caption exists
+ caption_content = render_caption_inline(caption_node)
+ caption_html = if caption_content.empty?
+ ''
+ else
+ table_caption = generate_table_header(id, caption_content)
+ %Q(#{table_caption}
\n)
+ end
+
+ # Generate dummy content like image_dummy_html
+ lines_content = if lines.empty?
+ "\n"
+ else
+ "\n" + lines.map { |line| escape(line) }.join("\n") + "\n"
+ end
+
+ # Check caption positioning like HTMLBuilder
+ if caption_top?('table') && !caption_content.empty?
+ %Q(\n#{caption_html}
#{lines_content} \n
\n)
+ else
+ %Q(\n
#{lines_content} \n#{caption_html}
\n)
+ end
+ end
+
+ def render_caption_inline(caption_node)
+ return '' unless caption_node
+
+ content = render_children(caption_node)
+ # Join lines like visit_paragraph does
+ join_paragraph_lines(content)
+ end
+
+ def render_caption_with_context(caption_node, caption_context)
+ return '' unless caption_node
+
+ render_children_with_context(caption_node, caption_context)
+ end
+
+ # Process raw embed content (//raw and @)
+ def process_raw_embed(node)
+ # Check if content should be output for this renderer
+ return '' unless node.targeted_for?('html')
+
+ # Get content
+ content = node.content || ''
+
+ # Process \n based on embed type
+ case node.embed_type
+ when :inline, :raw
+ # For inline and raw embeds, convert \\n to actual newlines
+ content = content.gsub('\\n', "\n")
+ end
+
+ # Apply XHTML compliance for HTML output
+ result = ensure_xhtml_compliance(content)
+
+ # For block embeds, add trailing newline
+ node.embed_type == :block ? result + "\n" : result
+ end
+
+ def ensure_xhtml_compliance(content)
+ content.gsub(/ ]*)?>/, ' ').
+ gsub(/ ]*)?>/, ' ').
+ gsub(%r{ ]*[^/])>}, ' ').
+ gsub(%r{ ]*[^/])>}, ' ')
+ end
+
+ # Builder compatibility - return target name for embed blocks
+ def target_name
+ 'html'
+ end
+
+ def render_children_with_context(node, context)
+ old_context = @rendering_context
+ @rendering_context = context
+ result = render_children(node)
+ @rendering_context = old_context
+ result
+ end
+
+ def visit_with_context(node, context)
+ old_context = @rendering_context
+ @rendering_context = context
+ result = visit(node)
+ @rendering_context = old_context
+ result
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/idgxml/inline_context.rb b/lib/review/renderer/idgxml/inline_context.rb
new file mode 100644
index 000000000..a2fa9548a
--- /dev/null
+++ b/lib/review/renderer/idgxml/inline_context.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/htmlutils'
+require_relative '../inline_render_proxy'
+
+module ReVIEW
+ module Renderer
+ module Idgxml
+ # Context for inline element rendering with business logic
+ # Used by InlineElementHandler
+ class InlineContext
+ include ReVIEW::HTMLUtils
+
+ attr_reader :config, :book, :chapter, :img_math
+
+ def initialize(config:, book:, chapter:, renderer:, img_math: nil)
+ @config = config
+ @book = book
+ @chapter = chapter
+ # Automatically create proxy from renderer to limit access
+ @render_proxy = InlineRenderProxy.new(renderer)
+ @img_math = img_math
+ end
+
+ # === HTMLUtils methods are available via include ===
+ # - escape_html(str)
+ # - normalize_id(id)
+
+ # Escape for IDGXML (uses HTML escaping)
+ def escape(str)
+ escape_html(str.to_s)
+ end
+
+ def chapter_link_enabled?
+ config['chapterlink']
+ end
+
+ def draft_mode?
+ config['draft']
+ end
+
+ def nolf_mode?
+ config.key?('nolf') ? config['nolf'] : true
+ end
+
+ def math_format
+ config['math_format']
+ end
+
+ def over_secnolevel?(n)
+ secnolevel = config['secnolevel'] || 2
+ secnolevel >= n.to_s.split('.').size
+ end
+
+ def get_chap # rubocop:disable Naming/AccessorMethodName
+ if config['secnolevel'] && config['secnolevel'] > 0 &&
+ !chapter.number.nil? && !chapter.number.to_s.empty?
+ if chapter.is_a?(ReVIEW::Book::Part)
+ return text_formatter.format_part_short(chapter)
+ else
+ return chapter.format_number(nil)
+ end
+ end
+ nil
+ end
+
+ def bibpaper_number(bib_id)
+ chapter.bibpaper(bib_id).number
+ end
+
+ def increment_texinlineequation
+ @render_proxy.increment_texinlineequation
+ end
+
+ def render_children(node)
+ @render_proxy.render_children(node)
+ end
+
+ def render_caption_inline(caption_node)
+ @render_proxy.render_caption_inline(caption_node)
+ end
+
+ def text_formatter
+ @render_proxy.text_formatter
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/idgxml/inline_element_handler.rb b/lib/review/renderer/idgxml/inline_element_handler.rb
new file mode 100644
index 000000000..730398ce4
--- /dev/null
+++ b/lib/review/renderer/idgxml/inline_element_handler.rb
@@ -0,0 +1,555 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'digest/sha2'
+
+module ReVIEW
+ module Renderer
+ module Idgxml
+ # Inline element handler for IDGXML rendering
+ # Uses InlineContext for shared logic
+ class InlineElementHandler
+ include ReVIEW::HTMLUtils
+ include ReVIEW::Loggable
+
+ def initialize(inline_context)
+ @ctx = inline_context
+ @img_math = @ctx.img_math
+ @logger = ReVIEW.logger
+ end
+
+ # Basic formatting
+ # Note: content is already escaped by visit_text, so don't escape again
+ def render_inline_b(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_i(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_em(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_strong(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_tt(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_ttb(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_ttbold(type, content, node)
+ render_inline_ttb(type, content, node)
+ end
+
+ def render_inline_tti(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_u(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_ins(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_del(_type, content, _node)
+ %Q(#{content})
+ end
+
+ def render_inline_sup(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_sub(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_ami(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_bou(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ def render_inline_keytop(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ # Code
+ def render_inline_code(_type, content, _node)
+ %Q(#{content} )
+ end
+
+ # Hints
+ def render_inline_hint(_type, content, _node)
+ if @ctx.config['nolf']
+ %Q(#{content} )
+ else
+ %Q(\n#{content} )
+ end
+ end
+
+ # Maru (circled numbers/letters)
+ def render_inline_maru(_type, content, node)
+ str = node.args.first || content
+
+ if /\A\d+\Z/.match?(str)
+ sprintf('%x;', 9311 + str.to_i)
+ elsif /\A[A-Z]\Z/.match?(str)
+ begin
+ sprintf('%x;', 9398 + str.codepoints.to_a[0] - 65)
+ rescue NoMethodError
+ sprintf('%x;', 9398 + str[0] - 65)
+ end
+ elsif /\A[a-z]\Z/.match?(str)
+ begin
+ sprintf('%x;', 9392 + str.codepoints.to_a[0] - 65)
+ rescue NoMethodError
+ sprintf('%x;', 9392 + str[0] - 65)
+ end
+ else
+ escape(str)
+ end
+ end
+
+ # Ruby (furigana)
+ def render_inline_ruby(_type, content, node)
+ if node.args.length >= 2
+ base = escape(node.args[0])
+ ruby = escape(node.args[1])
+ %Q(#{base} #{ruby} )
+ else
+ content
+ end
+ end
+
+ # Keyword
+ def render_inline_kw(_type, content, node)
+ if node.args.length >= 2
+ word = node.args[0]
+ alt = node.args[1]
+
+ result = ''
+ result += if alt && !alt.empty?
+ escape("#{word}(#{alt.strip})")
+ else
+ escape(word)
+ end
+ result += ' '
+
+ result += %Q( )
+
+ if alt && !alt.empty?
+ alt.split(/\s*,\s*/).each do |e|
+ result += %Q( )
+ end
+ end
+
+ result
+ elsif node.args.length == 1
+ # Single argument case - get raw string from args
+ word = node.args[0]
+ result = %Q(#{escape(word)} )
+ result += %Q( )
+ result
+ else
+ # Fallback
+ %Q(#{content} )
+ end
+ end
+
+ # Index
+ def render_inline_idx(_type, content, node)
+ str = node.args.first || content
+ %Q(#{escape(str)} )
+ end
+
+ def render_inline_hidx(_type, content, node)
+ str = node.args.first || content
+ %Q( )
+ end
+
+ # Links
+ def render_inline_href(_type, content, node)
+ if node.args.length >= 2
+ url = node.args[0].gsub('\,', ',').strip
+ label = node.args[1].gsub('\,', ',').strip
+ %Q(#{escape(label)} )
+ elsif node.args.length >= 1
+ url = node.args[0].gsub('\,', ',').strip
+ %Q(#{escape(url)} )
+ else
+ %Q(#{content} )
+ end
+ end
+
+ # References
+ def render_inline_list(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ base_ref = @ctx.text_formatter.format_reference(:list, data)
+ "#{base_ref} "
+ end
+
+ def render_inline_table(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ base_ref = @ctx.text_formatter.format_reference(:table, data)
+ "#{base_ref} "
+ end
+
+ def render_inline_img(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ base_ref = @ctx.text_formatter.format_reference(:image, data)
+ "#{base_ref} "
+ end
+
+ def render_inline_eq(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ base_ref = @ctx.text_formatter.format_reference(:equation, data)
+ "#{base_ref} "
+ end
+
+ def render_inline_imgref(type, content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+
+ # If no caption, fall back to render_inline_img
+ if data.caption_text.blank?
+ return render_inline_img(type, content, node)
+ end
+
+ # Build reference with caption
+ base_ref = @ctx.text_formatter.format_reference(:image, data)
+ caption = @ctx.text_formatter.format_image_quote(data.caption_text)
+ "#{base_ref}#{caption} "
+ end
+
+ # Column reference
+ def render_inline_column(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+
+ # Use caption_node to render inline elements if available
+ # For cross-chapter references, caption_node may not be available, so fall back to caption_text
+ compiled_caption = if data.caption_node
+ @ctx.render_caption_inline(data.caption_node)
+ else
+ escape(data.caption_text)
+ end
+
+ column_text = @ctx.text_formatter.format_column_label(compiled_caption)
+
+ if @ctx.chapter_link_enabled?
+ %Q( #{column_text})
+ else
+ column_text
+ end
+ end
+
+ # Footnotes
+ def render_inline_fn(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ if data.caption_node
+ # Render the stored AST node when available to preserve inline markup
+ rendered = @ctx.render_caption_inline(data.caption_node)
+ %Q(#{rendered} )
+ else
+ # Fallback: use caption_text
+ rendered_text = escape(data.caption_text.to_s.strip)
+ %Q(#{rendered_text} )
+ end
+ end
+
+ # Endnotes
+ def render_inline_endnote(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ %Q((#{data.item_number}) )
+ end
+
+ # Bibliography
+ def render_inline_bib(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ bib_id = data.item_id
+ bib_number = data.item_number
+ %Q([#{bib_number}] )
+ end
+
+ # Headline reference
+ def render_inline_hd(_type, content, node)
+ ref_node = node.children.first
+ return content unless ref_node.reference_node? && ref_node.resolved?
+
+ data = ref_node.resolved_data
+ @ctx.text_formatter.format_reference(:headline, data)
+ end
+
+ # Section number reference
+ def render_inline_sec(_type, _content, node)
+ ref_node = node.children.first
+ return '' unless ref_node.reference_node? && ref_node.resolved?
+
+ data = ref_node.resolved_data
+ n = data.headline_number
+ chapter_num = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+ # Get section number like Builder does (including chapter number)
+ if n.present? && chapter_num && !chapter_num.empty? && @ctx.over_secnolevel?(n)
+ ([chapter_num] + n).join('.')
+ else
+ ''
+ end
+ end
+
+ # Section title reference
+ def render_inline_sectitle(_type, content, node)
+ ref_node = node.children.first
+ return content unless ref_node.reference_node? && ref_node.resolved?
+
+ if ref_node.resolved_data.caption_node
+ @ctx.render_caption_inline(ref_node.resolved_data.caption_node)
+ else
+ ref_node.resolved_data.caption_text
+ end
+ end
+
+ # Chapter reference
+ def render_inline_chap(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ # Format chapter number to full form (e.g., "第1章", "付録A", "第II部")
+ chapter_num = @ctx.text_formatter.format_chapter_number_full(data.chapter_number, data.chapter_type)
+ if @ctx.chapter_link_enabled?
+ %Q( #{chapter_num})
+ else
+ chapter_num.to_s
+ end
+ end
+
+ def render_inline_chapref(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ display_str = @ctx.text_formatter.format_reference(:chapter, data)
+ if @ctx.chapter_link_enabled?
+ %Q( #{display_str})
+ else
+ display_str
+ end
+ end
+
+ def render_inline_title(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ title = data.chapter_title || ''
+ if @ctx.chapter_link_enabled?
+ %Q( #{title})
+ else
+ title
+ end
+ end
+
+ # Labels
+ def render_inline_labelref(_type, content, node)
+ # Get idref from node.args (raw, not escaped)
+ idref = node.args.first || content
+ marker = @ctx.text_formatter.format_label_marker(idref)
+ %Q([「#{escape(marker)}」])
+ end
+
+ def render_inline_ref(type, content, node)
+ render_inline_labelref(type, content, node)
+ end
+
+ def render_inline_pageref(_type, content, node)
+ idref = node.args.first || content
+ %Q(●● )
+ end
+
+ # Icon (inline image)
+ def render_inline_icon(_type, content, node)
+ id = node.args.first || content
+ begin
+ %Q( )
+ rescue StandardError
+ ''
+ end
+ end
+
+ # Balloon
+ def render_inline_balloon(_type, content, node)
+ # Content is already escaped and rendered from children
+ # Need to get raw text from node to process @maru markers
+ # Since InlineNode processes children first, we need raw args
+ if node.args.first
+ # Get raw string from args (not escaped yet)
+ str = node.args.first
+ processed = escape(str).gsub(/@maru\[(\d+)\]/) do
+ # $1 is the captured number string
+ number = $1
+ # Generate maru character directly
+ if /\A\d+\Z/.match?(number)
+ sprintf('%x;', 9311 + number.to_i)
+ else
+ "@maru[#{number}]"
+ end
+ end
+ %Q(#{processed} )
+ else
+ # Fallback: use content as-is
+ %Q(#{content} )
+ end
+ end
+
+ # Unicode character
+ def render_inline_uchar(_type, content, node)
+ str = node.args.first || content
+ %Q(#{str};)
+ end
+
+ # Math
+ def render_inline_m(_type, content, node)
+ str = node.args.first || content
+
+ if @ctx.math_format == 'imgmath'
+ require 'review/img_math'
+ @ctx.increment_texinlineequation
+
+ math_str = '$' + str + '$'
+ key = Digest::SHA256.hexdigest(str)
+ @img_math ||= ReVIEW::ImgMath.new(@ctx.config)
+ img_path = @img_math.defer_math_image(math_str, key)
+ %Q( )
+ else
+ counter_value = @ctx.increment_texinlineequation
+ %Q(#{escape(str)} )
+ end
+ end
+
+ # DTP processing instruction
+ def render_inline_dtp(_type, content, node)
+ str = node.args.first || content
+ ""
+ end
+
+ # Break
+ # Returns a protected newline marker that will be preserved through paragraph
+ # and nolf processing, then restored to an actual newline in visit_document
+ def render_inline_br(_type, _content, _node)
+ "\x01IDGXML_INLINE_NEWLINE\x01"
+ end
+
+ # Raw
+ def render_inline_raw(_type, _content, node)
+ if node.targeted_for?('idgxml')
+ # Convert \\n to actual newlines
+ (node.content || '').gsub('\\n', "\n")
+ else
+ ''
+ end
+ end
+
+ def render_inline_embed(_type, _content, node)
+ if node.targeted_for?('idgxml')
+ # Convert \\n to actual newlines
+ (node.content || '').gsub('\\n', "\n")
+ else
+ ''
+ end
+ end
+
+ # Comment
+ def render_inline_comment(_type, content, node)
+ if @ctx.draft_mode?
+ str = node.args.first || content
+ %Q(#{escape(str)} )
+ else
+ ''
+ end
+ end
+
+ # Recipe (FIXME placeholder)
+ def render_inline_recipe(_type, content, node)
+ id = node.args.first || content
+ %Q([XXX]「#{escape(id)}」 p.XX )
+ end
+
+ # Alias for secref
+ def render_inline_secref(type, content, node)
+ render_inline_hd(type, content, node)
+ end
+
+ private
+
+ def escape(str)
+ @ctx.escape(str)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/idgxml_renderer.rb b/lib/review/renderer/idgxml_renderer.rb
new file mode 100644
index 000000000..00752bd43
--- /dev/null
+++ b/lib/review/renderer/idgxml_renderer.rb
@@ -0,0 +1,1832 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+#
+# == Newline Protection Markers
+#
+# This renderer uses special markers to protect certain newlines from being
+# removed during paragraph joining and nolf (no-line-feed) processing:
+#
+# - IDGXML_INLINE_NEWLINE: Protects newlines from inline elements (@ {}, @{\n})
+# These newlines must be preserved in the final output as they are intentionally
+# inserted by the user for formatting purposes.
+#
+# - IDGXML_PRE_NEWLINE: Protects newlines inside tags during nolf processing
+#
+# - IDGXML_LISTINFO_NEWLINE: Protects newlines inside tags
+#
+# - IDGXML_ENDNOTE_NEWLINE: Protects newlines inside blocks
+#
+# The markers are restored to actual newlines at the end of visit_document.
+require 'review/htmlutils'
+require 'review/textutils'
+require 'review/sec_counter'
+require 'review/ast/caption_node'
+require 'review/ast/paragraph_node'
+require 'review/i18n'
+require 'review/loggable'
+require 'digest/sha2'
+require_relative 'base'
+require_relative 'rendering_context'
+require_relative 'idgxml/inline_context'
+require_relative 'idgxml/inline_element_handler'
+
+module ReVIEW
+ module Renderer
+ class IdgxmlRenderer < Base
+ include ReVIEW::HTMLUtils
+ include ReVIEW::TextUtils
+ include ReVIEW::Loggable
+
+ attr_reader :chapter, :book, :logger
+ attr_accessor :img_math, :img_graph
+
+ def initialize(chapter)
+ super
+
+ # Initialize logger for Loggable module
+ @logger = ReVIEW.logger
+
+ I18n.setup(config['language'] || 'ja')
+
+ # Initialize section counters like IDGXMLBuilder
+ @section = 0
+ @subsection = 0
+ @subsubsection = 0
+ @subsubsubsection = 0
+ @sec_counter = SecCounter.new(5, @chapter) if @chapter
+
+ # Initialize table state
+ @tablewidth = nil
+ @table_id = nil
+ @col = 0
+ @table_node_cellwidth = nil # Temporarily stores cellwidth from TableNode during table processing
+
+ # Initialize equation counters
+ @texblockequation = 0
+ @texinlineequation = 0
+
+ # Initialize ImgMath for math rendering
+ @img_math = nil
+
+ # Initialize ImgGraph for graph rendering
+ @img_graph = nil
+
+ # Initialize root element name
+ @rootelement = 'doc'
+
+ # Get structuredxml setting
+ @secttags = config['structuredxml']
+
+ # Initialize RenderingContext
+ @rendering_context = RenderingContext.new(:document)
+
+ # Initialize AST helpers
+ @ast_indexer = nil
+ @ast_compiler = nil
+
+ # Initialize IDGXML-specific inline context and inline element handler
+ @inline_context = Idgxml::InlineContext.new(
+ config: config,
+ book: book,
+ chapter: chapter,
+ renderer: self,
+ img_math: @img_math
+ )
+ @inline_element_handler = Idgxml::InlineElementHandler.new(@inline_context)
+ end
+
+ # Format type for this renderer
+ # @return [Symbol] Format type :idgxml
+ def format_type
+ :idgxml
+ end
+
+ # Increment texinlineequation counter and return new value
+ # Called from inline element handler via InlineContext
+ def increment_texinlineequation
+ @texinlineequation += 1
+ end
+
+ def visit_document(node)
+ # Check nolf mode (enabled by default for IDGXML)
+ # IDGXML format removes newlines between tags by default
+ nolf = config.key?('nolf') ? config['nolf'] : true
+
+ # Output XML declaration and root element
+ output = []
+ output << %Q()
+ output << %Q(<#{@rootelement} xmlns:aid="http://ns.adobe.com/AdobeInDesign/4.0/">)
+
+ # Render document content
+ content = render_children(node)
+
+ # Close section tags if structuredxml is enabled
+ closing_tags = ''
+ if @secttags
+ closing_tags += '' if @subsubsubsection > 0
+ closing_tags += '' if @subsubsection > 0
+ closing_tags += '' if @subsection > 0
+ closing_tags += '' if @section > 0
+ closing_tags += ''
+ end
+
+ # Combine all parts
+ output << content
+ output << closing_tags
+ output << "#{@rootelement}>\n"
+
+ result = output.join
+
+ # Remove newlines between tags if nolf mode is enabled (default)
+ # But preserve newlines inside tags and listinfo tags
+ if nolf
+ # Protect newlines inside tags
+ result = result.gsub(%r{(.*?) }m) do |match|
+ match.gsub("\n", "\x01IDGXML_PRE_NEWLINE\x01")
+ end
+
+ # Remove all newlines between tags and before closing tags
+ # This handles both >\n< and text\n< patterns
+ result = result.gsub(/\n+, '<')
+
+ # Restore newlines inside tags
+ result = result.gsub("\x01IDGXML_PRE_NEWLINE\x01", "\n")
+ end
+
+ # Restore protected newlines from listinfo, inline elements, and endnotes
+ result = result.gsub("\x01IDGXML_LISTINFO_NEWLINE\x01", "\n")
+ result = result.gsub("\x01IDGXML_INLINE_NEWLINE\x01", "\n")
+ result.gsub("\x01IDGXML_ENDNOTE_NEWLINE\x01", "\n")
+ end
+
+ def visit_headline(node)
+ # Skip nodisp headlines (display: no, TOC: yes)
+ return '' if node.nodisp?
+
+ level = node.level
+ label = node.label
+ caption = render_children(node.caption_node) if node.caption_node
+
+ result = []
+
+ # Close section tags as needed
+ closing = output_close_sect_tags(level)
+ result << closing if closing && !closing.empty?
+
+ # Handle section tag opening for structuredxml mode
+ case level
+ when 1
+ result << %Q() if @secttags
+ @section = 0
+ @subsection = 0
+ @subsubsection = 0
+ @subsubsubsection = 0
+ when 2
+ @section += 1
+ result << %Q() if @secttags
+ @subsection = 0
+ @subsubsection = 0
+ @subsubsubsection = 0
+ when 3
+ @subsection += 1
+ result << %Q() if @secttags
+ @subsubsection = 0
+ @subsubsubsection = 0
+ when 4
+ @subsubsection += 1
+ result << %Q() if @secttags
+ @subsubsubsection = 0
+ when 5
+ @subsubsubsection += 1
+ result << %Q() if @secttags
+ when 6
+ # ignore level 6
+ else
+ raise "caption level too deep or unsupported: #{level}"
+ end
+
+ # Get headline prefix
+ prefix, _anchor = headline_prefix(level) if @sec_counter
+
+ # Generate label attribute
+ label_attr = label.nil? ? '' : %Q( id="#{label}")
+
+ # Generate TOC caption (without footnotes and tags)
+ toccaption = escape(caption.to_s.gsub(/@\{.+?\}/, '').gsub(/<[^>]+>/, ''))
+
+ # Output title with DTP processing instruction
+ result << %Q(#{prefix}#{caption} )
+
+ result.join("\n") + "\n"
+ end
+
+ def visit_paragraph(node)
+ content = render_children(node)
+
+ # Join lines in paragraph by removing newlines (like join_lines in IDGXMLBuilder)
+ # Inline elements like @ {} and @{} use protected markers that are preserved
+ # unless join_lines_by_lang is explicitly enabled
+ content = if config['join_lines_by_lang']
+ content.tr("\n", ' ')
+ else
+ content.delete("\n")
+ end
+
+ # Handle noindent attribute
+ if node.attribute?(:noindent)
+ return %Q(#{content}
)
+ end
+
+ # Check for tab indentation (inlist attribute)
+ if content =~ /\A(\t+)/
+ indent_level = $1.size
+ content_without_tabs = content.sub(/\A\t+/, '')
+ return %Q(#{content_without_tabs}
)
+ end
+
+ # Regular paragraph
+ "#{content}
"
+ end
+
+ def visit_text(node)
+ escape(node.content.to_s)
+ end
+
+ def visit_reference(node)
+ if node.resolved?
+ format_resolved_reference(node.resolved_data)
+ else
+ # Reference resolution was skipped or disabled
+ # Return content as fallback
+ node.content || ''
+ end
+ end
+
+ # Format resolved reference based on ResolvedData
+ # Uses TextFormatter for centralized text formatting
+ def format_resolved_reference(data)
+ plain_text = text_formatter.format_reference(data.reference_type, data)
+ # IDGXML is XML-based, so escape all text content
+ escape_html(plain_text)
+ end
+
+ def visit_list(node)
+ case node.list_type
+ when :ul
+ visit_ul(node)
+ when :ol
+ visit_ol(node)
+ when :dl
+ visit_dl(node)
+ else
+ raise NotImplementedError, "IdgxmlRenderer does not support list_type #{node.list_type}"
+ end
+ end
+
+ def visit_list_item(node)
+ # Should not be called directly; handled by parent list
+ raise NotImplementedError, 'List item processing should be handled by visit_list'
+ end
+
+ # visit_code_block is now handled by Base renderer with dynamic method dispatch
+ # Aliases will be defined after the original methods
+
+ def visit_code_line(node)
+ # Render children and detab
+ content = render_children(node)
+ detab(content, tabwidth)
+ end
+
+ def visit_table(node)
+ # Handle imgtable specially
+ if node.table_type == :imgtable
+ return visit_imgtable(node)
+ end
+
+ # Regular table processing
+ visit_regular_table(node)
+ end
+
+ def visit_table_row(node)
+ # Should be handled by visit_table
+ raise NotImplementedError, 'Table row processing should be handled by visit_table'
+ end
+
+ def visit_table_cell(node)
+ # Should be handled by visit_table
+ raise NotImplementedError, 'Table cell processing should be handled by visit_table'
+ end
+
+ def visit_image(node)
+ image_type = node.image_type
+
+ case image_type
+ when :indepimage, :numberlessimage
+ visit_indepimage(node)
+ else
+ visit_regular_image(node)
+ end
+ end
+
+ def visit_minicolumn(node)
+ type = node.minicolumn_type.to_s
+ caption = render_children(node.caption_node) if node.caption_node
+ content = render_children(node)
+
+ # notice uses -t suffix when caption is present
+ if type == 'notice' && caption && !caption.empty?
+ captionblock_with_content('notice-t', content, caption, 'notice-title')
+ else
+ # Content already contains tags from paragraphs
+ captionblock_with_content(type, content, caption)
+ end
+ end
+
+ def visit_column(node)
+ caption = render_children(node.caption_node) if node.caption_node
+ content = render_children(node)
+
+ # Determine column type (empty string for regular column)
+ type = ''
+
+ # Generate column output using auto_id from Compiler
+ id_attr = %Q(id="#{node.auto_id}")
+
+ result = []
+ result << "<#{type}column #{id_attr}>"
+ if caption
+ result << %Q(
#{caption} )
+ end
+ result << content.chomp
+ result << "#{type}column>"
+
+ result.join("\n") + "\n"
+ end
+
+ # visit_block is now handled by Base renderer with dynamic method dispatch
+ # Individual block type visitors
+
+ def visit_block_quote(node)
+ content = render_children(node)
+ "#{content}
\n"
+ end
+
+ def visit_block_lead(node)
+ content = render_children(node)
+ "#{content} \n"
+ end
+
+ def visit_block_read(node)
+ content = render_children(node)
+ "#{content} \n"
+ end
+
+ def visit_block_note(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('note', content, caption)
+ end
+
+ def visit_block_memo(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('memo', content, caption)
+ end
+
+ def visit_block_tip(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('tip', content, caption)
+ end
+
+ def visit_block_info(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('info', content, caption)
+ end
+
+ def visit_block_warning(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('warning', content, caption)
+ end
+
+ def visit_block_important(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('important', content, caption)
+ end
+
+ def visit_block_caution(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('caution', content, caption)
+ end
+
+ def visit_block_planning(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('planning', content, caption)
+ end
+
+ def visit_block_best(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('best', content, caption)
+ end
+
+ def visit_block_security(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('security', content, caption)
+ end
+
+ def visit_block_reference(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('reference', content, caption)
+ end
+
+ def visit_block_link(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('link', content, caption)
+ end
+
+ def visit_block_practice(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('practice', content, caption)
+ end
+
+ def visit_block_expert(node)
+ caption = node.args.first
+ content = render_children(node)
+ captionblock('expert', content, caption)
+ end
+
+ def visit_block_point(node)
+ caption = node.args.first
+ content = render_block_content_with_paragraphs(node)
+ if caption && !caption.empty? && node.caption_node
+ caption_with_inline = render_caption_inline(node.caption_node)
+ captionblock_with_content('point-t', content, caption_with_inline, 'point-title')
+ else
+ captionblock_with_content('point', content, nil)
+ end
+ end
+
+ def visit_block_shoot(node)
+ caption = node.args.first
+ content = render_block_content_with_paragraphs(node)
+ if caption && !caption.empty? && node.caption_node
+ caption_with_inline = render_caption_inline(node.caption_node)
+ captionblock_with_content('shoot-t', content, caption_with_inline, 'shoot-title')
+ else
+ captionblock_with_content('shoot', content, nil)
+ end
+ end
+
+ def visit_block_notice(node)
+ caption = node.args.first
+ content = render_block_content_with_paragraphs(node)
+ if caption && !caption.empty? && node.caption_node
+ caption_with_inline = render_caption_inline(node.caption_node)
+ captionblock_with_content('notice-t', content, caption_with_inline, 'notice-title')
+ else
+ captionblock_with_content('notice', content, nil)
+ end
+ end
+
+ def visit_block_term(node)
+ content = render_block_content_with_paragraphs(node)
+ captionblock_with_content('term', content, nil)
+ end
+
+ def visit_block_insn(node)
+ visit_syntaxblock(node)
+ end
+
+ def visit_block_box(node)
+ visit_syntaxblock(node)
+ end
+
+ def visit_block_flushright(node)
+ content = render_children(node)
+ content.gsub('', %Q(
)) + "\n"
+ end
+
+ def visit_block_centering(node)
+ content = render_children(node)
+ content.gsub('
', %Q(
)) + "\n"
+ end
+
+ def visit_block_rawblock(node)
+ visit_rawblock(node)
+ end
+
+ def visit_block_comment(node)
+ visit_comment_block(node)
+ end
+
+ def visit_block_noindent(_node)
+ ''
+ end
+
+ def visit_block_blankline(_node)
+ "
\n"
+ end
+
+ def visit_block_pagebreak(_node)
+ " \n"
+ end
+
+ def visit_block_hr(_node)
+ " \n"
+ end
+
+ def visit_block_label(node)
+ label_id = node.args.first
+ %Q( \n)
+ end
+
+ def visit_block_dtp(node)
+ dtp_str = node.args.first
+ %Q(\n)
+ end
+
+ def visit_block_bpo(node)
+ content = render_children(node)
+ %Q(#{content.chomp} \n)
+ end
+
+ def visit_block_printendnotes(node)
+ visit_printendnotes(node)
+ end
+
+ def visit_block_bibpaper(node)
+ visit_bibpaper(node)
+ end
+
+ def visit_block_olnum(_node)
+ ''
+ end
+
+ def visit_block_tsize(_node)
+ # tsize is now processed by TsizeProcessor during AST compilation
+ ''
+ end
+
+ def visit_block_graph(node)
+ visit_graph(node)
+ end
+
+ def visit_block_beginchild(node)
+ visit_beginchild(node)
+ end
+
+ def visit_block_endchild(node)
+ visit_endchild(node)
+ end
+
+ def visit_beginchild(_node)
+ ''
+ end
+
+ def visit_endchild(_node)
+ ''
+ end
+
+ def visit_graph(node)
+ # Graph block generates an image file and then renders it as an image
+ # Args: [id, command, caption]
+ id = node.args[0]
+ command = node.args[1]
+
+ # Get graph content from child TextNodes
+ content = node.children.map { |child| child.is_a?(ReVIEW::AST::TextNode) ? child.content : '' }.join("\n")
+ content += "\n" unless content.empty?
+
+ # Initialize ImgGraph if needed and command is mermaid
+ if command == 'mermaid'
+ begin
+ require 'playwrightrunner'
+ unless @img_graph
+ require 'review/img_graph'
+ @img_graph = ReVIEW::ImgGraph.new(config, 'idgxml')
+ end
+ # Defer mermaid image generation
+ file_path = @img_graph.defer_mermaid_image(content, id)
+ rescue LoadError
+ # Playwright not available, skip graph generation
+ # But we still need a file path for rendering
+ c = 'idgxml'
+ dir = File.join(@book.imagedir, c)
+ file_path = File.join(dir, "#{id}.pdf")
+ end
+ else
+ # For other graph types, generate directly
+ c = 'idgxml' # target_name
+ dir = File.join(@book.imagedir, c)
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
+
+ # Determine image extension based on format
+ image_ext = 'pdf' # IDGXML typically uses PDF
+ file = "#{id}.#{image_ext}"
+ file_path = File.join(dir, file)
+
+ # Create temporary file and generate graph
+ require 'tempfile'
+ tf = Tempfile.new('review_graph')
+ tf.puts content
+ tf.close
+
+ begin
+ if command == 'graphviz' || command == 'dot'
+ system_graph_graphviz(id, file_path, tf.path)
+ elsif command == 'gnuplot'
+ system_graph_gnuplot(id, file_path, content, tf.path)
+ elsif command == 'blockdiag'
+ system_graph_blockdiag(id, file_path, tf.path, 'blockdiag')
+ elsif command == 'seqdiag'
+ system_graph_blockdiag(id, file_path, tf.path, 'seqdiag')
+ elsif command == 'actdiag'
+ system_graph_blockdiag(id, file_path, tf.path, 'actdiag')
+ elsif command == 'nwdiag'
+ system_graph_blockdiag(id, file_path, tf.path, 'nwdiag')
+ end
+ ensure
+ tf.unlink
+ end
+ end
+
+ # Add the generated file to the image index
+ @chapter.image_index.image_finder.add_entry(file_path) if @chapter.image_index
+
+ # Now render as a regular numbered image
+ # Use caption_node to render inline elements
+ caption_content = node.caption_node ? render_caption_inline(node.caption_node) : nil
+
+ result = []
+ result << ' '
+
+ if caption_top?('image') && caption_content
+ result << generate_image_header(id, caption_content)
+ end
+
+ result << %Q( )
+
+ if !caption_top?('image') && caption_content
+ result << generate_image_header(id, caption_content)
+ end
+
+ result << ''
+
+ result.join("\n") + "\n"
+ end
+
+ def visit_printendnotes(_node)
+ return '' unless @chapter && @chapter.endnotes
+
+ endnotes = @chapter.endnotes
+ return '' if endnotes.size == 0
+
+ result = []
+ result << ''
+
+ endnotes.each do |endnote_item|
+ id = endnote_item.id
+ number = endnote_item.number
+ # Use footnote_node.children if available to avoid re-parsing
+ content = if endnote_item.footnote_node?
+ endnote_item.footnote_node.children.map { |child| visit(child) }.join
+ else
+ render_caption_inline(endnote_item.content)
+ end
+ result << %Q((#{number}) \t#{content} )
+ end
+
+ result << ' '
+ # Protect newlines inside endnotes block from nolf processing
+ result.join("\x01IDGXML_ENDNOTE_NEWLINE\x01") + "\x01IDGXML_ENDNOTE_NEWLINE\x01"
+ end
+
+ def visit_bibpaper(node)
+ args = node.args || []
+ raise NotImplementedError, 'Malformed bibpaper block: insufficient arguments' if args.length < 2
+
+ bib_id = args[0]
+
+ result = []
+ result << %Q()
+
+ if node.caption_node
+ # Use caption_node to render inline elements
+ caption_inline = render_caption_inline(node.caption_node)
+ bib_number = resolve_bibpaper_number(bib_id)
+ result << %Q([#{bib_number}] #{caption_inline} )
+ end
+
+ content = render_children(node)
+ unless content.empty?
+ # Wrap content in tag like Builder does with split_paragraph
+ content = content.strip
+ result << "
#{content}
"
+ end
+
+ result << " \n"
+ result.join("\n")
+ end
+
+ def visit_tex_equation(node)
+ @texblockequation += 1
+ content = node.content
+
+ result = []
+
+ if node.id?
+ result << ''
+
+ # Render caption with inline elements
+ caption_node = node.caption_node
+ rendered_caption = caption_node ? render_children(caption_node) : ''
+
+ # Generate caption
+ caption_str = %Q(#{text_formatter.format_caption_plain('equation', get_chap, @chapter.equation(node.id).number, rendered_caption)} )
+
+ result << caption_str if caption_top?('equation')
+ end
+
+ # Handle math format
+ if config['math_format'] == 'imgmath'
+ # Initialize ImgMath if needed
+ unless @img_math
+ require 'review/img_math'
+ @img_math = ReVIEW::ImgMath.new(config)
+ end
+
+ fontsize = config.dig('imgmath_options', 'fontsize').to_f
+ lineheight = config.dig('imgmath_options', 'lineheight').to_f
+ math_str = "\\begin{equation*}\n\\fontsize{#{fontsize}}{#{lineheight}}\\selectfont\n#{content}\n\\end{equation*}\n"
+ key = Digest::SHA256.hexdigest(math_str)
+ img_path = @img_math.defer_math_image(math_str, key)
+ result << ''
+ result << %Q( )
+ result << ' '
+ else
+ result << %Q(#{content} )
+ end
+
+ if node.id?
+ result << caption_str unless caption_top?('equation')
+ result << ' '
+ end
+
+ result.join("\n") + "\n"
+ end
+
+ def visit_embed(node)
+ # All embed types now use unified processing
+ process_raw_embed(node)
+ end
+
+ def visit_footnote(_node)
+ # FootnoteNode is not rendered directly - it's just a definition
+ # The actual footnote output is generated by @{id} inline element
+ # Return empty string to indicate no output for this definition block
+ ''
+ end
+
+ def render_list(node, list_type)
+ tag_name = list_tag_name(node, list_type)
+
+ body = case list_type
+ when :ul
+ render_unordered_items(node)
+ when :ol
+ render_ordered_items(node)
+ when :dl
+ render_definition_items(node)
+ else
+ raise NotImplementedError, "IdgxmlRenderer does not support list_type #{list_type}"
+ end
+
+ "<#{tag_name}>#{body}#{tag_name}>"
+ end
+
+ def list_tag_name(node, list_type)
+ levels = node.children&.map { |item| item.respond_to?(:level) ? item.level : nil }&.compact
+ max_level = levels&.max || 1
+ max_level > 1 ? "#{list_type}#{max_level}" : list_type.to_s
+ end
+
+ def render_unordered_items(node)
+ node.children.map { |item| render_unordered_item(item) }.join
+ end
+
+ def render_unordered_item(item)
+ content = render_list_item_body(item)
+ %Q(#{content} )
+ end
+
+ def render_ordered_items(node)
+ # num attribute: display number from source (start_number or item.number)
+ # olnum attribute: InDesign's internal counter (set by OlnumProcessor)
+ #
+ # OlnumProcessor analyzes the list during AST compilation and sets:
+ # - start_number: the first item's display number
+ # - olnum_start: the starting value for InDesign's counter
+ # - For //olnum[N] directive: olnum_start = N
+ # - For explicit numbering: olnum_start = 1
+
+ start_number = node.start_number || 1
+ current_number = start_number
+ current_olnum = node.olnum_start || 1
+
+ items = node.children.map do |item|
+ # num: the display number (from source or calculated)
+ display_number = item.respond_to?(:number) && item.number ? item.number : current_number
+
+ content = render_list_item_body(item)
+ rendered = %Q(#{content} )
+ current_number += 1
+ current_olnum += 1
+ rendered
+ end
+
+ items.join
+ end
+
+ def render_definition_items(node)
+ node.children.map { |item| render_definition_item(item) }.join
+ end
+
+ def render_definition_item(item)
+ term_content = render_inline_nodes(item.term_children)
+
+ # Definition content handling:
+ # - Initial inline content (paragraphs) are joined together without tags
+ # - Block elements (lists) are rendered as-is
+ # - Paragraphs after block elements are wrapped in
tags
+ definition_parts = []
+ has_block_element = false
+
+ item.children.each do |child|
+ if child.is_a?(ReVIEW::AST::ParagraphNode)
+ # Render paragraph content
+ content = render_children(child)
+ # Join lines in paragraph by removing newlines (like join_lines in Builder)
+ content = if config['join_lines_by_lang']
+ content.tr("\n", ' ')
+ else
+ content.delete("\n")
+ end
+
+ definition_parts << if has_block_element
+ # After a block element, wrap paragraphs in
tags
+ "
#{content}
"
+ else
+ # Initial paragraphs are not wrapped
+ content
+ end
+ else
+ # Block element (list, etc.)
+ definition_parts << visit(child)
+ has_block_element = true
+ end
+ end
+
+ definition_content = definition_parts.join
+
+ if definition_content.empty?
+ %Q(#{term_content} )
+ else
+ %Q(#{term_content} #{definition_content} )
+ end
+ end
+
+ def render_list_item_body(item)
+ parts = []
+ inline_buffer = []
+
+ item.children.each do |child|
+ if inline_node?(child)
+ inline_buffer << visit(child)
+ else
+ unless inline_buffer.empty?
+ parts << format_inline_buffer(inline_buffer)
+ inline_buffer.clear
+ end
+ parts << visit(child)
+ end
+ end
+
+ parts << format_inline_buffer(inline_buffer) unless inline_buffer.empty?
+ content = parts.compact.join
+ content.end_with?("\n") ? content.chomp : content
+ end
+
+ def ast_compiler
+ @ast_compiler ||= ReVIEW::AST::Compiler.for_chapter(@chapter)
+ end
+
+ def render_inline_element(type, content, node)
+ # Delegate to inline element handler
+ method_name = "render_inline_#{type}"
+ if @inline_element_handler.respond_to?(method_name, true)
+ @inline_element_handler.send(method_name, type, content, node)
+ else
+ raise NotImplementedError, "Unknown inline element: #{type}"
+ end
+ end
+
+ # Helpers
+
+ def get_chap(chapter = @chapter)
+ if config['secnolevel'] && config['secnolevel'] > 0 &&
+ !chapter.number.nil? && !chapter.number.to_s.empty?
+ if chapter.is_a?(ReVIEW::Book::Part)
+ return text_formatter.format_part_short(chapter)
+ else
+ return chapter.format_number(nil)
+ end
+ end
+ nil
+ end
+
+ def over_secnolevel?(n)
+ secnolevel = config['secnolevel'] || 2
+ secnolevel >= n.to_s.split('.').size
+ end
+
+ # Render inline elements from caption_node
+ # @param caption_node [CaptionNode] Caption node to render
+ # @return [String] Rendered inline elements
+ def render_caption_inline(caption_node)
+ content = caption_node ? render_children(caption_node) : ''
+
+ if config['join_lines_by_lang']
+ content.gsub(/\n+/, ' ')
+ else
+ content.delete("\n")
+ end
+ end
+
+ # Escape for IDGXML (uses HTML escaping)
+ def escape(str)
+ escape_html(str.to_s)
+ end
+
+ private
+
+ def render_nodes(nodes)
+ return '' unless nodes && !nodes.empty?
+
+ nodes.map { |child| visit(child) }.join
+ end
+
+ def render_inline_nodes(nodes)
+ return '' unless nodes && !nodes.empty?
+
+ format_inline_buffer(nodes.map { |child| visit(child) })
+ end
+
+ def format_inline_buffer(buffer)
+ return '' if buffer.empty?
+
+ content = buffer.join("\n")
+ if config['join_lines_by_lang']
+ content.tr("\n", ' ')
+ else
+ content.delete("\n")
+ end
+ end
+
+ def inline_node?(node)
+ node.is_a?(ReVIEW::AST::TextNode) || node.is_a?(ReVIEW::AST::InlineNode)
+ end
+
+ # Close section tags based on level
+ def output_close_sect_tags(level)
+ return unless @secttags
+
+ closing_tags = []
+ closing_tags << ' ' if level <= 5 && @subsubsubsection > 0
+ closing_tags << ' ' if level <= 4 && @subsubsection > 0
+ closing_tags << ' ' if level <= 3 && @subsection > 0
+ closing_tags << ' ' if level <= 2 && @section > 0
+
+ closing_tags.join
+ end
+
+ # Get headline prefix
+ def headline_prefix(level)
+ return [nil, nil] unless @sec_counter
+
+ @sec_counter.inc(level)
+ anchor = @sec_counter.anchor(level)
+ prefix = @sec_counter.prefix(level, config['secnolevel'])
+ [prefix, anchor]
+ end
+
+ # Check caption position
+ def caption_top?(type)
+ config.dig('caption_position', type) == 'top'
+ end
+
+ # Handle metric for IDGXML
+ def handle_metric(str)
+ k, v = str.split('=', 2)
+ %Q(#{k}="#{v.sub(/\A["']/, '').sub(/["']\Z/, '')}")
+ end
+
+ def result_metric(array)
+ " #{array.join(' ')}"
+ end
+
+ # Captionblock helper for minicolumns
+ def captionblock(type, content, caption, specialstyle = nil)
+ result = []
+ result << "<#{type}>"
+ if caption && !caption.empty?
+ style = specialstyle || "#{type}-title"
+ result << %Q(#{caption} )
+ end
+ blocked_lines = split_paragraph_content(content)
+ result << blocked_lines.join.chomp
+ result << "#{type}>"
+ result.join("\n") + "\n"
+ end
+
+ # Captionblock helper for content that already contains tags
+ def captionblock_with_content(type, content, caption, specialstyle = nil)
+ result = []
+ result << "<#{type}>"
+ if caption && !caption.empty?
+ style = specialstyle || "#{type}-title"
+ result << %Q(
#{caption} )
+ end
+ # Content already contains tags, use as-is
+ result << content.chomp
+ result << "#{type}>"
+ result.join + "\n"
+ end
+
+ # Syntaxblock helper for special code blocks
+ def syntaxblock(type, content, caption)
+ result = []
+
+ captionstr = nil
+ if caption && !caption.empty?
+ titleopentag = %Q(caption aid:pstyle="#{type}-title")
+ titleclosetag = 'caption'
+ if type == 'insn'
+ titleopentag = %Q(floattitle type="insn")
+ titleclosetag = 'floattitle'
+ end
+ captionstr = %Q(<#{titleopentag}>#{caption}#{titleclosetag}>)
+ end
+
+ result << "<#{type}>"
+ result << captionstr if caption_top?('list') && captionstr
+ result << content.chomp
+ result << captionstr if !caption_top?('list') && captionstr
+ result << "#{type}>"
+
+ result.join("\n") + "\n"
+ end
+
+ # Split paragraph content (from TextUtils)
+ def split_paragraph_content(content)
+ # Split content by double newlines to create paragraphs
+ paragraphs = content.split(/\n\n+/)
+ paragraphs.map { |para| "
#{para.strip}
" }
+ end
+
+ # Render block content with paragraph grouping
+ # Used for point/shoot/notice/term blocks
+ def render_block_content_with_paragraphs(node)
+ # Render children directly - inline elements are already parsed during AST construction
+ render_children(node)
+ end
+
+ # Visit unordered list
+ def visit_ul(node)
+ render_list(node, :ul)
+ end
+
+ # Visit ordered list
+ def visit_ol(node)
+ render_list(node, :ol)
+ end
+
+ # Visit definition list
+ def visit_dl(node)
+ render_list(node, :dl)
+ end
+
+ # Visit list code block
+ def visit_code_block_list(node)
+ result = []
+ result << ''
+
+ # Generate caption if present
+ caption_content = nil
+ if node.caption_node && node.id?
+ caption_content = render_children(node.caption_node)
+ list_header_output = generate_list_header(node.id, caption_content)
+ result << list_header_output if caption_top?('list')
+ end
+
+ # Generate code content (already includes trailing newlines for each line)
+ code_content = generate_code_lines_body(node)
+ # Combine , code content, and in a single string
+ result << "#{code_content} "
+
+ # Add caption at bottom if configured
+ if caption_content && !caption_top?('list')
+ list_header_output = generate_list_header(node.id, caption_content)
+ result << list_header_output
+ end
+
+ result << ' '
+ # Join without newlines (nolf mode), then add final newline
+ result.join + "\n"
+ end
+
+ # Visit listnum code block
+ def visit_code_block_listnum(node)
+ result = []
+ result << ''
+
+ # Generate caption if present
+ caption_content = nil
+ if node.caption_node && node.id?
+ caption_content = render_children(node.caption_node)
+ list_header_output = generate_list_header(node.id, caption_content)
+ result << list_header_output if caption_top?('list')
+ end
+
+ # Generate code content with line numbers (already includes trailing newlines for each line)
+ code_content = generate_listnum_body(node)
+ # Combine , code content, and in a single string
+ result << "#{code_content} "
+
+ # Add caption at bottom if configured
+ if caption_content && !caption_top?('list')
+ list_header_output = generate_list_header(node.id, caption_content)
+ result << list_header_output
+ end
+
+ result << ' '
+ # Join without newlines (nolf mode), then add final newline
+ result.join + "\n"
+ end
+
+ # Visit emlist code block
+ def visit_code_block_emlist(node)
+ caption_content = node.caption_node ? render_children(node.caption_node) : nil
+ quotedlist(node, 'emlist', caption_content)
+ end
+
+ # Visit emlistnum code block
+ def visit_code_block_emlistnum(node)
+ caption_content = node.caption_node ? render_children(node.caption_node) : nil
+ quotedlist_with_linenum(node, 'emlistnum', caption_content)
+ end
+
+ # Visit cmd code block
+ def visit_code_block_cmd(node)
+ caption_content = node.caption_node ? render_children(node.caption_node) : nil
+ quotedlist(node, 'cmd', caption_content)
+ end
+
+ # Visit source code block
+ def visit_code_block_source(node)
+ result = []
+ result << ''
+
+ caption_content = node.caption_node ? render_children(node.caption_node) : nil
+ caption_content = nil if caption_content && caption_content.empty?
+
+ if caption_top?('list') && caption_content
+ result << %Q(#{caption_content} )
+ end
+
+ # Generate code content (already includes trailing newlines for each line)
+ code_content = generate_code_lines_body(node)
+ # Combine , code content, and in a single string
+ result << "#{code_content} "
+
+ if !caption_top?('list') && caption_content
+ result << %Q(#{caption_content} )
+ end
+
+ result << ' '
+ # Join without newlines (nolf mode), then add final newline
+ result.join + "\n"
+ end
+
+ # Generate list header like IDGXMLBuilder
+ def generate_list_header(id, caption)
+ return '' unless caption && !caption.empty?
+
+ %Q(#{text_formatter.format_caption_plain('list', get_chap, @chapter.list(id).number, caption)} )
+ end
+
+ # Generate code lines body like IDGXMLBuilder
+ def generate_code_lines_body(node)
+ lines = node.children.map { |line| visit(line) }
+
+ result = []
+ no = 1
+
+ lines.each do |line|
+ if config['listinfo']
+ line_output = %Q('
+ line_output += line
+ line_output += "\n"
+ line_output += ' '
+ result << line_output
+ else
+ result << (line + "\n")
+ end
+ no += 1
+ end
+
+ result.join
+ end
+
+ # Generate listnum body with line numbers
+ def generate_listnum_body(node)
+ lines = node.children.map { |line| visit(line) }
+
+ result = []
+ no = 1
+ first_line_num = node.first_line_num || 1
+
+ lines.each_with_index do |line, i|
+ # Add line number span
+ line_with_number = detab(%Q() + (i + first_line_num).to_s.rjust(2) + ': ' + line, tabwidth)
+
+ if config['listinfo']
+ line_output = %Q('
+ line_output += line_with_number
+ line_output += "\n"
+ line_output += ' '
+ result << line_output
+ else
+ result << (line_with_number + "\n")
+ end
+ no += 1
+ end
+
+ result.join
+ end
+
+ # Quotedlist helper
+ def quotedlist(node, css_class, caption)
+ result = []
+ result << %Q()
+
+ # Use present? like Builder to avoid empty caption tags
+ if caption_top?('list') && caption.present?
+ result << %Q(#{caption} )
+ end
+
+ # Generate code content (already includes trailing newlines for each line)
+ code_content = generate_code_lines_body(node)
+ # Combine , code content, and in a single string
+ # This matches IDGXMLBuilder behavior: print ''; print lines; puts ' '
+ result << "#{code_content} "
+
+ if !caption_top?('list') && caption.present?
+ result << %Q(#{caption} )
+ end
+
+ result << '
'
+ # Join without newlines (nolf mode), then add final newline
+ result.join + "\n"
+ end
+
+ # Quotedlist with line numbers
+ def quotedlist_with_linenum(node, css_class, caption)
+ result = []
+ result << %Q()
+
+ # Use present? like Builder to avoid empty caption tags
+ if caption_top?('list') && caption.present?
+ result << %Q(#{caption} )
+ end
+
+ # Generate code content with line numbers (already includes trailing newlines for each line)
+ code_content = generate_listnum_body(node)
+ # Combine , code content, and in a single string
+ result << "#{code_content} "
+
+ if !caption_top?('list') && caption.present?
+ result << %Q(#{caption} )
+ end
+
+ result << '
'
+ # Join without newlines (nolf mode), then add final newline
+ result.join + "\n"
+ end
+
+ # Visit regular table
+ def visit_regular_table(node)
+ @tablewidth = nil
+ if config['tableopt']
+ pt_unit = config['pt_to_mm_unit']
+ pt_unit = pt_unit.to_f if pt_unit
+ pt_unit = 1.0 if pt_unit.nil? || pt_unit == 0
+ @tablewidth = config['tableopt'].split(',')[0].to_f / pt_unit
+ end
+ @col = 0
+
+ # Parse table rows
+ all_rows = node.header_rows + node.body_rows
+ rows_data = parse_table_rows_from_ast(all_rows)
+
+ result = []
+ result << ''
+
+ caption_content = node.caption_node ? render_children(node.caption_node) : nil
+
+ # Caption at top if configured
+ if caption_top?('table') && caption_content
+ result << generate_table_header(node.id, caption_content)
+ end
+
+ # Generate tbody
+ result << if @tablewidth.nil?
+ ''
+ else
+ %Q( )
+ end
+
+ @table_id = node.id
+
+ # Get cellwidth from TableNode (set by TsizeProcessor) for use in generate_table_rows
+ # This is a raw array of width specifications (e.g., ["10", "20", "30"] for simple format)
+ @table_node_cellwidth = node.cellwidth
+
+ result << generate_table_rows(rows_data, node.header_rows.length)
+
+ result << ' '
+
+ # Caption at bottom if configured
+ if !caption_top?('table') && caption_content
+ result << generate_table_header(node.id, caption_content)
+ end
+
+ result << '
'
+
+ result.join("\n") + "\n"
+ end
+
+ # Parse table rows from AST
+ def parse_table_rows_from_ast(rows)
+ processed_rows = []
+
+ rows.each do |row_node|
+ cells = row_node.children.map do |cell_node|
+ render_children(cell_node)
+ end
+
+ col_count = cells.length
+ @col = col_count if col_count > @col
+
+ # Apply table width processing if enabled
+ if @tablewidth
+ cells = cells.map do |cell|
+ cell.gsub("\t.\t", "\tDUMMYCELLSPLITTER\t").
+ gsub("\t..\t", "\t.\t").
+ gsub(/\t\.\Z/, "\tDUMMYCELLSPLITTER").
+ gsub(/\t\.\.\Z/, "\t.").
+ gsub(/\A\./, '')
+ end
+ end
+
+ processed_rows << cells
+ end
+
+ { rows: processed_rows }
+ end
+
+ # Generate table header
+ def generate_table_header(id, caption)
+ return '' unless caption && !caption.empty?
+
+ if id.nil?
+ %Q(#{caption} )
+ else
+ %Q(#{text_formatter.format_caption_plain('table', get_chap, @chapter.table(id).number, caption)} )
+ end
+ end
+
+ # Generate table rows
+ def generate_table_rows(rows_data, header_count)
+ rows = rows_data[:rows]
+
+ # Calculate cell widths
+ cellwidth = []
+ if @tablewidth
+ if @table_node_cellwidth.nil?
+ # No tsize specified - distribute width equally
+ @col.times { |n| cellwidth[n] = @tablewidth / @col }
+ else
+ # Extract numeric values from cellwidth specifications
+ # For simple format: ["p{10mm}", "p{20mm}", "p{30mm}"] -> ["10", "20", "30"]
+ # For IDGXML simple format: ["10", "20", "30"] (already numeric)
+ cellwidth = @table_node_cellwidth.map do |spec|
+ # Extract numeric part from p{Nmm} format or use as-is if already numeric
+ if /\A(\d+(?:\.\d+)?)\z/.match?(spec)
+ spec
+ elsif spec =~ /p\{(\d+(?:\.\d+)?)mm\}/
+ $1
+ else # rubocop:disable Style/EmptyElse
+ # Unknown format - use default
+ nil
+ end
+ end.compact
+
+ totallength = 0
+ cellwidth.size.times do |n|
+ cellwidth[n] = cellwidth[n].to_f / config['pt_to_mm_unit']
+ totallength += cellwidth[n]
+ end
+ if cellwidth.size < @col
+ cw = (@tablewidth - totallength) / (@col - cellwidth.size)
+ (cellwidth.size..(@col - 1)).each { |i| cellwidth[i] = cw }
+ end
+ end
+ end
+
+ result = []
+
+ # Output header rows if present
+ if header_count > 0
+ header_count.times do |y|
+ if @tablewidth.nil?
+ result << %Q(#{rows.shift.join("\t")} )
+ else
+ i = 0
+ rows.shift.each_with_index do |cell, x|
+ result << %Q(#{cell.sub('DUMMYCELLSPLITTER', '')} )
+ i += 1
+ end
+ end
+ end
+ end
+
+ # Output body rows
+ if @tablewidth
+ rows.each_with_index do |row, y|
+ i = 0
+ row.each_with_index do |cell, x|
+ result << %Q(#{cell.sub('DUMMYCELLSPLITTER', '')} )
+ i += 1
+ end
+ end
+ else
+ lastline = rows.pop
+ rows.each { |row| result << "#{row.join("\t")} " }
+ result << %Q(#{lastline.join("\t")} ) if lastline
+ end
+
+ result.join("\n")
+ end
+
+ # Visit imgtable
+ def visit_imgtable(node)
+ caption_content = node.caption_node ? render_children(node.caption_node) : nil
+
+ if @chapter.image_bound?(node.id)
+ metrics = parse_metric('idgxml', node.metric)
+
+ result = []
+ result << ''
+
+ if caption_top?('table') && caption_content
+ result << generate_table_header(node.id, caption_content)
+ end
+
+ result << %Q( )
+
+ if !caption_top?('table') && caption_content
+ result << generate_table_header(node.id, caption_content)
+ end
+
+ result << '
'
+
+ result.join("\n") + "\n"
+ else
+ # Fall back to image dummy
+ visit_image_dummy(node.id, caption_content, [])
+ end
+ end
+
+ # Visit regular image
+ def visit_regular_image(node)
+ caption_content = node.caption_node ? render_children(node.caption_node) : nil
+
+ if @chapter.image_bound?(node.id)
+ metrics = parse_metric('idgxml', node.metric)
+
+ result = []
+ result << ' '
+
+ if caption_top?('image') && caption_content
+ result << generate_image_header(node.id, caption_content)
+ end
+
+ result << %Q( )
+
+ if !caption_top?('image') && caption_content
+ result << generate_image_header(node.id, caption_content)
+ end
+
+ result << ''
+
+ result.join("\n") + "\n"
+ else
+ # Fall back to dummy image
+ visit_image_dummy(node.id, caption_content, [])
+ end
+ end
+
+ # Visit indepimage
+ def visit_indepimage(node)
+ caption_content = node.caption_node ? render_children(node.caption_node) : nil
+ caption_content = nil if caption_content && caption_content.empty?
+ metrics = parse_metric('idgxml', node.metric)
+
+ result = []
+ result << ' '
+
+ if caption_top?('image') && caption_content
+ result << %Q(#{caption_content} )
+ end
+
+ begin
+ result << %Q( )
+ rescue StandardError
+ # Image not found, but continue
+ end
+
+ if !caption_top?('image') && caption_content
+ result << %Q(#{caption_content} )
+ end
+
+ result << ''
+
+ result.join("\n") + "\n"
+ end
+
+ # Visit image dummy
+ def visit_image_dummy(id, caption, lines)
+ result = []
+ result << ' '
+
+ if caption_top?('image') && caption
+ result << generate_image_header(id, caption)
+ end
+
+ result << %Q()
+ lines.each do |line|
+ result << detab(line, tabwidth)
+ result << "\n"
+ end
+ result << ' '
+
+ if !caption_top?('image') && caption
+ result << generate_image_header(id, caption)
+ end
+
+ result << ''
+
+ result.join("\n") + "\n"
+ end
+
+ # Generate image header
+ def generate_image_header(id, caption)
+ return '' unless caption && !caption.empty?
+
+ %Q(#{text_formatter.format_caption_plain('image', get_chap, @chapter.image(id).number, caption)} )
+ end
+
+ # Visit rawblock
+ def visit_rawblock(node)
+ result = []
+ no = 1
+
+ # Get lines from child TextNodes
+ lines = node.children.map { |child| child.is_a?(ReVIEW::AST::TextNode) ? child.content : '' }
+ lines.each do |line|
+ # Unescape HTML entities
+ unescaped = line.gsub('<', '<').gsub('>', '>').gsub('"', '"').gsub('&', '&')
+ result << unescaped
+ result << "\n" unless lines.length == no
+ no += 1
+ end
+
+ result.join
+ end
+
+ # Visit comment block
+ def visit_comment_block(node)
+ return '' unless config['draft']
+
+ lines = []
+ lines << escape(node.args.first) if node.args.first && !node.args.first.empty?
+
+ # Process children as separate text lines (not as paragraphs)
+ if node.children && !node.children.empty?
+ node.children.each do |child|
+ lines << if child.is_a?(ReVIEW::AST::TextNode)
+ escape(child.content.to_s)
+ else
+ # For other node types, render normally
+ visit(child)
+ end
+ end
+ end
+
+ return '' if lines.empty?
+
+ str = lines.join("\n")
+ "#{str} "
+ end
+
+ # Process raw embed
+ def process_raw_embed(node)
+ # Check if this embed is targeted for IDGXML
+ unless node.targeted_for?('idgxml')
+ return ''
+ end
+
+ # Get content
+ content = node.content || ''
+
+ # Process \n based on embed type
+ case node.embed_type
+ when :inline
+ # For inline raw/embed, convert literal \n to protected newline marker
+ content = content.gsub('\n', "\x01IDGXML_INLINE_NEWLINE\x01")
+ when :raw
+ # For raw blocks, convert \\n to actual newlines
+ content = content.gsub('\\n', "\n")
+ end
+
+ # For block embeds, add trailing newline
+ node.embed_type == :block ? content + "\n" : content
+ end
+
+ # Visit syntaxblock (box, insn) - processes lines with listinfo
+ def visit_syntaxblock(node)
+ type = node.block_type.to_s
+
+ # Render caption if present
+ captionstr = nil
+ if node.caption_node
+ titleopentag = %Q(caption aid:pstyle="#{type}-title")
+ titleclosetag = 'caption'
+ if type == 'insn'
+ titleopentag = %Q(floattitle type="insn")
+ titleclosetag = 'floattitle'
+ end
+ # Use caption_node to render inline elements
+ caption_with_inline = render_caption_inline(node.caption_node)
+ captionstr = %Q(<#{titleopentag}>#{caption_with_inline}#{titleclosetag}>)
+ end
+
+ result = []
+ result << "<#{type}>"
+
+ # Output caption at top if configured
+ result << captionstr if caption_top?('list') && captionstr
+
+ # Process lines with listinfo
+ lines = extract_lines_from_node(node)
+ if config['listinfo'] && lines.any?
+ # Generate all listinfo entries as a single string (like IDGXMLBuilder's print/puts)
+ listinfo_output = lines.map.with_index do |line, i|
+ no = i + 1
+ line_parts = []
+ line_parts << %Q('
+ # Always include line content (even if empty) followed by newline
+ # Protect newlines inside listinfo from nolf processing
+ line_parts << detab(line, tabwidth)
+ line_parts << "\x01IDGXML_LISTINFO_NEWLINE\x01"
+ line_parts << ' '
+ line_parts.join
+ end.join
+ result << listinfo_output
+ else
+ lines_output = lines.map { |line| detab(line, tabwidth) + "\n" }.join
+ result << lines_output
+ end
+
+ # Output caption at bottom if configured
+ result << captionstr if !caption_top?('list') && captionstr
+
+ result << "#{type}>"
+ result.join + "\n"
+ end
+
+ # Extract lines from block node and process inline elements
+ def extract_lines_from_node(node)
+ # If node has ParagraphNode children (e.g., box/insn blocks), treat each as a separate line
+ if node.children.all?(AST::ParagraphNode)
+ # Each ParagraphNode represents one line - inline elements are already parsed
+ node.children.map { |para| render_children(para) }
+ else
+ # Fallback: render all children and split by newlines
+ full_content = render_children(node)
+
+ # Split by newlines to get individual lines
+ # Keep empty lines (important for blank lines in the source)
+ lines = full_content.split("\n", -1)
+
+ # Remove the last empty line if present (split always creates one at the end)
+ lines.pop if lines.last == ''
+
+ lines
+ end
+ end
+
+ def resolve_bibpaper_number(bib_id)
+ if @chapter
+ begin
+ return @chapter.bibpaper(bib_id).number
+ rescue StandardError
+ # Fallback to AST indexer if chapter lookup fails
+ end
+ end
+
+ if @ast_indexer&.bibpaper_index
+ begin
+ return @ast_indexer.bibpaper_index.number(bib_id)
+ rescue StandardError
+ # fall through
+ end
+ end
+
+ '??'
+ end
+
+ # Parse tsize target specification like |idgxml|2 or |idgxml,html|2
+ def parse_tsize_target(arg)
+ # Format: |target1,target2,...|value
+ if arg =~ /\A\|([^|]+)\|(.+)/
+ targets = Regexp.last_match(1).split(',').map(&:strip)
+ value = Regexp.last_match(2)
+ [targets, value]
+ else
+ # No target specification (malformed)
+ [nil, arg]
+ end
+ end
+
+ # Get tabwidth setting (default to 8)
+ def tabwidth
+ config['tabwidth'] || 8
+ end
+
+ # Graph generation helper methods (for non-mermaid graphs)
+ def system_graph_graphviz(_id, file_path, tf_path)
+ system("dot -Tpdf -o#{file_path} #{tf_path}")
+ end
+
+ def system_graph_gnuplot(_id, file_path, content, tf_path)
+ File.open(tf_path, 'w') do |tf|
+ tf.puts <<~GNUPLOT
+ set terminal pdf
+ set output "#{file_path}"
+ #{content}
+ GNUPLOT
+ end
+ system("gnuplot #{tf_path}")
+ end
+
+ def system_graph_blockdiag(_id, file_path, tf_path, command)
+ system("#{command} -Tpdf -o #{file_path} #{tf_path}")
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/inline_render_proxy.rb b/lib/review/renderer/inline_render_proxy.rb
new file mode 100644
index 000000000..2f1c857e5
--- /dev/null
+++ b/lib/review/renderer/inline_render_proxy.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+module ReVIEW
+ module Renderer
+ # Shared proxy class that provides minimal interface to renderer for InlineContext classes.
+ # This proxy limits access to renderer methods, exposing only what's needed for inline rendering.
+ #
+ # This class is used by HTML, LaTeX, and IDGXML InlineContext classes to prevent
+ # tight coupling between InlineElementHandler and the full renderer interface.
+ #
+ # Common methods (always available):
+ # - render_children(node): Render all children of a node
+ # - text_formatter: Access to TextFormatter instance
+ #
+ # Optional methods (available if renderer supports them):
+ # - rendering_context: Current rendering context (for LaTeX footnote handling)
+ # - render_caption_inline(caption_node): Render caption with inline markup (for LaTeX/IDGXML)
+ # - increment_texinlineequation: Increment equation counter (for IDGXML math rendering)
+ class InlineRenderProxy
+ def initialize(renderer)
+ @renderer = renderer
+ end
+
+ # Render all children of a node and join the results
+ # @param node [Object] The parent node whose children should be rendered
+ # @return [String] The joined rendered output of all children
+ def render_children(node)
+ @renderer.render_children(node)
+ end
+
+ # Get TextFormatter instance from the renderer
+ # @return [ReVIEW::Renderer::TextFormatter] Text formatter instance
+ def text_formatter
+ @renderer.text_formatter
+ end
+
+ # Get current rendering context (LaTeX-specific feature)
+ # @return [RenderingContext, nil] Current rendering context if available
+ def rendering_context
+ @renderer.rendering_context if @renderer.respond_to?(:rendering_context)
+ end
+
+ # Render caption with inline markup (LaTeX/IDGXML-specific feature)
+ # @param caption_node [Object] Caption node to render
+ # @return [String, nil] Rendered caption if available
+ def render_caption_inline(caption_node)
+ if @renderer.respond_to?(:render_caption_inline)
+ @renderer.render_caption_inline(caption_node)
+ end
+ end
+
+ # Increment inline equation counter (IDGXML-specific feature)
+ # @return [Integer, nil] Counter value if available
+ def increment_texinlineequation
+ if @renderer.respond_to?(:increment_texinlineequation)
+ @renderer.increment_texinlineequation
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/latex/inline_context.rb b/lib/review/renderer/latex/inline_context.rb
new file mode 100644
index 000000000..8a3d9ae73
--- /dev/null
+++ b/lib/review/renderer/latex/inline_context.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/latexutils'
+require_relative '../inline_render_proxy'
+
+module ReVIEW
+ module Renderer
+ module Latex
+ # Context for inline element rendering with business logic
+ # Used by InlineElementHandler
+ class InlineContext
+ include ReVIEW::LaTeXUtils
+
+ attr_reader :config, :book, :chapter, :index_db, :index_mecab
+
+ def initialize(config:, book:, chapter:, renderer:)
+ @config = config
+ @book = book
+ @chapter = chapter
+ # Automatically create proxy from renderer to limit access
+ @render_proxy = InlineRenderProxy.new(renderer)
+ # Initialize index support
+ initialize_index_support
+ end
+
+ # Get current rendering context dynamically from renderer
+ # This ensures we always have the most up-to-date context,
+ # even when it changes during rendering (e.g., caption context)
+ def rendering_context
+ @render_proxy.rendering_context
+ end
+
+ def chapter_link_enabled?
+ config['chapterlink']
+ end
+
+ def draft_mode?
+ config['draft']
+ end
+
+ def over_secnolevel?(n)
+ secnolevel = config['secnolevel'] || 2
+ secnolevel >= n.to_s.split('.').size
+ end
+
+ def render_children(node)
+ @render_proxy.render_children(node)
+ end
+
+ def render_caption_inline(caption_node)
+ @render_proxy.render_caption_inline(caption_node)
+ end
+
+ def text_formatter
+ @render_proxy.text_formatter
+ end
+
+ def bibpaper_number(bib_id)
+ if book.bibpaper_index.blank?
+ raise ReVIEW::KeyError, "unknown bib: #{bib_id}"
+ end
+
+ book.bibpaper_index.number(bib_id)
+ end
+
+ private
+
+ # Initialize index support (database and MeCab)
+ def initialize_index_support
+ @index_db = {}
+ @index_mecab = nil
+
+ return unless config['pdfmaker'] && config['pdfmaker']['makeindex']
+
+ # Load index dictionary file
+ if config['pdfmaker']['makeindex_dic']
+ @index_db = load_idxdb(config['pdfmaker']['makeindex_dic'])
+ end
+
+ return unless config['pdfmaker']['makeindex_mecab']
+
+ # Initialize MeCab for Japanese text indexing
+ begin
+ begin
+ require 'MeCab'
+ rescue LoadError
+ require 'mecab'
+ end
+ require 'nkf'
+ @index_mecab = MeCab::Tagger.new(config['pdfmaker']['makeindex_mecab_opts'])
+ rescue LoadError
+ # MeCab not available, will fall back to text-only indexing
+ end
+ end
+
+ # Load index database from file
+ def load_idxdb(file)
+ table = {}
+ File.foreach(file) do |line|
+ key, value = *line.strip.split(/\t+/, 2)
+ table[key] = value
+ end
+ table
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/latex/inline_element_handler.rb b/lib/review/renderer/latex/inline_element_handler.rb
new file mode 100644
index 000000000..802939693
--- /dev/null
+++ b/lib/review/renderer/latex/inline_element_handler.rb
@@ -0,0 +1,834 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/latexutils'
+
+module ReVIEW
+ module Renderer
+ module Latex
+ # Inline element handler for LaTeX rendering
+ # Uses InlineContext for shared logic
+ class InlineElementHandler
+ include ReVIEW::LaTeXUtils
+
+ def initialize(inline_context)
+ @ctx = inline_context
+ @chapter = @ctx.chapter
+ @book = @ctx.book
+ @config = @ctx.config
+ # Initialize LaTeX character escaping
+ initialize_metachars(@config['texcommand'])
+ end
+
+ def render_inline_b(_type, content, _node)
+ "\\reviewbold{#{content}}"
+ end
+
+ def render_inline_i(_type, content, _node)
+ "\\reviewit{#{content}}"
+ end
+
+ def render_inline_em(_type, content, _node)
+ "\\reviewem{#{content}}"
+ end
+
+ def render_inline_tt(_type, content, _node)
+ "\\reviewtt{#{content}}"
+ end
+
+ def render_inline_ttb(_type, content, _node)
+ "\\reviewttb{#{content}}"
+ end
+
+ def render_inline_tti(_type, content, _node)
+ "\\reviewtti{#{content}}"
+ end
+
+ def render_inline_code(_type, content, _node)
+ "\\reviewcode{#{content}}"
+ end
+
+ def render_inline_u(_type, content, _node)
+ "\\reviewunderline{#{content}}"
+ end
+
+ def render_inline_strong(_type, content, _node)
+ "\\reviewstrong{#{content}}"
+ end
+
+ def render_inline_href(_type, content, node)
+ if node.args.length >= 2
+ url = node.args[0]
+ text = node.args[1]
+ # Handle internal references (URLs starting with #)
+ if url.start_with?('#')
+ anchor = url.sub(/\A#/, '')
+ "\\hyperref[#{escape_latex(anchor)}]{#{escape_latex(text)}}"
+ elsif /\A[a-z]+:/.match?(url)
+ # External URL with scheme
+ "\\href{#{escape_url(url)}}{#{escape_latex(text)}}"
+ else
+ # Plain reference without scheme
+ "\\ref{#{escape_latex(url)}}"
+ end
+ else
+ # For single argument href, get raw text from first text child to avoid double escaping
+ raw_url = if node.children.first.leaf_node?
+ node.children.first.content
+ else
+ raise NotImplementedError, "URL is invalid: #{content}"
+ end
+ # Handle internal references (URLs starting with #)
+ if raw_url.start_with?('#')
+ anchor = raw_url.sub(/\A#/, '')
+ "\\hyperref[#{escape_latex(anchor)}]{#{escape_latex(raw_url)}}"
+ elsif /\A[a-z]+:/.match?(raw_url)
+ # External URL with scheme
+ url_content = escape_url(raw_url)
+ "\\url{#{url_content}}"
+ else
+ # Plain reference without scheme
+ "\\ref{#{escape_latex(raw_url)}}"
+ end
+ end
+ end
+
+ def render_inline_fn(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ footnote_number = data.item_number
+
+ # Check if we need to use footnotetext mode
+ if @ctx.config['footnotetext']
+ "\\footnotemark[#{footnote_number}]"
+ elsif @ctx.rendering_context.requires_footnotetext?
+ if data.caption_node
+ @ctx.rendering_context.collect_footnote(data.caption_node, footnote_number)
+ end
+ '\\protect\\footnotemark{}'
+ else
+ footnote_content = if data.caption_node
+ @ctx.render_children(data.caption_node).strip
+ else
+ escape(data.caption_text || '')
+ end
+ "\\footnote{#{footnote_content}}"
+ end
+ end
+
+ # Render list reference
+ def render_inline_list(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ list_number = data.item_number
+
+ chapter_num = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+ if chapter_num && !chapter_num.empty?
+ "\\reviewlistref{#{chapter_num}.#{list_number}}"
+ else
+ "\\reviewlistref{#{list_number}}"
+ end
+ end
+
+ # Render listref reference (same as list)
+ def render_inline_listref(type, content, node)
+ render_inline_list(type, content, node)
+ end
+
+ # Render table reference
+ def render_inline_table(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ table_number = data.item_number
+ # Use current chapter ID if chapter_id is not set in resolved_data
+ chapter_id = data.chapter_id || @chapter&.id
+ table_label = "table:#{chapter_id}:#{data.item_id}"
+
+ short_num = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+ if short_num && !short_num.empty?
+ "\\reviewtableref{#{short_num}.#{table_number}}{#{table_label}}"
+ else
+ "\\reviewtableref{#{table_number}}{#{table_label}}"
+ end
+ end
+
+ # Render tableref reference (same as table)
+ def render_inline_tableref(type, content, node)
+ render_inline_table(type, content, node)
+ end
+
+ # Render image reference
+ def render_inline_img(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ image_number = data.item_number
+ # Use current chapter ID if chapter_id is not set in resolved_data
+ chapter_id = data.chapter_id || @chapter&.id
+ image_label = "image:#{chapter_id}:#{data.item_id}"
+
+ short_num = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+ if short_num && !short_num.empty?
+ "\\reviewimageref{#{short_num}.#{image_number}}{#{image_label}}"
+ else
+ "\\reviewimageref{#{image_number}}{#{image_label}}"
+ end
+ end
+
+ # Render imgref reference (same as img)
+ def render_inline_imgref(type, content, node)
+ render_inline_img(type, content, node)
+ end
+
+ # Render equation reference
+ def render_inline_eq(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ equation_number = data.item_number
+
+ short_num = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+ if short_num && !short_num.empty?
+ "\\reviewequationref{#{short_num}.#{equation_number}}"
+ else
+ "\\reviewequationref{#{equation_number}}"
+ end
+ end
+
+ # Render eqref reference (same as eq)
+ def render_inline_eqref(type, content, node)
+ render_inline_eq(type, content, node)
+ end
+
+ # Render same-chapter list reference
+ def render_same_chapter_list_reference(node)
+ list_ref = node.args.first.to_s
+ if @chapter && @ctx.chapter.list_index
+ begin
+ list_item = @ctx.chapter.list_index.number(list_ref)
+ if @ctx.chapter.number
+ chapter_num = @ctx.chapter.format_number(false)
+ "\\reviewlistref{#{chapter_num}.#{list_item}}"
+ else
+ "\\reviewlistref{#{list_item}}"
+ end
+ rescue ReVIEW::KeyError => e
+ raise NotImplementedError, "List reference failed for #{list_ref}: #{e.message}"
+ end
+ else
+ "\\ref{#{escape(list_ref)}}"
+ end
+ end
+
+ # Render bibliography reference
+ def render_inline_bib(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ bib_number = data.item_number
+ bib_id = data.item_id
+ "\\reviewbibref{[#{bib_number}]}{bib:#{bib_id}}"
+ end
+
+ # Render bibref reference (same as bib)
+ def render_inline_bibref(type, content, node)
+ render_inline_bib(type, content, node)
+ end
+
+ # Render same-chapter table reference
+ def render_same_chapter_table_reference(node)
+ table_ref = node.args.first.to_s
+ if @chapter && @ctx.chapter.table_index
+ begin
+ table_item = @ctx.chapter.table_index.number(table_ref)
+ table_label = "table:#{@ctx.chapter.id}:#{table_ref}"
+ if @ctx.chapter.number
+ chapter_num = @ctx.chapter.format_number(false)
+ "\\reviewtableref{#{chapter_num}.#{table_item}}{#{table_label}}"
+ else
+ "\\reviewtableref{#{table_item}}{#{table_label}}"
+ end
+ rescue ReVIEW::KeyError => e
+ raise NotImplementedError, "Table reference failed for #{table_ref}: #{e.message}"
+ end
+ else
+ "\\ref{#{escape(table_ref)}}"
+ end
+ end
+
+ # Render same-chapter image reference
+ def render_same_chapter_image_reference(node)
+ image_ref = node.args.first.to_s
+ if @chapter && @ctx.chapter.image_index
+ begin
+ image_item = @ctx.chapter.image_index.number(image_ref)
+ image_label = "image:#{@ctx.chapter.id}:#{image_ref}"
+ if @ctx.chapter.number
+ chapter_num = @ctx.chapter.format_number(false)
+ "\\reviewimageref{#{chapter_num}.#{image_item}}{#{image_label}}"
+ else
+ "\\reviewimageref{#{image_item}}{#{image_label}}"
+ end
+ rescue ReVIEW::KeyError => e
+ raise NotImplementedError, "Image reference failed for #{image_ref}: #{e.message}"
+ end
+ else
+ # Don't escape underscores in ref labels
+ "\\ref{#{image_ref}}"
+ end
+ end
+
+ # Render cross-chapter list reference
+ def render_cross_chapter_list_reference(node)
+ chapter_id, list_id = node.args
+
+ # Find the target chapter
+ target_chapter = @ctx.book.contents&.detect { |chap| chap.id == chapter_id }
+ unless target_chapter
+ raise NotImplementedError, "Cross-chapter list reference failed: chapter '#{chapter_id}' not found"
+ end
+
+ # Ensure the target chapter has list index
+ unless target_chapter.list_index
+ raise NotImplementedError, "Cross-chapter list reference failed: no list index for chapter '#{chapter_id}'"
+ end
+
+ begin
+ list_item = target_chapter.list_index.number(list_id)
+ if target_chapter.number
+ chapter_num = target_chapter.format_number(false)
+ "\\reviewlistref{#{chapter_num}.#{list_item}}"
+ else
+ "\\reviewlistref{#{list_item}}"
+ end
+ rescue ReVIEW::KeyError => e
+ raise NotImplementedError, "Cross-chapter list reference failed for #{chapter_id}|#{list_id}: #{e.message}"
+ end
+ end
+
+ # Render cross-chapter table reference
+ def render_cross_chapter_table_reference(node)
+ chapter_id, table_id = node.args
+
+ # Find the target chapter
+ target_chapter = @ctx.book.contents&.detect { |chap| chap.id == chapter_id }
+ unless target_chapter
+ raise NotImplementedError, "Cross-chapter table reference failed: chapter '#{chapter_id}' not found"
+ end
+
+ # Ensure the target chapter has table index
+ unless target_chapter.table_index
+ raise NotImplementedError, "Cross-chapter table reference failed: no table index for chapter '#{chapter_id}'"
+ end
+
+ begin
+ table_item = target_chapter.table_index.number(table_id)
+ table_label = "table:#{chapter_id}:#{table_id}"
+ if target_chapter.number
+ chapter_num = target_chapter.format_number(false)
+ "\\reviewtableref{#{chapter_num}.#{table_item}}{#{table_label}}"
+ else
+ "\\reviewtableref{#{table_item}}{#{table_label}}"
+ end
+ rescue ReVIEW::KeyError => e
+ raise NotImplementedError, "Cross-chapter table reference failed for #{chapter_id}|#{table_id}: #{e.message}"
+ end
+ end
+
+ # Render cross-chapter image reference
+ def render_cross_chapter_image_reference(node)
+ chapter_id, image_id = node.args
+
+ # Find the target chapter
+ target_chapter = @ctx.book.contents&.detect { |chap| chap.id == chapter_id }
+ unless target_chapter
+ raise NotImplementedError, "Cross-chapter image reference failed: chapter '#{chapter_id}' not found"
+ end
+
+ # Ensure the target chapter has image index
+ unless target_chapter.image_index
+ raise NotImplementedError, "Cross-chapter image reference failed: no image index for chapter '#{chapter_id}'"
+ end
+
+ begin
+ image_item = target_chapter.image_index.number(image_id)
+ image_label = "image:#{chapter_id}:#{image_id}"
+ if target_chapter.number
+ chapter_num = target_chapter.format_number(false)
+ "\\reviewimageref{#{chapter_num}.#{image_item}}{#{image_label}}"
+ else
+ "\\reviewimageref{#{image_item}}{#{image_label}}"
+ end
+ rescue ReVIEW::KeyError => e
+ raise NotImplementedError, "Cross-chapter image reference failed for #{chapter_id}|#{image_id}: #{e.message}"
+ end
+ end
+
+ # Render chapter number reference
+ def render_inline_chap(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ # Format chapter number to full form (e.g., "第1章", "付録A", "第II部")
+ chapter_num = @ctx.text_formatter.format_chapter_number_full(data.chapter_number, data.chapter_type)
+ "\\reviewchapref{#{chapter_num}}{chap:#{data.item_id}}"
+ end
+
+ # Render chapter title reference
+ def render_inline_chapref(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ display_str = @ctx.text_formatter.format_reference(:chapter, data)
+ "\\reviewchapref{#{escape(display_str)}}{chap:#{data.item_id}}"
+ end
+
+ # Extract heading reference from node.args, handling ReferenceResolver's array splitting
+ # ReferenceResolver splits "ch02|ブロック命令" into ["ch02", "ブロック命令"]
+ # We need to join them back together to get the original format
+ # Build heading reference parts from resolved_data
+ # Returns [section_number, section_label, section_title]
+ def build_heading_reference_parts(data)
+ # Get headline_number array (e.g., [1, 2] for section 1.2)
+ headline_number = data.headline_number || []
+
+ # Get caption from caption_node
+ section_title = data.caption_text
+
+ # Determine chapter context
+ if data.chapter_id && data.chapter_number
+ # Cross-chapter reference
+ short_chapter = @ctx.text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+ chapter_prefix = short_chapter
+ elsif @chapter && @ctx.chapter.number
+ # Same chapter reference
+ short_chapter = @ctx.chapter.format_number(false)
+ chapter_prefix = short_chapter
+ else
+ # Reference without chapter number
+ short_chapter = '0'
+ chapter_prefix = '0'
+ end
+
+ # Build section number for display
+ full_number_parts = [short_chapter] + headline_number
+ full_section_number = full_number_parts.join('.')
+
+ # Check if we should show the number based on secnolevel
+ section_number = if short_chapter != '0' && @ctx.over_secnolevel?(full_section_number)
+ # Show full number with chapter: "2.1", "2.1.2", etc.
+ full_section_number
+ else
+ # Without chapter number - use relative section number only
+ headline_number.join('.')
+ end
+
+ # Generate label using chapter prefix and relative section number
+ relative_parts = headline_number.join('-')
+ section_label = "sec:#{chapter_prefix}-#{relative_parts}"
+
+ [section_number, section_label, section_title]
+ end
+
+ # Render heading reference
+ def render_inline_hd(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ section_number, section_label, section_title = build_heading_reference_parts(data)
+ "\\reviewsecref{「#{section_number} #{escape(section_title)}」}{#{section_label}}"
+ end
+
+ # Render section reference
+ def render_inline_sec(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ section_number, section_label, _section_title = build_heading_reference_parts(data)
+ "\\reviewsecref{#{section_number}}{#{section_label}}"
+ end
+
+ # Render section reference with full title
+ def render_inline_secref(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ section_number, section_label, section_title = build_heading_reference_parts(data)
+ "\\reviewsecref{「#{section_number} #{escape(section_title)}」}{#{section_label}}"
+ end
+
+ # Render section title only
+ def render_inline_sectitle(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ _section_number, section_label, section_title = build_heading_reference_parts(data)
+ "\\reviewsecref{#{escape(section_title)}}{#{section_label}}"
+ end
+
+ # Render index entry
+ def render_inline_idx(_type, content, node)
+ return content unless node.args.first
+
+ index_str = node.args.first
+ # Process hierarchical index like LATEXBuilder's index method
+ index_entry = process_index(index_str)
+ # Index entry like LATEXBuilder - content first, then index
+ "#{content}\\index{#{index_entry}}"
+ end
+
+ # Render hidden index entry
+ def render_inline_hidx(_type, content, node)
+ return content unless node.args.first
+
+ index_str = node.args.first
+ # Process hierarchical index like LATEXBuilder's index method
+ index_entry = process_index(index_str)
+ # Hidden index entry like LATEXBuilder - just output index, content is already rendered
+ "\\index{#{index_entry}}"
+ end
+
+ # Process index string for hierarchical index entries (mendex/upmendex)
+ # This is a simplified version of LATEXBuilder's index method (latexbuilder.rb:1406-1427)
+ def process_index(str)
+ # Split by <<>> delimiter for hierarchical index entries
+ parts = str.split('<<>>')
+
+ # Process each part and format for mendex
+ formatted_parts = parts.map { |item| format_index_item(item) }
+
+ # Join hierarchical parts with '!' for mendex/upmendex
+ formatted_parts.join('!')
+ end
+
+ # Format a single index item for mendex/upmendex
+ def format_index_item(item)
+ if ascii_only?(item)
+ format_ascii_index_item(item)
+ else
+ format_japanese_index_item(item)
+ end
+ end
+
+ # Check if string contains only ASCII characters
+ def ascii_only?(str)
+ str =~ /\A[[:ascii:]]+\Z/
+ end
+
+ # Format ASCII-only index item
+ def format_ascii_index_item(item)
+ escaped_item = escape(item)
+ mendex_escaped = escape_index(escaped_item)
+
+ # If no escaping was needed, just return the item
+ return item if mendex_escaped == item
+
+ # Generate key@display format for proper sorting like LATEXBuilder (latexbuilder.rb:1418)
+ "#{escape_mendex_key(escape_index(item))}@#{escape_mendex_display(mendex_escaped)}"
+ end
+
+ # Format Japanese (non-ASCII) index item with yomi reading
+ def format_japanese_index_item(item)
+ # Check dictionary first like LATEXBuilder (latexbuilder.rb:1411-1412)
+ index_db = @ctx.index_db
+ yomi = if index_db && index_db[item]
+ index_db[item]
+ else
+ # Generate yomi using MeCab like LATEXBuilder (latexbuilder.rb:1421-1422)
+ generate_yomi(item)
+ end
+ escaped_item = escape(item)
+ "#{escape_mendex_key(escape_index(yomi))}@#{escape_mendex_display(escape_index(escaped_item))}"
+ end
+
+ # Generate yomi (reading) for Japanese text using MeCab + NKF like LATEXBuilder (latexbuilder.rb:1421)
+ def generate_yomi(text)
+ # If MeCab is available, use it to parse and generate reading
+ index_mecab = @ctx.index_mecab
+ if index_mecab
+ require 'nkf'
+ NKF.nkf('-w --hiragana', index_mecab.parse(text).force_encoding('UTF-8').chomp)
+ else
+ # Fallback: use the original text as-is if MeCab is unavailable
+ text
+ end
+ rescue LoadError, ArgumentError, TypeError, RuntimeError
+ # Fallback: use the original text as-is if processing fails
+ text
+ end
+
+ # Render keyword notation
+ def render_inline_kw(_type, content, node)
+ if node.args.length >= 2
+ term = escape(node.args[0])
+ description = escape(node.args[1])
+ "\\reviewkw{#{term}}(#{description})"
+ else
+ "\\reviewkw{#{content}}"
+ end
+ end
+
+ # Render ruby notation
+ def render_inline_ruby(_type, content, node)
+ if node.args.length >= 2
+ base_text = escape(node.args[0])
+ ruby_text = escape(node.args[1])
+ "\\ruby{#{base_text}}{#{ruby_text}}"
+ else
+ content
+ end
+ end
+
+ # Render icon
+ def render_inline_icon(_type, content, node)
+ return content unless node.args.first
+
+ icon_id = node.args.first
+ image_path = find_image_path(icon_id)
+
+ if image_path
+ command = 'reviewicon'
+ "\\#{command}{#{image_path}}"
+ else
+ "\\verb|--[[path = #{icon_id} (not exist)]]--|"
+ end
+ end
+
+ # Render ami notation
+ def render_inline_ami(_type, content, _node)
+ "\\reviewami{#{content}}"
+ end
+
+ # Render bou notation
+ def render_inline_bou(_type, content, _node)
+ # Boudou (emphasis)
+ "\\reviewbou{#{content}}"
+ end
+
+ # Render tcy notation (tate-chu-yoko: horizontal-in-vertical text)
+ def render_inline_tcy(_type, content, _node)
+ # Tate-chu-yoko (縦中横) for vertical typesetting
+ "\\reviewtcy{#{content}}"
+ end
+
+ # Render balloon notation
+ def render_inline_balloon(_type, content, _node)
+ # Balloon annotation - content contains the balloon text
+ "\\reviewballoon{#{content}}"
+ end
+
+ # Render mathematical expression
+ def render_inline_m(_type, content, node)
+ # Mathematical expressions - don't escape content
+ "$#{node.args.first || content}$"
+ end
+
+ # Render superscript
+ def render_inline_sup(_type, content, _node)
+ "\\textsuperscript{#{content}}"
+ end
+
+ # Render subscript
+ def render_inline_sub(_type, content, _node)
+ "\\textsubscript{#{content}}"
+ end
+
+ # Render strikethrough
+ def render_inline_del(_type, content, _node)
+ "\\reviewstrike{#{content}}"
+ end
+
+ # Render strikethrough (alias)
+ def render_inline_strike(type, content, node)
+ render_inline_del(type, content, node)
+ end
+
+ # Render insert
+ def render_inline_ins(_type, content, _node)
+ "\\reviewinsert{#{content}}"
+ end
+
+ # Render insert (alias)
+ def render_inline_insert(type, content, node)
+ render_inline_ins(type, content, node)
+ end
+
+ # Render unicode character
+ def render_inline_uchar(_type, content, node)
+ # Unicode character handling like LATEXBuilder
+ if node.args.first
+ char_code = node.args.first
+ texcompiler = @ctx.config['texcommand']
+ if texcompiler&.start_with?('platex')
+ # with otf package - use \UTF macro
+ "\\UTF{#{escape(char_code)}}"
+ else
+ # upLaTeX or other - convert to actual Unicode character
+ [char_code.to_i(16)].pack('U')
+ end
+ else
+ content
+ end
+ end
+
+ # Render line break
+ def render_inline_br(_type, _content, _node)
+ "\\\\\n"
+ end
+
+ # Render word expansion
+ def render_inline_w(_type, content, _node)
+ # Word expansion - pass through content
+ content
+ end
+
+ # Render word expansion (bold)
+ def render_inline_wb(_type, content, _node)
+ # Word expansion - pass through content
+ content
+ end
+
+ # Render raw content
+ def render_inline_raw(_type, _content, node)
+ node.targeted_for?('latex') ? (node.content || '') : ''
+ end
+
+ # Render embedded content
+ def render_inline_embed(_type, _content, node)
+ node.targeted_for?('latex') ? (node.content || '') : ''
+ end
+
+ # Render label reference
+ def render_inline_labelref(_type, content, node)
+ # Use resolved content from ReferenceResolver if available,
+ # otherwise fall back to legacy behavior
+ if content && !content.empty?
+ "\\textbf{#{escape(content)}}"
+ elsif node.args.first
+ ref_id = node.args.first
+ "\\ref{#{escape(ref_id)}}"
+ else
+ ''
+ end
+ end
+
+ # Render reference (same as labelref)
+ def render_inline_ref(type, content, node)
+ render_inline_labelref(type, content, node)
+ end
+
+ # Render inline comment
+ def render_inline_comment(_type, content, _node)
+ if @ctx.draft_mode?
+ "\\pdfcomment{#{escape(content)}}"
+ else
+ ''
+ end
+ end
+
+ # Render column reference
+ def render_inline_column(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ column_number = data.item_number
+ chapter_id = data.chapter_id || @ctx.chapter&.id
+ column_label = "column:#{chapter_id}:#{column_number}"
+
+ # Render caption with inline markup
+ compiled_caption = if data.caption_node
+ @ctx.render_caption_inline(data.caption_node)
+ else
+ data.caption_text
+ end
+ column_text = @ctx.text_formatter.format_column_label(compiled_caption)
+ "\\reviewcolumnref{#{column_text}}{#{column_label}}"
+ end
+
+ # Render endnote
+ def render_inline_endnote(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ endnote_content = escape(data.caption_text || '')
+ "\\endnote{#{endnote_content}}"
+ end
+
+ # Render title reference (@{chapter_id})
+ def render_inline_title(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ title = data.chapter_title || ''
+ if @ctx.chapter_link_enabled?
+ "\\reviewchapref{#{escape(title)}}{chap:#{data.item_id}}"
+ else
+ escape(title)
+ end
+ end
+
+ private
+
+ # Find image path for icon
+ def find_image_path(icon_id)
+ @ctx.chapter&.image(icon_id)&.path
+ rescue StandardError
+ nil
+ end
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/latex_renderer.rb b/lib/review/renderer/latex_renderer.rb
new file mode 100644
index 000000000..4ee3c3322
--- /dev/null
+++ b/lib/review/renderer/latex_renderer.rb
@@ -0,0 +1,1551 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/ast/caption_node'
+require 'review/ast/table_column_width_parser'
+require 'review/latexutils'
+require 'review/sec_counter'
+require 'review/i18n'
+require 'review/textutils'
+require_relative 'base'
+require_relative 'rendering_context'
+require_relative 'latex/inline_context'
+require_relative 'latex/inline_element_handler'
+
+module ReVIEW
+ module Renderer
+ class LatexRenderer < Base
+ include ReVIEW::LaTeXUtils
+ include ReVIEW::TextUtils
+
+ attr_reader :chapter, :book, :rendering_context
+
+ def initialize(chapter)
+ super
+
+ # For AST rendering, we need to set up indexing properly
+ # The indexing will be done when we process the AST
+ @ast_indexer = nil
+ @ast_compiler = nil
+ @list_structure_normalizer = nil
+
+ I18n.setup(config['language'] || 'ja')
+
+ # Initialize LaTeX character escaping
+ initialize_metachars(config['texcommand'])
+
+ # Initialize section counter like LATEXBuilder
+ @sec_counter = SecCounter.new(5, @chapter) if @chapter
+
+ # Initialize RenderingContext for cleaner state management
+ @rendering_context = RenderingContext.new(:document)
+
+ # Initialize LaTeX-specific inline context and inline element handler
+ @inline_context = Latex::InlineContext.new(
+ config: config,
+ book: book,
+ chapter: chapter,
+ renderer: self
+ )
+ @inline_element_handler = Latex::InlineElementHandler.new(@inline_context)
+ end
+
+ # Format type for this renderer
+ # @return [Symbol] Format type :latex
+ def format_type
+ :latex
+ end
+
+ def visit_document(node)
+ # Generate content with proper separation between document-level elements
+ content = render_document_children(node)
+
+ # Wrap Part documents with reviewpart environment
+ if should_wrap_part_with_reviewpart?
+ content = "\\begin{reviewpart}\n" + content + "\\end{reviewpart}\n"
+ end
+
+ # Add any remaining collected footnotetext commands
+ if @rendering_context.footnote_collector.any?
+ content += generate_footnotetext_from_collector(@rendering_context.footnote_collector)
+ @rendering_context.footnote_collector.clear
+ end
+
+ # Ensure content ends with single newline if it contains content
+ # Remove all trailing newlines and add exactly one back
+ if content && !content.empty?
+ content.sub(/\n+\z/, '') + "\n"
+ else
+ content || ''
+ end
+ end
+
+ def visit_headline(node)
+ level = node.level
+ caption = render_children(node.caption_node) if node.caption_node
+
+ # Handle special headline options (nonum, notoc, nodisp)
+ # These do NOT increment the section counter (matching LATEXBuilder behavior)
+ if node.nodisp?
+ # nodisp: Only add TOC entry, no visible heading
+ return generate_toc_entry(level, caption)
+ elsif node.nonum?
+ # nonum: Unnumbered section that appears in TOC
+ return generate_nonum_headline(level, caption, node)
+ elsif node.notoc?
+ # notoc: Unnumbered section that does NOT appear in TOC
+ return generate_notoc_headline(level, caption, node)
+ end
+
+ # Update section counter like LATEXBuilder (only for regular numbered headlines)
+ if @sec_counter
+ @sec_counter.inc(level)
+ end
+
+ # Regular headline processing
+ section_command = headline_name(level)
+
+ # Format with exact newlines like LATEXBuilder to match expected format
+ result = []
+ result << "\\#{section_command}{#{caption}}"
+
+ # Add \addcontentsline for unnumbered sections within toclevel
+ # Match LATEXBuilder logic: only add to TOC if level is within toclevel
+ if (level > config['secnolevel'] || (@chapter.number.to_s.empty? && level > 1)) &&
+ level <= config['toclevel'].to_i
+ # Get the base section name for TOC entry
+ toc_section_name = get_base_section_name(level)
+ result << "\\addcontentsline{toc}{#{toc_section_name}}{#{caption}}"
+ end
+
+ # Generate labels like LATEXBuilder - add both automatic and custom labels
+ if level == 1 && @chapter
+ result << "\\label{chap:#{@chapter.id}}"
+ elsif @sec_counter && level >= 2
+ # Generate section labels like LATEXBuilder (sec:x-y format)
+ anchor = @sec_counter.anchor(level)
+ result << "\\label{sec:#{anchor}}"
+ # Add custom label if specified (only for level > 1, matching LATEXBuilder)
+ if node.label && !node.label.empty?
+ result << "\\label{#{escape(node.label)}}"
+ end
+ end
+
+ result.join("\n") + "\n\n"
+ end
+
+ def visit_paragraph(node)
+ content = render_children(node)
+
+ # Check for noindent attribute
+ if node.attribute?(:noindent)
+ # Add \noindent command like LATEXBuilder
+ "\\noindent\n#{content}\n\n"
+ else
+ # Add double newline for paragraph separation (LaTeX standard)
+ "#{content}\n\n"
+ end
+ end
+
+ def visit_text(node)
+ content = node.content.to_s
+ # Preserve newlines and escape content properly
+ # Don't escape newlines so they are preserved in the output
+ escape(content)
+ end
+
+ # Process caption for code blocks with proper context management
+ # @param node [CodeBlockNode] The code block node
+ # @return [Array] [caption, caption_collector]
+ def process_code_block_caption(node)
+ caption = nil
+ caption_collector = nil
+
+ if node.caption_node
+ @rendering_context.with_child_context(:caption) do |caption_context|
+ caption = render_children_with_context(node.caption_node, caption_context)
+ caption_collector = caption_context.footnote_collector
+ end
+ end
+
+ [caption, caption_collector]
+ end
+
+ # Add footnotetext commands from collector to result
+ # @param result [String] The rendered result
+ # @param caption_collector [Object] The footnote collector
+ # @return [String] Result with footnotetext commands appended
+ def append_footnotetext_from_collector(result, caption_collector)
+ if caption_collector && caption_collector.any?
+ result += generate_footnotetext_from_collector(caption_collector)
+ caption_collector.clear
+ end
+ result
+ end
+
+ # Visit list code block
+ def visit_code_block_list(node)
+ caption, caption_collector = process_code_block_caption(node)
+ content = render_children(node)
+ result = visit_list_block(node, content, caption)
+ append_footnotetext_from_collector(result, caption_collector)
+ end
+
+ # Visit listnum code block (list with line numbers)
+ def visit_code_block_listnum(node)
+ caption, caption_collector = process_code_block_caption(node)
+ content = render_children(node)
+ result = visit_list_block(node, add_line_numbers(content, node), caption)
+ append_footnotetext_from_collector(result, caption_collector)
+ end
+
+ # Visit emlist code block
+ def visit_code_block_emlist(node)
+ caption, caption_collector = process_code_block_caption(node)
+ content = render_children(node)
+ result = visit_emlist_block(node, content, caption)
+ append_footnotetext_from_collector(result, caption_collector)
+ end
+
+ # Visit emlistnum code block (emlist with line numbers)
+ def visit_code_block_emlistnum(node)
+ caption, caption_collector = process_code_block_caption(node)
+ content = render_children(node)
+ result = visit_emlist_block(node, add_line_numbers(content, node), caption)
+ append_footnotetext_from_collector(result, caption_collector)
+ end
+
+ # Visit cmd code block
+ def visit_code_block_cmd(node)
+ caption, caption_collector = process_code_block_caption(node)
+ content = render_children(node)
+ result = visit_cmd_block(node, content, caption)
+ append_footnotetext_from_collector(result, caption_collector)
+ end
+
+ # Visit source code block
+ def visit_code_block_source(node)
+ caption, caption_collector = process_code_block_caption(node)
+ content = render_children(node)
+ result = visit_source_block(node, content, caption)
+ append_footnotetext_from_collector(result, caption_collector)
+ end
+
+ def visit_code_line(node)
+ # Render children (TextNode and InlineNode) to process inline elements properly
+ content = render_children(node)
+ # Add proper newline for LaTeX code line formatting
+ "#{content}\n"
+ end
+
+ def visit_table(node)
+ # Process caption with proper context management and collect footnotes
+ caption = nil
+ caption_collector = nil
+
+ if node.caption_node
+ @rendering_context.with_child_context(:caption) do |caption_context|
+ caption = render_children_with_context(node.caption_node, caption_context)
+ # Save the collector for later processing
+ caption_collector = caption_context.footnote_collector
+ end
+ end
+
+ table_type = node.table_type
+
+ # Handle imgtable specially - it should be rendered as an image
+ if table_type == :imgtable
+ result = visit_imgtable(node, caption)
+ # Add collected footnotetext commands from caption context for imgtable
+ if caption_collector && caption_collector.any?
+ result += generate_footnotetext_from_collector(caption_collector)
+ caption_collector.clear
+ end
+ return result
+ end
+
+ # Process table content with table context
+ table_context = nil
+ table_content = @rendering_context.with_child_context(:table) do |ctx|
+ table_context = ctx
+
+ # Get column specification from TableNode (set by TsizeProcessor)
+ # or use default values if not set
+ col_spec = node.col_spec || node.default_col_spec
+ cellwidth = node.cellwidth || node.default_cellwidth
+
+ # Store cellwidth temporarily for visit_table_cell_with_index to access
+ # This is needed because cell rendering happens in nested visitor calls
+ @current_table_cellwidth = cellwidth
+
+ # Get all rows for processing
+ all_rows = node.header_rows + node.body_rows
+
+ result = []
+
+ # Only output \begin{table} if caption is present (like LATEXBuilder)
+ if caption.present?
+ result << if node.id?
+ "\\begin{table}%%#{node.id}"
+ else
+ '\\begin{table}%%'
+ end
+ end
+
+ # Process caption and label
+ if caption.present?
+ # emtable uses reviewtablecaption* (with asterisk)
+ caption_command = table_type == :emtable ? 'reviewtablecaption*' : 'reviewtablecaption'
+ result << "\\#{caption_command}{#{caption}}"
+ end
+
+ if node.id?
+ # Generate label like LATEXBuilder: table:chapter:id
+ # Don't escape underscores in labels - they're allowed in LaTeX label names
+ result << if @chapter
+ "\\label{table:#{@chapter.id}:#{node.id}}"
+ else
+ "\\label{table:test:#{node.id}}"
+ end
+ end
+
+ result << "\\begin{reviewtable}{#{col_spec}}"
+ result << '\\hline'
+
+ # Process all rows using visitor pattern with table context
+ all_rows.each do |row|
+ row_content = visit_with_context(row, table_context)
+ result << "#{row_content} \\\\ \\hline"
+ end
+
+ result << '\\end{reviewtable}'
+
+ # Only output \end{table} if caption is present (like LATEXBuilder)
+ if caption.present?
+ result << '\\end{table}'
+ end
+
+ result.join("\n") + "\n"
+ end
+
+ # Add collected footnotetext commands from caption context
+ if caption_collector && caption_collector.any?
+ table_content += generate_footnotetext_from_collector(caption_collector)
+ caption_collector.clear
+ end
+
+ # Add collected footnotetext commands from table context
+ if table_context && table_context.footnote_collector.any?
+ table_content += generate_footnotetext_from_collector(table_context.footnote_collector)
+ table_context.footnote_collector.clear
+ end
+
+ table_content.chomp + "\n\n"
+ end
+
+ def visit_imgtable(node, caption)
+ # imgtable is rendered as table with image inside (like LATEXBuilder)
+ result = []
+
+ # Check if image is bound like LATEXBuilder does
+ unless node.id? && @chapter && @chapter.image_bound?(node.id)
+ # No ID or chapter, or image not bound - return dummy
+ result << '\\begin{reviewdummyimage}'
+ result << "% image not bound: #{node.id}" if node.id?
+ result << '\\end{reviewdummyimage}'
+ return result.join("\n") + "\n"
+ end
+
+ # Get image path - image is bound, so this should succeed
+ image_path = @chapter.image(node.id).path
+
+ # Generate table structure with image like LATEXBuilder
+ # Start table environment if caption exists (line 911)
+ if caption && !caption.empty?
+ result << "\\begin{table}[h]%%#{node.id}"
+
+ # Add caption and label at top if caption_top?
+ if caption_top?('table')
+ result << "\\reviewimgtablecaption{#{caption}}"
+ end
+
+ # Add table label (line 919) - this needs table index
+ begin
+ result << "\\label{table:#{@chapter.id}:#{node.id}}"
+ rescue ReVIEW::KeyError
+ # If table lookup fails, still continue
+ end
+ end
+
+ # Add image inside reviewimage environment (lines 937-949)
+ result << "\\begin{reviewimage}%%#{node.id}"
+
+ # Parse metric option like LATEXBuilder
+ metrics = parse_metric('latex', node.metric)
+ command = 'reviewincludegraphics'
+
+ # Use metric if provided, otherwise use default width
+ result << if metrics.present?
+ "\\#{command}[#{metrics}]{#{image_path}}"
+ else
+ "\\#{command}[width=\\maxwidth]{#{image_path}}"
+ end
+
+ result << '\\end{reviewimage}'
+
+ # Close table if caption exists
+ if caption.present?
+ # Add caption at bottom if not caption_top?
+ unless caption_top?('table')
+ result << "\\reviewimgtablecaption{#{caption}}"
+ end
+
+ result << '\\end{table}'
+ end
+
+ result.join("\n") + "\n\n"
+ end
+
+ def visit_table_row(node)
+ # Process all cells in the row using visitor pattern while maintaining table context
+ # Note: table context should already be set by visit_table
+ cells = node.children.map.with_index do |cell, col_index|
+ visit_table_cell_with_index(cell, col_index)
+ end
+ cells.join(' & ')
+ end
+
+ def visit_table_cell(node)
+ # Fallback method if called without index
+ visit_table_cell_with_index(node, 0)
+ end
+
+ def visit_table_cell_with_index(node, col_index)
+ # Process cell content while maintaining table context to collect footnotes
+ # Note: table context should already be set by visit_table
+ content = render_children(node)
+
+ # Get cellwidth for this column from current table's cellwidth array
+ cellwidth = @current_table_cellwidth && @current_table_cellwidth[col_index] ? @current_table_cellwidth[col_index] : 'l'
+
+ # Check if content contains line breaks (from @ {})
+ # Like LATEXBuilder: use \newline{} for fixed-width cells (p{...}), otherwise use \shortstack
+ if /\\\\/.match?(content)
+ # Check if cellwidth is fixed-width format (contains `{`)
+ if AST::TableColumnWidthParser.fixed_width?(cellwidth)
+ # Fixed-width cell: replace \\\n with \newline{}
+ content = content.gsub("\\\\\n", '\\newline{}')
+ if node.cell_type == :th
+ "\\reviewth{#{content}}"
+ else
+ content
+ end
+ elsif node.cell_type == :th
+ # Non-fixed-width cell: use \shortstack[l] like LATEXBuilder does
+ "\\reviewth{\\shortstack[l]{#{content}}}"
+ else
+ "\\shortstack[l]{#{content}}"
+ end
+ elsif node.cell_type == :th
+ # No line breaks - standard formatting
+ "\\reviewth{#{content}}"
+ else
+ content
+ end
+ end
+
+ def visit_image(node)
+ # Process caption with proper context management
+ caption = nil
+ caption_collector = nil
+
+ if node.caption_node
+ @rendering_context.with_child_context(:caption) do |caption_context|
+ caption = render_children_with_context(node.caption_node, caption_context)
+ # Save the collector for later processing
+ caption_collector = caption_context.footnote_collector
+ end
+ end
+
+ image_type = node.image_type
+
+ result = case image_type
+ when :indepimage, :numberlessimage
+ visit_indepimage(node, caption)
+ else
+ visit_regular_image(node, caption)
+ end
+
+ # Add collected footnotetext commands from caption context
+ if caption_collector && caption_collector.any?
+ result += generate_footnotetext_from_collector(caption_collector)
+ caption_collector.clear
+ end
+
+ result
+ end
+
+ def visit_regular_image(node, caption)
+ image_path = find_image_path(node.id)
+
+ if image_path
+ render_existing_image(node, image_path, caption, with_label: true)
+ else
+ render_dummy_image(node, caption, double_escape_id: false, with_label: true)
+ end
+ end
+
+ def visit_indepimage(node, caption)
+ image_path = find_image_path(node.id)
+
+ if image_path
+ render_existing_indepimage(node, image_path, caption)
+ else
+ render_dummy_image(node, caption, double_escape_id: true, with_label: false)
+ end
+ end
+
+ def visit_list(node)
+ case node.list_type
+ when :ul
+ # Unordered list - generate LaTeX itemize environment
+ items = node.children.map { |item| "\\item #{render_children(item)}" }.join("\n")
+ "\n\\begin{itemize}\n#{items}\n\\end{itemize}\n\n"
+ when :ol
+ # Ordered list - generate LaTeX enumerate environment
+ items = node.children.map { |item| "\\item #{render_children(item)}" }.join("\n")
+
+ # Check if this list has start_number
+ if node.start_number && node.start_number != 1
+ # Generate enumerate with setcounter for non-default start
+ start_num = node.start_number - 1 # LaTeX counter is 0-based
+ "\n\\begin{enumerate}\n\\setcounter{enumi}{#{start_num}}\n#{items}\n\\end{enumerate}\n\n"
+ else
+ "\n\\begin{enumerate}\n#{items}\n\\end{enumerate}\n\n"
+ end
+ when :dl
+ # Definition list - generate LaTeX description environment like LATEXBuilder
+ visit_definition_list(node)
+ else
+ raise NotImplementedError, "Unsupported list type: #{node.list_type}"
+ end
+ end
+
+ def visit_list_item(node)
+ raise NotImplementedError, 'List item processing should be handled by visit_list, not as standalone items'
+ end
+
+ # Visit quote block
+ def visit_block_quote(node)
+ content = render_children(node)
+ result = "\n\\begin{quote}\n#{content.chomp}\\end{quote}\n\n"
+ apply_noindent_if_needed(node, result)
+ end
+
+ # Visit source block (code block without caption)
+ def visit_block_source(node)
+ content = render_children(node)
+ "\\begin{reviewcmd}\n#{content}\\end{reviewcmd}\n"
+ end
+
+ # Visit lead block (lead paragraph)
+ def visit_block_lead(node)
+ content = render_children(node)
+ result = "\n\\begin{quotation}\n#{content.chomp}\\end{quotation}\n\n"
+ apply_noindent_if_needed(node, result)
+ end
+
+ # Visit olnum block (set ordered list counter)
+ def visit_block_olnum(node)
+ # olnum is now handled as metadata in list processing
+ # If we encounter it here, it means there was no following ordered list
+ # In this case, we should still generate the setcounter command for compatibility
+ if node.args.first
+ num = node.args.first.to_i
+ "\\setcounter{enumi}{#{num - 1}}\n"
+ else
+ "\\setcounter{enumi}{0}\n"
+ end
+ end
+
+ # Visit footnote block
+ def visit_block_footnote(node)
+ # Handle footnote blocks - generate \footnotetext LaTeX command
+ if node.args.length >= 2
+ footnote_id = node.args[0]
+ footnote_content = escape(node.args[1])
+ # Generate footnote number like LaTeXBuilder does
+ if @chapter && @chapter.footnote_index
+ begin
+ footnote_number = @chapter.footnote_index.number(footnote_id)
+ "\\footnotetext[#{footnote_number}]{#{footnote_content}}\n"
+ rescue ReVIEW::KeyError => e
+ raise NotImplementedError, "Footnote block processing failed for #{footnote_id}: #{e.message}"
+ end
+ else
+ raise NotImplementedError, 'Footnote processing requires chapter context but none provided'
+ end
+ else
+ raise NotImplementedError, 'Malformed footnote block: insufficient arguments'
+ end
+ end
+
+ # Visit tsize block (table size control)
+ def visit_block_tsize(_node)
+ # tsize is now processed by TsizeProcessor during AST compilation
+ # The tsize block nodes are removed from AST by TsizeProcessor,
+ # so this case should not be reached. Return empty string for safety.
+ ''
+ end
+
+ # Visit texequation block (mathematical equation)
+ def visit_block_texequation(node)
+ content = render_children(node)
+ # Handle mathematical equation blocks - output content directly
+ # without LaTeX environment wrapping since content is raw LaTeX math
+ content.strip.empty? ? '' : "\n#{content}\n\n"
+ end
+
+ # Visit comment block
+ def visit_block_comment(node)
+ # Handle comment blocks - only output in draft mode
+ visit_comment_block(node)
+ end
+
+ # Visit beginchild block (child nesting control)
+ def visit_block_beginchild(_node)
+ # Child nesting control commands - produce no output
+ ''
+ end
+
+ # Visit endchild block (child nesting control)
+ def visit_block_endchild(_node)
+ # Child nesting control commands - produce no output
+ ''
+ end
+
+ # Visit centering block (center alignment)
+ def visit_block_centering(node)
+ content = render_children(node)
+ "\n\\begin{center}\n#{content.chomp}\\end{center}\n\n"
+ end
+
+ # Visit flushright block (right alignment)
+ def visit_block_flushright(node)
+ content = render_children(node)
+ "\n\\begin{flushright}\n#{content.chomp}\\end{flushright}\n\n"
+ end
+
+ # Visit address block (similar to flushright)
+ def visit_block_address(node)
+ content = render_children(node)
+ "\n\\begin{flushright}\n#{content.chomp}\\end{flushright}\n\n"
+ end
+
+ # Visit talk block (dialog/conversation)
+ def visit_block_talk(node)
+ content = render_children(node)
+ "#{content}\n"
+ end
+
+ # Visit read block (reading material)
+ def visit_block_read(node)
+ content = render_children(node)
+ "\n\\begin{quotation}\n#{content.chomp}\\end{quotation}\n\n"
+ end
+
+ # Visit blockquote block
+ def visit_block_blockquote(node)
+ content = render_children(node)
+ "\n\\begin{quote}\n#{content.chomp}\\end{quote}\n\n"
+ end
+
+ # Visit printendnotes block (print collected endnotes)
+ def visit_block_printendnotes(_node)
+ "\n\\theendnotes\n\n"
+ end
+
+ # Visit label block (create label)
+ def visit_block_label(node)
+ if node.args.first
+ label_id = node.args.first
+ "\\label{#{escape(label_id)}}\n"
+ else
+ ''
+ end
+ end
+
+ # Visit blankline block (control command)
+ def visit_block_blankline(_node)
+ "\\par\\vspace{\\baselineskip}\\par\n\n"
+ end
+
+ # Visit noindent block (control command)
+ def visit_block_noindent(_node)
+ ''
+ end
+
+ # Visit pagebreak block (control command)
+ def visit_block_pagebreak(_node)
+ ''
+ end
+
+ # Visit endnote block (control command)
+ def visit_block_endnote(_node)
+ ''
+ end
+
+ # Visit hr block (control command)
+ def visit_block_hr(_node)
+ ''
+ end
+
+ # Visit bpo block (control command)
+ def visit_block_bpo(_node)
+ ''
+ end
+
+ # Visit parasep block (control command)
+ def visit_block_parasep(_node)
+ ''
+ end
+
+ # Visit bibpaper block (bibliography paper)
+ def visit_block_bibpaper(node)
+ visit_bibpaper(node)
+ end
+
+ def visit_minicolumn(node)
+ # Process caption with proper context management and collect footnotes
+ caption = nil
+ caption_collector = nil
+
+ if node.caption_node
+ @rendering_context.with_child_context(:caption) do |caption_context|
+ caption = render_children_with_context(node.caption_node, caption_context)
+ # Save the collector for later processing
+ caption_collector = caption_context.footnote_collector
+ end
+ end
+
+ content = render_children(node)
+
+ env_name = case node.minicolumn_type.to_s
+ when 'note'
+ 'reviewnote'
+ when 'memo'
+ 'reviewmemo'
+ when 'tip'
+ 'reviewtip'
+ when 'info'
+ 'reviewinfo'
+ when 'warning'
+ 'reviewwarning'
+ when 'important'
+ 'reviewimportant'
+ when 'caution'
+ 'reviewcaution'
+ when 'notice'
+ 'reviewnotice'
+ else
+ 'reviewcolumn'
+ end
+
+ result = []
+ result << if caption && !caption.empty?
+ "\\begin{#{env_name}}[#{caption}]"
+ else
+ "\\begin{#{env_name}}"
+ end
+ result << '' # blank line after begin
+ result << content.chomp
+ result << "\\end{#{env_name}}"
+
+ output = result.join("\n") + "\n\n"
+
+ # Add collected footnotetext commands from caption context
+ if caption_collector && caption_collector.any?
+ output += generate_footnotetext_from_collector(caption_collector)
+ caption_collector.clear
+ end
+
+ output
+ end
+
+ def visit_caption(node)
+ render_children(node)
+ end
+
+ def visit_comment_block(node)
+ # block comment - only display in draft mode
+ return '' unless config['draft']
+
+ content_lines = []
+
+ # add argument if it exists
+ if node.args.first&.then { |arg| !arg.empty? }
+ content_lines << escape(node.args.first)
+ end
+
+ # add body content
+ if node.content && !node.content.empty?
+ body_content = render_children(node)
+ content_lines << body_content unless body_content.empty?
+ end
+
+ return '' if content_lines.empty?
+
+ # use pdfcomment macro in LaTeX
+ content_str = content_lines.join('\\par ')
+ "\\pdfcomment{#{content_str}}\n"
+ end
+
+ def visit_column(node)
+ caption = render_children(node.caption_node) if node.caption_node
+
+ # Generate column label for hypertarget (using auto_id from Compiler)
+ column_label = generate_column_label(node, caption)
+ hypertarget = "\\hypertarget{#{column_label}}{}"
+
+ # Process column content with :column context to collect footnotes
+ column_context = nil
+ content = @rendering_context.with_child_context(:column) do |ctx|
+ column_context = ctx
+ render_children_with_context(node, column_context)
+ end
+
+ result = []
+ result << '' # blank line before column
+
+ # support Re:VIEW Version 3+ format only
+ caption_part = caption ? "[#{caption}#{hypertarget}]" : "[#{hypertarget}]"
+ result << "\\begin{reviewcolumn}#{caption_part}"
+
+ # Add TOC entry if within toclevel
+ if node.level && caption && node.level <= config['toclevel'].to_i
+ toc_level = case node.level
+ when 1
+ 'chapter'
+ when 2
+ 'section'
+ when 3
+ 'subsection'
+ when 4
+ 'subsubsection'
+ else # rubocop:disable Lint/DuplicateBranch
+ 'subsection' # fallback
+ end
+ result << "\\addcontentsline{toc}{#{toc_level}}{#{caption}}"
+ end
+
+ result << '' # blank line after header
+ result << content.chomp
+ result << '\\end{reviewcolumn}'
+ result << '' # blank line after column
+
+ output = result.join("\n") + "\n"
+
+ # Add collected footnotetext commands from column context
+ if column_context && column_context.footnote_collector.any?
+ output += generate_footnotetext_from_collector(column_context.footnote_collector)
+ column_context.footnote_collector.clear
+ end
+
+ output
+ end
+
+ def visit_embed(node)
+ # All embed types now use unified processing
+ process_raw_embed(node)
+ end
+
+ # Code block type handlers
+ def visit_list_block(node, content, caption)
+ result = []
+ result << '\\begin{reviewlistblock}'
+
+ if caption && !caption.empty?
+ # Use LATEXBuilder logic for list caption with proper numbering
+ if node.id?
+ # For list blocks with ID, generate numbered caption like LATEXBuilder
+ begin
+ list_item = @chapter.list(node.id)
+ list_num = list_item.number
+ chapter_num = @chapter.number
+ captionstr = "\\reviewlistcaption{#{text_formatter.format_caption('list', chapter_num, list_num, caption)}}"
+ result << captionstr
+ rescue ReVIEW::KeyError
+ raise NotImplementedError, "no such list: #{node.id}"
+ end
+ else
+ # For list blocks without ID, use simple caption
+ result << "\\reviewlistcaption{#{caption}}"
+ end
+ end
+
+ result << '\\begin{reviewlist}'
+ result << content.chomp
+ result << '\\end{reviewlist}'
+ result << '\\end{reviewlistblock}'
+ result.join("\n") + "\n\n"
+ end
+
+ def visit_emlist_block(_node, content, caption)
+ result = []
+ result << '\\begin{reviewlistblock}'
+
+ if caption && !caption.empty?
+ result << "\\reviewemlistcaption{#{caption}}"
+ end
+
+ result << '\\begin{reviewemlist}'
+ result << content.chomp
+ result << '\\end{reviewemlist}'
+
+ result << '\\end{reviewlistblock}'
+ result.join("\n") + "\n\n"
+ end
+
+ def visit_cmd_block(_node, content, caption)
+ result = []
+ result << '\\begin{reviewlistblock}'
+
+ if caption && !caption.empty?
+ result << "\\reviewcmdcaption{#{caption}}"
+ end
+
+ result << '\\begin{reviewcmd}'
+ result << content.chomp
+ result << '\\end{reviewcmd}'
+ result << '\\end{reviewlistblock}'
+ result.join("\n") + "\n\n"
+ end
+
+ def visit_source_block(_node, content, caption)
+ result = []
+ result << '\\begin{reviewlistblock}'
+
+ if caption && !caption.empty?
+ result << "\\reviewsourcecaption{#{caption}}"
+ end
+
+ result << '\\begin{reviewsource}'
+ result << content.chomp
+ result << '\\end{reviewsource}'
+ result << '\\end{reviewlistblock}'
+ result.join("\n") + "\n\n"
+ end
+
+ def visit_tex_equation(node)
+ # Handle LaTeX mathematical equation blocks
+ # Output the LaTeX content directly without escaping since it's raw LaTeX
+ content = node.content
+
+ if node.id? && node.caption?
+ # Equation with ID and caption - use reviewequationblock like traditional compiler
+ equation_num = get_equation_number(node.id)
+ caption_content = render_children(node.caption_node)
+ result = []
+ result << '\\begin{reviewequationblock}'
+ result << "\\reviewequationcaption{#{escape("式#{equation_num}: #{caption_content}")}}"
+ result << '\\begin{equation*}'
+ result << content
+ result << '\\end{equation*}'
+ result << '\\end{reviewequationblock}'
+ elsif node.id?
+ # Equation with ID only - still use reviewequationblock for consistency
+ equation_num = get_equation_number(node.id)
+ result = []
+ result << '\\begin{reviewequationblock}'
+ result << "\\reviewequationcaption{#{escape("式#{equation_num}")}}"
+ result << '\\begin{equation*}'
+ result << content
+ result << '\\end{equation*}'
+ result << '\\end{reviewequationblock}'
+ else
+ # Equation without ID - use equation* environment (no numbering)
+ result = []
+ result << '\\begin{equation*}'
+ result << content
+ result << '\\end{equation*}'
+ end
+ result.join("\n") + "\n\n"
+ end
+
+ # Get equation number for texequation blocks
+ def get_equation_number(equation_id)
+ if @chapter && @chapter.equation_index
+ begin
+ equation_number = @chapter.equation_index.number(equation_id)
+ if @chapter.number
+ "#{@chapter.number}.#{equation_number}"
+ else
+ equation_number.to_s
+ end
+ rescue ReVIEW::KeyError
+ # Fallback if equation not found in index
+ '??'
+ end
+ else
+ '??'
+ end
+ end
+
+ def visit_bibpaper(node)
+ # Extract bibliography arguments
+ if node.args.length >= 2
+ bib_id = node.args[0]
+ bib_caption = node.args[1]
+
+ # Process content
+ content = render_children(node)
+
+ # Generate bibliography entry like LATEXBuilder
+ result = []
+
+ # Header with number and caption
+ if @book.bibpaper_index
+ begin
+ bib_number = @book.bibpaper_index.number(bib_id)
+ result << "[#{bib_number}] #{escape(bib_caption)}"
+ rescue ReVIEW::KeyError => e
+ # Fallback if not found in index
+ warn "Bibpaper #{bib_id} not found in index: #{e.message}" if $DEBUG
+ result << "[??] #{escape(bib_caption)}"
+ end
+ elsif @ast_indexer && @ast_indexer.bibpaper_index
+ # Try to get from AST indexer if chapter index not available
+ begin
+ bib_number = @ast_indexer.bibpaper_index.number(bib_id)
+ result << "[#{bib_number}] #{escape(bib_caption)}"
+ rescue ReVIEW::KeyError
+ result << "[??] #{escape(bib_caption)}"
+ end
+ else
+ result << "[??] #{escape(bib_caption)}"
+ end
+
+ # Add label for cross-references
+ result << "\\label{bib:#{escape(bib_id)}}"
+ result << ''
+
+ # Add content - process paragraphs
+ result << if config['join_lines_by_lang']
+ split_paragraph(content).join("\n\n")
+ else
+ content
+ end
+
+ result.join("\n") + "\n"
+ else
+ raise NotImplementedError, 'Malformed bibpaper block: insufficient arguments'
+ end
+ end
+
+ # Add line numbers to content like LATEXBuilder does
+ def add_line_numbers(content, node = nil)
+ lines = content.split("\n")
+ numbered_lines = []
+
+ # Use node.first_line_num if set, otherwise start from 1
+ start_num = node&.first_line_num || 1
+
+ lines.each_with_index do |line, i|
+ next if line.strip.empty? && i == lines.length - 1 # Skip last empty line
+
+ numbered_lines << sprintf('%2d: %s', start_num + i, line)
+ end
+
+ numbered_lines.join("\n")
+ end
+
+ # Render footnote content for footnotetext
+ # This method processes the footnote node's children to properly handle
+ # inline markup like @{text} within footnotes
+ def render_footnote_content(footnote_node)
+ render_children(footnote_node)
+ end
+
+ # Render inline elements from caption_node
+ # @param caption_node [CaptionNode] Caption node to render
+ # @return [String] Rendered inline elements
+ def render_caption_inline(caption_node)
+ caption_node ? render_children(caption_node) : ''
+ end
+
+ private
+
+ # Get image path, returning nil if image doesn't exist
+ def find_image_path(id)
+ path = @chapter.image(id).path
+ path && !path.empty? ? path : nil
+ rescue StandardError
+ nil
+ end
+
+ # Render existing image (for regular //image)
+ def render_existing_image(node, image_path, caption, with_label:)
+ result = []
+ result << if node.id?
+ "\\begin{reviewimage}%%#{node.id}"
+ else
+ '\\begin{reviewimage}'
+ end
+
+ metrics = parse_metric('latex', node.metric)
+ command = 'reviewincludegraphics'
+
+ result << if metrics && !metrics.empty?
+ "\\#{command}[#{metrics}]{#{image_path}}"
+ else
+ "\\#{command}[width=\\maxwidth]{#{image_path}}"
+ end
+
+ result << "\\reviewimagecaption{#{caption}}" if caption && !caption.empty?
+
+ if with_label && node.id?
+ result << if @chapter
+ "\\label{image:#{@chapter.id}:#{node.id}}"
+ else
+ "\\label{image:test:#{node.id}}"
+ end
+ end
+
+ result << '\\end{reviewimage}'
+ result.join("\n") + "\n"
+ end
+
+ # Render existing indepimage (for //indepimage)
+ def render_existing_indepimage(node, image_path, caption)
+ result = []
+ result << "\\begin{reviewimage}%%#{node.id}"
+
+ if caption_top?('image') && caption && !caption.empty?
+ caption_str = "\\reviewindepimagecaption{#{text_formatter.format_numberless_image}#{text_formatter.format_caption_prefix}#{caption}}"
+ result << caption_str
+ end
+
+ metrics = parse_metric('latex', node.metric)
+ command = 'reviewincludegraphics'
+
+ result << if metrics && !metrics.empty?
+ "\\#{command}[#{metrics}]{#{image_path}}"
+ else
+ "\\#{command}[width=\\maxwidth]{#{image_path}}"
+ end
+
+ if !caption_top?('image') && caption && !caption.empty?
+ caption_str = "\\reviewindepimagecaption{#{text_formatter.format_numberless_image}#{text_formatter.format_caption_prefix}#{caption}}"
+ result << caption_str
+ end
+
+ result << '\\end{reviewimage}'
+ result.join("\n") + "\n"
+ end
+
+ # Render dummy image for missing images
+ def render_dummy_image(node, caption, double_escape_id:, with_label:)
+ result = []
+ result << '\\begin{reviewdummyimage}'
+
+ if node.id?
+ # For regular images: single escape, for indepimage: double escape (like Builder)
+ result << if double_escape_id
+ escape_latex("--[[path = #{escape_latex(node.id)} (not exist)]]--")
+ else
+ escape_latex("--[[path = #{node.id} (not exist)]]--")
+ end
+ end
+
+ if with_label && node.id?
+ result << if @chapter
+ "\\label{image:#{@chapter.id}:#{node.id}}"
+ else
+ "\\label{image:test:#{node.id}}"
+ end
+ end
+
+ if caption && !caption.empty?
+ result << if double_escape_id
+ # indepimage uses reviewindepimagecaption
+ "\\reviewindepimagecaption{#{text_formatter.format_numberless_image}#{text_formatter.format_caption_prefix}#{caption}}"
+ else
+ # regular image uses reviewimagecaption
+ "\\reviewimagecaption{#{caption}}"
+ end
+ end
+
+ result << '\\end{reviewdummyimage}'
+ result.join("\n") + "\n"
+ end
+
+ def ast_compiler
+ @ast_compiler ||= ReVIEW::AST::Compiler.for_chapter(@chapter)
+ end
+
+ # Render definition list with proper footnote handling
+ # Footnotes in definition terms require special handling in LaTeX:
+ # they must use \protect\footnotemark{} in the term and \footnotetext
+ # after the description environment
+ def visit_definition_list(node)
+ dl_context = nil
+ items_content = @rendering_context.with_child_context(:dl) do |ctx|
+ dl_context = ctx
+ # Temporarily set the renderer's context to the dl context
+ old_context = @rendering_context
+ @rendering_context = dl_context
+
+ items = node.children.map do |item|
+ render_definition_item(item, dl_context)
+ end.join("\n")
+
+ # Restore the previous context
+ @rendering_context = old_context
+ items
+ end
+
+ # Build output
+ result = "\n\\begin{description}\n#{items_content}\n\\end{description}\n\n"
+
+ # Add collected footnotetext commands from dt contexts (transferred to dl_context)
+ if dl_context && dl_context.footnote_collector.any?
+ result += generate_footnotetext_from_collector(dl_context.footnote_collector)
+ dl_context.footnote_collector.clear
+ end
+
+ result
+ end
+
+ # Render a single definition list item
+ def render_definition_item(item, dl_context)
+ # Render term with :dt context like LATEXBuilder does (latexbuilder.rb:361-382)
+ term = render_definition_term(item, dl_context)
+
+ # Escape square brackets in terms like LATEXBuilder does
+ term = term.gsub('[', '\\lbrack{}').gsub(']', '\\rbrack{}')
+
+ # Handle definition content (all children are definition content)
+ if item.children && !item.children.empty?
+ definition_parts = item.children.map do |child|
+ result = visit(child) # Use visit instead of render_children for individual nodes
+ # Strip all trailing whitespace and newlines
+ # LATEXBuilder's dd() joins lines with "\n", so we need single newlines between paragraphs
+ result.rstrip
+ end
+ # Join with single newline to match LATEXBuilder dd() behavior (lines.map(&:chomp).join("\n"))
+ definition = definition_parts.join("\n")
+
+ # Use exact LATEXBuilder format: \item[term] \mbox{} \\
+ "\\item[#{term}] \\mbox{} \\\\\n#{definition}"
+ else
+ # No definition content - term only
+ "\\item[#{term}] \\mbox{} \\\\"
+ end
+ end
+
+ # Render definition term with proper footnote collection
+ def render_definition_term(item, dl_context)
+ term = nil
+ dt_footnote_collector = nil
+
+ @rendering_context.with_child_context(:dt) do |dt_context|
+ # Temporarily set renderer's context to dt context for term rendering
+ old_dt_context = @rendering_context
+ @rendering_context = dt_context
+
+ term = if item.term_children&.any?
+ # Render term children (which contain inline elements)
+ item.term_children.map { |child| visit(child) }.join
+ else
+ ''
+ end
+
+ @rendering_context = old_dt_context
+
+ # Save dt_context's footnote collector to transfer footnotes to dl_context
+ dt_footnote_collector = dt_context.footnote_collector
+ end
+
+ # Transfer footnotes from dt_context to dl_context
+ if dt_footnote_collector && dt_footnote_collector.any?
+ dt_footnote_collector.each do |entry|
+ dl_context.collect_footnote(entry.node, entry.number)
+ end
+ dt_footnote_collector.clear
+ end
+
+ term
+ end
+
+ # Generate LaTeX footnotetext commands from collected footnotes
+ # @param collector [FootnoteCollector] the footnote collector
+ # @return [String] LaTeX footnotetext commands
+ def generate_footnotetext_from_collector(collector)
+ return '' unless collector.any?
+
+ footnotetext_commands = []
+ collector.each do |entry|
+ content = render_footnote_content(entry.node)
+ footnotetext_commands << "\\footnotetext[#{entry.number}]{#{content}}"
+ end
+
+ footnotetext_commands.join("\n") + "\n"
+ end
+
+ HEADLINE = { # rubocop:disable Lint/UselessConstantScoping
+ 1 => 'chapter',
+ 2 => 'section',
+ 3 => 'subsection',
+ 4 => 'subsubsection',
+ 5 => 'paragraph',
+ 6 => 'subparagraph'
+ }.freeze
+
+ def headline_name(level)
+ name = if @chapter.is_a?(ReVIEW::Book::Part) && level == 1
+ 'part'
+ else
+ HEADLINE[level] || raise(CompileError, "Unsupported headline level: #{level}. LaTeX only supports levels 1-6")
+ end
+
+ if level > config['secnolevel'] || (@chapter.number.to_s.empty? && level > 1)
+ "#{name}*"
+ else
+ name
+ end
+ end
+
+ def render_inline_element(type, content, node)
+ # Delegate to inline element handler
+ method_name = "render_inline_#{type}"
+ if @inline_element_handler.respond_to?(method_name, true)
+ @inline_element_handler.send(method_name, type, content, node)
+ else
+ raise NotImplementedError, "Unknown inline element: #{type}"
+ end
+ end
+
+ def visit_reference(node)
+ if node.resolved?
+ format_resolved_reference(node.resolved_data)
+ else
+ # Reference resolution was skipped or disabled
+ # Return content as fallback
+ escape(node.content || '')
+ end
+ end
+
+ # Format resolved reference based on ResolvedData
+ # Gets plain text from TextFormatter and wraps it with LaTeX markup
+ def format_resolved_reference(data)
+ # Get plain text from TextFormatter (no LaTeX markup)
+ plain_text = text_formatter.format_reference(data.reference_type, data)
+
+ # Wrap with LaTeX-specific markup based on reference type
+ case data.reference_type
+ when :image, :table, :list
+ # For image/table/list, use \ref command
+ if data.cross_chapter?
+ "\\ref{#{data.chapter_id}:#{data.item_id}}"
+ else
+ "\\ref{#{data.item_id}}"
+ end
+ when :equation
+ # For equation, use \ref command
+ "\\ref{#{data.item_id}}"
+ when :footnote
+ # For footnote, use \footnotemark command
+ "\\footnotemark[#{data.item_number}]"
+ when :bibpaper
+ # For bibliography, use \reviewbibref command
+ "\\reviewbibref{[#{data.item_number}]}{bib:#{data.item_id}}"
+ else
+ # For other types (chapter, headline, column, word, endnote), return escaped plain text
+ escape(plain_text)
+ end
+ end
+
+ # Render document children with proper separation
+ def render_document_children(node)
+ results = []
+ node.children.each_with_index do |child, _index|
+ result = visit(child)
+ next if result.nil? || result.empty?
+
+ # Add proper separation after raw embeds
+ if child.is_a?(ReVIEW::AST::EmbedNode) && child.embed_type == :raw && !result.end_with?("\n")
+ result += "\n"
+ end
+
+ results << result
+ end
+
+ content = results.join
+
+ # Post-process to fix consecutive minicolumn blocks spacing like LATEXBuilder's solve_nest
+ # When minicolumn blocks are consecutive, remove extra blank line between them
+ # Pattern: \end{reviewnote}\n\n\begin{reviewnote} should become \end{reviewnote}\n\begin{reviewnote}
+ content.gsub!(/\\end\{(reviewnote|reviewmemo|reviewtip|reviewinfo|reviewwarning|reviewimportant|reviewcaution|reviewnotice)\}\n\n\\begin\{(reviewnote|reviewmemo|reviewtip|reviewinfo|reviewwarning|reviewimportant|reviewcaution|reviewnotice)\}/,
+ "\\\\end{\\1}\n\\\\begin{\\2}")
+
+ content
+ end
+
+ # Render children with specific rendering context
+ def render_children_with_context(node, context)
+ old_context = @rendering_context
+ @rendering_context = context
+ result = render_children(node)
+ @rendering_context = old_context
+ result
+ end
+
+ # Visit node with specific rendering context
+ def visit_with_context(node, context)
+ old_context = @rendering_context
+ @rendering_context = context
+ result = visit(node)
+ @rendering_context = old_context
+ result
+ end
+
+ def visit_footnote(_node)
+ # FootnoteNode represents a footnote definition (//footnote[id][content])
+ # In AST rendering, footnote definitions do not produce direct output.
+ # Instead, footnotes are rendered via:
+ # 1. @{id} inline references produce \footnotemark or \footnote
+ # 2. Collected footnotes (from captions/tables) are output as \footnotetext
+ # by the parent node (code_block, table, image) after processing
+ ''
+ end
+
+ # Check caption position configuration
+ def caption_top?(type)
+ unless %w[top bottom].include?(config.dig('caption_position', type))
+ # Default to top if not configured
+ return true
+ end
+
+ config['caption_position'][type] != 'bottom'
+ end
+
+ # This method calls super to use the base implementation, then applies LaTeX-specific logic
+ def parse_metric(type, metric)
+ s = super
+ # If use_original_image_size is enabled and result is empty and no metric provided
+ if config&.dig('pdfmaker', 'use_original_image_size') && s.empty? && !metric&.present?
+ return ' ' # pass empty space to \reviewincludegraphics to use original size
+ end
+
+ s
+ end
+
+ # Handle individual metric transformations (like scale to width conversion)
+ def handle_metric(str)
+ # Check if image_scale2width is enabled and metric is scale
+ if config&.dig('pdfmaker', 'image_scale2width') && str =~ /\Ascale=([\d.]+)\Z/
+ return "width=#{$1}\\maxwidth"
+ end
+
+ str
+ end
+
+ # Apply noindent if the node has the noindent attribute
+ def apply_noindent_if_needed(node, content)
+ if node.attribute?(:noindent)
+ "\\noindent\n#{content}"
+ else
+ content
+ end
+ end
+
+ # Check if Part document should be wrapped with reviewpart environment
+ def should_wrap_part_with_reviewpart?
+ @chapter.is_a?(ReVIEW::Book::Part)
+ end
+
+ # Generate TOC entry only (for nodisp headlines)
+ def generate_toc_entry(level, caption)
+ toc_type = case level
+ when 1
+ 'chapter'
+ when 2
+ 'section'
+ else
+ 'subsection'
+ end
+ "\\addcontentsline{toc}{#{toc_type}}{#{caption}}\n"
+ end
+
+ # Generate unnumbered headline with TOC entry (for nonum headlines)
+ def generate_nonum_headline(level, caption, _node)
+ section_command = get_base_section_name(level) + '*'
+
+ # Add TOC entry
+ toc_type = case level
+ when 1
+ 'chapter'
+ when 2
+ 'section'
+ else
+ 'subsection'
+ end
+
+ "\\#{section_command}{#{caption}}\n\\addcontentsline{toc}{#{toc_type}}{#{caption}}\n\n"
+ end
+
+ # Generate unnumbered headline without TOC entry (for notoc headlines)
+ def generate_notoc_headline(level, caption, _node)
+ section_command = get_base_section_name(level) + '*'
+
+ "\\#{section_command}{#{caption}}\n\n"
+ end
+
+ # Get base section name without star
+ def get_base_section_name(level)
+ if @chapter.is_a?(ReVIEW::Book::Part) && level == 1
+ 'part'
+ else
+ HEADLINE[level] || raise(CompileError, "Unsupported headline level: #{level}")
+ end
+ end
+
+ # Generate column label for hypertarget (matches LATEXBuilder behavior)
+ def generate_column_label(node, _caption)
+ # Use column_number directly instead of parsing auto_id
+ num = node.column_number || 'unknown'
+
+ "column:#{@chapter&.id || 'unknown'}:#{num}"
+ end
+
+ # Process //raw command with LATEXBuilder-compatible behavior
+ def process_raw_embed(node)
+ # Skip HTML embeds (from Markdown raw HTML) - they are not compatible with LaTeX
+ return '' if node.embed_type.to_s == 'html'
+
+ # Check if this embed is targeted for LaTeX builder
+ unless node.targeted_for?('latex')
+ return ''
+ end
+
+ # Get content
+ content = node.content || ''
+
+ # Process \n based on embed type
+ case node.embed_type
+ when :inline, :raw
+ # For inline and raw embeds, convert \\n to actual newlines
+ content = content.gsub('\\n', "\n")
+ end
+
+ # For block embeds, add trailing newline
+ node.embed_type == :block ? content + "\n" : content
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/markdown_renderer.rb b/lib/review/renderer/markdown_renderer.rb
new file mode 100644
index 000000000..380664176
--- /dev/null
+++ b/lib/review/renderer/markdown_renderer.rb
@@ -0,0 +1,1039 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/htmlutils'
+require 'review/textutils'
+require 'review/loggable'
+require_relative 'base'
+
+module ReVIEW
+ module Renderer
+ class MarkdownRenderer < Base
+ include ReVIEW::HTMLUtils
+ include ReVIEW::TextUtils
+ include ReVIEW::Loggable
+
+ def initialize(chapter)
+ super
+ @blank_seen = true
+ @ul_indent = 0
+ @table_rows = []
+ @table_header_count = 0
+ @rendering_context = nil
+ end
+
+ def target_name
+ 'markdown'
+ end
+
+ def visit_document(node)
+ render_children(node)
+ end
+
+ def visit_headline(node)
+ level = node.level
+ caption = render_caption_inline(node.caption_node)
+
+ # Use Markdown # syntax
+ prefix = '#' * level
+ "#{prefix} #{caption}\n\n"
+ end
+
+ def visit_paragraph(node)
+ # Render children with spacing between adjacent inline elements
+ content = render_children_with_inline_spacing(node)
+ return '' if content.empty?
+
+ lines = content.split("\n")
+ result = lines.join(' ')
+
+ "#{result}\n\n"
+ end
+
+ def visit_list(node)
+ result = +''
+
+ case node.list_type
+ when :ul
+ node.children.each do |item|
+ result += visit_list_item(item, :ul)
+ end
+ when :ol
+ node.children.each_with_index do |item, index|
+ result += visit_list_item(item, :ol, index + 1)
+ end
+ when :dl
+ node.children.each do |item|
+ result += visit_definition_item(item)
+ end
+ else
+ raise NotImplementedError, "MarkdownRenderer does not support list_type #{node.list_type}."
+ end
+
+ result + "\n"
+ end
+
+ def visit_list_item(node, type = :ul, number = nil)
+ # Separate text content from nested lists
+ text_content = +''
+ nested_lists = +''
+
+ node.children.each do |child|
+ if child.class.name.include?('ListNode')
+ # This is a nested list - render it separately
+ nested_lists += visit(child)
+ else
+ # This is regular content
+ text_content += visit(child)
+ end
+ end
+
+ text_content = text_content.chomp
+
+ # Use the level attribute from the node for proper indentation
+ level = node.level || 1
+
+ result = case type
+ when :ul
+ # Calculate indent based on level (0-based indentation: level 1 = 0 spaces, level 2 = 2 spaces, etc.)
+ indent = ' ' * (level - 1)
+ "#{indent}* #{text_content}\n"
+ when :ol
+ # For ordered lists, also apply indentation based on level
+ indent = ' ' * (level - 1)
+ "#{indent}#{number}. #{text_content}\n"
+ end
+
+ # Add any nested lists after the item
+ result += nested_lists
+ result
+ end
+
+ def visit_item(node)
+ # Handle list items that come directly without parent list context
+ content = render_children(node).chomp
+ "* #{content}\n"
+ end
+
+ def visit_definition_item(node)
+ # Check if term contains inline elements that render as ** (bold/strong)
+ # to avoid nesting issues like ****bold****
+ term_has_bold = node.term_children&.any? do |child|
+ child.is_a?(ReVIEW::AST::InlineNode) && %i[b strong].include?(child.inline_type)
+ end
+
+ # Handle definition term - use term_children (AST structure)
+ term = if node.term_children && !node.term_children.empty?
+ # Render term children (which contain inline elements)
+ node.term_children.map { |child| visit(child) }.join
+ else
+ '' # No term available
+ end
+
+ # Handle definition content (all children are definition content)
+ definition_parts = node.children.map do |child|
+ visit(child) # Use visit instead of render_children for individual nodes
+ end
+ definition = definition_parts.join(' ').strip
+
+ # Format term: if term contains bold inline elements, don't wrap in **
+ if term_has_bold
+ # Term already has strong emphasis, use it as-is
+ "#{term}: #{definition}\n\n"
+ else
+ # Wrap plain term in bold
+ "**#{term}**: #{definition}\n\n"
+ end
+ end
+
+ # Common code block rendering method used by all code block types
+ def render_code_block_common(node)
+ result = ''
+ lang = node.lang || ''
+
+ # Add div wrapper with ID (if node has id)
+ if node.id && !node.id.empty?
+ list_id = normalize_id(node.id)
+ result += %Q(\n\n)
+ end
+
+ # Add caption if present
+ caption = render_caption_inline(node.caption_node)
+ if caption && !caption.empty?
+ result += "**#{caption}**\n\n"
+ end
+
+ # Generate fenced code block
+ result += "```#{lang}\n"
+
+ # Handle line numbers if needed
+ if node.line_numbers
+ code_content = render_children(node).chomp
+ lines = code_content.split("\n")
+ first_line_number = (node.respond_to?(:first_line_number) && node.first_line_number) || 1
+
+ lines.each_with_index do |line, i|
+ line_num = (first_line_number + i).to_s.rjust(3)
+ result += "#{line_num}: #{line}\n"
+ end
+ else
+ code_content = render_children(node)
+ # Remove trailing newline if present to avoid double newlines
+ code_content = code_content.chomp if code_content.end_with?("\n")
+ result += code_content
+ result += "\n"
+ end
+
+ result += "```\n\n"
+
+ # Close div wrapper if added
+ if node.id && !node.id.empty?
+ result += "
\n\n"
+ end
+
+ result
+ end
+
+ # Individual code block type visitors that delegate to common method
+ def visit_code_block_list(node)
+ render_code_block_common(node)
+ end
+
+ def visit_code_block_listnum(node)
+ render_code_block_common(node)
+ end
+
+ def visit_code_block_emlist(node)
+ render_code_block_common(node)
+ end
+
+ def visit_code_block_emlistnum(node)
+ render_code_block_common(node)
+ end
+
+ def visit_code_block_cmd(node)
+ render_code_block_common(node)
+ end
+
+ def visit_code_block_source(node)
+ render_code_block_common(node)
+ end
+
+ def visit_code_line(node)
+ render_children(node) + "\n"
+ end
+
+ def visit_table(node)
+ @table_rows = []
+ @table_header_count = 0
+
+ # Add div wrapper with ID if present
+ id_attr = node.id ? %Q( id="#{normalize_id(node.id)}") : ''
+ result = "\n\n"
+
+ # Add caption if present
+ caption = render_caption_inline(node.caption_node)
+ result += "**#{caption}**\n\n" unless caption.empty?
+
+ # Process table content
+ render_children(node)
+
+ # Generate markdown table
+ if @table_rows.any?
+ result += generate_markdown_table
+ end
+
+ result += "\n
\n\n"
+ result
+ end
+
+ def visit_table_row(node)
+ cells = []
+ node.children.each do |cell|
+ cell_content = render_children(cell).gsub('|', '\\|')
+ # Skip separator rows (rows that contain only dashes)
+ unless /^-+$/.match?(cell_content.strip)
+ cells << cell_content
+ end
+ end
+
+ # Only add non-empty rows
+ if cells.any? { |cell| !cell.strip.empty? }
+ @table_rows << cells
+ @table_header_count = [@table_header_count, cells.length].max if @table_rows.length == 1
+ end
+ ''
+ end
+
+ def visit_table_cell(node)
+ render_children(node)
+ end
+
+ def visit_image(node)
+ # Use node.id as the image path, get path from chapter if image is bound
+ image_path = begin
+ if @chapter&.image_bound?(node.id)
+ @chapter.image(node.id).path
+ else
+ node.id
+ end
+ rescue StandardError
+ # If image lookup fails (e.g., incomplete book structure), use node.id
+ node.id
+ end
+
+ caption = render_caption_inline(node.caption_node)
+
+ # Remove ./ prefix if present
+ image_path = image_path.sub(%r{\A\./}, '')
+
+ # Generate markdown image syntax
+ "\n\n"
+ end
+
+ def visit_minicolumn(node)
+ result = +''
+
+ # Use HTML div for minicolumns as Markdown doesn't have native support
+ css_class = node.minicolumn_type.to_s
+
+ result += %Q(\n\n)
+
+ caption = render_caption_inline(node.caption_node)
+ result += "**#{caption}**\n\n" unless caption.empty?
+
+ result += render_children(node)
+ result += "\n
\n\n"
+
+ result
+ end
+
+ # visit_block is now handled by Base renderer with dynamic method dispatch
+
+ def visit_block_quote(node)
+ content = render_children(node).chomp
+ lines = content.split("\n")
+ quoted_lines = lines.map { |line| "> #{line}" }
+ "#{quoted_lines.join("\n")}\n\n"
+ end
+
+ def visit_block_centering(node)
+ # Use HTML div for centering in Markdown
+ content = render_children(node)
+ "\n\n#{content}\n
\n\n"
+ end
+
+ def visit_block_flushright(node)
+ # Use HTML div for right alignment in Markdown
+ content = render_children(node)
+ "\n\n#{content}\n
\n\n"
+ end
+
+ def visit_block_captionblock(node)
+ # Use HTML div for caption blocks
+ result = %Q(\n\n)
+ result += render_children(node)
+ result += "\n
\n\n"
+ result
+ end
+
+ def visit_embed(node)
+ # Handle //raw and @ commands with target builder specification
+ if node.targeted_for?('markdown')
+ content = node.content || ''
+ # Convert \n to actual newlines
+ content.gsub('\\n', "\n")
+ else
+ ''
+ end
+ end
+
+ def visit_column(node)
+ result = +''
+
+ # Use HTML div for columns as Markdown doesn't have native support
+ css_class = node.column_type.to_s
+
+ result += %Q(\n\n)
+
+ caption = render_caption_inline(node.caption_node)
+ result += "**#{caption}**\n\n" unless caption.empty?
+
+ result += render_children(node)
+ result += "\n
\n\n"
+
+ result
+ end
+
+ def visit_block_lead(node)
+ # Lead paragraphs - render as regular paragraphs in Markdown
+ render_children(node) + "\n"
+ end
+
+ def visit_block_bibpaper(node)
+ # Bibliography entries - render as list items
+ result = +''
+
+ # Get ID and caption
+ bib_id = node.id || ''
+ caption = render_caption_inline(node.caption_node)
+
+ # Format as markdown list item with ID
+ result += "* **[#{bib_id}]** #{caption}\n" unless caption.empty?
+
+ # Add content if any
+ content = render_children(node)
+ result += " #{content.gsub("\n", "\n ")}\n" unless content.strip.empty?
+
+ result + "\n"
+ end
+
+ def visit_block_blankline(_node)
+ # Blank line directive - render as double newline
+ "\n\n"
+ end
+
+ def visit_block_hr(_node)
+ # Horizontal rule - render as Markdown horizontal line
+ "---\n\n"
+ end
+
+ def visit_tex_equation(node)
+ # LaTeX equation block - render as math code block
+ content = node.content.strip
+ result = +''
+
+ if node.id? && node.caption?
+ # With ID and caption
+ caption = render_caption_inline(node.caption_node)
+ result += "**#{caption}**\n\n" unless caption.empty?
+ end
+
+ # Render equation in display math mode ($$...$$)
+ result += "$$\n#{content}\n$$\n\n"
+ result
+ end
+
+ def render_inline_element(type, content, node)
+ method_name = "render_inline_#{type}"
+ if respond_to?(method_name, true)
+ send(method_name, type, content, node)
+ else
+ # Fallback for unknown inline elements: render as plain text
+ # This allows graceful degradation for specialized elements
+ ReVIEW.logger.warn("Unknown inline element: @<#{type}>{...} - rendering as plain text")
+ content
+ end
+ end
+
+ def render_caption_inline(caption_node)
+ return '' unless caption_node
+
+ # Use inline spacing for captions as well
+ content = render_children_with_inline_spacing(caption_node)
+ # Join lines like visit_paragraph does
+ lines = content.split("\n")
+ lines.join(' ')
+ end
+
+ def visit_footnote(node)
+ footnote_id = normalize_id(node.id)
+ content = render_children(node)
+
+ # Use Markdown standard footnote definition notation
+ "[^#{footnote_id}]: #{content}\n\n"
+ end
+
+ def visit_endnote(node)
+ # Endnote definition - treat similar to footnotes
+ endnote_id = node.id
+ content = render_children(node)
+
+ "[^#{endnote_id}]: #{content}\n\n"
+ end
+
+ def visit_block_label(node)
+ # Label definition for cross-references - render as HTML anchor
+ # Label ID is stored in args[0], not in node.id
+ label_id = node.args&.first
+ return '' unless label_id
+
+ " \n\n"
+ end
+
+ def visit_block_printendnotes(_node)
+ # Directive to print endnotes - in Markdown, endnotes are collected automatically
+ # Just output a horizontal rule or section marker
+ "---\n\n**Endnotes**\n\n"
+ end
+
+ def visit_text(node)
+ node.content || ''
+ end
+
+ def visit_reference(node)
+ node.content || ''
+ end
+
+ def render_inline_b(_type, content, _node)
+ "**#{escape_asterisks(content)}**"
+ end
+
+ def render_inline_strong(_type, content, _node)
+ "**#{escape_asterisks(content)}**"
+ end
+
+ def render_inline_i(_type, content, _node)
+ "*#{escape_asterisks(content)}*"
+ end
+
+ def render_inline_em(_type, content, _node)
+ "*#{escape_asterisks(content)}*"
+ end
+
+ def render_inline_code(_type, content, _node)
+ "`#{content}`"
+ end
+
+ def render_inline_tt(_type, content, _node)
+ "`#{content}`"
+ end
+
+ def render_inline_ttb(_type, content, _node)
+ # Bold + monospace: **`content`**
+ "**`#{content}`**"
+ end
+
+ def render_inline_ttbold(type, content, node)
+ # Alias for ttb
+ render_inline_ttb(type, content, node)
+ end
+
+ def render_inline_tti(_type, content, _node)
+ # Italic + monospace: *`content`*
+ "*`#{content}`*"
+ end
+
+ def render_inline_kbd(_type, content, _node)
+ "`#{content}`"
+ end
+
+ def render_inline_samp(_type, content, _node)
+ "`#{content}`"
+ end
+
+ def render_inline_var(_type, content, _node)
+ "*#{escape_asterisks(content)}*"
+ end
+
+ def render_inline_sup(_type, content, _node)
+ "#{escape_content(content)} "
+ end
+
+ def render_inline_sub(_type, content, _node)
+ "#{escape_content(content)} "
+ end
+
+ def render_inline_del(_type, content, _node)
+ "~~#{content}~~"
+ end
+
+ def render_inline_ins(_type, content, _node)
+ "#{escape_content(content)} "
+ end
+
+ def render_inline_u(_type, content, _node)
+ "#{escape_content(content)} "
+ end
+
+ def render_inline_br(_type, _content, _node)
+ "\n"
+ end
+
+ def render_inline_raw(_type, _content, node)
+ node.targeted_for?('markdown') ? (node.content || '') : ''
+ end
+
+ def render_inline_embed(_type, _content, node)
+ node.targeted_for?('markdown') ? (node.content || '') : ''
+ end
+
+ def render_inline_chap(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ chapter_num = text_formatter.format_chapter_number_full(data.chapter_number, data.chapter_type)
+ chapter_id = data.item_id
+
+ # Generate HTML link (same as HtmlRenderer)
+ %Q(#{escape_content(chapter_num.to_s)} )
+ end
+
+ def render_inline_title(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ title = data.chapter_title || ''
+ chapter_id = data.item_id
+
+ # Generate HTML link with title
+ %Q(#{escape_content(title)} )
+ end
+
+ def render_inline_chapref(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ display_str = text_formatter.format_reference(:chapter, data)
+ chapter_id = data.item_id
+
+ # Generate HTML link with full chapter reference
+ %Q(#{escape_content(display_str)} )
+ end
+
+ def render_inline_list(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text = text_formatter.format_reference_text(:list, data)
+ wrap_reference_with_html(text, data, 'listref')
+ end
+
+ def render_inline_img(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text = text_formatter.format_reference_text(:image, data)
+ wrap_reference_with_html(text, data, 'imgref')
+ end
+
+ def render_inline_icon(_type, content, node)
+ if node.args.first
+ image_path = node.args.first
+ image_path = image_path.sub(%r{\A\./}, '')
+ ""
+ else
+ ""
+ end
+ end
+
+ def render_inline_table(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text = text_formatter.format_reference_text(:table, data)
+ wrap_reference_with_html(text, data, 'tableref')
+ end
+
+ def render_inline_fn(_type, _content, node)
+ ref_node = node.children.first
+
+ # Handle both resolved and unresolved references
+ if ref_node.reference_node? && ref_node.resolved?
+ data = ref_node.resolved_data
+ fn_id = normalize_id(data.item_id)
+ elsif ref_node.reference_node?
+ # Unresolved reference - use the ref_id directly
+ fn_id = ref_node.ref_id
+ elsif node.args.any?
+ # Fallback to args if available
+ fn_id = node.args.first
+ end
+
+ # Use Markdown standard footnote notation
+ "[^#{fn_id}]"
+ end
+
+ def render_inline_endnote(_type, content, node)
+ # Endnote references - treat similar to footnotes
+ if node.args.first
+ endnote_id = node.args.first
+ "[^#{endnote_id}]"
+ else
+ "[^#{content}]"
+ end
+ end
+
+ def render_inline_bib(_type, _content, node)
+ # Bibliography reference
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ bib_number = data.item_number
+ # Format as [number] like other builders
+ "[#{bib_number}]"
+ end
+
+ def render_inline_kw(_type, content, node)
+ if node.args.length >= 2
+ word = node.args[0]
+ alt = node.args[1]
+ "**#{escape_asterisks(word)}** (#{escape_content(alt)})"
+ else
+ "**#{escape_asterisks(content)}**"
+ end
+ end
+
+ def render_inline_bou(_type, content, _node)
+ "*#{escape_asterisks(content)}*"
+ end
+
+ def render_inline_ami(_type, content, _node)
+ "*#{escape_asterisks(content)}*"
+ end
+
+ def render_inline_href(_type, content, node)
+ args = node.args || []
+ if args.length >= 2
+ # @{url,text} format
+ url = args[0]
+ text = args[1]
+ "[#{escape_content(text)}](#{url})"
+ elsif args.length == 1
+ # @{url} format - use URL as both text and href
+ url = args[0]
+ "[#{escape_content(url)}](#{url})"
+ else
+ # Fallback to content
+ "[#{escape_content(content)}](#{content})"
+ end
+ end
+
+ def render_inline_ruby(_type, content, node)
+ if node.args.length >= 2
+ base = node.args[0]
+ ruby = node.args[1]
+ "#{escape_content(base)}#{escape_content(ruby)} "
+ else
+ escape_content(content)
+ end
+ end
+
+ def render_inline_m(_type, content, _node)
+ "$$#{content}$$"
+ end
+
+ def render_inline_idx(_type, content, _node)
+ escape_content(content)
+ end
+
+ def render_inline_hidx(_type, _content, _node)
+ ''
+ end
+
+ def render_inline_comment(_type, content, _node)
+ if @book&.config&.[]('draft')
+ ""
+ else
+ ''
+ end
+ end
+
+ def render_inline_hd(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ n = data.headline_number
+ chapter_num = text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+
+ # Render caption with inline markup
+ caption_html = if data.caption_node
+ render_children(data.caption_node)
+ else
+ escape_content(data.caption_text)
+ end
+
+ # Build full section number
+ full_number = if n.present? && chapter_num && !chapter_num.to_s.empty? && over_secnolevel?(n)
+ ([chapter_num] + n).join('.')
+ end
+
+ str = text_formatter.format_headline_quote(full_number, caption_html)
+
+ # Generate HTML link if section number exists
+ if full_number
+ chapter_id = data.chapter_id || @chapter.id
+ anchor = 'h' + full_number.tr('.', '-')
+ %Q(#{str} )
+ else
+ str
+ end
+ end
+
+ def render_inline_column(_type, _content, node)
+ # Column reference
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+
+ # Use TextFormatter to format column reference (e.g., "コラム「タイトル」")
+ text_formatter.format_reference_text(:column, data)
+ end
+
+ def render_inline_sec(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ n = data.headline_number
+ chapter_num = text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+
+ # Build full section number
+ full_number = if n.present? && chapter_num && !chapter_num.to_s.empty? && over_secnolevel?(n)
+ ([chapter_num] + n).join('.')
+ else
+ ''
+ end
+
+ # Generate HTML link if section number exists
+ if full_number.present?
+ chapter_id = data.chapter_id || @chapter.id
+ anchor = 'h' + full_number.tr('.', '-')
+ %Q(#{escape_content(full_number)} )
+ else
+ escape_content(full_number)
+ end
+ end
+
+ def render_inline_secref(_type, _content, node)
+ # secref is usually same as sec
+ render_inline_sec(nil, nil, node)
+ end
+
+ def render_inline_sectitle(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+
+ # Render title with inline markup
+ title_html = if data.caption_node
+ render_children(data.caption_node)
+ else
+ escape_content(data.caption_text)
+ end
+
+ # Generate HTML link
+ n = data.headline_number
+ chapter_num = text_formatter.format_chapter_number_short(data.chapter_number, data.chapter_type)
+ full_number = ([chapter_num] + n).join('.')
+ anchor = 'h' + full_number.tr('.', '-')
+ chapter_id = data.chapter_id || @chapter.id
+ %Q(#{title_html} )
+ end
+
+ def render_inline_labelref(_type, content, _node)
+ escape_content(content)
+ end
+
+ def render_inline_ref(_type, content, _node)
+ escape_content(content)
+ end
+
+ def render_inline_pageref(_type, content, _node)
+ escape_content(content)
+ end
+
+ def render_inline_w(_type, content, _node)
+ # Dictionary lookup for word substitution
+ dictionary = @book&.config&.[]('dictionary') || {}
+ translated = dictionary[content]
+ escape_content(translated || "[missing word: #{content}]")
+ end
+
+ def render_inline_wb(_type, content, _node)
+ # Dictionary lookup with bold formatting
+ dictionary = @book&.config&.[]('dictionary') || {}
+ word_content = dictionary[content] || "[missing word: #{content}]"
+ "**#{escape_asterisks(word_content)}**"
+ end
+
+ def render_inline_uchar(_type, content, _node)
+ # Convert hex code to Unicode character
+ [content.to_i(16)].pack('U').force_encoding('UTF-8')
+ end
+
+ # Helper methods
+ def escape_content(str)
+ escape(str)
+ end
+
+ def escape_asterisks(str)
+ str.gsub('*', '\\*')
+ end
+
+ private
+
+ # Render children with spacing between adjacent inline elements
+ # This prevents Markdown parsing issues when inline elements are adjacent
+ #
+ # Rules:
+ # - Same type adjacent inlines are merged: @{a}@{b} → **ab**
+ # - Different type adjacent inlines get space: @{a}@{b} → **a** *b*
+ def render_children_with_inline_spacing(node)
+ return '' if node.children.empty?
+
+ # Group consecutive inline nodes of the same type
+ groups = group_inline_nodes(node.children)
+
+ result = +''
+ prev_group_was_inline = false
+
+ groups.each do |group|
+ if group[:type] == :inline_group
+ # Add space if previous group was also inline (but different type)
+ result += ' ' if prev_group_was_inline
+
+ # Merge same-type inline nodes and render together
+ merged_content = group[:nodes].map { |n| render_children(n) }.join
+ inline_type = group[:inline_type]
+
+ # Render the merged content as a single inline element
+ result += render_inline_element(inline_type, merged_content, group[:nodes].first)
+
+ prev_group_was_inline = true
+ else
+ # Regular nodes (text, etc.) - just render normally
+ group[:nodes].each do |n|
+ result += visit(n)
+ end
+ prev_group_was_inline = false
+ end
+ end
+
+ result
+ end
+
+ # Group consecutive inline nodes by type
+ # Returns array of groups: [{type: :inline_group, inline_type: 'b', nodes: [...]}, ...]
+ def group_inline_nodes(children)
+ groups = []
+ current_group = nil
+
+ children.each do |child|
+ if child.is_a?(ReVIEW::AST::InlineNode)
+ inline_type = child.inline_type
+
+ # Start new group if type changed or first inline
+ if current_group.nil? || current_group[:type] != :inline_group || current_group[:inline_type] != inline_type
+ # Save previous group if exists
+ groups << current_group if current_group
+
+ # Start new inline group
+ current_group = {
+ type: :inline_group,
+ inline_type: inline_type,
+ nodes: [child]
+ }
+ else
+ # Add to current group (same type)
+ current_group[:nodes] << child
+ end
+ else
+ # Non-inline node
+ # Save previous inline group if exists
+ if current_group && current_group[:type] == :inline_group
+ groups << current_group
+ current_group = nil
+ end
+
+ # Start or continue regular node group
+ if current_group.nil? || current_group[:type] != :regular
+ groups << current_group if current_group
+ current_group = { type: :regular, nodes: [child] }
+ else
+ current_group[:nodes] << child
+ end
+ end
+ end
+
+ # Don't forget the last group
+ groups << current_group if current_group
+
+ groups
+ end
+
+ def generate_markdown_table
+ return '' if @table_rows.empty?
+
+ result = +''
+
+ # Header row
+ header = @table_rows.first
+ result += "| #{header.join(' | ')} |\n"
+
+ # Separator row
+ separators = header.map { ':--' }
+ result += "| #{separators.join(' | ')} |\n"
+
+ # Data rows
+ @table_rows[1..-1]&.each do |row|
+ # Pad row to match header length
+ padded_row = row + ([''] * (@table_header_count - row.length))
+ result += "| #{padded_row.join(' | ')} |\n"
+ end
+
+ result
+ end
+
+ # Get text formatter for reference formatting
+ def text_formatter
+ @text_formatter ||= ReVIEW::TextUtils.new(@chapter)
+ end
+
+ # Wrap reference with HTML span and link (same as HtmlRenderer)
+ def wrap_reference_with_html(text, data, css_class)
+ escaped_text = escape_content(text)
+ chapter_id = data.chapter_id || @chapter&.id
+ item_id = normalize_id(data.item_id)
+
+ # Generate HTML with span and link
+ %Q(#{escaped_text} )
+ end
+
+ # Check if section number should be displayed (based on secnolevel)
+ def over_secnolevel?(n)
+ secnolevel = config['secnolevel'] || 0
+ # Section level = chapter level (1) + n.size
+ section_level = n.is_a?(::Array) ? (1 + n.size) : (1 + n.to_s.split('.').size)
+ secnolevel >= section_level
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/plaintext_renderer.rb b/lib/review/renderer/plaintext_renderer.rb
new file mode 100644
index 000000000..7d4bcf15f
--- /dev/null
+++ b/lib/review/renderer/plaintext_renderer.rb
@@ -0,0 +1,702 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/textutils'
+require 'review/loggable'
+require_relative 'base'
+
+module ReVIEW
+ module Renderer
+ class PlaintextRenderer < Base
+ include ReVIEW::TextUtils
+ include ReVIEW::Loggable
+
+ def initialize(chapter)
+ super
+ @blank_seen = true
+ @ol_num = nil
+ @logger = ReVIEW.logger
+ end
+
+ # Format type for this renderer
+ # @return [Symbol] Format type :text
+ def format_type
+ :text
+ end
+
+ def target_name
+ 'plaintext'
+ end
+
+ def visit_document(node)
+ render_children(node)
+ end
+
+ def visit_headline(node)
+ level = node.level
+ caption = render_caption_inline(node.caption_node)
+
+ # Get headline prefix like PLAINTEXTBuilder
+ prefix = headline_prefix(level)
+ "#{prefix}#{caption}\n"
+ end
+
+ def visit_paragraph(node)
+ content = render_children(node)
+ # Join lines to single paragraph like PLAINTEXTBuilder's join_lines_to_paragraph
+ lines = content.split("\n")
+ result = lines.join
+ "#{result}\n"
+ end
+
+ def visit_list(node)
+ result = +''
+
+ case node.list_type
+ when :ul
+ node.children.each do |item|
+ result += visit_list_item(item, :ul)
+ end
+ when :ol
+ # Reset ol counter
+ @ol_num = node.start_number || 1
+ node.children.each do |item|
+ result += visit_list_item(item, :ol)
+ @ol_num += 1
+ end
+ @ol_num = nil
+ when :dl
+ node.children.each do |item|
+ result += visit_definition_item(item)
+ end
+ else
+ raise NotImplementedError, "PlaintextRenderer does not support list_type #{node.list_type}."
+ end
+
+ "\n#{result}\n"
+ end
+
+ def visit_list_item(node, type = :ul)
+ content = render_children(node)
+ # Remove paragraph newlines and join
+ text = content.gsub(/\n+/, ' ').strip
+
+ case type
+ when :ul
+ "#{text}\n"
+ when :ol
+ "#{@ol_num} #{text}\n"
+ end
+ end
+
+ def visit_definition_item(node)
+ # Handle definition term
+ term = if node.term_children && !node.term_children.empty?
+ node.term_children.map { |child| visit(child) }.join
+ else
+ ''
+ end
+
+ # Handle definition content
+ definition_parts = node.children.map { |child| visit(child) }
+ definition = definition_parts.join.delete("\n")
+
+ "#{term}\n#{definition}\n"
+ end
+
+ # Numbered code block (listnum, emlistnum)
+ def render_numbered_code_block(node)
+ result = +''
+ caption = render_caption_inline(node.caption_node)
+ lines_content = render_children(node)
+
+ lines = lines_content.split("\n")
+ lines.pop if lines.last && lines.last.empty?
+
+ first_line_number = node.first_line_num || 1
+
+ result += "\n" if caption_top?('list') && !caption.empty?
+ result += "#{caption}\n" if caption_top?('list') && !caption.empty?
+ result += "\n" if caption_top?('list') && !caption.empty?
+
+ lines.each_with_index do |line, i|
+ result += "#{(i + first_line_number).to_s.rjust(2)}: #{detab(line)}\n"
+ end
+
+ result += "\n" unless caption_top?('list')
+ result += "#{caption}\n" unless caption_top?('list') || caption.empty?
+ result += "\n"
+
+ result
+ end
+
+ # Regular code block (emlist, cmd, source, etc.)
+ def render_regular_code_block(node)
+ result = +''
+ caption = render_caption_inline(node.caption_node)
+ lines_content = render_children(node)
+
+ result += "\n" if caption_top?('list') && !caption.empty?
+ result += "#{caption}\n" if caption_top?('list') && !caption.empty?
+
+ lines_content.each_line do |line|
+ result += detab(line.chomp) + "\n"
+ end
+
+ result += "#{caption}\n" unless caption_top?('list') || caption.empty?
+ result += "\n"
+
+ result
+ end
+
+ def visit_code_block_list(node)
+ result = +''
+ caption = render_caption_inline(node.caption_node)
+ lines_content = render_children(node)
+
+ result += "\n" if caption_top?('list') && !caption.empty?
+ result += generate_list_header(node.id, caption) + "\n" if caption_top?('list') && !caption.empty?
+ result += "\n" if caption_top?('list') && !caption.empty?
+
+ lines_content.each_line do |line|
+ result += detab(line.chomp) + "\n"
+ end
+
+ result += "\n" unless caption_top?('list')
+ result += generate_list_header(node.id, caption) + "\n" unless caption_top?('list') || caption.empty?
+ result += "\n"
+
+ result
+ end
+
+ def visit_code_block_listnum(node)
+ render_numbered_code_block(node)
+ end
+
+ def visit_code_block_emlist(node)
+ render_regular_code_block(node)
+ end
+
+ def visit_code_block_emlistnum(node)
+ render_numbered_code_block(node)
+ end
+
+ def visit_code_block_cmd(node)
+ render_regular_code_block(node)
+ end
+
+ def visit_code_block_source(node)
+ render_regular_code_block(node)
+ end
+
+ def visit_code_line(node)
+ line_content = render_children(node)
+ # Add newline after each line
+ line_content + "\n"
+ end
+
+ def visit_table(node)
+ result = +''
+
+ # Check if this is an imgtable
+ if node.table_type == :imgtable
+ return render_imgtable(node)
+ end
+
+ # Add caption
+ caption = render_caption_inline(node.caption_node)
+ unless caption.empty?
+ result += "\n"
+ result += if node.id
+ generate_table_header(node.id, caption) + "\n"
+ else
+ "#{caption}\n"
+ end
+ result += "\n" if caption_top?('table')
+ end
+
+ # Process table rows
+ all_rows = node.header_rows + node.body_rows
+ all_rows.each do |row|
+ result += visit_table_row(row)
+ end
+
+ result += "\n" unless caption_top?('table')
+ result += "\n"
+
+ result
+ end
+
+ def visit_table_row(node)
+ cells = node.children.map { |cell| render_children(cell) }
+ cells.join("\t") + "\n"
+ end
+
+ def visit_table_cell(node)
+ render_children(node)
+ end
+
+ def visit_image(node)
+ result = +''
+ caption = render_caption_inline(node.caption_node)
+
+ result += "\n"
+ if node.id && @chapter
+ result += "#{text_formatter.format_caption_plain('image', get_chap, @chapter.image(node.id).number, caption)}\n"
+ else
+ result += "図 #{caption}\n" unless caption.empty?
+ end
+ result += "\n"
+
+ result
+ end
+
+ def visit_minicolumn(node)
+ result = +''
+ caption = render_caption_inline(node.caption_node)
+
+ result += "\n"
+ result += "#{caption}\n" unless caption.empty?
+ result += render_children(node)
+ result += "\n"
+
+ result
+ end
+
+ def visit_column(node)
+ result = +''
+ caption = render_caption_inline(node.caption_node)
+
+ result += "\n"
+ result += "#{caption}\n" unless caption.empty?
+ result += render_children(node)
+ result += "\n"
+
+ result
+ end
+
+ # visit_block is now handled by Base renderer with dynamic method dispatch
+
+ def visit_block_quote(node)
+ result = +"\n"
+ result += render_children(node)
+ result += "\n"
+ result
+ end
+
+ def visit_block_blockquote(node)
+ visit_block_quote(node)
+ end
+
+ def visit_block_comment(_node)
+ # Comments are not rendered in plaintext
+ ''
+ end
+
+ def visit_block_blankline(_node)
+ "\n"
+ end
+
+ def visit_block_pagebreak(_node)
+ # Page breaks are not meaningful in plaintext
+ ''
+ end
+
+ def visit_block_label(_node)
+ # Labels are not rendered
+ ''
+ end
+
+ def visit_block_printendnotes(_node)
+ # Print all endnotes collected in the chapter
+ return '' unless @chapter
+ return '' if @chapter.endnotes.size == 0
+
+ result = +''
+ @chapter.endnotes.each do |en|
+ # Format: (number) content
+ number = en.number
+ content_text = en.content || ''
+ result += "(#{number}) #{content_text}\n"
+ end
+ result
+ end
+
+ def visit_block_tsize(_node)
+ # Table size control is not meaningful in plaintext
+ ''
+ end
+
+ def visit_block_lead(node)
+ result = +"\n"
+ result += render_children(node)
+ result += "\n"
+ result
+ end
+
+ alias_method :visit_block_read, :visit_block_lead
+
+ def visit_block_flushright(node)
+ result = +"\n"
+ result += render_children(node)
+ result += "\n"
+ result
+ end
+
+ def visit_block_centering(node)
+ result = +"\n"
+ result += render_children(node)
+ result += "\n"
+ result
+ end
+
+ def visit_block_bibpaper(node)
+ visit_bibpaper_block(node)
+ end
+
+ def visit_bibpaper_block(node)
+ id = node.args[0]
+ caption_text = node.args[1]
+
+ result = +''
+ if id && @chapter
+ bibpaper_number = @chapter.bibpaper(id).number
+ result += "#{bibpaper_number} "
+ end
+ result += "#{caption_text}\n" if caption_text
+
+ content = render_children(node)
+ result += "#{content}\n" unless content.strip.empty?
+
+ result
+ end
+
+ def visit_generic_block(node)
+ result = +''
+ caption = render_caption_inline(node.caption_node) if node.respond_to?(:caption_node)
+
+ result += "\n"
+ result += "#{caption}\n" if caption && !caption.empty?
+ result += render_children(node)
+ result += "\n"
+
+ result
+ end
+
+ def visit_tex_equation(node)
+ result = +''
+ content = node.content
+
+ result += "\n"
+
+ if node.id? && @chapter
+ caption = render_caption_inline(node.caption_node)
+ result += "#{text_formatter.format_caption_plain('equation', get_chap, @chapter.equation(node.id).number, caption)}\n" if caption_top?('equation')
+ end
+
+ result += "#{content}\n"
+
+ if node.id? && @chapter
+ caption = render_caption_inline(node.caption_node)
+ result += "#{text_formatter.format_caption_plain('equation', get_chap, @chapter.equation(node.id).number, caption)}\n" unless caption_top?('equation')
+ end
+
+ result += "\n"
+ result
+ end
+
+ def render_inline_element(type, content, node)
+ method_name = "render_inline_#{type}"
+ if respond_to?(method_name, true)
+ send(method_name, type, content, node)
+ else
+ # For unknown inline elements (typically reference types), return content as-is
+ # Reference types (list, img, table, hd, column, etc.) have their content already resolved
+ content || ''
+ end
+ end
+
+ def visit_text(node)
+ node.content || ''
+ end
+
+ def visit_reference(node)
+ node.content || ''
+ end
+
+ def visit_footnote(node)
+ footnote_id = node.id
+ content = render_children(node)
+ footnote_number = @chapter&.footnote(footnote_id)&.number || '??'
+
+ "注#{footnote_number} #{content}\n"
+ end
+
+ def visit_embed(node)
+ # Check if content should be output for this renderer
+ return '' unless node.targeted_for?('plaintext') || node.targeted_for?('text')
+
+ # Get content
+ content = node.content || ''
+
+ # Process \n based on embed type
+ case node.embed_type
+ when :inline, :raw
+ # For inline and raw embeds, convert \\n to actual newlines
+ content = content.gsub('\\n', "\n")
+ end
+
+ # For block embeds, add trailing newline
+ node.embed_type == :block ? content + "\n" : content
+ end
+
+ # Inline rendering methods
+ def render_inline_fn(_type, _content, node)
+ fn_id = node.target_item_id
+ return '' unless fn_id && @chapter
+
+ footnote_number = @chapter.footnote(fn_id).number
+ " 注#{footnote_number} "
+ rescue ReVIEW::KeyError
+ ''
+ end
+
+ def render_inline_kw(_type, _content, node)
+ if node.args.length >= 2
+ word = node.args[0]
+ alt = node.args[1].strip
+ "#{word}(#{alt})"
+ else
+ node.args.first || ''
+ end
+ end
+
+ def render_inline_href(_type, _content, node)
+ args = node.args || []
+ if args.length >= 2
+ url = args[0]
+ label = args[1]
+ "#{label}(#{url})"
+ else
+ args.first || ''
+ end
+ end
+
+ def render_inline_ruby(_type, _content, node)
+ # Ruby base text only, ignore ruby annotation
+ node.args.first || ''
+ end
+
+ def render_inline_br(_type, _content, _node)
+ "\n"
+ end
+
+ def render_inline_raw(_type, _content, node)
+ # Convert \n to actual newlines like PLAINTEXTBuilder
+ if node.targeted_for?('plaintext') || node.targeted_for?('text')
+ (node.content || '').gsub('\\n', "\n")
+ else
+ ''
+ end
+ end
+
+ def render_inline_embed(_type, _content, node)
+ # Convert \n to actual newlines like PLAINTEXTBuilder
+ if node.targeted_for?('plaintext') || node.targeted_for?('text')
+ (node.content || '').gsub('\\n', "\n")
+ else
+ ''
+ end
+ end
+
+ def render_inline_hidx(_type, _content, _node)
+ ''
+ end
+
+ def render_inline_icon(_type, _content, _node)
+ ''
+ end
+
+ def render_inline_comment(_type, _content, _node)
+ ''
+ end
+
+ def render_inline_balloon(_type, content, _node)
+ "←#{content}"
+ end
+
+ def render_inline_uchar(_type, content, _node)
+ [content.to_i(16)].pack('U')
+ end
+
+ def render_inline_bib(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ data.item_number.to_s
+ end
+
+ def render_inline_hd(_type, _content, node)
+ # Headline reference
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text_formatter.format_reference(:headline, data)
+ end
+
+ def render_inline_labelref(_type, _content, _node)
+ '●'
+ end
+
+ alias_method :render_inline_ref, :render_inline_labelref
+
+ def render_inline_pageref(_type, _content, _node)
+ '●ページ'
+ end
+
+ def render_inline_chap(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text_formatter.format_chapter_number_full(data.chapter_number, data.chapter_type).to_s
+ end
+
+ def render_inline_chapref(_type, _content, node)
+ ref_node = node.children.first
+ unless ref_node.reference_node? && ref_node.resolved?
+ raise 'BUG: Reference should be resolved at AST construction time'
+ end
+
+ data = ref_node.resolved_data
+ text_formatter.format_reference(:chapter, data)
+ end
+
+ def render_inline_default(_type, content, _node)
+ content
+ end
+
+ # Default inline rendering - just return content
+ alias_method :render_inline_b, :render_inline_default
+ alias_method :render_inline_strong, :render_inline_default
+ alias_method :render_inline_i, :render_inline_default
+ alias_method :render_inline_em, :render_inline_default
+ alias_method :render_inline_tt, :render_inline_default
+ alias_method :render_inline_code, :render_inline_default
+ alias_method :render_inline_ttb, :render_inline_default
+ alias_method :render_inline_ttbold, :render_inline_default
+ alias_method :render_inline_tti, :render_inline_default
+ alias_method :render_inline_ttibold, :render_inline_default
+ alias_method :render_inline_u, :render_inline_default
+ alias_method :render_inline_bou, :render_inline_default
+ alias_method :render_inline_keytop, :render_inline_default
+ alias_method :render_inline_m, :render_inline_default
+ alias_method :render_inline_ami, :render_inline_default
+ alias_method :render_inline_sup, :render_inline_default
+ alias_method :render_inline_sub, :render_inline_default
+ alias_method :render_inline_hint, :render_inline_default
+ alias_method :render_inline_maru, :render_inline_default
+ alias_method :render_inline_idx, :render_inline_default
+ alias_method :render_inline_ins, :render_inline_default
+ alias_method :render_inline_del, :render_inline_default
+ alias_method :render_inline_tcy, :render_inline_default
+
+ # Helper methods
+ def render_caption_inline(caption_node)
+ return '' unless caption_node
+
+ content = render_children(caption_node)
+ # Join lines like visit_paragraph does
+ lines = content.split("\n")
+ lines.join
+ end
+
+ def headline_prefix(level)
+ return '' unless @chapter
+ return '' unless config['secnolevel'] && config['secnolevel'] > 0
+
+ # Generate headline prefix like PLAINTEXTBuilder
+ case level
+ when 1
+ if @chapter.number
+ "第#{@chapter.number}章 "
+ else
+ ''
+ end
+ when 2, 3, 4, 5
+ # For subsections, use section counter if available
+ ''
+ else # rubocop:disable Lint/DuplicateBranch
+ ''
+ end
+ end
+
+ def generate_list_header(id, caption)
+ return caption unless id && @chapter
+
+ list_item = @chapter.list(id)
+ text_formatter.format_caption_plain('list', get_chap, list_item.number, caption)
+ rescue ReVIEW::KeyError
+ caption
+ end
+
+ def generate_table_header(id, caption)
+ return caption unless id && @chapter
+
+ table_item = @chapter.table(id)
+ text_formatter.format_caption_plain('table', get_chap, table_item.number, caption)
+ rescue ReVIEW::KeyError
+ caption
+ end
+
+ def render_imgtable(node)
+ result = +''
+ caption = render_caption_inline(node.caption_node)
+
+ result += "\n"
+ if node.id && !caption.empty?
+ result += generate_table_header(node.id, caption) + "\n"
+ result += "\n"
+ end
+ result += "\n"
+
+ result
+ end
+
+ def get_chap(chapter = @chapter)
+ return nil unless chapter
+ return nil unless config['secnolevel'] && config['secnolevel'] > 0
+ return nil if chapter.number.nil? || chapter.number.to_s.empty?
+
+ if chapter.is_a?(ReVIEW::Book::Part)
+ text_formatter.format_part_short(chapter)
+ else
+ chapter.format_number(nil)
+ end
+ end
+
+ def over_secnolevel?(n, _chapter = @chapter)
+ secnolevel = config['secnolevel'] || 0
+ secnolevel >= n.to_s.split('.').size
+ end
+
+ def escape(str)
+ # Plaintext doesn't need escaping
+ str.to_s
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/rendering_context.rb b/lib/review/renderer/rendering_context.rb
new file mode 100644
index 000000000..93c6c20a1
--- /dev/null
+++ b/lib/review/renderer/rendering_context.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require_relative 'footnote_collector'
+
+module ReVIEW
+ module Renderer
+ # RenderingContext - Manages rendering state and context for AST renderers
+ #
+ # This class provides automatic scope management for rendering contexts,
+ # replacing the manual @doc_status flag management with a cleaner,
+ # context-aware approach.
+ #
+ # Key responsibilities:
+ # - Track current rendering context (table, caption, minicolumn, etc.)
+ # - Manage parent-child context relationships for nested structures
+ # - Determine when footnotes require special handling (footnotetext vs footnote)
+ # - Collect and process footnotes within problematic contexts
+ # - Provide automatic cleanup when contexts end
+ class RenderingContext
+ attr_reader :context_type, :parent_context, :footnote_collector
+
+ # Context types that require footnotetext instead of direct footnote
+ FOOTNOTETEXT_REQUIRED_CONTEXTS = %i[table caption minicolumn column dt].freeze
+
+ def initialize(context_type, parent_context = nil)
+ @context_type = context_type
+ @parent_context = parent_context
+ @footnote_collector = FootnoteCollector.new
+ end
+
+ # Determines if footnotes in this context require footnotetext handling
+ # @return [Boolean] true if footnotetext is required
+ def requires_footnotetext?
+ footnotetext_context? || parent_requires_footnotetext?
+ end
+
+ # Check if this specific context requires footnotetext
+ # @return [Boolean] true if this context type requires footnotetext
+ def footnotetext_context?
+ FOOTNOTETEXT_REQUIRED_CONTEXTS.include?(@context_type)
+ end
+
+ # Create and yield a child context, ensuring proper cleanup
+ # @param child_type [Symbol] the type of child context
+ # @yield [RenderingContext] the child context
+ # @return [Object] the result of the block
+ def with_child_context(child_type)
+ child_context = RenderingContext.new(child_type, self)
+
+ yield(child_context)
+ end
+
+ # Add a footnote to this context's collector
+ # @param footnote_node [AST::FootnoteNode] the footnote node
+ # @param footnote_number [Integer] the footnote number
+ def collect_footnote(footnote_node, footnote_number)
+ @footnote_collector.add(footnote_node, footnote_number)
+ end
+
+ # Get a string representation for debugging
+ # @return [String] string representation
+ def to_s
+ context_chain = ancestors.map(&:context_type)
+ "RenderingContext[#{context_chain.join(' > ')}]"
+ end
+
+ # Get all ancestors (including self) in order from root to current
+ # @return [Array] array of contexts
+ def ancestors
+ Enumerator.produce(self, &:parent_context).take_while(&:itself).reverse
+ end
+
+ private
+
+ # Check if parent context requires footnotetext
+ # @return [Boolean] true if any parent requires footnotetext
+ def parent_requires_footnotetext?
+ @parent_context&.requires_footnotetext? || false
+ end
+ end
+ end
+end
diff --git a/lib/review/renderer/text_formatter.rb b/lib/review/renderer/text_formatter.rb
new file mode 100644
index 000000000..5920347f4
--- /dev/null
+++ b/lib/review/renderer/text_formatter.rb
@@ -0,0 +1,512 @@
+# frozen_string_literal: true
+
+# Copyright (c) 2025 Kenshi Muto, Masayoshi Takahashi
+#
+# This program is free software.
+# You can distribute or modify this program under the terms of
+# the GNU LGPL, Lesser General Public License version 2.1.
+
+require 'review/i18n'
+
+module ReVIEW
+ module Renderer
+ # TextFormatter - Centralized text formatting and I18n service
+ #
+ # This class consolidates all text formatting and internationalization logic
+ # that was previously scattered across Renderer, InlineElementHandler, Formatter,
+ # and ResolvedData classes.
+ #
+ # Design principles:
+ # - Single responsibility: All I18n and text generation in one place
+ # - Format-agnostic core with format-specific decorations
+ # - Reusable from Renderer, InlineElementHandler, and ResolvedData
+ class TextFormatter
+ attr_reader :config, :chapter
+
+ # Initialize formatter
+ # @param config [Hash] Configuration hash
+ # @param chapter [Chapter, nil] Current chapter (optional, used for HTML reference links)
+ def initialize(config:, chapter: nil)
+ @config = config
+ @chapter = chapter
+ end
+
+ # Format a numbered item's caption for HTML/LaTeX (e.g., "図1.1: キャプション")
+ # Uses format_number_header (with colon) + caption_prefix
+ # @param label_key [String] I18n key for the label (e.g., 'image', 'table', 'list')
+ # @param chapter_number [String, nil] Chapter number (e.g., "第1章")
+ # @param item_number [Integer] Item number within chapter
+ # @param caption_text [String, nil] Caption text
+ # @return [String] Formatted caption
+ def format_caption(label_key, chapter_number, item_number, caption_text = nil)
+ label = I18n.t(label_key)
+ number_text = format_number_header(chapter_number, item_number)
+ separator = I18n.t('caption_prefix')
+
+ base = "#{label}#{number_text}"
+ return base if caption_text.nil? || caption_text.empty?
+
+ "#{base}#{separator}#{caption_text}"
+ end
+
+ # Format a numbered item's caption for IDGXML/TOP/TEXT (e.g., "図1.1 キャプション")
+ # Uses format_number (without colon) + caption_separator
+ # @param label_key [String] I18n key for the label (e.g., 'image', 'table', 'list')
+ # @param chapter_number [String, nil] Chapter number (e.g., "第1章")
+ # @param item_number [Integer] Item number within chapter
+ # @param caption_text [String, nil] Caption text
+ # @return [String] Formatted caption
+ def format_caption_plain(label_key, chapter_number, item_number, caption_text = nil)
+ label = I18n.t(label_key)
+ number_text = format_number(chapter_number, item_number)
+ separator = caption_separator
+
+ base = "#{label}#{number_text}"
+ return base if caption_text.nil? || caption_text.empty?
+
+ "#{base}#{separator}#{caption_text}"
+ end
+
+ # Format just the number part (e.g., "1.1" or "1")
+ # @param chapter_number [String, nil] Chapter number
+ # @param item_number [Integer] Item number
+ # @return [String] Formatted number
+ def format_number(chapter_number, item_number)
+ if chapter_number && !chapter_number.to_s.empty?
+ I18n.t('format_number', [chapter_number, item_number])
+ else
+ I18n.t('format_number_without_chapter', [item_number])
+ end
+ end
+
+ # Format number for caption header (HTML/LaTeX style)
+ # Used in block elements (//image, //table, //list, //equation) caption headers
+ # @param chapter_number [String, nil] Chapter number
+ # @param item_number [Integer] Item number
+ # @return [String] Formatted number for header
+ def format_number_header(chapter_number, item_number)
+ if chapter_number && !chapter_number.to_s.empty?
+ I18n.t('format_number_header', [chapter_number, item_number])
+ else
+ I18n.t('format_number_header_without_chapter', [item_number])
+ end
+ end
+
+ # Format a reference as plain text (without format-specific decorations)
+ # This method returns pure text suitable for wrapping with HTML tags, LaTeX commands, etc.
+ # @param type [Symbol] Reference type (:image, :table, :list, :equation, etc.)
+ # @param data [ResolvedData] Resolved reference data
+ # @return [String] Plain text reference (e.g., "図1.1", "表2.3")
+ def format_reference_text(type, data)
+ case type
+ when :image
+ format_numbered_reference_text('image', data)
+ when :table
+ format_numbered_reference_text('table', data)
+ when :list
+ format_numbered_reference_text('list', data)
+ when :equation
+ format_numbered_reference_text('equation', data)
+ when :footnote
+ format_footnote_reference_text(data)
+ when :endnote
+ format_endnote_reference_text(data)
+ when :chapter
+ format_chapter_reference_text(data)
+ when :headline
+ format_headline_reference_text(data)
+ when :column
+ format_column_reference_text(data)
+ when :bibpaper
+ format_bibpaper_reference_text(data)
+ when :word
+ data.word_content.to_s
+ else
+ raise ArgumentError, "Unknown reference type: #{type}"
+ end
+ end
+
+ # Format a reference to an item (with format-specific decorations)
+ # Used by LaTeX, IDGXML, TOP, and TEXT renderers
+ # @param type [Symbol] Reference type (:image, :table, :list, :equation, etc.)
+ # @param data [ResolvedData] Resolved reference data
+ # @return [String] Formatted reference
+ def format_reference(type, data)
+ case type
+ when :image
+ format_image_reference(data)
+ when :table
+ format_table_reference(data)
+ when :list
+ format_list_reference(data)
+ when :equation
+ format_equation_reference(data)
+ when :footnote
+ format_footnote_reference(data)
+ when :endnote
+ format_endnote_reference(data)
+ when :chapter
+ format_chapter_reference(data)
+ when :headline
+ format_headline_reference(data)
+ when :column
+ format_column_reference(data)
+ when :bibpaper
+ format_bibpaper_reference(data)
+ when :word
+ format_word_reference(data)
+ else
+ raise ArgumentError, "Unknown reference type: #{type}"
+ end
+ end
+
+ # Format chapter number with I18n (long form, e.g., "第1章", "Appendix A", "Part I")
+ # Used for @, @, @