diff --git a/bibtexparser/middlewares/latex_encoding.py b/bibtexparser/middlewares/latex_encoding.py index af24842..8f1f33c 100644 --- a/bibtexparser/middlewares/latex_encoding.py +++ b/bibtexparser/middlewares/latex_encoding.py @@ -1,6 +1,7 @@ import abc import logging import re +from collections.abc import Collection import pylatexenc from pylatexenc.latex2text import LatexNodes2Text @@ -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]: @@ -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 @@ -91,7 +110,10 @@ 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, @@ -99,10 +121,12 @@ def __init__( 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): @@ -161,7 +185,10 @@ 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, @@ -169,10 +196,12 @@ def __init__( 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): diff --git a/docs/source/customize.rst b/docs/source/customize.rst index 8e2e449..c15a607 100644 --- a/docs/source/customize.rst +++ b/docs/source/customize.rst @@ -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 diff --git a/tests/middleware_tests/test_latex_encoding.py b/tests/middleware_tests/test_latex_encoding.py index 7c11933..2790b48 100644 --- a/tests/middleware_tests/test_latex_encoding.py +++ b/tests/middleware_tests/test_latex_encoding.py @@ -6,6 +6,7 @@ from copy import deepcopy import pytest +from pylatexenc.latex2text import LatexNodes2Text import bibtexparser from bibtexparser import Library @@ -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,