Skip to content

Commit

Permalink
Custom anchors for bibliographies and citations. (#265)
Browse files Browse the repository at this point in the history
Closes issue #264.
  • Loading branch information
mcmtroffaes committed Sep 5, 2021
1 parent 259410b commit b9ec080
Show file tree
Hide file tree
Showing 13 changed files with 312 additions and 23 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
The ``parse_bibfile`` and ``process_bibfile`` functions have been been
replaced by ``parse_bibdata`` and ``process_bibdata`` in the API.

* New ``bibtex_cite_id``, ``bibtex_footcite_id``,
``bibtex_bibliography_id``, and ``bibtex_footbibliography_id`` settings,
which allow custom ids (which can be used as html anchors)
to be generated for citations and bibliographies,
based on the citation keys rather than some random numbers
(see issue #264, reported by kmuehlbauer).
Refer to the documentation for detailed usage and examples.


2.3.0 (1 June 2021)
-------------------

Expand Down
58 changes: 58 additions & 0 deletions doc/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,63 @@ Now ``author_year_round`` will be available to you as a formatting style:
An minimal example is available here:
https://github.com/mcmtroffaes/sphinxcontrib-bibtex/tree/develop/test/roots/test-citation_style_round_brackets

Custom Html Anchors
~~~~~~~~~~~~~~~~~~~

.. versionadded:: 2.4.0

For every citation and every bibliography, an identifier
of the form ``idxxx`` (where ``xxx`` is some number) is generated.
These identifiers can be used as html anchors.
They are automatically
generated by docutils and are thereby guaranteed not to clash.

However, sometimes it is useful to refer to bibliographic entries from other
external documents that have not been generated with Sphinx.
Since the generated identifiers can easily break when updating documents,
they can be customized through string templates should you need this.
If you do so, it is your responsibility to ensure that no anchors will clash,
by setting up the appropriate identifier templates in your ``conf.py`` file,
for instance as follows:

.. code-block:: python
bibtex_cite_id = "cite-{bibliography_count}-{key}"
bibtex_footcite_id = "footcite-{key}"
bibtex_bibliography_id = "bibliography-{bibliography_count}"
bibtex_footbibliography_id = "footbibliography-{footbibliography_count}"
If you have at most one :rst:dir:`bibliography` directive per document,
then you can also use:

.. code-block:: python
bibtex_cite_id = "cite-{key}"
The ``bibliography_count`` template variable
counts :rst:dir:`bibliography` directives in the current document,
thus giving a unique number for each :rst:dir:`bibliography` directive
within a document.
The ``footbibliography_count`` template variable works similarly but for
:rst:dir:`footbibliography` directives.
The ``key`` template variable corresponds to the bibtex citation key,
including the key prefix if specified.
After formatting the template, the resulting string is filtered through
docutils's ``make_id`` function, which will remove and/or translate
any illegal characters.
In particular, colons and underscores will be translated into dashes.

.. warning::

If you have more than one :rst:dir:`bibliography` directive in any document,
then you *must* include ``bibliography_count``
as part of your ``bibtex_cite_id``
template to avoid issues with duplicate identifiers,
*even if there are no duplicate citations*.
This is because the extension must generate an identifier for every key
for each :rst:dir:`bibliography` directive
prior to knowing whether or not the citation needs to be included.

Custom Bibliography Header
~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -842,6 +899,7 @@ The complete list of warning subtypes that can be suppressed is::
bibtex.bibfile_data_error
bibtex.bibfile_error
bibtex.duplicate_citation
bibtex.duplicate_id
bibtex.duplicate_label
bibtex.filter_overrides
bibtex.filter_syntax_error
Expand Down
6 changes: 5 additions & 1 deletion src/sphinxcontrib/bibtex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def setup(app: Sphinx) -> Dict[str, Any]:
app.add_config_value("bibtex_footbibliography_header", "", "html")
app.add_config_value("bibtex_reference_style", "label", "env")
app.add_config_value("bibtex_foot_reference_style", "foot", "env")
app.add_config_value("bibtex_cite_id", "", "html")
app.add_config_value("bibtex_footcite_id", "", "html")
app.add_config_value("bibtex_bibliography_id", "", "html")
app.add_config_value("bibtex_footbibliography_id", "", "html")
app.add_domain(BibtexDomain)
app.add_directive("bibliography", BibliographyDirective)
app.add_role("cite", CiteRole())
Expand All @@ -46,7 +50,7 @@ def setup(app: Sphinx) -> Dict[str, Any]:

return {
'version': '2.4.0a0',
'env_version': 8,
'env_version': 9,
'parallel_read_safe': True,
'parallel_write_safe': True,
}
21 changes: 20 additions & 1 deletion src/sphinxcontrib/bibtex/bibfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
"""
import math
import os.path
from typing import TYPE_CHECKING, Dict, NamedTuple, List
from typing import TYPE_CHECKING, Dict, NamedTuple, List, Set

from docutils.nodes import make_id
from pybtex.database.input.bibtex import Parser
from pybtex.database import BibliographyData, BibliographyDataError
from sphinx.util.logging import getLogger
Expand Down Expand Up @@ -103,3 +104,21 @@ def process_bibdata(bibdata: BibData,
else:
logger.info("up to date")
return bibdata


# function does not really fit in any module, but used by both
# cite and footcite domains, so for now it's residing here
def _make_ids(docname: str, lineno: int, ids: Set[str], raw_id: str
) -> List[str]:
if raw_id:
id_ = make_id(raw_id)
if id_ in ids:
logger.warning(f"duplicate citation id {id_}",
location=(docname, lineno),
type="bibtex", subtype="duplicate_id")
return []
else:
ids.add(id_)
return [id_]
else:
return []
21 changes: 18 additions & 3 deletions src/sphinxcontrib/bibtex/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import docutils.nodes
import sphinx.util

from .bibfile import normpath_filename
from .bibfile import normpath_filename, _make_ids
from .nodes import bibliography as bibliography_node

if TYPE_CHECKING:
Expand Down Expand Up @@ -160,13 +160,28 @@ def run(self):
citation_node_class = docutils.nodes.list_item
else:
citation_node_class = docutils.nodes.citation
node = bibliography_node('', docname=env.docname)
bibliography_count = env.temp_data["bibtex_bibliography_count"] = \
env.temp_data.get("bibtex_bibliography_count", 0) + 1
ids = set(self.state.document.ids.keys())
node = bibliography_node(
'', docname=env.docname, ids=_make_ids(
docname=env.docname, lineno=self.lineno,
ids=ids,
raw_id=env.app.config.bibtex_bibliography_id.format(
bibliography_count=bibliography_count)))
self.state.document.note_explicit_target(node, node)
# we only know which citations to included at resolve stage
# but we need to know their ids before resolve stage
# so for now we generate a node, and thus, an id, for every entry
citation_nodes: Dict[str, docutils.nodes.Element] = {
keyprefix + entry.key: citation_node_class()
keyprefix + entry.key:
citation_node_class(ids=_make_ids(
docname=env.docname,
lineno=self.lineno,
ids=ids,
raw_id=env.app.config.bibtex_cite_id.format(
bibliography_count=bibliography_count,
key=keyprefix + entry.key)))
for entry in domain.get_entries(bibfiles)}
for citation_node in citation_nodes.values():
self.state.document.note_explicit_target(
Expand Down
12 changes: 12 additions & 0 deletions src/sphinxcontrib/bibtex/foot_directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from typing import TYPE_CHECKING, cast
from docutils.parsers.rst import Directive

from .bibfile import _make_ids

if TYPE_CHECKING:
from sphinx.environment import BuildEnvironment
from .domain import BibtexDomain
Expand All @@ -28,6 +30,9 @@ def run(self):
env = cast("BuildEnvironment", self.state.document.settings.env)
foot_old_refs = env.temp_data.setdefault("bibtex_foot_old_refs", set())
foot_new_refs = env.temp_data.setdefault("bibtex_foot_new_refs", set())
footbibliography_count = \
env.temp_data["bibtex_footbibliography_count"] = \
env.temp_data.get("bibtex_footbibliography_count", 0) + 1
if not foot_new_refs:
return []
else:
Expand All @@ -41,4 +46,11 @@ def run(self):
domain = cast("BibtexDomain", env.get_domain('cite'))
for bibfile in domain.bibdata.bibfiles:
env.note_dependency(bibfile)
foot_bibliography['ids'] += _make_ids(
docname=env.docname, lineno=self.lineno,
ids=set(self.state.document.ids.keys()),
raw_id=env.app.config.bibtex_footbibliography_id.format(
footbibliography_count=footbibliography_count))
self.state.document.note_explicit_target(
foot_bibliography, foot_bibliography)
return [foot_bibliography]
36 changes: 29 additions & 7 deletions src/sphinxcontrib/bibtex/foot_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import docutils.nodes
import pybtex_docutils
from docutils.nodes import make_id
from pybtex.plugin import find_plugin
from sphinx.roles import XRefRole
from sphinx.util.logging import getLogger
Expand All @@ -38,6 +39,7 @@ class FootReferenceInfo(NamedTuple):
"""
key: str #: Citation key.
document: "docutils.nodes.document" #: Current docutils document.
refname: str #: Citation reference name.


class FootReferenceText(BaseReferenceText[FootReferenceInfo]):
Expand All @@ -50,9 +52,8 @@ def render(self, backend: "BaseBackend"):
"FootReferenceText only supports the docutils backend"
info = self.info[0]
# see docutils.parsers.rst.states.Body.footnote_reference()
refname = docutils.nodes.fully_normalize_name(info.key)
refnode = docutils.nodes.footnote_reference(
'[#%s]_' % info.key, refname=refname, auto=1)
'[#%s]_' % info.key, refname=info.refname, auto=1)
info.document.note_autofootnote_ref(refnode)
info.document.note_footnote_ref(refnode)
return [refnode]
Expand Down Expand Up @@ -93,19 +94,40 @@ def result_nodes(self, document: "docutils.nodes.document",
self.config.bibtex_default_style)()
references = []
domain = cast("BibtexDomain", self.env.get_domain('cite'))
# count only incremented at directive, see foot_directives run method
footbibliography_count = env.temp_data.setdefault(
"bibtex_footbibliography_count", 0)
footcite_names = env.temp_data.setdefault(
"bibtex_footcite_names", {})
for key in keys:
entry = domain.bibdata.data.entries.get(key)
if entry is not None:
formatted_entry = style.format_entry(label='', entry=entry)
references.append(
(entry, formatted_entry,
FootReferenceInfo(key=entry.key, document=document)))
if key not in (foot_old_refs | foot_new_refs):
footnote = domain.backend.footnote(
formatted_entry, document)
footnote = docutils.nodes.footnote(auto=1)
# no automatic ids for footnotes: force non-empty template
template: str = \
env.app.config.bibtex_footcite_id \
if env.app.config.bibtex_footcite_id \
else "footcite-{key}"
raw_id = template.format(
footbibliography_count=footbibliography_count + 1,
key=entry.key)
# format name with make_id for consistency with cite role
name = make_id(raw_id)
footnote['names'] += [name]
footcite_names[entry.key] = name
footnote += domain.backend.paragraph(formatted_entry)
document.note_autofootnote(footnote)
document.note_explicit_target(footnote, footnote)
node_text_transform(footnote)
foot_bibliography += footnote
foot_new_refs.add(key)
references.append(
(entry, formatted_entry,
FootReferenceInfo(
key=entry.key, refname=footcite_names[entry.key],
document=document)))
else:
logger.warning('could not find bibtex key "%s"' % key,
location=(env.docname, self.lineno),
Expand Down
10 changes: 8 additions & 2 deletions test/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,16 @@ def html_footnotes(id_=RE_ID, text=RE_TEXT):
return re.compile(
r'<dt class="label" id="(?P<id_>{id_})">'
r'<span class="brackets">'
r'<a class="fn-backref" href="#(?P<backref>{backref_id})">'
r'(?:<a class="fn-backref" href="#(?P<backref>{backref_id})">)?'
r'(?P<label>{label})'
r'</a>'
r'(?:</a>)?'
r'</span>'
r'(?:<span class="fn-backref">\('
r'<a href="#(?P<backref1>{backref_id})">1</a>'
r',<a href="#(?P<backref2>{backref_id}\w+)">2</a>'
r'(,<a href="#(?P<backref3>{backref_id}\w+)">3</a>)?'
r'(,<a href="#\w+">\d+</a>)*' # no named group for additional backrefs
r'\)</span>)?'
r'</dt>\n'
r'<dd><p>(?P<text>{text})</p>\n</dd>'.format(
id_=id_, backref_id=RE_ID, label=RE_NUM, text=text))
Expand Down
7 changes: 7 additions & 0 deletions test/roots/test-bibliography_custom_ids/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
extensions = ['sphinxcontrib.bibtex']
exclude_patterns = ['_build']
bibtex_bibfiles = ['test.bib']
bibtex_cite_id = "cite-id-{bibliography_count}-{key}"
bibtex_footcite_id = "footcite-id-{key}"
bibtex_bibliography_id = "bibliography-id-{bibliography_count}"
bibtex_footbibliography_id = "footbibliography-id-{footbibliography_count}"
33 changes: 33 additions & 0 deletions test/roots/test-bibliography_custom_ids/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Custom Ids
==========

Regular Citations
-----------------

First citation :cite:`2009:mandel`.
Second citation :cite:`2003:evensen`.
Third citation :cite:`1986:lorenc`.

.. bibliography::
:filter: False

2009:mandel
2003:evensen

.. bibliography::
:filter: False

1986:lorenc

Footnote Citations
------------------

First citation :footcite:`2009:mandel`.
Second citation :footcite:`2003:evensen`.

.. footbibliography::

And first citation again :footcite:`2009:mandel`.
Third citation :footcite:`1986:lorenc`.

.. footbibliography::
42 changes: 42 additions & 0 deletions test/roots/test-bibliography_custom_ids/test.bib
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
@Misc{2009:mandel,
author = {Jan Mandel},
title = {A Brief Tutorial on the Ensemble {K}alman Filter},
howpublished = {arXiv:0901.3725v1 [physics.ao-ph]},
month = jan,
year = {2009},
OPTnote = {},
OPTannote = {},
archivePrefix = {arXiv},
eprint = {0901.3725},
primaryClass = {physics.ao-ph}
}

@Article{2003:evensen,
author = {Geir Evensen},
title = {The Ensemble {K}alman Filter: theoretical formulation and practical implementation},
journal = {Ocean Dynamics},
year = {2003},
OPTkey = {},
volume = {53},
number = {4},
pages = {343-367},
OPTmonth = {},
OPTnote = {},
OPTannote = {},
doi = {10.1007/s10236-003-0036-9}
}

@Article{1986:lorenc,
author = {Andrew C. Lorenc},
title = {Analysis methods for numerical weather prediction},
journal = {Quarterly Journal of the Royal Meteorological Society},
year = {1986},
OPTkey = {},
volume = {112},
number = {474},
pages = {1177-1194},
OPTmonth = {},
OPTnote = {},
OPTannote = {},
doi = {10.1002/qj.49711247414}
}

0 comments on commit b9ec080

Please sign in to comment.