summaryrefslogtreecommitdiffstats
path: root/sphinx/builders/latex/transforms.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/builders/latex/transforms.py')
-rw-r--r--sphinx/builders/latex/transforms.py642
1 files changed, 642 insertions, 0 deletions
diff --git a/sphinx/builders/latex/transforms.py b/sphinx/builders/latex/transforms.py
new file mode 100644
index 0000000..ca1e4f3
--- /dev/null
+++ b/sphinx/builders/latex/transforms.py
@@ -0,0 +1,642 @@
+"""Transforms for LaTeX builder."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, cast
+
+from docutils import nodes
+from docutils.transforms.references import Substitutions
+
+from sphinx import addnodes
+from sphinx.builders.latex.nodes import (
+ captioned_literal_block,
+ footnotemark,
+ footnotetext,
+ math_reference,
+ thebibliography,
+)
+from sphinx.domains.citation import CitationDomain
+from sphinx.locale import __
+from sphinx.transforms import SphinxTransform
+from sphinx.transforms.post_transforms import SphinxPostTransform
+from sphinx.util.nodes import NodeMatcher
+
+if TYPE_CHECKING:
+ from docutils.nodes import Element, Node
+
+ from sphinx.application import Sphinx
+
+URI_SCHEMES = ('mailto:', 'http:', 'https:', 'ftp:')
+
+
+class FootnoteDocnameUpdater(SphinxTransform):
+ """Add docname to footnote and footnote_reference nodes."""
+ default_priority = 700
+ TARGET_NODES = (nodes.footnote, nodes.footnote_reference)
+
+ def apply(self, **kwargs: Any) -> None:
+ matcher = NodeMatcher(*self.TARGET_NODES)
+ for node in self.document.findall(matcher): # type: Element
+ node['docname'] = self.env.docname
+
+
+class SubstitutionDefinitionsRemover(SphinxPostTransform):
+ """Remove ``substitution_definition`` nodes from doctrees."""
+
+ # should be invoked after Substitutions process
+ default_priority = Substitutions.default_priority + 1
+ formats = ('latex',)
+
+ def run(self, **kwargs: Any) -> None:
+ for node in list(self.document.findall(nodes.substitution_definition)):
+ node.parent.remove(node)
+
+
+class ShowUrlsTransform(SphinxPostTransform):
+ """Expand references to inline text or footnotes.
+
+ For more information, see :confval:`latex_show_urls`.
+
+ .. note:: This transform is used for integrated doctree
+ """
+ default_priority = 400
+ formats = ('latex',)
+
+ # references are expanded to footnotes (or not)
+ expanded = False
+
+ def run(self, **kwargs: Any) -> None:
+ try:
+ # replace id_prefix temporarily
+ settings: Any = self.document.settings
+ id_prefix = settings.id_prefix
+ settings.id_prefix = 'show_urls'
+
+ self.expand_show_urls()
+ if self.expanded:
+ self.renumber_footnotes()
+ finally:
+ # restore id_prefix
+ settings.id_prefix = id_prefix
+
+ def expand_show_urls(self) -> None:
+ show_urls = self.config.latex_show_urls
+ if show_urls is False or show_urls == 'no':
+ return
+
+ for node in list(self.document.findall(nodes.reference)):
+ uri = node.get('refuri', '')
+ if uri.startswith(URI_SCHEMES):
+ if uri.startswith('mailto:'):
+ uri = uri[7:]
+ if node.astext() != uri:
+ index = node.parent.index(node)
+ docname = self.get_docname_for_node(node)
+ if show_urls == 'footnote':
+ fn, fnref = self.create_footnote(uri, docname)
+ node.parent.insert(index + 1, fn)
+ node.parent.insert(index + 2, fnref)
+
+ self.expanded = True
+ else: # all other true values (b/w compat)
+ textnode = nodes.Text(" (%s)" % uri)
+ node.parent.insert(index + 1, textnode)
+
+ def get_docname_for_node(self, node: Node) -> str:
+ while node:
+ if isinstance(node, nodes.document):
+ return self.env.path2doc(node['source']) or ''
+ elif isinstance(node, addnodes.start_of_file):
+ return node['docname']
+ else:
+ node = node.parent
+
+ try:
+ source = node['source'] # type: ignore[index]
+ except TypeError:
+ raise ValueError(__('Failed to get a docname!')) from None
+ raise ValueError(__('Failed to get a docname '
+ 'for source {source!r}!').format(source=source))
+
+ def create_footnote(
+ self, uri: str, docname: str,
+ ) -> tuple[nodes.footnote, nodes.footnote_reference]:
+ reference = nodes.reference('', nodes.Text(uri), refuri=uri, nolinkurl=True)
+ footnote = nodes.footnote(uri, auto=1, docname=docname)
+ footnote['names'].append('#')
+ footnote += nodes.label('', '#')
+ footnote += nodes.paragraph('', '', reference)
+ self.document.note_autofootnote(footnote)
+
+ footnote_ref = nodes.footnote_reference('[#]_', auto=1,
+ refid=footnote['ids'][0], docname=docname)
+ footnote_ref += nodes.Text('#')
+ self.document.note_autofootnote_ref(footnote_ref)
+ footnote.add_backref(footnote_ref['ids'][0])
+
+ return footnote, footnote_ref
+
+ def renumber_footnotes(self) -> None:
+ collector = FootnoteCollector(self.document)
+ self.document.walkabout(collector)
+
+ num = 0
+ for footnote in collector.auto_footnotes:
+ # search unused footnote number
+ while True:
+ num += 1
+ if str(num) not in collector.used_footnote_numbers:
+ break
+
+ # assign new footnote number
+ old_label = cast(nodes.label, footnote[0])
+ old_label.replace_self(nodes.label('', str(num)))
+ if old_label in footnote['names']:
+ footnote['names'].remove(old_label.astext())
+ footnote['names'].append(str(num))
+
+ # update footnote_references by new footnote number
+ docname = footnote['docname']
+ for ref in collector.footnote_refs:
+ if docname == ref['docname'] and footnote['ids'][0] == ref['refid']:
+ ref.remove(ref[0])
+ ref += nodes.Text(str(num))
+
+
+class FootnoteCollector(nodes.NodeVisitor):
+ """Collect footnotes and footnote references on the document"""
+
+ def __init__(self, document: nodes.document) -> None:
+ self.auto_footnotes: list[nodes.footnote] = []
+ self.used_footnote_numbers: set[str] = set()
+ self.footnote_refs: list[nodes.footnote_reference] = []
+ super().__init__(document)
+
+ def unknown_visit(self, node: Node) -> None:
+ pass
+
+ def unknown_departure(self, node: Node) -> None:
+ pass
+
+ def visit_footnote(self, node: nodes.footnote) -> None:
+ if node.get('auto'):
+ self.auto_footnotes.append(node)
+ else:
+ for name in node['names']:
+ self.used_footnote_numbers.add(name)
+
+ def visit_footnote_reference(self, node: nodes.footnote_reference) -> None:
+ self.footnote_refs.append(node)
+
+
+class LaTeXFootnoteTransform(SphinxPostTransform):
+ """Convert footnote definitions and references to appropriate form to LaTeX.
+
+ * Replace footnotes on restricted zone (e.g. headings) by footnotemark node.
+ In addition, append a footnotetext node after the zone.
+
+ Before::
+
+ <section>
+ <title>
+ headings having footnotes
+ <footnote_reference>
+ 1
+ <footnote ids="id1">
+ <label>
+ 1
+ <paragraph>
+ footnote body
+
+ After::
+
+ <section>
+ <title>
+ headings having footnotes
+ <footnotemark refid="id1">
+ 1
+ <footnotetext ids="id1">
+ <label>
+ 1
+ <paragraph>
+ footnote body
+
+ * Integrate footnote definitions and footnote references to single footnote node
+
+ Before::
+
+ blah blah blah
+ <footnote_reference refid="id1">
+ 1
+ blah blah blah ...
+
+ <footnote ids="id1">
+ <label>
+ 1
+ <paragraph>
+ footnote body
+
+ After::
+
+ blah blah blah
+ <footnote ids="id1">
+ <label>
+ 1
+ <paragraph>
+ footnote body
+ blah blah blah ...
+
+ * Replace second and subsequent footnote references which refers same footnote definition
+ by footnotemark node. Additionally, the footnote definition node is marked as
+ "referred".
+
+ Before::
+
+ blah blah blah
+ <footnote_reference refid="id1">
+ 1
+ blah blah blah
+ <footnote_reference refid="id1">
+ 1
+ blah blah blah ...
+
+ <footnote ids="id1">
+ <label>
+ 1
+ <paragraph>
+ footnote body
+
+ After::
+
+ blah blah blah
+ <footnote ids="id1" referred=True>
+ <label>
+ 1
+ <paragraph>
+ footnote body
+ blah blah blah
+ <footnotemark refid="id1">
+ 1
+ blah blah blah ...
+
+ * Remove unreferenced footnotes
+
+ Before::
+
+ <footnote ids="id1">
+ <label>
+ 1
+ <paragraph>
+ Unreferenced footnote!
+
+ After::
+
+ <!-- nothing! -->
+
+ * Move footnotes in a title of table or thead to head of tbody
+
+ Before::
+
+ <table>
+ <title>
+ title having footnote_reference
+ <footnote_reference refid="id1">
+ 1
+ <tgroup>
+ <thead>
+ <row>
+ <entry>
+ header having footnote_reference
+ <footnote_reference refid="id2">
+ 2
+ <tbody>
+ <row>
+ ...
+
+ <footnote ids="id1">
+ <label>
+ 1
+ <paragraph>
+ footnote body
+
+ <footnote ids="id2">
+ <label>
+ 2
+ <paragraph>
+ footnote body
+
+ After::
+
+ <table>
+ <title>
+ title having footnote_reference
+ <footnotemark refid="id1">
+ 1
+ <tgroup>
+ <thead>
+ <row>
+ <entry>
+ header having footnote_reference
+ <footnotemark refid="id2">
+ 2
+ <tbody>
+ <footnotetext ids="id1">
+ <label>
+ 1
+ <paragraph>
+ footnote body
+
+ <footnotetext ids="id2">
+ <label>
+ 2
+ <paragraph>
+ footnote body
+ <row>
+ ...
+ """
+
+ default_priority = 600
+ formats = ('latex',)
+
+ def run(self, **kwargs: Any) -> None:
+ footnotes = list(self.document.findall(nodes.footnote))
+ for node in footnotes:
+ node.parent.remove(node)
+
+ visitor = LaTeXFootnoteVisitor(self.document, footnotes)
+ self.document.walkabout(visitor)
+
+
+class LaTeXFootnoteVisitor(nodes.NodeVisitor):
+ def __init__(self, document: nodes.document, footnotes: list[nodes.footnote]) -> None:
+ self.appeared: dict[tuple[str, str], nodes.footnote] = {}
+ self.footnotes: list[nodes.footnote] = footnotes
+ self.pendings: list[nodes.footnote] = []
+ self.table_footnotes: list[nodes.footnote] = []
+ self.restricted: Element | None = None
+ super().__init__(document)
+
+ def unknown_visit(self, node: Node) -> None:
+ pass
+
+ def unknown_departure(self, node: Node) -> None:
+ pass
+
+ def restrict(self, node: Element) -> None:
+ if self.restricted is None:
+ self.restricted = node
+
+ def unrestrict(self, node: Element) -> None:
+ if self.restricted == node:
+ self.restricted = None
+ pos = node.parent.index(node)
+ for i, footnote, in enumerate(self.pendings):
+ fntext = footnotetext('', *footnote.children, ids=footnote['ids'])
+ node.parent.insert(pos + i + 1, fntext)
+ self.pendings = []
+
+ def visit_figure(self, node: nodes.figure) -> None:
+ self.restrict(node)
+
+ def depart_figure(self, node: nodes.figure) -> None:
+ self.unrestrict(node)
+
+ def visit_term(self, node: nodes.term) -> None:
+ self.restrict(node)
+
+ def depart_term(self, node: nodes.term) -> None:
+ self.unrestrict(node)
+
+ def visit_caption(self, node: nodes.caption) -> None:
+ self.restrict(node)
+
+ def depart_caption(self, node: nodes.caption) -> None:
+ self.unrestrict(node)
+
+ def visit_title(self, node: nodes.title) -> None:
+ if isinstance(node.parent, (nodes.section, nodes.table)):
+ self.restrict(node)
+
+ def depart_title(self, node: nodes.title) -> None:
+ if isinstance(node.parent, nodes.section):
+ self.unrestrict(node)
+ elif isinstance(node.parent, nodes.table):
+ self.table_footnotes += self.pendings
+ self.pendings = []
+ self.unrestrict(node)
+
+ def visit_thead(self, node: nodes.thead) -> None:
+ self.restrict(node)
+
+ def depart_thead(self, node: nodes.thead) -> None:
+ self.table_footnotes += self.pendings
+ self.pendings = []
+ self.unrestrict(node)
+
+ def depart_table(self, node: nodes.table) -> None:
+ tbody = next(node.findall(nodes.tbody))
+ for footnote in reversed(self.table_footnotes):
+ fntext = footnotetext('', *footnote.children, ids=footnote['ids'])
+ tbody.insert(0, fntext)
+
+ self.table_footnotes = []
+
+ def visit_footnote(self, node: nodes.footnote) -> None:
+ self.restrict(node)
+
+ def depart_footnote(self, node: nodes.footnote) -> None:
+ self.unrestrict(node)
+
+ def visit_footnote_reference(self, node: nodes.footnote_reference) -> None:
+ number = node.astext().strip()
+ docname = node['docname']
+ if (docname, number) in self.appeared:
+ footnote = self.appeared[(docname, number)]
+ footnote["referred"] = True
+
+ mark = footnotemark('', number, refid=node['refid'])
+ node.replace_self(mark)
+ else:
+ footnote = self.get_footnote_by_reference(node)
+ if self.restricted:
+ mark = footnotemark('', number, refid=node['refid'])
+ node.replace_self(mark)
+ self.pendings.append(footnote)
+ else:
+ self.footnotes.remove(footnote)
+ node.replace_self(footnote)
+ footnote.walkabout(self)
+
+ self.appeared[(docname, number)] = footnote
+ raise nodes.SkipNode
+
+ def get_footnote_by_reference(self, node: nodes.footnote_reference) -> nodes.footnote:
+ docname = node['docname']
+ for footnote in self.footnotes:
+ if docname == footnote['docname'] and footnote['ids'][0] == node['refid']:
+ return footnote
+
+ raise ValueError(__('No footnote was found for given reference node %r') % node)
+
+
+class BibliographyTransform(SphinxPostTransform):
+ """Gather bibliography entries to tail of document.
+
+ Before::
+
+ <document>
+ <paragraph>
+ blah blah blah
+ <citation>
+ ...
+ <paragraph>
+ blah blah blah
+ <citation>
+ ...
+ ...
+
+ After::
+
+ <document>
+ <paragraph>
+ blah blah blah
+ <paragraph>
+ blah blah blah
+ ...
+ <thebibliography>
+ <citation>
+ ...
+ <citation>
+ ...
+ """
+ default_priority = 750
+ formats = ('latex',)
+
+ def run(self, **kwargs: Any) -> None:
+ citations = thebibliography()
+ for node in list(self.document.findall(nodes.citation)):
+ node.parent.remove(node)
+ citations += node
+
+ if len(citations) > 0:
+ self.document += citations
+
+
+class CitationReferenceTransform(SphinxPostTransform):
+ """Replace pending_xref nodes for citation by citation_reference.
+
+ To handle citation reference easily on LaTeX writer, this converts
+ pending_xref nodes to citation_reference.
+ """
+ default_priority = 5 # before ReferencesResolver
+ formats = ('latex',)
+
+ def run(self, **kwargs: Any) -> None:
+ domain = cast(CitationDomain, self.env.get_domain('citation'))
+ matcher = NodeMatcher(addnodes.pending_xref, refdomain='citation', reftype='ref')
+ for node in self.document.findall(matcher): # type: addnodes.pending_xref
+ docname, labelid, _ = domain.citations.get(node['reftarget'], ('', '', 0))
+ if docname:
+ citation_ref = nodes.citation_reference('', '', *node.children,
+ docname=docname, refname=labelid)
+ node.replace_self(citation_ref)
+
+
+class MathReferenceTransform(SphinxPostTransform):
+ """Replace pending_xref nodes for math by math_reference.
+
+ To handle math reference easily on LaTeX writer, this converts pending_xref
+ nodes to math_reference.
+ """
+ default_priority = 5 # before ReferencesResolver
+ formats = ('latex',)
+
+ def run(self, **kwargs: Any) -> None:
+ equations = self.env.get_domain('math').data['objects']
+ for node in self.document.findall(addnodes.pending_xref):
+ if node['refdomain'] == 'math' and node['reftype'] in ('eq', 'numref'):
+ docname, _ = equations.get(node['reftarget'], (None, None))
+ if docname:
+ refnode = math_reference('', docname=docname, target=node['reftarget'])
+ node.replace_self(refnode)
+
+
+class LiteralBlockTransform(SphinxPostTransform):
+ """Replace container nodes for literal_block by captioned_literal_block."""
+ default_priority = 400
+ formats = ('latex',)
+
+ def run(self, **kwargs: Any) -> None:
+ matcher = NodeMatcher(nodes.container, literal_block=True)
+ for node in self.document.findall(matcher): # type: nodes.container
+ newnode = captioned_literal_block('', *node.children, **node.attributes)
+ node.replace_self(newnode)
+
+
+class DocumentTargetTransform(SphinxPostTransform):
+ """Add :doc label to the first section of each document."""
+ default_priority = 400
+ formats = ('latex',)
+
+ def run(self, **kwargs: Any) -> None:
+ for node in self.document.findall(addnodes.start_of_file):
+ section = node.next_node(nodes.section)
+ if section:
+ section['ids'].append(':doc') # special label for :doc:
+
+
+class IndexInSectionTitleTransform(SphinxPostTransform):
+ """Move index nodes in section title to outside of the title.
+
+ LaTeX index macro is not compatible with some handling of section titles
+ such as uppercasing done on LaTeX side (cf. fncychap handling of ``\\chapter``).
+ Moving the index node to after the title node fixes that.
+
+ Before::
+
+ <section>
+ <title>
+ blah blah <index entries=[...]/>blah
+ <paragraph>
+ blah blah blah
+ ...
+
+ After::
+
+ <section>
+ <title>
+ blah blah blah
+ <index entries=[...]/>
+ <paragraph>
+ blah blah blah
+ ...
+ """
+ default_priority = 400
+ formats = ('latex',)
+
+ def run(self, **kwargs: Any) -> None:
+ for node in list(self.document.findall(nodes.title)):
+ if isinstance(node.parent, nodes.section):
+ for i, index in enumerate(node.findall(addnodes.index)):
+ # move the index node next to the section title
+ node.remove(index)
+ node.parent.insert(i + 1, index)
+
+
+def setup(app: Sphinx) -> dict[str, Any]:
+ app.add_transform(FootnoteDocnameUpdater)
+ app.add_post_transform(SubstitutionDefinitionsRemover)
+ app.add_post_transform(BibliographyTransform)
+ app.add_post_transform(CitationReferenceTransform)
+ app.add_post_transform(DocumentTargetTransform)
+ app.add_post_transform(IndexInSectionTitleTransform)
+ app.add_post_transform(LaTeXFootnoteTransform)
+ app.add_post_transform(LiteralBlockTransform)
+ app.add_post_transform(MathReferenceTransform)
+ app.add_post_transform(ShowUrlsTransform)
+
+ return {
+ 'version': 'builtin',
+ 'parallel_read_safe': True,
+ 'parallel_write_safe': True,
+ }