From 184f98ae9561d4a59d57e665b5fa0828cd156451 Mon Sep 17 00:00:00 2001 From: jfbu <2589111+jfbu@users.noreply.github.com> Date: Sun, 13 Feb 2022 12:28:52 +0100 Subject: [PATCH] LaTeX: correct footnote marks, extended with page of link target Fix #10188 Footnotes in some LaTeX environments (tables, fulllineitems for object descriptions) are gathered and appear after the environment, causing the footnote to possibly appear on a page later than some of the footnote marks referring it. With this commit, the footnote mark compares page numbers and incorporates the destination page number if it turns out to be distinct from the page where it stands. --- sphinx/texinputs/sphinx.sty | 13 +- sphinx/texinputs/sphinxpackagefootnote.sty | 151 ++++++++++++--------- sphinx/writers/latex.py | 10 +- tests/test_build_latex.py | 61 ++++----- 4 files changed, 117 insertions(+), 118 deletions(-) diff --git a/sphinx/texinputs/sphinx.sty b/sphinx/texinputs/sphinx.sty index 6c9f1606eb9..1f7eb1bd0ca 100644 --- a/sphinx/texinputs/sphinx.sty +++ b/sphinx/texinputs/sphinx.sty @@ -298,18 +298,11 @@ %% FOOTNOTES % % Support scopes for footnote numbering +% This is currently stepped at each input file \newcounter{sphinxscope} \newcommand{\sphinxstepscope}{\stepcounter{sphinxscope}} -% Some footnotes are multiply referred-to. For unique hypertarget in pdf, -% we need an additional counter. It is called "sphinxexplicit" for legacy -% reasons as "explicitly" numbered footnotes may be multiply referred-to. -\newcounter{sphinxexplicit} -\newcommand{\sphinxstepexplicit}{\stepcounter{sphinxexplicit}} -% Some babel/polyglossia languages fiddle with \@arabic, so let's be extra -% cautious and redefine \thesphinxscope with \number not \@arabic. -% Memo: we expect some subtle redefinition of \thesphinxscope to be a part of page -% scoping for footnotes, when we shall implement it. -\renewcommand{\thesphinxscope}{\number\value{sphinxscope}.\number\value{sphinxexplicit}} +% We ensure \thesphinxscope expands to digits tokens, independently of language +\renewcommand{\thesphinxscope}{\number\value{sphinxscope}} \newcommand\sphinxthefootnotemark[2]{% % this is used to make reference to an explicitly numbered footnote not on same page % #1=label of footnote text, #2=page number where footnote text was printed diff --git a/sphinx/texinputs/sphinxpackagefootnote.sty b/sphinx/texinputs/sphinxpackagefootnote.sty index a6071cf103f..a43034e8056 100644 --- a/sphinx/texinputs/sphinxpackagefootnote.sty +++ b/sphinx/texinputs/sphinxpackagefootnote.sty @@ -1,27 +1,37 @@ \NeedsTeXFormat{LaTeX2e} \ProvidesPackage{sphinxpackagefootnote}% - [2021/02/04 v1.1d footnotehyper adapted to sphinx (Sphinx team)] -% Provides support for this output mark-up from Sphinx latex writer: -% - footnote environment -% - savenotes environment (table templates) -% - \sphinxfootnotemark -% + [2022/02/12 v4.5.0 Sphinx custom footnotehyper package (Sphinx team)] %% %% Package: sphinxpackagefootnote %% Version: based on footnotehyper.sty 2021/02/04 v1.1d -%% as available at https://www.ctan.org/pkg/footnotehyper +%% https://www.ctan.org/pkg/footnotehyper %% License: the one applying to Sphinx %% -%% Refer to the PDF documentation at https://www.ctan.org/pkg/footnotehyper for -%% the code comments. +% Provides support for footnote mark-up from Sphinx latex writer: +% - "footnote" and "footnotetext" environments allowing verbatim material +% - "savenotes" environment for wrapping environments, such as for tables +% which have problems with LaTeX footnotes +% - hyperlinks +% +% Sphinx uses exclusively this mark-up for footnotes: +% - \begin{footnote}[N] +% - \begin{footnotetext}[N] +% - \sphinxfootnotemark[N] +% where N is a number. +% +%% Some small differences from upstream footnotehyper.sty: +%% - a tabulary compatibility layer (partial but enough for Sphinx), +%% - usage of \spx@opt@BeforeFootnote +%% - usage of \sphinxunactivateextrasandspace from sphinx.sty, +%% - \sphinxlongtablepatch +%% +%% Starting with Sphinx v4.5.0, inherited footnotehyper macros for +%% footnote/footnotetext receive some Sphinx specific extras to +%% implement "intelligent" footnote marks checking page numbers. %% -%% Differences: -%% 1. a partial tabulary compatibility layer added (enough for Sphinx mark-up), -%% 2. use of \spx@opt@BeforeFootnote from sphinx.sty, -%% 3. use of \sphinxunactivateextrasandspace from sphinx.sty, -%% 4. macro definition \sphinxfootnotemark, -%% 5. macro definition \sphinxlongtablepatch -%% 6. replaced some \undefined by \@undefined +%% All footnotes output from Sphinx are hyperlinked. With "savenotes" +%% footnotes may appear on page distinct from footnote mark, the latter +%% will indicate page number of the footnote. \newif\iffootnotehyperparse\footnotehyperparsetrue \DeclareOption*{\PackageWarning{sphinxpackagefootnote}{Option `\CurrentOption' is unknown}}% \ProcessOptions\relax @@ -42,6 +52,7 @@ \let\footnotetext \FNH@footnotetext \let\endfootnote \FNH@endfntext \let\endfootnotetext\FNH@endfntext + % always True branch taken with Sphinx \@ifpackageloaded{hyperref} {\ifHy@hyperfootnotes \let\FNH@H@@footnotetext\H@@footnotetext @@ -175,12 +186,33 @@ }% \def\FNH@footnoteenv@i[#1]{% \begingroup + % This legacy code from LaTeX core restricts #1 to be digits only + % This limitation could be lifted but legacy Sphinx anyhow obeys it \csname c@\@mpfn\endcsname #1\relax \unrestored@protected@xdef\@thefnmark{\thempfn}% \endgroup +% -- Sphinx specific: + \global\let\spx@saved@thefnmark\@thefnmark + % this is done to access robustly the page number where footnote mark is + \refstepcounter{sphinxfootnotemark}\label{footnotemark.\thesphinxfootnotemark}% + % if possible, compare page numbers of mark and footnote to define \@thefnmark + \ltx@ifundefined{r@\thesphinxscope.footnote.#1}% + {}% one more latex run is needed + {\sphinx@xdef@thefnmark{#1}}% check of page numbers possible +% -- \@footnotemark \def\FNH@endfntext@fntext{\@footnotetext}% +% -- Sphinx specific: + % we need to reset \@thefnmark as it is used by \FNH@startfntext via + % \FNH@startnote to set \@currentlabel which will be used by \label + \global\let\@thefnmark\spx@saved@thefnmark +% -- \FNH@startfntext +% -- again Sphinx specific + % \@currentlabel as needed by \label got set by \FNH@startnote + % insert this at start of footnote text then the label will allow + % to robustly know on which page the footnote text ends up + \phantomsection\label{\thesphinxscope.footnote.#1}% }% \def\FNH@footnotetext{% \ifx\@currenvir\FNH@footnotetext@envname @@ -207,6 +239,8 @@ \def\FNH@endfntext@fntext{\FNH@H@@footnotetext}% \fi \FNH@startfntext +% -- Sphinx specific addition + \phantomsection\label{\thesphinxscope.footnote.#1}% }% \def\FNH@startfntext{% \setbox\z@\vbox\bgroup @@ -329,60 +363,51 @@ }% % % some extras for Sphinx : -% \sphinxfootnotemark: usable in section titles and silently removed from TOCs. +% \sphinxfootnotemark: +% - if in section titles will auto-remove itself from TOC \def\sphinxfootnotemark [#1]% {\ifx\thepage\relax\else\sphinxfootref{#1}\fi}% -% \sphinxfootref: -% - \spx@opt@BeforeFootnote is from BeforeFootnote sphinxsetup option -% - \ref: -% the latex.py writer inserts a \phantomsection\label{.} -% whenever -% - the footnote was explicitly numbered in sources, -% - or it was in restrained context and is rendered using footnotetext -% -% These are the two types of footnotes that \sphinxfootnotemark must -% handle. But for explicitly numbered footnotes the same number -% can be found in document. So a secondary part in is updated -% at each novel such footnote to know what is the target from then on -% for \sphinxfootnotemark and already encountered [1], or [2],... -% -% LaTeX package varioref is not supported by hyperref (from its doc: "There -% are too many problems with varioref. Nobody has time to sort them out. -% Therefore this package is now unsupported.") So we will simply use our own -% macros to access the page number of footnote text and decide whether to print -% it. \pagename is internationalized by latex-babel. -\def\spx@thefnmark#1#2{% - % #1=label for reference, #2=page where footnote was printed - \ifx\spx@tempa\spx@tempb - % same page - #1% - \else - \sphinxthefootnotemark{#1}{#2}% - \fi +\newcounter{sphinxfootnotemark} +\renewcommand\thesphinxfootnotemark{\number\value{sphinxfootnotemark}} +% - compares page number of footnote mark versus the one of footnote text +\def\sphinx@xdef@thefnmark#1{% + \expandafter\expandafter\expandafter\sphinx@footref@get + \csname r@\thesphinxscope.footnote.#1\endcsname\relax + \expandafter\expandafter\expandafter\sphinx@footmark@getpage + \csname r@footnotemark.\thesphinxfootnotemark\endcsname\thepage\relax + \protected@xdef\@thefnmark{% + \ifx\spx@footmarkpage\spx@footrefpage + \spx@footreflabel + \else + % the macro \sphinxthefootnotemark is in sphinx.sty + \sphinxthefootnotemark{\spx@footreflabel}{\spx@footrefpage}% + \fi + }% }% -\def\sphinxfootref@get #1#2#3#4#5\relax{% - \def\sphinxfootref@label{#1}% - \def\sphinxfootref@page {#2}% - \def\sphinxfootref@Href {#4}% +\def\sphinx@footref@get #1#2#3#4#5\relax{% + \def\spx@footreflabel{#1}% + \def\spx@footrefpage {#2}% + \def\spx@footrefHref {#4}% }% -\protected\def\sphinxfootref#1{% #1 always explicit number in Sphinx usage +\def\sphinx@footmark@getpage #1#2#3\relax{% + \def\spx@footmarkpage{#2}% +}% +\protected\def\sphinxfootref#1{% #1 always is explicit number in Sphinx \spx@opt@BeforeFootnote - \ltx@ifundefined{r@\thesphinxscope.#1}% - {\gdef\@thefnmark{?}\H@@footnotemark}% - {\expandafter\expandafter\expandafter\sphinxfootref@get - \csname r@\thesphinxscope.#1\endcsname\relax - \edef\spx@tempa{\thepage}\edef\spx@tempb{\sphinxfootref@page}% - \protected@xdef\@thefnmark{\spx@thefnmark{\sphinxfootref@label}{\sphinxfootref@page}}% - \let\spx@@makefnmark\@makefnmark - \def\@makefnmark{% - \hyper@linkstart{link}{\sphinxfootref@Href}% - \spx@@makefnmark + \refstepcounter{sphinxfootnotemark}\label{footnotemark.\thesphinxfootnotemark}% + \let\spx@saved@makefnmark\@makefnmark + \ltx@ifundefined{r@\thesphinxscope.footnote.#1}% + {\gdef\@thefnmark{?}}% on first LaTeX run + {\sphinx@xdef@thefnmark{#1}% also defines \spx@footrefHref + \def\@makefnmark{% will be used by \H@@footnotemark + \hyper@linkstart{link}{\spx@footrefHref}% + \spx@saved@makefnmark \hyper@linkend - }% - \H@@footnotemark - \let\@makefnmark\spx@@makefnmark + }% }% -}% + \H@@footnotemark + \let\@makefnmark\spx@saved@makefnmark +}% \AtBeginDocument{% % let hyperref less complain \pdfstringdefDisableCommands{\def\sphinxfootnotemark [#1]{}}% diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 680f06bf5cf..246f523626e 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -814,16 +814,14 @@ def depart_rubric(self, node: Element) -> None: def visit_footnote(self, node: Element) -> None: self.in_footnote += 1 label = cast(nodes.label, node[0]) - if 'referred' in node: - self.body.append(r'\sphinxstepexplicit ') if self.in_parsed_literal: self.body.append(r'\begin{footnote}[%s]' % label.astext()) else: self.body.append('%' + CR) self.body.append(r'\begin{footnote}[%s]' % label.astext()) if 'referred' in node: - self.body.append(r'\phantomsection' - r'\label{\thesphinxscope.%s}%%' % label.astext() + CR) + # TODO: in future maybe output a latex macro with backrefs here + pass self.body.append(r'\sphinxAtStartFootnote' + CR) def depart_footnote(self, node: Element) -> None: @@ -1717,9 +1715,7 @@ def depart_footnotemark(self, node: Element) -> None: def visit_footnotetext(self, node: Element) -> None: label = cast(nodes.label, node[0]) self.body.append('%' + CR) - self.body.append(r'\begin{footnotetext}[%s]' - r'\phantomsection\label{\thesphinxscope.%s}%%' - % (label.astext(), label.astext()) + CR) + self.body.append(r'\begin{footnotetext}[%s]' % label.astext()) self.body.append(r'\sphinxAtStartFootnote' + CR) def depart_footnotetext(self, node: Element) -> None: diff --git a/tests/test_build_latex.py b/tests/test_build_latex.py index 50b2b693a63..da853d0b5fb 100644 --- a/tests/test_build_latex.py +++ b/tests/test_build_latex.py @@ -723,13 +723,9 @@ def test_footnote(app, status, warning): assert '\\sphinxcite{footnote:bar}' in result assert ('\\bibitem[bar]{footnote:bar}\n\\sphinxAtStartPar\ncite\n') in result assert '\\sphinxcaption{Table caption \\sphinxfootnotemark[4]' in result - assert ('\\hline%\n\\begin{footnotetext}[4]' - '\\phantomsection\\label{\\thesphinxscope.4}%\n' - '\\sphinxAtStartFootnote\n' + assert ('\\hline%\n\\begin{footnotetext}[4]\\sphinxAtStartFootnote\n' 'footnote in table caption\n%\n\\end{footnotetext}\\ignorespaces %\n' - '\\begin{footnotetext}[5]' - '\\phantomsection\\label{\\thesphinxscope.5}%\n' - '\\sphinxAtStartFootnote\n' + '\\begin{footnotetext}[5]\\sphinxAtStartFootnote\n' 'footnote in table header\n%\n\\end{footnotetext}\\ignorespaces ' '\n\\sphinxAtStartPar\n' 'VIDIOC\\_CROPCAP\n&\n\\sphinxAtStartPar\n') in result @@ -755,27 +751,19 @@ def test_reference_in_caption_and_codeblock_in_footnote(app, status, warning): assert '\\subsubsection*{The rubric title with a reference to {[}AuthorYear{]}}' in result assert ('\\chapter{The section with a reference to \\sphinxfootnotemark[6]}\n' '\\label{\\detokenize{index:the-section-with-a-reference-to}}' - '%\n\\begin{footnotetext}[6]' - '\\phantomsection\\label{\\thesphinxscope.6}%\n' - '\\sphinxAtStartFootnote\n' + '%\n\\begin{footnotetext}[6]\\sphinxAtStartFootnote\n' 'Footnote in section\n%\n\\end{footnotetext}') in result assert ('\\caption{This is the figure caption with a footnote to ' '\\sphinxfootnotemark[8].}\\label{\\detokenize{index:id35}}\\end{figure}\n' - '%\n\\begin{footnotetext}[8]' - '\\phantomsection\\label{\\thesphinxscope.8}%\n' - '\\sphinxAtStartFootnote\n' + '%\n\\begin{footnotetext}[8]\\sphinxAtStartFootnote\n' 'Footnote in caption\n%\n\\end{footnotetext}') in result assert ('\\sphinxcaption{footnote \\sphinxfootnotemark[9] in ' 'caption of normal table}\\label{\\detokenize{index:id36}}') in result assert ('\\caption{footnote \\sphinxfootnotemark[10] ' 'in caption \\sphinxfootnotemark[11] of longtable\\strut}') in result - assert ('\\endlastfoot\n%\n\\begin{footnotetext}[10]' - '\\phantomsection\\label{\\thesphinxscope.10}%\n' - '\\sphinxAtStartFootnote\n' + assert ('\\endlastfoot\n%\n\\begin{footnotetext}[10]\\sphinxAtStartFootnote\n' 'Foot note in longtable\n%\n\\end{footnotetext}\\ignorespaces %\n' - '\\begin{footnotetext}[11]' - '\\phantomsection\\label{\\thesphinxscope.11}%\n' - '\\sphinxAtStartFootnote\n' + '\\begin{footnotetext}[11]\\sphinxAtStartFootnote\n' 'Second footnote in caption of longtable\n') in result assert ('This is a reference to the code\\sphinxhyphen{}block in the footnote:\n' '{\\hyperref[\\detokenize{index:codeblockinfootnote}]' @@ -795,13 +783,13 @@ def test_footnote_referred_multiple_times(app, status, warning): print(status.getvalue()) print(warning.getvalue()) - assert ('Explicitly numbered footnote: \\sphinxstepexplicit %\n' - '\\begin{footnote}[100]\\phantomsection\\label{\\thesphinxscope.100}%\n' + assert ('Explicitly numbered footnote: %\n' + '\\begin{footnote}[100]' '\\sphinxAtStartFootnote\nNumbered footnote\n%\n' '\\end{footnote} \\sphinxfootnotemark[100]\n' in result) - assert ('Named footnote: \\sphinxstepexplicit %\n' - '\\begin{footnote}[13]\\phantomsection\\label{\\thesphinxscope.13}%\n' + assert ('Named footnote: %\n' + '\\begin{footnote}[13]' '\\sphinxAtStartFootnote\nNamed footnote\n%\n' '\\end{footnote} \\sphinxfootnotemark[13]\n' in result) @@ -837,9 +825,7 @@ def test_latex_show_urls_is_inline(app, status, warning): assert '\\sphinxhref{http://sphinx-doc.org/}{Sphinx} (http://sphinx\\sphinxhyphen{}doc.org/)' in result assert ('Third footnote: %\n\\begin{footnote}[3]\\sphinxAtStartFootnote\n' 'Third \\sphinxfootnotemark[4]\n%\n\\end{footnote}%\n' - '\\begin{footnotetext}[4]' - '\\phantomsection\\label{\\thesphinxscope.4}%\n' - '\\sphinxAtStartFootnote\n' + '\\begin{footnotetext}[4]\\sphinxAtStartFootnote\n' 'Footnote inside footnote\n%\n\\end{footnotetext}\\ignorespaces') in result assert ('Fourth footnote: %\n\\begin{footnote}[5]\\sphinxAtStartFootnote\n' 'Fourth\n%\n\\end{footnote}\n') in result @@ -849,8 +835,12 @@ def test_latex_show_urls_is_inline(app, status, warning): '(http://sphinx\\sphinxhyphen{}doc.org/)}\n' '\\sphinxAtStartPar\nDescription' in result) assert ('\\sphinxlineitem{Footnote in term \\sphinxfootnotemark[7]}%\n' - '\\begin{footnotetext}[7]\\phantomsection\\label{\\thesphinxscope.7}%\n' - '\\sphinxAtStartFootnote\n' + '\\begin{footnotetext}[7]\\sphinxAtStartFootnote\n') + assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{URL in term} ' + '(http://sphinx\\sphinxhyphen{}doc.org/)}\n' + '\\sphinxAtStartPar\nDescription' in result) + assert ('\\sphinxlineitem{Footnote in term \\sphinxfootnotemark[7]}%\n' + '\\begin{footnotetext}[7]\\sphinxAtStartFootnote\n' 'Footnote in term\n%\n\\end{footnotetext}\\ignorespaces ' '\n\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{Term in deflist} ' @@ -893,9 +883,7 @@ def test_latex_show_urls_is_footnote(app, status, warning): '\\sphinxnolinkurl{http://sphinx-doc.org/}\n%\n\\end{footnote}') in result assert ('Third footnote: %\n\\begin{footnote}[6]\\sphinxAtStartFootnote\n' 'Third \\sphinxfootnotemark[7]\n%\n\\end{footnote}%\n' - '\\begin{footnotetext}[7]' - '\\phantomsection\\label{\\thesphinxscope.7}%\n' - '\\sphinxAtStartFootnote\n' + '\\begin{footnotetext}[7]\\sphinxAtStartFootnote\n' 'Footnote inside footnote\n%\n' '\\end{footnotetext}\\ignorespaces') in result assert ('Fourth footnote: %\n\\begin{footnote}[8]\\sphinxAtStartFootnote\n' @@ -905,18 +893,18 @@ def test_latex_show_urls_is_footnote(app, status, warning): '\\sphinxnolinkurl{http://sphinx-doc.org/~test/}\n%\n\\end{footnote}') in result assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}' '{URL in term}\\sphinxfootnotemark[10]}%\n' - '\\begin{footnotetext}[10]\\phantomsection\\label{\\thesphinxscope.10}%\n' + '\\begin{footnotetext}[10]' '\\sphinxAtStartFootnote\n' '\\sphinxnolinkurl{http://sphinx-doc.org/}\n%\n' '\\end{footnotetext}\\ignorespaces \n\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxlineitem{Footnote in term \\sphinxfootnotemark[12]}%\n' - '\\begin{footnotetext}[12]\\phantomsection\\label{\\thesphinxscope.12}%\n' + '\\begin{footnotetext}[12]' '\\sphinxAtStartFootnote\n' 'Footnote in term\n%\n\\end{footnotetext}\\ignorespaces ' '\n\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{Term in deflist}' '\\sphinxfootnotemark[11]}%\n' - '\\begin{footnotetext}[11]\\phantomsection\\label{\\thesphinxscope.11}%\n' + '\\begin{footnotetext}[11]' '\\sphinxAtStartFootnote\n' '\\sphinxnolinkurl{http://sphinx-doc.org/}\n%\n' '\\end{footnotetext}\\ignorespaces \n\\sphinxAtStartPar\nDescription') in result @@ -955,9 +943,7 @@ def test_latex_show_urls_is_no(app, status, warning): assert '\\sphinxhref{http://sphinx-doc.org/}{Sphinx}' in result assert ('Third footnote: %\n\\begin{footnote}[3]\\sphinxAtStartFootnote\n' 'Third \\sphinxfootnotemark[4]\n%\n\\end{footnote}%\n' - '\\begin{footnotetext}[4]' - '\\phantomsection\\label{\\thesphinxscope.4}%\n' - '\\sphinxAtStartFootnote\n' + '\\begin{footnotetext}[4]\\sphinxAtStartFootnote\n' 'Footnote inside footnote\n%\n\\end{footnotetext}\\ignorespaces') in result assert ('Fourth footnote: %\n\\begin{footnote}[5]\\sphinxAtStartFootnote\n' 'Fourth\n%\n\\end{footnote}\n') in result @@ -965,8 +951,7 @@ def test_latex_show_urls_is_no(app, status, warning): assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{URL in term}}\n' '\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxlineitem{Footnote in term \\sphinxfootnotemark[7]}%\n' - '\\begin{footnotetext}[7]\\phantomsection\\label{\\thesphinxscope.7}%\n' - '\\sphinxAtStartFootnote\n' + '\\begin{footnotetext}[7]\\sphinxAtStartFootnote\n' 'Footnote in term\n%\n\\end{footnotetext}\\ignorespaces ' '\n\\sphinxAtStartPar\nDescription') in result assert ('\\sphinxlineitem{\\sphinxhref{http://sphinx-doc.org/}{Term in deflist}}'