[PATCH 1/2] An implementation of --latex-footnotes

Matěj Cepl <[email protected]> Tue, 22 Jul 2025 01:52:08 +0200
Newsgroups gmane.text.docutils.devel
Message-ID <[email protected]>
From: John Thorvald Wodder II <[email protected]>

Attached is a patch that implements the --latex-footnotes
option for 99% of use cases. I don't know whether you'd find it
satisfactory enough to accept, but I thought I'd at least try.

Shortcomings of this implementation:

 * Footnotes aren't hyperlinked back to their references.
   I am not aware of a way to solve this without basically
   reimplemeting docutils-footnotes.

 * Recursive footnotes are not supported and will cause
   a recursion error. Support would require tracking and
   referencing (à la https://tex.stackexchange.com/a/23158/) the
   number that LaTeX assigns to each footnote, which normally
   resets on chapters and would be broken by packages like
   footmisc and perpage.

 * If the same footnote is referenced multiple times, it will be
   treated as a new footnote each time. I believe this has the
   same solution as the above.

 * If a footnote contains two or more nested
   footnotes, the numbering will be messed up; see
   https://tex.stackexchange.com/q/38643/ for a way to address
   this.

Originally: https://sourceforge.net/p/docutils/patches/182/
---
 docutils/docutils/writers/latex2e/__init__.py |  91 +++++++++-----
 docutils/test/test_writers/test_latex2e.py    | 116 ++++++++++++++++++
 2 files changed, 179 insertions(+), 28 deletions(-)

diff --git a/docutils/docutils/writers/latex2e/__init__.py b/docutils/docutils/writers/latex2e/__init__.py
index b8264ee7d..1dcf13466 100644
--- a/docutils/docutils/writers/latex2e/__init__.py
+++ b/docutils/docutils/writers/latex2e/__init__.py
@@ -227,14 +227,17 @@ class Writer(writers.Writer):
           {'dest': 'legacy_column_widths',
            'action': 'store_false',
            'validator': frontend.validate_boolean}),
-         # TODO: implement "latex footnotes" alternative
-         ('Footnotes with numbers/symbols by Docutils. (default) '
-          '(The alternative, --latex-footnotes, is not implemented yet.)',
+         ('Footnotes with numbers/symbols by Docutils. (default)',
           ['--docutils-footnotes'],
           {'default': True,
            'action': 'store_true',
            'validator': frontend.validate_boolean}),
          ),
+         ('Footnotes with numbers by LaTeX.',
+           ['--latex-footnotes'],
+           {'dest': 'docutils_footnotes',
+            'action': 'store_false',
+            'validator': frontend.validate_boolean}),
         )
 
     relative_path_settings = ('template',)
@@ -1253,7 +1256,6 @@ def __init__(self, document, babel_class=Babel) -> None:
         else:
             self.graphicx_package = (r'\usepackage[%s]{graphicx}' %
                                      settings.graphicx_option)
-        # footnotes: TODO: implement LaTeX footnotes
         self.docutils_footnotes = settings.docutils_footnotes
 
         # Output collection stacks
@@ -1319,6 +1321,15 @@ def __init__(self, document, babel_class=Babel) -> None:
         self.out = self.body
         self.out_stack = []  # stack of output collectors
 
+        # Texts of nested footnotes to emit once we finish the topmost
+        # footnote.  footnote_queues[i] contains the text of footnotes
+        # encountered while processing the current footnote (which is nested
+        # within `i` higher footnotes).  If i == 0, they will be emitted
+        # immediately after the current footnote ends; if i > 0; they will be
+        # added to footnote_queues[i-1] after ending the current footnote,
+        # which is added to the same queue before them.
+        self.footnote_queues = []
+
         # Process settings
         # ~~~~~~~~~~~~~~~~
         # Encodings:
@@ -2324,11 +2335,11 @@ def depart_footer(self, node) -> None:
         self.pop_output_collector()
 
     def visit_footnote(self, node) -> None:
-        try:
-            backref = node['backrefs'][0]
-        except IndexError:
-            backref = node['ids'][0]  # no backref, use self-ref instead
         if self.docutils_footnotes:
+            try:
+                backref = node['backrefs'][0]
+            except IndexError:
+                backref = node['ids'][0] # no backref, use self-ref instead
             self.provide_fallback('footnotes')
             num = node[0].astext()
             if self.settings.footnote_references == 'brackets':
@@ -2341,10 +2352,12 @@ def visit_footnote(self, node) -> None:
             # prevent spurious whitespace if footnote starts with paragraph:
             if len(node) > 1 and isinstance(node[1], nodes.paragraph):
                 self.out.append('%')
-        # TODO: "real" LaTeX \footnote{}s (see visit_footnotes_reference())
+        elif not self.footnote_queues:
+            raise nodes.SkipNode
 
     def depart_footnote(self, node) -> None:
-        self.out.append('}\n')
+        if self.docutils_footnotes:
+            self.out.append('}\n')
 
     def visit_footnote_reference(self, node) -> None:
         href = ''
@@ -2352,25 +2365,47 @@ def visit_footnote_reference(self, node) -> None:
             href = node['refid']
         elif 'refname' in node:
             href = self.document.nameids[node['refname']]
-        # if not self.docutils_footnotes:
-        #     # TODO: insert footnote content at (or near) this place
-        #     #       see also docs/dev/todo.rst
-        #     try:
-        #         referenced_node = self.document.ids[node['refid']]
-        #     except (AttributeError, KeyError):
-        #         self.document.reporter.error(
-        #             'unresolved footnote-reference %s' % node)
-        #     print('footnote-ref to %s' % referenced_node)
-        format = self.settings.footnote_references
-        if format == 'brackets':
-            self.append_hypertargets(node)
-            self.out.append('\\hyperlink{%s}{[' % href)
-            self.context.append(']}')
+        if self.docutils_footnotes:
+            format = self.settings.footnote_references
+            if format == 'brackets':
+                self.append_hypertargets(node)
+                self.out.append('\\hyperlink{%s}{[' % href)
+                self.context.append(']}')
+            else:
+                if not self.fallback_stylesheet:
+                    self.fallbacks['footnotes'] = PreambleCmds.footnotes
+                self.out.append(r'\DUfootnotemark{%s}{%s}{' %
+                                (node['ids'][0], href))
+                self.context.append('}')
         else:
-            self.provide_fallback('footnotes')
-            self.out.append(r'\DUfootnotemark{%s}{%s}{' %
-                            (node['ids'][0], href))
-            self.context.append('}')
+            footnotes = (self.document.footnotes +
+                         self.document.autofootnotes +
+                         self.document.symbol_footnotes)
+            for footnote in footnotes:
+                if href in footnote['ids']:
+                    self.footnote_queues.append([])
+                    self.push_output_collector([])
+                    footnote.walkabout(self)
+                    text = ''.join(self.out)
+                    self.pop_output_collector()
+                    break
+            else:
+                self.document.reporter.error("Footnote %s referenced but not found" % href)
+                raise nodes.SkipNode
+            queued = self.footnote_queues.pop()
+            if not self.footnote_queues:
+                self.out.append("\\footnote{%")
+                self.out.append(text)
+                self.out.append("}")
+                for fn in queued:
+                    self.out.append("\\footnotetext{%")
+                    self.out.append(fn)
+                    self.out.append("}")
+            else:
+                self.out.append("\\footnotemark{}")
+                self.footnote_queues[-1].append(text)
+                self.footnote_queues[-1].extend(queued)
+            raise nodes.SkipNode
 
     def depart_footnote_reference(self, node) -> None:
         self.out.append(self.context.pop())
diff --git a/docutils/test/test_writers/test_latex2e.py b/docutils/test/test_writers/test_latex2e.py
index 84bb94e02..434786690 100755
--- a/docutils/test/test_writers/test_latex2e.py
+++ b/docutils/test/test_writers/test_latex2e.py
@@ -500,5 +500,121 @@ def test_body(self):
 ])
 
 
+totest_latex_footnotes['simple'] = [
+# input
+["""\
+Paragraphs contain text and may contain footnote references (manually
+numbered [1]_, anonymous auto-numbered [#]_, labeled auto-numbered
+[#label]_, or symbolic [*]_).
+
+.. [1] A footnote contains body elements, consistently indented by at
+   least 3 spaces.
+
+   This is the footnote's second paragraph.
+
+.. [#label] Footnotes may be numbered, either manually or
+   automatically using a "#"-prefixed label.  This footnote has a
+   label so it can be referred to from multiple places, both as a
+   footnote reference and as a hyperlink reference.
+
+.. [#] This footnote is numbered automatically and anonymously using a
+   label of "#" only.
+
+.. [*] Footnotes may also use symbols, specified with a "*" label.
+""",
+## # expected output
+head_template.substitute(dict(parts)) + r"""
+Paragraphs contain text and may contain footnote references (manually
+numbered\footnote{%
+A footnote contains body elements, consistently indented by at
+least 3 spaces.
+
+This is the footnote's second paragraph.
+}, anonymous auto-numbered\footnote{%
+This footnote is numbered automatically and anonymously using a
+label of \textquotedbl{}\#\textquotedbl{} only.
+}, labeled auto-numbered\footnote{%
+Footnotes may be numbered, either manually or
+automatically using a \textquotedbl{}\#\textquotedbl{}-prefixed label.  This footnote has a
+label so it can be referred to from multiple places, both as a
+footnote reference and as a hyperlink reference.
+}, or symbolic\footnote{%
+Footnotes may also use symbols, specified with a \textquotedbl{}*\textquotedbl{} label.
+}).
+
+\end{document}
+"""],
+]
+
+totest_latex_footnotes['nested'] = [
+# input
+["""\
+It's possible to produce nested footnotes in LaTeX. [#]_
+
+.. [#] It takes some work, though. [#]_
+.. [#] And don't even get me started on how tricky recursive footnotes would be.
+""",
+## # expected output
+head_template.substitute(dict(parts)) + r"""
+It's possible to produce nested footnotes in LaTeX.\footnote{%
+It takes some work, though.\footnotemark{}
+}\footnotetext{%
+And don't even get me started on how tricky recursive footnotes would be.
+}
+
+\end{document}
+"""],
+]
+
+totest_latex_footnotes['chained'] = [
+# input
+["""\
+It's possible to produce chained footnotes in LaTeX. [#]_
+
+.. [#] They're just a special case of nested footnotes. [#]_
+.. [#] A nested footnote is a footnote on a footnote. [#]_
+.. [#] This is a footnote on a footnote on a footnote.
+""",
+## # expected output
+head_template.substitute(dict(parts)) + r"""
+It's possible to produce chained footnotes in LaTeX.\footnote{%
+They're just a special case of nested footnotes.\footnotemark{}
+}\footnotetext{%
+A nested footnote is a footnote on a footnote.\footnotemark{}
+}\footnotetext{%
+This is a footnote on a footnote on a footnote.
+}
+
+\end{document}
+"""],
+]
+
+totest_latex_footnotes['multinested'] = [
+# input
+["""\
+LaTeX isn't the best at nested footnote support. [#]_
+
+.. [#] Specifically, it gets the numbers wrong [#]_ for "multinested"
+   footnotes. [#]_
+.. [#] For example, this should be footnote 2, but both it and the next one
+   show up as footnote 3.
+.. [#] That's a footnote that contains more than one footnote of its own.
+""",
+## # expected output
+head_template.substitute(dict(parts)) + r"""
+LaTeX isn't the best at nested footnote support.\footnote{%
+Specifically, it gets the numbers wrong\footnotemark{} for \textquotedbl{}multinested\textquotedbl{}
+footnotes.\footnotemark{}
+}\footnotetext{%
+For example, this should be footnote 2, but both it and the next one
+show up as footnote 3.
+}\footnotetext{%
+That's a footnote that contains more than one footnote of its own.
+}
+
+\end{document}
+"""],
+]
+
 if __name__ == '__main__':
     unittest.main()
-- 
2.50.1



_______________________________________________
Docutils-develop mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/docutils-develop

Please use "Reply All" to reply to the list.