diff --git a/bibtex_linter/ieeetran_rules.py b/bibtex_linter/ieeetran_rules.py index 9dccff5..f9b0b5a 100644 --- a/bibtex_linter/ieeetran_rules.py +++ b/bibtex_linter/ieeetran_rules.py @@ -11,6 +11,30 @@ ) +# LaTeX escapes of special characters that can appear in a DOI, mapped to the character they represent +_LATEX_ESCAPES = { + r"\textasciitilde{}": "~", + r"\textasciitilde": "~", + r"\~{}": "~", + r"\_": "_", + r"\#": "#", + r"\%": "%", + r"\&": "&", + r"\$": "$", +} +_LATEX_ESCAPE_PATTERN = re.compile("|".join(re.escape(escape) for escape in _LATEX_ESCAPES)) + + +def _unescape_latex(text: str) -> str: + """ + Replace the LaTeX escapes of special characters (e.g. `\\_`) in the given text with the characters themselves. + + :param text: The text to unescape + :return: The text with all escaped special characters unescaped + """ + return _LATEX_ESCAPE_PATTERN.sub(lambda match: _LATEX_ESCAPES[match.group(0)], text) + + @linter_rule(entry_type=None) def check_url_field(entry: BibTeXEntry) -> List[str]: """ @@ -22,8 +46,9 @@ def check_url_field(entry: BibTeXEntry) -> List[str]: doi: \\href{https://doi.org/10.xxxx/yyy}{10.xxxx/yyy} ``` Note, that the backslash had to be escaped here and is only meant to be a single one. - In the DOI form, the shown DOI must be the same as the DOI in the URL. Since a DOI is persistent, it has no - access date. + In the DOI form, the shown DOI must be the same as the DOI in the URL. Special characters in the shown DOI may be + escaped (e.g. `10.1007/11574620\\_45`), since the shown DOI is typeset as normal text. Since a DOI is persistent, + it has no access date. :param entry: The BibTeXEntry :return: A list of string descriptions of rule violations for this entry. @@ -38,8 +63,10 @@ def check_url_field(entry: BibTeXEntry) -> List[str]: if "note" in entry.fields.keys(): note_content: str = entry.fields["note"] online_pattern = r"^\[ONLINE\]\. Available: \\url\{(.+?)\}, Accessed: (\d{4}-\d{2}-\d{2})$" - doi_pattern = r"^doi: \\href\{https://doi\.org/(10\.\d{4,9}/\S+)\}\{\1\}$" - if not (re.match(online_pattern, note_content) or re.match(doi_pattern, note_content)): + doi_pattern = r"^doi: \\href\{https://doi\.org/(10\.\d{4,9}/\S+)\}\{(\S+)\}$" + doi_match = re.match(doi_pattern, note_content) + is_valid_doi = doi_match is not None and doi_match.group(1) == _unescape_latex(doi_match.group(2)) + if not (re.match(online_pattern, note_content) or is_valid_doi): invariant_violations.append( "Contains a malformed field [note]. " "Make sure the [note] field follows one of the following patterns: " diff --git a/test/test_verification.py b/test/test_verification.py index 10ca4e6..30ae11f 100644 --- a/test/test_verification.py +++ b/test/test_verification.py @@ -118,6 +118,38 @@ def test_doi_note_mismatching_doi(self) -> None: ) self.assertEqual([self.MALFORMED_NOTE], check_url_field(entry)) + def test_doi_note_unescaped_underscore(self) -> None: + entry = BibTeXEntry( + entry_type="incollection", + name="doi_underscore", + fields={"note": "doi: \\href{https://doi.org/10.1007/11574620_45}{10.1007/11574620_45}"} + ) + self.assertEqual([], check_url_field(entry)) + + def test_doi_note_escaped_special_characters(self) -> None: + for url_doi, shown_doi in ( + ("10.1007/11574620_45", "10.1007/11574620\\_45"), + ("10.1007/978-3-642-25073-6_18", "10.1007/978-3-642-25073-6\\_18"), + ("10.1234/a#b%c&d$e", "10.1234/a\\#b\\%c\\&d\\$e"), + ("10.1234/a~b", "10.1234/a\\textasciitilde{}b"), + ("10.1234/a~b", "10.1234/a\\~{}b"), + ): + with self.subTest(shown_doi=shown_doi): + entry = BibTeXEntry( + entry_type="incollection", + name="doi_escaped", + fields={"note": f"doi: \\href{{https://doi.org/{url_doi}}}{{{shown_doi}}}"} + ) + self.assertEqual([], check_url_field(entry)) + + def test_doi_note_escaped_mismatching_doi(self) -> None: + entry = BibTeXEntry( + entry_type="incollection", + name="doi_escaped_mismatch", + fields={"note": "doi: \\href{https://doi.org/10.1007/11574620_45}{10.1007/11574620\\_46}"} + ) + self.assertEqual([self.MALFORMED_NOTE], check_url_field(entry)) + def test_doi_note_with_access_date(self) -> None: entry = BibTeXEntry( entry_type="article",