Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bibtexparser/middlewares/latex_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pylatexenc
from pylatexenc.latex2text import LatexNodes2Text
from pylatexenc.latex2text import MacroTextSpec
from pylatexenc.latex2text import SpecialsTextSpec
from pylatexenc.latexencode import RULE_REGEX
from pylatexenc.latexencode import UnicodeToLatexConversionRule
from pylatexenc.latexencode import UnicodeToLatexEncoder
Expand Down Expand Up @@ -202,6 +203,8 @@ def __init__(
# Do not wrap urls in '< ... >'
MacroTextSpec("url", simplify_repl="%s")
],
# Bibliographic values often contain literal '&', not tabular alignment.
specials=[SpecialsTextSpec("&", "&")],
)

decoder = LatexNodes2Text(
Expand Down
5 changes: 5 additions & 0 deletions docs/source/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ Encoding and Enclosing of Values
* :mod:`bibtexparser.middlewares.LatexEncodingMiddleware`
* :mod:`bibtexparser.middlewares.LatexDecodingMiddleware`

The default :class:`bibtexparser.middlewares.LatexDecodingMiddleware` preserves
literal ampersands in bibliographic values: both ``Cats & Dogs`` and
``Cats \& Dogs`` decode to ``Cats & Dogs``. This also preserves query separators
in URLs. If you pass a custom ``decoder``, its own conversion rules apply.

.. _middleware_references:

Value References and Representation
Expand Down
48 changes: 48 additions & 0 deletions tests/middleware_tests/test_latex_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from copy import deepcopy

import pytest
from pylatexenc.latex2text import LatexNodes2Text

import bibtexparser
from bibtexparser import Library
Expand All @@ -28,6 +29,18 @@
pytest.param(r"Kristoffer H\o{}gsbro Rose", "Kristoffer Høgsbro Rose", id=r"\o{}"),
pytest.param(r"Einstein $ e=m_c^2 $", "Einstein $ e=m_c^2 $", id=r"Keep math mode"),
pytest.param(r"I payed \$10", "I payed $10", id=r"Keep \$"),
pytest.param("Cats & Dogs", "Cats & Dogs", id="literal ampersand"),
pytest.param(r"Cats \& Dogs", "Cats & Dogs", id="escaped ampersand"),
pytest.param("R&D & A&B", "R&D & A&B", id="multiple ampersands"),
pytest.param(
r"\textit{Research & Development}", "Research & Development", id="macro ampersand"
),
pytest.param(r"$a & b$", r"$a & b$", id="math ampersand"),
pytest.param(
r"\url{https://example.org/?a=1&b=2}",
"https://example.org/?a=1&b=2",
id="url ampersand",
),
pytest.param(
r"{Walther Andreas} Muller",
"Walther Andreas Muller",
Expand Down Expand Up @@ -134,6 +147,41 @@ def test_string_block_value_transformation(middleware_class, input_value, expect
assert transformed_value == expected_value


@pytest.mark.parametrize("enclosing", ["{}", '""'])
@pytest.mark.parametrize("inplace", [True, False])
def test_ampersands_survive_parse_encode_decode_roundtrip(enclosing, inplace):
"""Unescaped ampersands in imported fields and string definitions must not lose data."""
value = f"{enclosing[0]}Cats & Dogs{enclosing[1]}"
bibtex = f"@string{{publisher = {value}}}\n@article{{k, title = {value}}}"
decoded = bibtexparser.parse_string(
bibtex,
append_middleware=[LatexDecodingMiddleware(allow_inplace_modification=inplace)],
)
assert not decoded.failed_blocks
assert decoded.entries[0]["title"] == "Cats & Dogs"
assert decoded.strings[0].value == "Cats & Dogs"

encoded = bibtexparser.write_string(
decoded, prepend_middleware=[LatexEncodingMiddleware(allow_inplace_modification=False)]
)
assert encoded.count(r"Cats \& Dogs") == 2
reparsed = bibtexparser.parse_string(encoded, append_middleware=[LatexDecodingMiddleware()])
assert not reparsed.failed_blocks
assert reparsed.entries[0]["title"] == "Cats & Dogs"
assert reparsed.strings[0].value == "Cats & Dogs"


def test_custom_decoder_controls_ampersands():
"""A supplied decoder retains its own rules, independently of middleware defaults."""
decoder = LatexNodes2Text()
middleware = LatexDecodingMiddleware(decoder=decoder)
# Constructing a default middleware must not change pylatexenc's global context either.
LatexDecodingMiddleware()
library = middleware.transform(Library([_entry_with_latex_string("Cats & Dogs")]))
assert library.entries[0]["tested_field"] == "Cats Dogs"
assert LatexNodes2Text().latex_to_text("Cats & Dogs") == "Cats Dogs"


def test_failing_string_transformation_becomes_error_block():
"""Transformation errors on String blocks must not be swallowed (issue #529)."""

Expand Down
Loading