diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index 3a0d9fd62..fe5b44c3d 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -19,7 +19,9 @@ from six import text_type from sphinx import package_dir, addnodes, highlighting from sphinx.builders import Builder -from sphinx.builders.latex.transforms import FootnoteDocnameUpdater, ShowUrlsTransform +from sphinx.builders.latex.transforms import ( + FootnoteDocnameUpdater, LaTeXFootnoteTransform, ShowUrlsTransform +) from sphinx.config import string_classes, ENUM from sphinx.environment import NoUri from sphinx.environment.adapters.asset import ImageAdapter @@ -219,7 +221,8 @@ class LaTeXBuilder(Builder): transformer = SphinxTransformer(doctree) transformer.set_environment(self.env) transformer.add_transforms([SubstitutionDefinitionsRemover, - ShowUrlsTransform]) + ShowUrlsTransform, + LaTeXFootnoteTransform]) transformer.apply_transforms() def finish(self): diff --git a/sphinx/builders/latex/nodes.py b/sphinx/builders/latex/nodes.py new file mode 100644 index 000000000..bc05538ae --- /dev/null +++ b/sphinx/builders/latex/nodes.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" + sphinx.builders.latex.nodes + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + docutils nodes for LaTeX builder. + + :copyright: Copyright 2007-2018 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +from docutils import nodes + + +class footnotemark(nodes.Inline, nodes.Referential, nodes.TextElement): + """A node represents ``\footnotemark``.""" + pass + + +class footnotetext(nodes.General, nodes.BackLinkable, nodes.Element, + nodes.Labeled, nodes.Targetable): + """A node represents ``\footnotetext``.""" + pass diff --git a/sphinx/builders/latex/transforms.py b/sphinx/builders/latex/transforms.py index bb0611d11..91b14b51f 100644 --- a/sphinx/builders/latex/transforms.py +++ b/sphinx/builders/latex/transforms.py @@ -12,6 +12,7 @@ from docutils import nodes from sphinx import addnodes +from sphinx.builders.latex.nodes import footnotemark, footnotetext from sphinx.transforms import SphinxTransform if False: @@ -35,6 +36,8 @@ class ShowUrlsTransform(SphinxTransform): """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 @@ -165,3 +168,302 @@ class FootnoteCollector(nodes.NodeVisitor): def visit_footnote_reference(self, node): # type: (nodes.footnote_reference) -> None self.footnote_refs.append(node) + + +class LaTeXFootnoteTransform(SphinxTransform): + """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:: + +
+ + headings having footnotes + <footnote_reference> + 1 + <footnote ids="1"> + <label> + 1 + <paragraph> + footnote body + + After:: + + <section> + <title> + headings having footnotes + <footnotemark> + 1 + <footnotetext> + footnote body + <footnotetext> + <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="1"> + <label> + 1 + <paragraph> + footnote body + + After:: + + blah blah blah + <footnote ids="1"> + <label> + 1 + <paragraph> + footnote body + blah blah blah ... + + * Replace second and subsequent footnote references which refers same footnote definition + by footnotemark node. + + Before:: + + blah blah blah + <footnote_reference refid="id1"> + 1 + blah blah blah + <footnote_reference refid="id1"> + 1 + blah blah blah ... + + <footnote ids="1"> + <label> + 1 + <paragraph> + footnote body + + After:: + + blah blah blah + <footnote ids="1"> + <label> + 1 + <paragraph> + footnote body + blah blah blah + <footnotemark> + 1 + blah blah blah ... + + * Remove unreferenced footnotes + + Before:: + + <footnote ids="1"> + <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="1"> + 1 + <tgroup> + <thead> + <row> + <entry> + header having footnote_reference + <footnote_reference refid="2"> + 2 + <tbody> + <row> + ... + + <footnote ids="1"> + <label> + 1 + <paragraph> + footnote body + + <footnote ids="2"> + <label> + 2 + <paragraph> + footnote body + + After:: + + <table> + <title> + title having footnote_reference + <footnotemark> + 1 + <tgroup> + <thead> + <row> + <entry> + header having footnote_reference + <footnotemark> + 2 + <tbody> + <footnotetext> + <label> + 1 + <paragraph> + footnote body + + <footnotetext> + <label> + 2 + <paragraph> + footnote body + <row> + ... + """ + + default_priority = 600 + + def apply(self): + footnotes = list(self.document.traverse(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, footnotes): + # type: (nodes.document, List[nodes.footnote]) -> None + self.appeared = set() # type: Set[Tuple[unicode, nodes.footnote]] + self.footnotes = footnotes # type: List[nodes.footnote] + self.pendings = [] # type: List[nodes.Node] + self.table_footnotes = [] # type: List[nodes.Node] + self.restricted = None # type: nodes.Node + nodes.NodeVisitor.__init__(self, document) + + def unknown_visit(self, node): + # type: (nodes.Node) -> None + pass + + def unknown_departure(self, node): + # type: (nodes.Node) -> None + pass + + def restrict(self, node): + # type: (nodes.Node) -> None + if self.restricted is None: + self.restricted = node + + def unrestrict(self, node): + # type: (nodes.Node) -> None + if self.restricted == node: + self.restricted = None + pos = node.parent.index(node) + for i, footnote, in enumerate(self.pendings): + fntext = footnotetext('', *footnote.children) + node.parent.insert(pos + i + 1, fntext) + self.pendings = [] + + def visit_figure(self, node): + # type: (nodes.Node) -> None + self.restrict(node) + + def depart_figure(self, node): + # type: (nodes.Node) -> None + self.unrestrict(node) + + def visit_term(self, node): + # type: (nodes.Node) -> None + self.restrict(node) + + def depart_term(self, node): + # type: (nodes.Node) -> None + self.unrestrict(node) + + def visit_caption(self, node): + # type: (nodes.Node) -> None + self.restrict(node) + + def depart_caption(self, node): + # type: (nodes.Node) -> None + self.unrestrict(node) + + def visit_title(self, node): + # type: (nodes.Node) -> None + if isinstance(node.parent, (nodes.section, nodes.table)): + self.restrict(node) + + def depart_title(self, node): + # type: (nodes.Node) -> 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): + # type: (nodes.Node) -> None + self.restrict(node) + + def depart_thead(self, node): + # type: (nodes.Node) -> None + self.table_footnotes += self.pendings + self.pendings = [] + self.unrestrict(node) + + def depart_table(self, node): + # type: (nodes.Node) -> None + tbody = list(node.traverse(nodes.tbody))[0] + for footnote in reversed(self.table_footnotes): + fntext = footnotetext('', *footnote.children) + tbody.insert(0, fntext) + + self.table_footnotes = [] + + def visit_footnote_reference(self, node): + # type: (nodes.Node) -> None + number = node.astext().strip() + docname = node['docname'] + if self.restricted: + mark = footnotemark('', number) + node.replace_self(mark) + if (docname, number) not in self.appeared: + footnote = self.get_footnote_by_reference(node) + self.pendings.append(footnote) + elif (docname, number) in self.appeared: + mark = footnotemark('', number) + node.replace_self(mark) + else: + footnote = self.get_footnote_by_reference(node) + self.footnotes.remove(footnote) + node.replace_self(footnote) + + self.appeared.add((docname, number)) + raise nodes.SkipNode + + def get_footnote_by_reference(self, node): + # type: (nodes.Node) -> nodes.Node + docname = node['docname'] + for footnote in self.footnotes: + if docname == footnote['docname'] and footnote['ids'][0] == node['refid']: + return footnote + + return None diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index e7d04fe93..2fb14cf88 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -23,6 +23,7 @@ from six import itervalues, text_type from sphinx import addnodes from sphinx import highlighting +from sphinx.builders.latex.nodes import footnotetext from sphinx.builders.latex.transforms import URI_SCHEMES, ShowUrlsTransform # NOQA # for compatibility from sphinx.errors import SphinxError from sphinx.locale import admonitionlabels, _, __ @@ -834,7 +835,6 @@ class LaTeXTranslator(nodes.NodeVisitor): def visit_document(self, node): # type: (nodes.Node) -> None - self.footnotestack.append(self.collect_footnotes(node)) self.curfilestack.append(node.get('docname', '')) if self.first_document == 1: # the first document is all the regular content ... @@ -868,8 +868,6 @@ class LaTeXTranslator(nodes.NodeVisitor): def visit_start_of_file(self, node): # type: (nodes.Node) -> None - # collect new footnotes - self.footnotestack.append(self.collect_footnotes(node)) # also add a document target self.next_section_ids.add(':doc') self.curfilestack.append(node['docname']) @@ -898,7 +896,6 @@ class LaTeXTranslator(nodes.NodeVisitor): def depart_start_of_file(self, node): # type: (nodes.Node) -> None - self.footnotestack.pop() self.curfilestack.pop() self.hlsettingstack.pop() @@ -1010,7 +1007,6 @@ class LaTeXTranslator(nodes.NodeVisitor): self.body.append(r'\%s%s{' % (self.sectionnames[-1], short)) self.context.append('}\n') - self.restrict_footnote(node) if self.next_section_ids: for id in self.next_section_ids: self.context[-1] += self.hypertarget(id, anchor=False) @@ -1027,7 +1023,6 @@ class LaTeXTranslator(nodes.NodeVisitor): elif isinstance(parent, nodes.table): # Redirect body output until title is finished. self.pushbody([]) - self.restrict_footnote(node) else: logger.warning(__('encountered title node not in section, topic, table, ' 'admonition or sidebar'), @@ -1041,14 +1036,8 @@ class LaTeXTranslator(nodes.NodeVisitor): self.in_title = 0 if isinstance(node.parent, nodes.table): self.table.caption = self.popbody() - # temporary buffer for footnotes from caption - self.pushbody([]) - self.unrestrict_footnote(node) - # the footnote texts from caption - self.table.caption_footnotetexts = self.popbody() else: self.body.append(self.context.pop()) - self.unrestrict_footnote(node) def visit_subtitle(self, node): # type: (nodes.Node) -> None @@ -1224,32 +1213,20 @@ class LaTeXTranslator(nodes.NodeVisitor): self.body.append(self.context.pop()) def visit_footnote(self, node): - # type: (nodes.Node) -> None - raise nodes.SkipNode - - def visit_collected_footnote(self, node): # type: (nodes.Node) -> None self.in_footnote += 1 - if 'footnotetext' in node: - self.body.append('%%\n\\begin{footnotetext}[%s]' - '\\sphinxAtStartFootnote\n' % node['number']) + if self.in_parsed_literal: + self.body.append('\\begin{footnote}[%s]' % node[0].astext()) else: - if self.in_parsed_literal: - self.body.append('\\begin{footnote}[%s]' % node['number']) - else: - self.body.append('%%\n\\begin{footnote}[%s]' % node['number']) - self.body.append('\\sphinxAtStartFootnote\n') + self.body.append('%%\n\\begin{footnote}[%s]' % node[0].astext()) + self.body.append('\\sphinxAtStartFootnote\n') - def depart_collected_footnote(self, node): + def depart_footnote(self, node): # type: (nodes.Node) -> None - if 'footnotetext' in node: - # the \ignorespaces in particular for after table header use - self.body.append('%\n\\end{footnotetext}\\ignorespaces ') + if self.in_parsed_literal: + self.body.append('\\end{footnote}') else: - if self.in_parsed_literal: - self.body.append('\\end{footnote}') - else: - self.body.append('%\n\\end{footnote}') + self.body.append('%\n\\end{footnote}') self.in_footnote -= 1 def visit_label(self, node): @@ -1320,25 +1297,15 @@ class LaTeXTranslator(nodes.NodeVisitor): # type: (nodes.Node) -> None # Redirect head output until header is finished. self.pushbody(self.table.header) - # footnotes in longtable header must be restricted - self.restrict_footnote(node) def depart_thead(self, node): # type: (nodes.Node) -> None self.popbody() - # temporary buffer for footnotes from table header - self.pushbody([]) - self.unrestrict_footnote(node) - # the footnote texts from header - self.table.header_footnotetexts = self.popbody() def visit_tbody(self, node): # type: (nodes.Node) -> None # Redirect body output until table is finished. self.pushbody(self.table.body) - # insert footnotetexts from header at start of body (due to longtable) - # those from caption are handled by templates (to allow caption at foot) - self.body.extend(self.table.header_footnotetexts) def depart_tbody(self, node): # type: (nodes.Node) -> None @@ -1534,13 +1501,11 @@ class LaTeXTranslator(nodes.NodeVisitor): if node.get('ids'): ctx += self.hypertarget(node['ids'][0]) self.body.append('\\item[{') - self.restrict_footnote(node) self.context.append(ctx) def depart_term(self, node): # type: (nodes.Node) -> None self.body.append(self.context.pop()) - self.unrestrict_footnote(node) self.in_term -= 1 def visit_classifier(self, node): @@ -1591,8 +1556,9 @@ class LaTeXTranslator(nodes.NodeVisitor): not isinstance(node.parent[index - 1], nodes.compound)): # insert blank line, if the paragraph follows a non-paragraph node in a compound self.body.append('\\noindent\n') - elif index == 0 and isinstance(node.parent, nodes.footnote): - # don't insert blank line, if the paragraph is first child of a footnote + elif index == 1 and isinstance(node.parent, (nodes.footnote, footnotetext)): + # don't insert blank line, if the paragraph is second child of a footnote + # (first one is label node) pass else: self.body.append('\n') @@ -1735,7 +1701,6 @@ class LaTeXTranslator(nodes.NodeVisitor): ids += self.hypertarget(id, anchor=False) if node['ids']: ids += self.hypertarget(node['ids'][0], anchor=False) - self.restrict_footnote(node) if (len(node.children) and isinstance(node.children[0], nodes.image) and node.children[0]['ids']): @@ -1774,12 +1739,10 @@ class LaTeXTranslator(nodes.NodeVisitor): def depart_figure(self, node): # type: (nodes.Node) -> None self.body.append(self.context.pop()) - self.unrestrict_footnote(node) def visit_caption(self, node): # type: (nodes.Node) -> None self.in_caption += 1 - self.restrict_footnote(node) if self.in_container_literal_block: self.body.append('\\sphinxSetupCaptionForVerbatim{') elif self.in_minipage and isinstance(node.parent, nodes.figure): @@ -1793,7 +1756,6 @@ class LaTeXTranslator(nodes.NodeVisitor): # type: (nodes.Node) -> None self.body.append('}') self.in_caption -= 1 - self.unrestrict_footnote(node) def visit_legend(self, node): # type: (nodes.Node) -> None @@ -2172,27 +2134,26 @@ class LaTeXTranslator(nodes.NodeVisitor): def visit_footnote_reference(self, node): # type: (nodes.Node) -> None - num = node.astext().strip() - try: - footnode, used = self.footnotestack[-1][num] - except (KeyError, IndexError): - raise nodes.SkipNode - # if a footnote has been inserted once, it shouldn't be repeated - # by the next reference - if used: - self.body.append('\\sphinxfootnotemark[%s]' % num) - elif self.footnote_restricted: - self.footnotestack[-1][num][1] = True - self.body.append('\\sphinxfootnotemark[%s]' % num) - self.pending_footnotes.append(footnode) - else: - self.footnotestack[-1][num][1] = True - footnode.walkabout(self) # type: ignore - raise nodes.SkipChildren + raise nodes.SkipNode - def depart_footnote_reference(self, node): + def visit_footnotemark(self, node): # type: (nodes.Node) -> None - pass + self.body.append('\\sphinxfootnotemark[') + + def depart_footnotemark(self, node): + # type: (nodes.Node) -> None + self.body.append(']') + + def visit_footnotetext(self, node): + # type: (nodes.Node) -> None + number = node[0].astext() + self.body.append('%%\n\\begin{footnotetext}[%s]' + '\\sphinxAtStartFootnote\n' % number) + + def depart_footnotetext(self, node): + # type: (nodes.Node) -> None + # the \ignorespaces in particular for after table header use + self.body.append('%\n\\end{footnotetext}\\ignorespaces ') def visit_literal_block(self, node): # type: (nodes.Node) -> None