summaryrefslogtreecommitdiffstats
path: root/sphinx/builders/latex
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/builders/latex')
-rw-r--r--sphinx/builders/latex/__init__.py551
-rw-r--r--sphinx/builders/latex/constants.py210
-rw-r--r--sphinx/builders/latex/nodes.py37
-rw-r--r--sphinx/builders/latex/theming.py135
-rw-r--r--sphinx/builders/latex/transforms.py642
-rw-r--r--sphinx/builders/latex/util.py48
6 files changed, 1623 insertions, 0 deletions
diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py
new file mode 100644
index 0000000..3ece571
--- /dev/null
+++ b/sphinx/builders/latex/__init__.py
@@ -0,0 +1,551 @@
+"""LaTeX builder."""
+
+from __future__ import annotations
+
+import os
+import warnings
+from os import path
+from typing import TYPE_CHECKING, Any
+
+from docutils.frontend import OptionParser
+
+import sphinx.builders.latex.nodes # noqa: F401,E501 # Workaround: import this before writer to avoid ImportError
+from sphinx import addnodes, highlighting, package_dir
+from sphinx.builders import Builder
+from sphinx.builders.latex.constants import ADDITIONAL_SETTINGS, DEFAULT_SETTINGS, SHORTHANDOFF
+from sphinx.builders.latex.theming import Theme, ThemeFactory
+from sphinx.builders.latex.util import ExtBabel
+from sphinx.config import ENUM, Config
+from sphinx.environment.adapters.asset import ImageAdapter
+from sphinx.errors import NoUri, SphinxError
+from sphinx.locale import _, __
+from sphinx.util import logging, texescape
+from sphinx.util.console import bold, darkgreen # type: ignore[attr-defined]
+from sphinx.util.display import progress_message, status_iterator
+from sphinx.util.docutils import SphinxFileOutput, new_document
+from sphinx.util.fileutil import copy_asset_file
+from sphinx.util.i18n import format_date
+from sphinx.util.nodes import inline_all_toctrees
+from sphinx.util.osutil import SEP, make_filename_from_project
+from sphinx.util.template import LaTeXRenderer
+from sphinx.writers.latex import LaTeXTranslator, LaTeXWriter
+
+# load docutils.nodes after loading sphinx.builders.latex.nodes
+from docutils import nodes # isort:skip
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+ from docutils.nodes import Node
+
+ from sphinx.application import Sphinx
+
+XINDY_LANG_OPTIONS = {
+ # language codes from docutils.writers.latex2e.Babel
+ # ! xindy language names may differ from those in use by LaTeX/babel
+ # ! xindy does not support all Latin scripts as recognized by LaTeX/babel
+ # ! not all xindy-supported languages appear in Babel.language_codes
+ # cd /usr/local/texlive/2018/texmf-dist/xindy/modules/lang
+ # find . -name '*utf8.xdy'
+ # LATIN
+ 'sq': '-L albanian -C utf8 ',
+ 'hr': '-L croatian -C utf8 ',
+ 'cs': '-L czech -C utf8 ',
+ 'da': '-L danish -C utf8 ',
+ 'nl': '-L dutch-ij-as-ij -C utf8 ',
+ 'en': '-L english -C utf8 ',
+ 'eo': '-L esperanto -C utf8 ',
+ 'et': '-L estonian -C utf8 ',
+ 'fi': '-L finnish -C utf8 ',
+ 'fr': '-L french -C utf8 ',
+ 'de': '-L german-din5007 -C utf8 ',
+ 'is': '-L icelandic -C utf8 ',
+ 'it': '-L italian -C utf8 ',
+ 'la': '-L latin -C utf8 ',
+ 'lv': '-L latvian -C utf8 ',
+ 'lt': '-L lithuanian -C utf8 ',
+ 'dsb': '-L lower-sorbian -C utf8 ',
+ 'ds': '-L lower-sorbian -C utf8 ', # trick, no conflict
+ 'nb': '-L norwegian -C utf8 ',
+ 'no': '-L norwegian -C utf8 ', # and what about nynorsk?
+ 'pl': '-L polish -C utf8 ',
+ 'pt': '-L portuguese -C utf8 ',
+ 'ro': '-L romanian -C utf8 ',
+ 'sk': '-L slovak-small -C utf8 ', # there is also slovak-large
+ 'sl': '-L slovenian -C utf8 ',
+ 'es': '-L spanish-modern -C utf8 ', # there is also spanish-traditional
+ 'sv': '-L swedish -C utf8 ',
+ 'tr': '-L turkish -C utf8 ',
+ 'hsb': '-L upper-sorbian -C utf8 ',
+ 'hs': '-L upper-sorbian -C utf8 ', # trick, no conflict
+ 'vi': '-L vietnamese -C utf8 ',
+ # CYRILLIC
+ # for usage with pdflatex, needs also cyrLICRutf8.xdy module
+ 'be': '-L belarusian -C utf8 ',
+ 'bg': '-L bulgarian -C utf8 ',
+ 'mk': '-L macedonian -C utf8 ',
+ 'mn': '-L mongolian-cyrillic -C utf8 ',
+ 'ru': '-L russian -C utf8 ',
+ 'sr': '-L serbian -C utf8 ',
+ 'sh-cyrl': '-L serbian -C utf8 ',
+ 'sh': '-L serbian -C utf8 ', # trick, no conflict
+ 'uk': '-L ukrainian -C utf8 ',
+ # GREEK
+ # can work only with xelatex/lualatex, not supported by texindy+pdflatex
+ 'el': '-L greek -C utf8 ',
+ # FIXME, not compatible with [:2] slice but does Sphinx support Greek ?
+ 'el-polyton': '-L greek-polytonic -C utf8 ',
+}
+
+XINDY_CYRILLIC_SCRIPTS = [
+ 'be', 'bg', 'mk', 'mn', 'ru', 'sr', 'sh', 'uk',
+]
+
+logger = logging.getLogger(__name__)
+
+
+class LaTeXBuilder(Builder):
+ """
+ Builds LaTeX output to create PDF.
+ """
+ name = 'latex'
+ format = 'latex'
+ epilog = __('The LaTeX files are in %(outdir)s.')
+ if os.name == 'posix':
+ epilog += __("\nRun 'make' in that directory to run these through "
+ "(pdf)latex\n"
+ "(use `make latexpdf' here to do that automatically).")
+
+ supported_image_types = ['application/pdf', 'image/png', 'image/jpeg']
+ supported_remote_images = False
+ default_translator_class = LaTeXTranslator
+
+ def init(self) -> None:
+ self.babel: ExtBabel
+ self.context: dict[str, Any] = {}
+ self.docnames: Iterable[str] = {}
+ self.document_data: list[tuple[str, str, str, str, str, bool]] = []
+ self.themes = ThemeFactory(self.app)
+ texescape.init()
+
+ self.init_context()
+ self.init_babel()
+ self.init_multilingual()
+
+ def get_outdated_docs(self) -> str | list[str]:
+ return 'all documents' # for now
+
+ def get_target_uri(self, docname: str, typ: str | None = None) -> str:
+ if docname not in self.docnames:
+ raise NoUri(docname, typ)
+ return '%' + docname
+
+ def get_relative_uri(self, from_: str, to: str, typ: str | None = None) -> str:
+ # ignore source path
+ return self.get_target_uri(to, typ)
+
+ def init_document_data(self) -> None:
+ preliminary_document_data = [list(x) for x in self.config.latex_documents]
+ if not preliminary_document_data:
+ logger.warning(__('no "latex_documents" config value found; no documents '
+ 'will be written'))
+ return
+ # assign subdirs to titles
+ self.titles: list[tuple[str, str]] = []
+ for entry in preliminary_document_data:
+ docname = entry[0]
+ if docname not in self.env.all_docs:
+ logger.warning(__('"latex_documents" config value references unknown '
+ 'document %s'), docname)
+ continue
+ self.document_data.append(entry) # type: ignore[arg-type]
+ if docname.endswith(SEP + 'index'):
+ docname = docname[:-5]
+ self.titles.append((docname, entry[2]))
+
+ def init_context(self) -> None:
+ self.context = DEFAULT_SETTINGS.copy()
+
+ # Add special settings for latex_engine
+ self.context.update(ADDITIONAL_SETTINGS.get(self.config.latex_engine, {}))
+
+ # Add special settings for (latex_engine, language_code)
+ key = (self.config.latex_engine, self.config.language[:2])
+ self.context.update(ADDITIONAL_SETTINGS.get(key, {}))
+
+ # Apply user settings to context
+ self.context.update(self.config.latex_elements)
+ self.context['release'] = self.config.release
+ self.context['use_xindy'] = self.config.latex_use_xindy
+ self.context['booktabs'] = 'booktabs' in self.config.latex_table_style
+ self.context['borderless'] = 'borderless' in self.config.latex_table_style
+ self.context['colorrows'] = 'colorrows' in self.config.latex_table_style
+
+ if self.config.today:
+ self.context['date'] = self.config.today
+ else:
+ self.context['date'] = format_date(self.config.today_fmt or _('%b %d, %Y'),
+ language=self.config.language)
+
+ if self.config.latex_logo:
+ self.context['logofilename'] = path.basename(self.config.latex_logo)
+
+ # for compatibilities
+ self.context['indexname'] = _('Index')
+ if self.config.release:
+ # Show the release label only if release value exists
+ self.context.setdefault('releasename', _('Release'))
+
+ def update_context(self) -> None:
+ """Update template variables for .tex file just before writing."""
+ # Apply extension settings to context
+ registry = self.app.registry
+ self.context['packages'] = registry.latex_packages
+ self.context['packages_after_hyperref'] = registry.latex_packages_after_hyperref
+
+ def init_babel(self) -> None:
+ self.babel = ExtBabel(self.config.language, not self.context['babel'])
+ if not self.babel.is_supported_language():
+ # emit warning if specified language is invalid
+ # (only emitting, nothing changed to processing)
+ logger.warning(__('no Babel option known for language %r'),
+ self.config.language)
+
+ def init_multilingual(self) -> None:
+ if self.context['latex_engine'] == 'pdflatex':
+ if not self.babel.uses_cyrillic():
+ if 'X2' in self.context['fontenc']:
+ self.context['substitutefont'] = '\\usepackage{substitutefont}'
+ self.context['textcyrillic'] = ('\\usepackage[Xtwo]'
+ '{sphinxpackagecyrillic}')
+ elif 'T2A' in self.context['fontenc']:
+ self.context['substitutefont'] = '\\usepackage{substitutefont}'
+ self.context['textcyrillic'] = ('\\usepackage[TtwoA]'
+ '{sphinxpackagecyrillic}')
+ if 'LGR' in self.context['fontenc']:
+ self.context['substitutefont'] = '\\usepackage{substitutefont}'
+ else:
+ self.context['textgreek'] = ''
+ if self.context['substitutefont'] == '':
+ self.context['fontsubstitution'] = ''
+
+ # 'babel' key is public and user setting must be obeyed
+ if self.context['babel']:
+ self.context['classoptions'] += ',' + self.babel.get_language()
+ # this branch is not taken for xelatex/lualatex if default settings
+ self.context['multilingual'] = self.context['babel']
+ self.context['shorthandoff'] = SHORTHANDOFF
+
+ # Times fonts don't work with Cyrillic languages
+ if self.babel.uses_cyrillic() and 'fontpkg' not in self.config.latex_elements:
+ self.context['fontpkg'] = ''
+ elif self.context['polyglossia']:
+ self.context['classoptions'] += ',' + self.babel.get_language()
+ options = self.babel.get_mainlanguage_options()
+ if options:
+ language = fr'\setmainlanguage[{options}]{{{self.babel.get_language()}}}'
+ else:
+ language = r'\setmainlanguage{%s}' % self.babel.get_language()
+
+ self.context['multilingual'] = f'{self.context["polyglossia"]}\n{language}'
+
+ def write_stylesheet(self) -> None:
+ highlighter = highlighting.PygmentsBridge('latex', self.config.pygments_style)
+ stylesheet = path.join(self.outdir, 'sphinxhighlight.sty')
+ with open(stylesheet, 'w', encoding="utf-8") as f:
+ f.write('\\NeedsTeXFormat{LaTeX2e}[1995/12/01]\n')
+ f.write('\\ProvidesPackage{sphinxhighlight}'
+ '[2022/06/30 stylesheet for highlighting with pygments]\n')
+ f.write('% Its contents depend on pygments_style configuration variable.\n\n')
+ f.write(highlighter.get_stylesheet())
+
+ def copy_assets(self) -> None:
+ self.copy_support_files()
+
+ if self.config.latex_additional_files:
+ self.copy_latex_additional_files()
+
+ def write(self, *ignored: Any) -> None:
+ docwriter = LaTeXWriter(self)
+ with warnings.catch_warnings():
+ warnings.filterwarnings('ignore', category=DeprecationWarning)
+ # DeprecationWarning: The frontend.OptionParser class will be replaced
+ # by a subclass of argparse.ArgumentParser in Docutils 0.21 or later.
+ docsettings: Any = OptionParser(
+ defaults=self.env.settings,
+ components=(docwriter,),
+ read_config_files=True).get_default_values()
+
+ self.init_document_data()
+ self.write_stylesheet()
+ self.copy_assets()
+
+ for entry in self.document_data:
+ docname, targetname, title, author, themename = entry[:5]
+ theme = self.themes.get(themename)
+ toctree_only = False
+ if len(entry) > 5:
+ toctree_only = entry[5]
+ destination = SphinxFileOutput(destination_path=path.join(self.outdir, targetname),
+ encoding='utf-8', overwrite_if_changed=True)
+ with progress_message(__("processing %s") % targetname):
+ doctree = self.env.get_doctree(docname)
+ toctree = next(doctree.findall(addnodes.toctree), None)
+ if toctree and toctree.get('maxdepth') > 0:
+ tocdepth = toctree.get('maxdepth')
+ else:
+ tocdepth = None
+
+ doctree = self.assemble_doctree(
+ docname, toctree_only,
+ appendices=(self.config.latex_appendices if theme.name != 'howto' else []))
+ doctree['docclass'] = theme.docclass
+ doctree['contentsname'] = self.get_contentsname(docname)
+ doctree['tocdepth'] = tocdepth
+ self.post_process_images(doctree)
+ self.update_doc_context(title, author, theme)
+ self.update_context()
+
+ with progress_message(__("writing")):
+ docsettings._author = author
+ docsettings._title = title
+ docsettings._contentsname = doctree['contentsname']
+ docsettings._docname = docname
+ docsettings._docclass = theme.name
+
+ doctree.settings = docsettings
+ docwriter.theme = theme
+ docwriter.write(doctree, destination)
+
+ def get_contentsname(self, indexfile: str) -> str:
+ tree = self.env.get_doctree(indexfile)
+ contentsname = ''
+ for toctree in tree.findall(addnodes.toctree):
+ if 'caption' in toctree:
+ contentsname = toctree['caption']
+ break
+
+ return contentsname
+
+ def update_doc_context(self, title: str, author: str, theme: Theme) -> None:
+ self.context['title'] = title
+ self.context['author'] = author
+ self.context['docclass'] = theme.docclass
+ self.context['papersize'] = theme.papersize
+ self.context['pointsize'] = theme.pointsize
+ self.context['wrapperclass'] = theme.wrapperclass
+
+ def assemble_doctree(
+ self, indexfile: str, toctree_only: bool, appendices: list[str],
+ ) -> nodes.document:
+ self.docnames = set([indexfile] + appendices)
+ logger.info(darkgreen(indexfile) + " ", nonl=True)
+ tree = self.env.get_doctree(indexfile)
+ tree['docname'] = indexfile
+ if toctree_only:
+ # extract toctree nodes from the tree and put them in a
+ # fresh document
+ new_tree = new_document('<latex output>')
+ new_sect = nodes.section()
+ new_sect += nodes.title('<Set title in conf.py>',
+ '<Set title in conf.py>')
+ new_tree += new_sect
+ for node in tree.findall(addnodes.toctree):
+ new_sect += node
+ tree = new_tree
+ largetree = inline_all_toctrees(self, self.docnames, indexfile, tree,
+ darkgreen, [indexfile])
+ largetree['docname'] = indexfile
+ for docname in appendices:
+ appendix = self.env.get_doctree(docname)
+ appendix['docname'] = docname
+ largetree.append(appendix)
+ logger.info('')
+ logger.info(__("resolving references..."))
+ self.env.resolve_references(largetree, indexfile, self)
+ # resolve :ref:s to distant tex files -- we can't add a cross-reference,
+ # but append the document name
+ for pendingnode in largetree.findall(addnodes.pending_xref):
+ docname = pendingnode['refdocname']
+ sectname = pendingnode['refsectname']
+ newnodes: list[Node] = [nodes.emphasis(sectname, sectname)]
+ for subdir, title in self.titles:
+ if docname.startswith(subdir):
+ newnodes.append(nodes.Text(_(' (in ')))
+ newnodes.append(nodes.emphasis(title, title))
+ newnodes.append(nodes.Text(')'))
+ break
+ else:
+ pass
+ pendingnode.replace_self(newnodes)
+ return largetree
+
+ def finish(self) -> None:
+ self.copy_image_files()
+ self.write_message_catalog()
+
+ @progress_message(__('copying TeX support files'))
+ def copy_support_files(self) -> None:
+ """copy TeX support files from texinputs."""
+ # configure usage of xindy (impacts Makefile and latexmkrc)
+ # FIXME: convert this rather to a confval with suitable default
+ # according to language ? but would require extra documentation
+ xindy_lang_option = XINDY_LANG_OPTIONS.get(self.config.language[:2],
+ '-L general -C utf8 ')
+ xindy_cyrillic = self.config.language[:2] in XINDY_CYRILLIC_SCRIPTS
+
+ context = {
+ 'latex_engine': self.config.latex_engine,
+ 'xindy_use': self.config.latex_use_xindy,
+ 'xindy_lang_option': xindy_lang_option,
+ 'xindy_cyrillic': xindy_cyrillic,
+ }
+ logger.info(bold(__('copying TeX support files...')))
+ staticdirname = path.join(package_dir, 'texinputs')
+ for filename in os.listdir(staticdirname):
+ if not filename.startswith('.'):
+ copy_asset_file(path.join(staticdirname, filename),
+ self.outdir, context=context)
+
+ # use pre-1.6.x Makefile for make latexpdf on Windows
+ if os.name == 'nt':
+ staticdirname = path.join(package_dir, 'texinputs_win')
+ copy_asset_file(path.join(staticdirname, 'Makefile_t'),
+ self.outdir, context=context)
+
+ @progress_message(__('copying additional files'))
+ def copy_latex_additional_files(self) -> None:
+ for filename in self.config.latex_additional_files:
+ logger.info(' ' + filename, nonl=True)
+ copy_asset_file(path.join(self.confdir, filename), self.outdir)
+
+ def copy_image_files(self) -> None:
+ if self.images:
+ stringify_func = ImageAdapter(self.app.env).get_original_image_uri
+ for src in status_iterator(self.images, __('copying images... '), "brown",
+ len(self.images), self.app.verbosity,
+ stringify_func=stringify_func):
+ dest = self.images[src]
+ try:
+ copy_asset_file(path.join(self.srcdir, src),
+ path.join(self.outdir, dest))
+ except Exception as err:
+ logger.warning(__('cannot copy image file %r: %s'),
+ path.join(self.srcdir, src), err)
+ if self.config.latex_logo:
+ if not path.isfile(path.join(self.confdir, self.config.latex_logo)):
+ raise SphinxError(__('logo file %r does not exist') % self.config.latex_logo)
+ copy_asset_file(path.join(self.confdir, self.config.latex_logo), self.outdir)
+
+ def write_message_catalog(self) -> None:
+ formats = self.config.numfig_format
+ context = {
+ 'addtocaptions': r'\@iden',
+ 'figurename': formats.get('figure', '').split('%s', 1),
+ 'tablename': formats.get('table', '').split('%s', 1),
+ 'literalblockname': formats.get('code-block', '').split('%s', 1),
+ }
+
+ if self.context['babel'] or self.context['polyglossia']:
+ context['addtocaptions'] = r'\addto\captions%s' % self.babel.get_language()
+
+ filename = path.join(package_dir, 'templates', 'latex', 'sphinxmessages.sty_t')
+ copy_asset_file(filename, self.outdir, context=context, renderer=LaTeXRenderer())
+
+
+def validate_config_values(app: Sphinx, config: Config) -> None:
+ for key in list(config.latex_elements):
+ if key not in DEFAULT_SETTINGS:
+ msg = __("Unknown configure key: latex_elements[%r], ignored.")
+ logger.warning(msg % (key,))
+ config.latex_elements.pop(key)
+
+
+def validate_latex_theme_options(app: Sphinx, config: Config) -> None:
+ for key in list(config.latex_theme_options):
+ if key not in Theme.UPDATABLE_KEYS:
+ msg = __("Unknown theme option: latex_theme_options[%r], ignored.")
+ logger.warning(msg % (key,))
+ config.latex_theme_options.pop(key)
+
+
+def install_packages_for_ja(app: Sphinx) -> None:
+ """Install packages for Japanese."""
+ if app.config.language == 'ja' and app.config.latex_engine in ('platex', 'uplatex'):
+ app.add_latex_package('pxjahyper', after_hyperref=True)
+
+
+def default_latex_engine(config: Config) -> str:
+ """ Better default latex_engine settings for specific languages. """
+ if config.language == 'ja':
+ return 'uplatex'
+ if config.language.startswith('zh'):
+ return 'xelatex'
+ if config.language == 'el':
+ return 'xelatex'
+ return 'pdflatex'
+
+
+def default_latex_docclass(config: Config) -> dict[str, str]:
+ """ Better default latex_docclass settings for specific languages. """
+ if config.language == 'ja':
+ if config.latex_engine == 'uplatex':
+ return {'manual': 'ujbook',
+ 'howto': 'ujreport'}
+ else:
+ return {'manual': 'jsbook',
+ 'howto': 'jreport'}
+ else:
+ return {}
+
+
+def default_latex_use_xindy(config: Config) -> bool:
+ """ Better default latex_use_xindy settings for specific engines. """
+ return config.latex_engine in {'xelatex', 'lualatex'}
+
+
+def default_latex_documents(config: Config) -> list[tuple[str, str, str, str, str]]:
+ """ Better default latex_documents settings. """
+ project = texescape.escape(config.project, config.latex_engine)
+ author = texescape.escape(config.author, config.latex_engine)
+ return [(config.root_doc,
+ make_filename_from_project(config.project) + '.tex',
+ texescape.escape_abbr(project),
+ texescape.escape_abbr(author),
+ config.latex_theme)]
+
+
+def setup(app: Sphinx) -> dict[str, Any]:
+ app.setup_extension('sphinx.builders.latex.transforms')
+
+ app.add_builder(LaTeXBuilder)
+ app.connect('config-inited', validate_config_values, priority=800)
+ app.connect('config-inited', validate_latex_theme_options, priority=800)
+ app.connect('builder-inited', install_packages_for_ja)
+
+ app.add_config_value('latex_engine', default_latex_engine, False,
+ ENUM('pdflatex', 'xelatex', 'lualatex', 'platex', 'uplatex'))
+ app.add_config_value('latex_documents', default_latex_documents, False)
+ app.add_config_value('latex_logo', None, False, [str])
+ app.add_config_value('latex_appendices', [], False)
+ app.add_config_value('latex_use_latex_multicolumn', False, False)
+ app.add_config_value('latex_use_xindy', default_latex_use_xindy, False, [bool])
+ app.add_config_value('latex_toplevel_sectioning', None, False,
+ ENUM(None, 'part', 'chapter', 'section'))
+ app.add_config_value('latex_domain_indices', True, False, [list])
+ app.add_config_value('latex_show_urls', 'no', False)
+ app.add_config_value('latex_show_pagerefs', False, False)
+ app.add_config_value('latex_elements', {}, False)
+ app.add_config_value('latex_additional_files', [], False)
+ app.add_config_value('latex_table_style', ['booktabs', 'colorrows'], False, [list])
+ app.add_config_value('latex_theme', 'manual', False, [str])
+ app.add_config_value('latex_theme_options', {}, False)
+ app.add_config_value('latex_theme_path', [], False, [list])
+
+ app.add_config_value('latex_docclass', default_latex_docclass, False)
+
+ return {
+ 'version': 'builtin',
+ 'parallel_read_safe': True,
+ 'parallel_write_safe': True,
+ }
diff --git a/sphinx/builders/latex/constants.py b/sphinx/builders/latex/constants.py
new file mode 100644
index 0000000..ce646d0
--- /dev/null
+++ b/sphinx/builders/latex/constants.py
@@ -0,0 +1,210 @@
+"""constants for LaTeX builder."""
+
+from __future__ import annotations
+
+from typing import Any
+
+PDFLATEX_DEFAULT_FONTPKG = r'''
+\usepackage{tgtermes}
+\usepackage{tgheros}
+\renewcommand{\ttdefault}{txtt}
+'''
+
+PDFLATEX_DEFAULT_FONTSUBSTITUTION = r'''
+\expandafter\ifx\csname T@LGR\endcsname\relax
+\else
+% LGR was declared as font encoding
+ \substitutefont{LGR}{\rmdefault}{cmr}
+ \substitutefont{LGR}{\sfdefault}{cmss}
+ \substitutefont{LGR}{\ttdefault}{cmtt}
+\fi
+\expandafter\ifx\csname T@X2\endcsname\relax
+ \expandafter\ifx\csname T@T2A\endcsname\relax
+ \else
+ % T2A was declared as font encoding
+ \substitutefont{T2A}{\rmdefault}{cmr}
+ \substitutefont{T2A}{\sfdefault}{cmss}
+ \substitutefont{T2A}{\ttdefault}{cmtt}
+ \fi
+\else
+% X2 was declared as font encoding
+ \substitutefont{X2}{\rmdefault}{cmr}
+ \substitutefont{X2}{\sfdefault}{cmss}
+ \substitutefont{X2}{\ttdefault}{cmtt}
+\fi
+'''
+
+XELATEX_DEFAULT_FONTPKG = r'''
+\setmainfont{FreeSerif}[
+ Extension = .otf,
+ UprightFont = *,
+ ItalicFont = *Italic,
+ BoldFont = *Bold,
+ BoldItalicFont = *BoldItalic
+]
+\setsansfont{FreeSans}[
+ Extension = .otf,
+ UprightFont = *,
+ ItalicFont = *Oblique,
+ BoldFont = *Bold,
+ BoldItalicFont = *BoldOblique,
+]
+\setmonofont{FreeMono}[
+ Extension = .otf,
+ UprightFont = *,
+ ItalicFont = *Oblique,
+ BoldFont = *Bold,
+ BoldItalicFont = *BoldOblique,
+]
+'''
+
+XELATEX_GREEK_DEFAULT_FONTPKG = (XELATEX_DEFAULT_FONTPKG +
+ '\n\\newfontfamily\\greekfont{FreeSerif}' +
+ '\n\\newfontfamily\\greekfontsf{FreeSans}' +
+ '\n\\newfontfamily\\greekfonttt{FreeMono}')
+
+LUALATEX_DEFAULT_FONTPKG = XELATEX_DEFAULT_FONTPKG
+
+DEFAULT_SETTINGS: dict[str, Any] = {
+ 'latex_engine': 'pdflatex',
+ 'papersize': '',
+ 'pointsize': '',
+ 'pxunit': '.75bp',
+ 'classoptions': '',
+ 'extraclassoptions': '',
+ 'maxlistdepth': '',
+ 'sphinxpkgoptions': '',
+ 'sphinxsetup': '',
+ 'fvset': '\\fvset{fontsize=auto}',
+ 'passoptionstopackages': '',
+ 'geometry': '\\usepackage{geometry}',
+ 'inputenc': '',
+ 'utf8extra': '',
+ 'cmappkg': '\\usepackage{cmap}',
+ 'fontenc': '\\usepackage[T1]{fontenc}',
+ 'amsmath': '\\usepackage{amsmath,amssymb,amstext}',
+ 'multilingual': '',
+ 'babel': '\\usepackage{babel}',
+ 'polyglossia': '',
+ 'fontpkg': PDFLATEX_DEFAULT_FONTPKG,
+ 'fontsubstitution': PDFLATEX_DEFAULT_FONTSUBSTITUTION,
+ 'substitutefont': '',
+ 'textcyrillic': '',
+ 'textgreek': '\\usepackage{textalpha}',
+ 'fncychap': '\\usepackage[Bjarne]{fncychap}',
+ 'hyperref': ('% Include hyperref last.\n'
+ '\\usepackage{hyperref}\n'
+ '% Fix anchor placement for figures with captions.\n'
+ '\\usepackage{hypcap}% it must be loaded after hyperref.\n'
+ '% Set up styles of URL: it should be placed after hyperref.\n'
+ '\\urlstyle{same}'),
+ 'contentsname': '',
+ 'extrapackages': '',
+ 'preamble': '',
+ 'title': '',
+ 'release': '',
+ 'author': '',
+ 'releasename': '',
+ 'makeindex': '\\makeindex',
+ 'shorthandoff': '',
+ 'maketitle': '\\sphinxmaketitle',
+ 'tableofcontents': '\\sphinxtableofcontents',
+ 'atendofbody': '',
+ 'printindex': '\\printindex',
+ 'transition': '\n\n\\bigskip\\hrule\\bigskip\n\n',
+ 'figure_align': 'htbp',
+ 'tocdepth': '',
+ 'secnumdepth': '',
+}
+
+ADDITIONAL_SETTINGS: dict[Any, dict[str, Any]] = {
+ 'pdflatex': {
+ 'inputenc': '\\usepackage[utf8]{inputenc}',
+ 'utf8extra': ('\\ifdefined\\DeclareUnicodeCharacter\n'
+ '% support both utf8 and utf8x syntaxes\n'
+ ' \\ifdefined\\DeclareUnicodeCharacterAsOptional\n'
+ ' \\def\\sphinxDUC#1{\\DeclareUnicodeCharacter{"#1}}\n'
+ ' \\else\n'
+ ' \\let\\sphinxDUC\\DeclareUnicodeCharacter\n'
+ ' \\fi\n'
+ ' \\sphinxDUC{00A0}{\\nobreakspace}\n'
+ ' \\sphinxDUC{2500}{\\sphinxunichar{2500}}\n'
+ ' \\sphinxDUC{2502}{\\sphinxunichar{2502}}\n'
+ ' \\sphinxDUC{2514}{\\sphinxunichar{2514}}\n'
+ ' \\sphinxDUC{251C}{\\sphinxunichar{251C}}\n'
+ ' \\sphinxDUC{2572}{\\textbackslash}\n'
+ '\\fi'),
+ },
+ 'xelatex': {
+ 'latex_engine': 'xelatex',
+ 'polyglossia': '\\usepackage{polyglossia}',
+ 'babel': '',
+ 'fontenc': ('\\usepackage{fontspec}\n'
+ '\\defaultfontfeatures[\\rmfamily,\\sffamily,\\ttfamily]{}'),
+ 'fontpkg': XELATEX_DEFAULT_FONTPKG,
+ 'fvset': '\\fvset{fontsize=\\small}',
+ 'fontsubstitution': '',
+ 'textgreek': '',
+ 'utf8extra': ('\\catcode`^^^^00a0\\active\\protected\\def^^^^00a0'
+ '{\\leavevmode\\nobreak\\ }'),
+ },
+ 'lualatex': {
+ 'latex_engine': 'lualatex',
+ 'polyglossia': '\\usepackage{polyglossia}',
+ 'babel': '',
+ 'fontenc': ('\\usepackage{fontspec}\n'
+ '\\defaultfontfeatures[\\rmfamily,\\sffamily,\\ttfamily]{}'),
+ 'fontpkg': LUALATEX_DEFAULT_FONTPKG,
+ 'fvset': '\\fvset{fontsize=\\small}',
+ 'fontsubstitution': '',
+ 'textgreek': '',
+ 'utf8extra': ('\\catcode`^^^^00a0\\active\\protected\\def^^^^00a0'
+ '{\\leavevmode\\nobreak\\ }'),
+ },
+ 'platex': {
+ 'latex_engine': 'platex',
+ 'babel': '',
+ 'classoptions': ',dvipdfmx',
+ 'fontpkg': PDFLATEX_DEFAULT_FONTPKG,
+ 'fontsubstitution': '',
+ 'textgreek': '',
+ 'fncychap': '',
+ 'geometry': '\\usepackage[dvipdfm]{geometry}',
+ },
+ 'uplatex': {
+ 'latex_engine': 'uplatex',
+ 'babel': '',
+ 'classoptions': ',dvipdfmx',
+ 'fontpkg': PDFLATEX_DEFAULT_FONTPKG,
+ 'fontsubstitution': '',
+ 'textgreek': '',
+ 'fncychap': '',
+ 'geometry': '\\usepackage[dvipdfm]{geometry}',
+ },
+
+ # special settings for latex_engine + language_code
+ ('xelatex', 'fr'): {
+ # use babel instead of polyglossia by default
+ 'polyglossia': '',
+ 'babel': '\\usepackage{babel}',
+ },
+ ('xelatex', 'zh'): {
+ 'polyglossia': '',
+ 'babel': '\\usepackage{babel}',
+ 'fontenc': '\\usepackage{xeCJK}',
+ # set formatcom=\xeCJKVerbAddon to prevent xeCJK from adding extra spaces in
+ # fancyvrb Verbatim environment.
+ 'fvset': '\\fvset{fontsize=\\small,formatcom=\\xeCJKVerbAddon}',
+ },
+ ('xelatex', 'el'): {
+ 'fontpkg': XELATEX_GREEK_DEFAULT_FONTPKG,
+ },
+}
+
+
+SHORTHANDOFF = r'''
+\ifdefined\shorthandoff
+ \ifnum\catcode`\=\string=\active\shorthandoff{=}\fi
+ \ifnum\catcode`\"=\active\shorthandoff{"}\fi
+\fi
+'''
diff --git a/sphinx/builders/latex/nodes.py b/sphinx/builders/latex/nodes.py
new file mode 100644
index 0000000..2c008b9
--- /dev/null
+++ b/sphinx/builders/latex/nodes.py
@@ -0,0 +1,37 @@
+"""Additional nodes for LaTeX writer."""
+
+from docutils import nodes
+
+
+class captioned_literal_block(nodes.container):
+ """A node for a container of literal_block having a caption."""
+ pass
+
+
+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``."""
+
+
+class math_reference(nodes.Inline, nodes.Referential, nodes.TextElement):
+ """A node for a reference for equation."""
+ pass
+
+
+class thebibliography(nodes.container):
+ """A node for wrapping bibliographies."""
+ pass
+
+
+HYPERLINK_SUPPORT_NODES = (
+ nodes.figure,
+ nodes.literal_block,
+ nodes.table,
+ nodes.section,
+ captioned_literal_block,
+)
diff --git a/sphinx/builders/latex/theming.py b/sphinx/builders/latex/theming.py
new file mode 100644
index 0000000..21b49e8
--- /dev/null
+++ b/sphinx/builders/latex/theming.py
@@ -0,0 +1,135 @@
+"""Theming support for LaTeX builder."""
+
+from __future__ import annotations
+
+import configparser
+from os import path
+from typing import TYPE_CHECKING
+
+from sphinx.errors import ThemeError
+from sphinx.locale import __
+from sphinx.util import logging
+
+if TYPE_CHECKING:
+ from sphinx.application import Sphinx
+ from sphinx.config import Config
+
+logger = logging.getLogger(__name__)
+
+
+class Theme:
+ """A set of LaTeX configurations."""
+
+ LATEX_ELEMENTS_KEYS = ['papersize', 'pointsize']
+ UPDATABLE_KEYS = ['papersize', 'pointsize']
+
+ def __init__(self, name: str) -> None:
+ self.name = name
+ self.docclass = name
+ self.wrapperclass = name
+ self.papersize = 'letterpaper'
+ self.pointsize = '10pt'
+ self.toplevel_sectioning = 'chapter'
+
+ def update(self, config: Config) -> None:
+ """Override theme settings by user's configuration."""
+ for key in self.LATEX_ELEMENTS_KEYS:
+ if config.latex_elements.get(key):
+ value = config.latex_elements[key]
+ setattr(self, key, value)
+
+ for key in self.UPDATABLE_KEYS:
+ if key in config.latex_theme_options:
+ value = config.latex_theme_options[key]
+ setattr(self, key, value)
+
+
+class BuiltInTheme(Theme):
+ """A built-in LaTeX theme."""
+
+ def __init__(self, name: str, config: Config) -> None:
+ super().__init__(name)
+
+ if name == 'howto':
+ self.docclass = config.latex_docclass.get('howto', 'article')
+ else:
+ self.docclass = config.latex_docclass.get('manual', 'report')
+
+ if name in ('manual', 'howto'):
+ self.wrapperclass = 'sphinx' + name
+ else:
+ self.wrapperclass = name
+
+ # we assume LaTeX class provides \chapter command except in case
+ # of non-Japanese 'howto' case
+ if name == 'howto' and not self.docclass.startswith('j'):
+ self.toplevel_sectioning = 'section'
+ else:
+ self.toplevel_sectioning = 'chapter'
+
+
+class UserTheme(Theme):
+ """A user defined LaTeX theme."""
+
+ REQUIRED_CONFIG_KEYS = ['docclass', 'wrapperclass']
+ OPTIONAL_CONFIG_KEYS = ['papersize', 'pointsize', 'toplevel_sectioning']
+
+ def __init__(self, name: str, filename: str) -> None:
+ super().__init__(name)
+ self.config = configparser.RawConfigParser()
+ self.config.read(path.join(filename), encoding='utf-8')
+
+ for key in self.REQUIRED_CONFIG_KEYS:
+ try:
+ value = self.config.get('theme', key)
+ setattr(self, key, value)
+ except configparser.NoSectionError as exc:
+ raise ThemeError(__('%r doesn\'t have "theme" setting') %
+ filename) from exc
+ except configparser.NoOptionError as exc:
+ raise ThemeError(__('%r doesn\'t have "%s" setting') %
+ (filename, exc.args[0])) from exc
+
+ for key in self.OPTIONAL_CONFIG_KEYS:
+ try:
+ value = self.config.get('theme', key)
+ setattr(self, key, value)
+ except configparser.NoOptionError:
+ pass
+
+
+class ThemeFactory:
+ """A factory class for LaTeX Themes."""
+
+ def __init__(self, app: Sphinx) -> None:
+ self.themes: dict[str, Theme] = {}
+ self.theme_paths = [path.join(app.srcdir, p) for p in app.config.latex_theme_path]
+ self.config = app.config
+ self.load_builtin_themes(app.config)
+
+ def load_builtin_themes(self, config: Config) -> None:
+ """Load built-in themes."""
+ self.themes['manual'] = BuiltInTheme('manual', config)
+ self.themes['howto'] = BuiltInTheme('howto', config)
+
+ def get(self, name: str) -> Theme:
+ """Get a theme for given *name*."""
+ if name in self.themes:
+ theme = self.themes[name]
+ else:
+ theme = self.find_user_theme(name) or Theme(name)
+
+ theme.update(self.config)
+ return theme
+
+ def find_user_theme(self, name: str) -> Theme | None:
+ """Find a theme named as *name* from latex_theme_path."""
+ for theme_path in self.theme_paths:
+ config_path = path.join(theme_path, name, 'theme.conf')
+ if path.isfile(config_path):
+ try:
+ return UserTheme(name, config_path)
+ except ThemeError as exc:
+ logger.warning(exc)
+
+ return None
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,
+ }
diff --git a/sphinx/builders/latex/util.py b/sphinx/builders/latex/util.py
new file mode 100644
index 0000000..01597f9
--- /dev/null
+++ b/sphinx/builders/latex/util.py
@@ -0,0 +1,48 @@
+"""Utilities for LaTeX builder."""
+
+from __future__ import annotations
+
+from docutils.writers.latex2e import Babel
+
+
+class ExtBabel(Babel):
+ cyrillic_languages = ('bulgarian', 'kazakh', 'mongolian', 'russian', 'ukrainian')
+
+ def __init__(self, language_code: str, use_polyglossia: bool = False) -> None:
+ self.language_code = language_code
+ self.use_polyglossia = use_polyglossia
+ self.supported = True
+ super().__init__(language_code)
+
+ def uses_cyrillic(self) -> bool:
+ return self.language in self.cyrillic_languages
+
+ def is_supported_language(self) -> bool:
+ return self.supported
+
+ def language_name(self, language_code: str) -> str:
+ language = super().language_name(language_code)
+ if language == 'ngerman' and self.use_polyglossia:
+ # polyglossia calls new orthography (Neue Rechtschreibung) as
+ # german (with new spelling option).
+ return 'german'
+ elif language:
+ return language
+ elif language_code.startswith('zh'):
+ return 'english' # fallback to english (behaves like supported)
+ else:
+ self.supported = False
+ return 'english' # fallback to english
+
+ def get_mainlanguage_options(self) -> str | None:
+ """Return options for polyglossia's ``\\setmainlanguage``."""
+ if self.use_polyglossia is False:
+ return None
+ elif self.language == 'german':
+ language = super().language_name(self.language_code)
+ if language == 'ngerman':
+ return 'spelling=new'
+ else:
+ return 'spelling=old'
+ else:
+ return None