diff --git a/CHANGES.rst b/CHANGES.rst index 75cf998fa93..ceef1609bdc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,9 @@ Release 9.0.4 (in development) Bugs fixed ---------- +* #14143: Fix spurious build warnings when translators reorder references + in strings, or use translated display text in references. + Patch by Matt Wang. Release 9.0.3 (released Dec 04, 2025) ===================================== diff --git a/sphinx/transforms/i18n.py b/sphinx/transforms/i18n.py index b87894dbb7d..7dc175fc948 100644 --- a/sphinx/transforms/i18n.py +++ b/sphinx/transforms/i18n.py @@ -2,6 +2,7 @@ from __future__ import annotations +from operator import attrgetter from re import DOTALL, match from textwrap import indent from typing import TYPE_CHECKING, Any, TypeVar @@ -28,7 +29,7 @@ ) if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence from docutils.frontend import Values @@ -133,11 +134,30 @@ def compare_references( old_refs: Sequence[nodes.Element], new_refs: Sequence[nodes.Element], warning_msg: str, + *, + key_func: Callable[[nodes.Element], Any] = attrgetter('rawsource'), + ignore_order: bool = False, ) -> None: - """Warn about mismatches between references in original and translated content.""" - old_ref_rawsources = [ref.rawsource for ref in old_refs] - new_ref_rawsources = [ref.rawsource for ref in new_refs] - if not self.noqa and old_ref_rawsources != new_ref_rawsources: + """Warn about mismatches between references in original and translated content. + + :param key_func: A function to extract the comparison key from each reference. + Defaults to extracting the ``rawsource`` attribute. + :param ignore_order: If True, ignore the order of references when comparing. + This allows translators to reorder references while still catching + missing or extra references. + """ + old_ref_keys = list(map(key_func, old_refs)) + new_ref_keys = list(map(key_func, new_refs)) + + if ignore_order: + # The ref_keys lists may contain ``None``, so compare hashes. + # Recall objects which compare equal have the same hash value. + old_ref_keys.sort(key=hash) + new_ref_keys.sort(key=hash) + + if not self.noqa and old_ref_keys != new_ref_keys: + old_ref_rawsources = [ref.rawsource for ref in old_refs] + new_ref_rawsources = [ref.rawsource for ref in new_refs] logger.warning( warning_msg.format(old_ref_rawsources, new_ref_rawsources), location=self.node, @@ -347,6 +367,10 @@ def update_pending_xrefs(self) -> None: 'inconsistent term references in translated message.' ' original: {0}, translated: {1}' ), + # Compare by reftarget only, allowing translated display text. + # Ignore order since translators may legitimately reorder references. + key_func=lambda ref: ref.get('reftarget'), + ignore_order=True, ) xref_reftarget_map: dict[tuple[str, str, str] | None, dict[str, Any]] = {} diff --git a/tests/roots/test-intl/index.txt b/tests/roots/test-intl/index.txt index 52644e34be1..a5c031dcdbd 100644 --- a/tests/roots/test-intl/index.txt +++ b/tests/roots/test-intl/index.txt @@ -33,6 +33,7 @@ CONTENTS topic markup backslashes + refs_reordered .. toctree:: :maxdepth: 2 diff --git a/tests/roots/test-intl/refs_reordered.txt b/tests/roots/test-intl/refs_reordered.txt new file mode 100644 index 00000000000..f5dd37fdfe3 --- /dev/null +++ b/tests/roots/test-intl/refs_reordered.txt @@ -0,0 +1,16 @@ +:tocdepth: 2 + +i18n with reordered and translated references +============================================== + +.. glossary:: + + term one + First glossary term + + term two + Second glossary term + +1. Multiple refs reordered: :term:`term one` and :term:`term two`. + +2. Single ref with translated display text: :term:`term one`. diff --git a/tests/roots/test-intl/xx/LC_MESSAGES/refs_reordered.po b/tests/roots/test-intl/xx/LC_MESSAGES/refs_reordered.po new file mode 100644 index 00000000000..4391b8fdb31 --- /dev/null +++ b/tests/roots/test-intl/xx/LC_MESSAGES/refs_reordered.po @@ -0,0 +1,31 @@ +# Test for reordered and translated references. +# These should NOT trigger inconsistency warnings. +# +msgid "" +msgstr "" +"Project-Id-Version: sphinx 1.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-01-01 00:00+0000\n" +"PO-Revision-Date: 2024-01-01 00:00+0000\n" +"Last-Translator: Test\n" +"Language-Team: xx\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "i18n with reordered and translated references" +msgstr "I18N WITH REORDERED AND TRANSLATED REFERENCES" + +msgid "First glossary term" +msgstr "FIRST GLOSSARY TERM" + +msgid "Second glossary term" +msgstr "SECOND GLOSSARY TERM" + +# Reordered references - should NOT warn because targets are the same +msgid "Multiple refs reordered: :term:`term one` and :term:`term two`." +msgstr "MULTIPLE REFS REORDERED: :term:`term two` AND :term:`term one`." + +# Translated display text - should NOT warn because reftarget is the same +msgid "Single ref with translated display text: :term:`term one`." +msgstr "SINGLE REF WITH TRANSLATED DISPLAY TEXT: :term:`TRANSLATED TERM ONE `." diff --git a/tests/test_intl/test_intl.py b/tests/test_intl/test_intl.py index e6a2737746f..f213009392e 100644 --- a/tests/test_intl/test_intl.py +++ b/tests/test_intl/test_intl.py @@ -378,6 +378,25 @@ def test_text_glossary_term_inconsistencies(app): ) +@sphinx_intl +@pytest.mark.sphinx('text', testroot='intl') +@pytest.mark.test_params(shared_result='test_intl_basic') +def test_text_refs_reordered_no_warning(app): + app.build() + # --- refs_reordered: verify no inconsistency warnings + result = (app.outdir / 'refs_reordered.txt').read_text(encoding='utf8') + # Verify the translation was applied + assert 'MULTIPLE REFS REORDERED' in result + assert 'SINGLE REF WITH TRANSLATED DISPLAY TEXT' in result + + warnings = getwarning(app.warning) + # Should NOT have any inconsistent_references warnings for refs_reordered.txt + unexpected_warning_expr = '.*/refs_reordered.txt.*inconsistent.*references' + assert not re.search(unexpected_warning_expr, warnings), ( + f'Unexpected warning found: {warnings!r}' + ) + + @sphinx_intl @pytest.mark.sphinx('gettext', testroot='intl') @pytest.mark.test_params(shared_result='test_intl_gettext')