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
35 changes: 32 additions & 3 deletions bibtexparser/middlewares/latex_encoding.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import abc
import logging
import re
from collections.abc import Collection

import pylatexenc
from pylatexenc.latex2text import LatexNodes2Text
Expand All @@ -23,7 +24,23 @@


class _PyStringTransformerMiddleware(BlockMiddleware, abc.ABC):
"""Abstract utility class allowing to modify python-strings"""
"""Abstract utility class allowing to modify python-strings.

:param exclude_fields: Entry field keys whose values should not be transformed.
Matching is case-insensitive and exact. String blocks are always transformed.
"""

def __init__(
self,
allow_parallel_execution: bool = True,
allow_inplace_modification: bool = True,
exclude_fields: Collection[str] = (),
):
super().__init__(
allow_parallel_execution=allow_parallel_execution,
allow_inplace_modification=allow_inplace_modification,
)
self._exclude_fields = {field.casefold() for field in exclude_fields}

@abc.abstractmethod
def _transform_python_value_string(self, python_string: str) -> tuple[str, str]:
Expand All @@ -49,6 +66,8 @@ def _transform_all_strings(self, list_of_strings: list[str], errors: list[str])
def transform_entry(self, entry: Entry, library: Library) -> Block:
errors = []
for field in entry.fields:
if field.key.casefold() in self._exclude_fields:
continue
if isinstance(field.value, str):
# The value setter resets `enclosing`; only the representation changes here.
enclosing = field.enclosing
Expand Down Expand Up @@ -91,18 +110,23 @@ def transform_string(self, string: String, library: "Library") -> Block:


class LatexEncodingMiddleware(_PyStringTransformerMiddleware):
"""Latex-Encodes all strings in the library"""
"""Latex-encode entry fields and string blocks.

All entry fields are transformed by default; ``exclude_fields`` can skip named fields.
"""

def __init__(
self,
keep_math: bool | None = None,
enclose_urls: bool | None = None,
encoder: UnicodeToLatexEncoder | None = None,
allow_inplace_modification: bool = True,
exclude_fields: Collection[str] = (),
):
super().__init__(
allow_inplace_modification=allow_inplace_modification,
allow_parallel_execution=True,
exclude_fields=exclude_fields,
)

if encoder is not None and (keep_math is not None or enclose_urls is not None):
Expand Down Expand Up @@ -161,18 +185,23 @@ def _transform_python_value_string(self, python_string: str) -> tuple[str, str]:


class LatexDecodingMiddleware(_PyStringTransformerMiddleware):
"""Latex-Decodes all strings in the library"""
"""Latex-decode entry fields and string blocks.

All entry fields are transformed by default; ``exclude_fields`` can skip named fields.
"""

def __init__(
self,
allow_inplace_modification: bool = True,
keep_braced_groups: bool | None = None,
keep_math_mode: bool | None = None,
decoder: LatexNodes2Text | None = None,
exclude_fields: Collection[str] = (),
):
super().__init__(
allow_inplace_modification=allow_inplace_modification,
allow_parallel_execution=True,
exclude_fields=exclude_fields,
)

if decoder is not None and (keep_braced_groups is not None or keep_math_mode is not None):
Expand Down
20 changes: 20 additions & 0 deletions docs/source/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ Encoding and Enclosing of Values
* :mod:`bibtexparser.middlewares.LatexEncodingMiddleware`
* :mod:`bibtexparser.middlewares.LatexDecodingMiddleware`

The LaTeX encoding and decoding middlewares transform all entry fields by default.
Pass ``exclude_fields`` to skip a collection of exact field keys; matching is case-insensitive,
and the default is an empty collection. For example, this keeps identifiers unchanged while
decoding regular text fields:

.. code-block:: python

library = bibtexparser.parse_string(
bibtex,
append_middleware=[
m.LatexDecodingMiddleware(exclude_fields=("url", "doi", "custom_url"))
],
)

``@string`` blocks are still transformed. With the normal default parse stack, string references
are resolved before appended middleware runs, so an excluded entry field also remains unchanged
when its value came from an ``@string`` definition. Excluded values are preserved verbatim, which
keeps plain identifiers containing a literal ``~`` intact; LaTeX escapes in those fields are not
decoded.

.. _middleware_references:

Value References and Representation
Expand Down
129 changes: 129 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 Down Expand Up @@ -238,6 +239,134 @@ def test_concatenation_roundtrips_with_latex_decoding():
assert bibtexparser.write_string(library).strip() == bibtex.strip()


def test_excluded_fields_preserve_identifiers_after_default_stack_resolution():
"""Excluded fields keep URL-like identifiers while regular text is decoded."""
bibtex = r"""
@article{someEntry,
Title = {An {\"u}ber title},
URL = url_reference,
doi = {10.1234/article~id},
Custom_Url = {https://example.com/custom~id},
url_extra = {A~regular text value}
}
@string{url_reference = {https://example.com/reference~id}}
"""

library = bibtexparser.parse_string(
bibtex,
append_middleware=[LatexDecodingMiddleware(exclude_fields=("uRl", "DOI", "custom_url"))],
)

fields = library.entries[0].fields_dict
assert fields["Title"].value == "An über title"
assert fields["URL"].value == "https://example.com/reference~id"
assert fields["doi"].value == "10.1234/article~id"
assert fields["Custom_Url"].value == "https://example.com/custom~id"
assert fields["url_extra"].value == "A\xa0regular text value"
# @string blocks remain subject to transformation. The default parse stack
# resolves the reference before this middleware sees the entry field.
assert library.strings[0].value == "https://example.com/reference\xa0id"


@pytest.mark.parametrize(
"middleware_class,input_value,expected_value",
[
pytest.param(LatexDecodingMiddleware, r"M{\"u}ller", "Müller", id="decoding"),
pytest.param(LatexEncodingMiddleware, "Müller", r"M\"uller", id="encoding"),
],
)
def test_excluded_fields_are_case_insensitive_and_exact(
middleware_class, input_value, expected_value
):
"""An exclusion matches a complete field key, regardless of its casing."""
entry = Entry(
start_line=1,
raw="irrelevant",
entry_type="article",
key="someEntry",
fields=[
Field(key="URL", value="A~B"),
Field(key="url_extra", value=input_value),
],
)

transformed_library = middleware_class(exclude_fields=("url",)).transform(Library([entry]))
fields = transformed_library.entries[0].fields_dict

assert fields["URL"].value == "A~B"
assert fields["url_extra"].value == expected_value


@pytest.mark.parametrize(
"middleware_class,excluded_value,included_value,expected_included_value",
[
pytest.param(
LatexDecodingMiddleware,
"A~B",
r"M{\"u}ller",
"Müller",
id="decoding",
),
pytest.param(
LatexEncodingMiddleware,
"A~B",
"Müller",
r"M\"uller",
id="encoding",
),
],
)
def test_excluded_fields_respect_copy_mode_and_enclosing_metadata(
middleware_class, excluded_value, included_value, expected_included_value
):
"""Skipping a field does not drop its enclosing demand or mutate the input copy."""
entry = Entry(
start_line=1,
raw="irrelevant",
entry_type="article",
key="someEntry",
fields=[
Field(key="URL", value=excluded_value, enclosing='"'),
Field(key="title", value=included_value, enclosing="{"),
],
)
original_copy = deepcopy(entry)

transformed_library = middleware_class(
allow_inplace_modification=False, exclude_fields=("url",)
).transform(Library([entry]))
transformed_entry = transformed_library.entries[0]

assert transformed_entry is not entry
assert transformed_entry.fields_dict["URL"].value == excluded_value
assert transformed_entry.fields_dict["URL"].enclosing == '"'
assert transformed_entry.fields_dict["title"].value == expected_included_value
assert entry == original_copy


def test_excluded_fields_work_with_custom_decoder():
"""Exclusions apply without replacing a user-provided decoder."""
entry = Entry(
start_line=1,
raw="irrelevant",
entry_type="article",
key="someEntry",
fields=[
Field(key="url", value="A~B"),
Field(key="title", value=r"{Walther Andreas} Muller"),
],
)

decoder = LatexNodes2Text(keep_braced_groups=True)
transformed_library = LatexDecodingMiddleware(
decoder=decoder, exclude_fields=("URL",)
).transform(Library([entry]))
fields = transformed_library.entries[0].fields_dict

assert fields["url"].value == "A~B"
assert fields["title"].value == "{Walther Andreas} Muller"


def _entry_with_latex_string(latex_string, enclosing=None):
return Entry(
start_line=1,
Expand Down