Repository: sphinx-doc/sphinx (Python)

Issue:
"Untranslated" class added on paragraphs which are translated but not different from original
### Describe the bug

As a follow-up to https://github.com/sphinx-doc/sphinx/pull/11509: a little quirk is that if you translate a paragraph, but the translation is the same as the original, which can happen, e.g., with paragraphs like

```
You can install packages from

- `PyPI <https://pypi.org>`_
...
```

then the paragraph is wrongly marked as "untranslated".

### How to Reproduce

`conf.py`:

```
translation_progress_classes = True
```

`index.rst`:

```
$ cat index.rst 
Foo bar baz.

Foo bar baz 2.

Foo bar baz 3.
```

`locales/fr/LC_MESSAGES/index.po`:

```
# SOME DESCRIPTIVE TITLE.
# Copyright (C)
# This file is distributed under the same license as the Python package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Python \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-01 17:07+0200\n"
"PO-Revision-Date: 2023-08-01 17:08+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"

#: ../index.rst:1
msgid "Foo bar baz."
msgstr "Foo bar baz translated."

#: ../index.rst:3
msgid "Foo bar baz 2."
msgstr ""

#: ../index.rst:5
msgid "Foo bar baz 3."
msgstr "Foo bar baz 3."
```

Build command: `sphinx-build -Dlanguage=fr . build`

Excerpt from HTML output:

```html
  <p class="translated">Foo bar baz translated.</p>
<p class="untranslated">Foo bar baz 2.</p>
<p class="untranslated">Foo bar baz 3.</p>
```

### Environment Information

```text
Platform:              linux; (Linux-6.3.12-200.fc38.x86_64-x86_64-with-glibc2.37)
Python version:        3.11.4 (main, Jun  7 2023, 00:00:00) [GCC 13.1.1 20230511 (Red Hat 13.1.1-2)])
Python implementation: CPython
Sphinx version:        7.1.1
Docutils version:      0.20.1
Jinja2 version:        3.1.2
Pygments version:      2.15.1
```


### Sphinx extensions

```python
None
```


### Additional context

As far as I can see, in the Python `gettext` API, the original message is returned unchanged when there is no translation, which doesn't allow distinguishing this case from the case where the translation is present but happens to be the same as the original.

However, it should be possible to force a distinction to be made using a fallback:

https://docs.python.org/3/library/gettext.html#gettext.NullTranslations.add_fallback

(this is the approach I used in my hand-made extension for translation progress before #11509).

@AA-Turner


Discussion:
+1 for this. 

Although I never use localization, it is quite important if two acronyms are used the same way in two languages. For instance NATO in English translates to NATO in German but OTAN in French. And if there are a lot of NATO occurrences, you might end up having an incorrect amount of "untranslated" content (which may not be that good I think). 



Patch:
diff --git a/sphinx/transforms/i18n.py b/sphinx/transforms/i18n.py
index f6bad514321..c291d727c92 100644
--- a/sphinx/transforms/i18n.py
+++ b/sphinx/transforms/i18n.py
@@ -353,9 +353,16 @@ def apply(self, **kwargs: Any) -> None:
         if not has_catalog:
             return
 
+        catalogues = [getattr(catalog, '_catalog', None)]
+        while (catalog := catalog._fallback) is not None:  # type: ignore[attr-defined]
+            catalogues.append(getattr(catalog, '_catalog', None))
+        merged: dict[str, str] = {}
+        for catalogue in filter(None, reversed(catalogues)):  # type: dict[str, str]
+            merged |= catalogue
+
         # phase1: replace reference ids with translated names
         for node, msg in extract_messages(self.document):
-            msgstr = catalog.gettext(msg)
+            msgstr = merged.get(msg, '')
 
             # There is no point in having #noqa on literal blocks because
             # they cannot contain references.  Recognizing it would just
@@ -364,10 +371,14 @@ def apply(self, **kwargs: Any) -> None:
             if not isinstance(node, LITERAL_TYPE_NODES):
                 msgstr, _ = parse_noqa(msgstr)
 
-            if not msgstr or msgstr == msg or not msgstr.strip():
+            if msgstr.strip() == '':
                 # as-of-yet untranslated
                 node['translated'] = False
                 continue
+            if msgstr == msg:
+                # identical source and translated messages
+                node['translated'] = True
+                continue
 
             # Avoid "Literal block expected; none found." warnings.
             # If msgstr ends with '::' then it cause warning message at
@@ -416,7 +427,7 @@ def apply(self, **kwargs: Any) -> None:
             if node.setdefault('translated', False):  # to avoid double translation
                 continue  # skip if the node is already translated by phase1
 
-            msgstr = catalog.gettext(msg)
+            msgstr = merged.get(msg, '')
             noqa = False
 
             # See above.
@@ -510,7 +521,7 @@ def apply(self, **kwargs: Any) -> None:
                     msg_parts = split_index_msg(entry_type, value)
                     msgstr_parts = []
                     for part in msg_parts:
-                        msgstr = catalog.gettext(part)
+                        msgstr = merged.get(part, '')
                         if not msgstr:
                             msgstr = part
                         msgstr_parts.append(msgstr)


Test patch:
diff --git a/tests/roots/test-intl/translation_progress.txt b/tests/roots/test-intl/translation_progress.txt
index 61f893e2245..f70ab7aa602 100644
--- a/tests/roots/test-intl/translation_progress.txt
+++ b/tests/roots/test-intl/translation_progress.txt
@@ -17,16 +17,20 @@ Desiring this man’s art and that man’s scope,
 
 With what I most enjoy contented least;
 
+.. idempotent translations (2 out of 14 lines):
+
 Yet in these thoughts myself almost despising,
 
 Haply I think on thee, and then my state,
 
-Like to the lark at break of day arising
+.. untranslated (2 out of 14 lines):
 
-.. untranslated (3 out of 14 lines):
+Like to the lark at break of day arising
 
 From sullen earth, sings hymns at heaven’s gate;
 
+.. translation missing (2 out of 14 lines):
+
 For thy sweet love remember’d such wealth brings
 
 That then I scorn to change my state with kings.
diff --git a/tests/roots/test-intl/xx/LC_MESSAGES/translation_progress.po b/tests/roots/test-intl/xx/LC_MESSAGES/translation_progress.po
index a83112f48f6..94673d17582 100644
--- a/tests/roots/test-intl/xx/LC_MESSAGES/translation_progress.po
+++ b/tests/roots/test-intl/xx/LC_MESSAGES/translation_progress.po
@@ -38,11 +38,20 @@ msgstr "DESIRING THIS MAN’S ART AND THAT MAN’S SCOPE,"
 msgid "With what I most enjoy contented least;"
 msgstr "WITH WHAT I MOST ENJOY CONTENTED LEAST;"
 
+# idempotent translations (2 out of 14 lines):
+
 msgid "Yet in these thoughts myself almost despising,"
-msgstr "YET IN THESE THOUGHTS MYSELF ALMOST DESPISING,"
+msgstr "Yet in these thoughts myself almost despising,"
 
 msgid "Haply I think on thee, and then my state,"
-msgstr "HAPLY I THINK ON THEE, AND THEN MY STATE,"
+msgstr "Haply I think on thee, and then my state,"
+
+# untranslated (2 out of 14 lines):
 
 msgid "Like to the lark at break of day arising"
-msgstr "LIKE TO THE LARK AT BREAK OF DAY ARISING"
+msgstr ""
+
+msgid "From sullen earth, sings hymns at heaven’s gate;"
+msgstr ""
+
+# translation missing (2 out of 14 lines):
diff --git a/tests/test_intl.py b/tests/test_intl.py
index c02036af170..a07ebfb63b8 100644
--- a/tests/test_intl.py
+++ b/tests/test_intl.py
@@ -612,24 +612,24 @@ def test_gettext_buildr_ignores_only_directive(app):
 
 @sphinx_intl
 def test_node_translated_attribute(app):
-    app.build()
+    app.builder.build_specific([app.srcdir / 'translation_progress.txt'])
 
     doctree = app.env.get_doctree('translation_progress')
 
     translated_nodes = sum(1 for _ in doctree.findall(NodeMatcher(translated=True)))
-    assert translated_nodes == 11 + 1  # 11 lines + title
+    assert translated_nodes == 10 + 1  # 10 lines + title
 
     untranslated_nodes = sum(1 for _ in doctree.findall(NodeMatcher(translated=False)))
-    assert untranslated_nodes == 3 + 1  # 3 lines + substitution reference
+    assert untranslated_nodes == 2 + 2 + 1  # 2 lines + 2 lines + substitution reference
 
 
 @sphinx_intl
 def test_translation_progress_substitution(app):
-    app.build()
+    app.builder.build_specific([app.srcdir / 'translation_progress.txt'])
 
     doctree = app.env.get_doctree('translation_progress')
 
-    assert doctree[0][17][0] == '75.00%'  # 12 out of 16 lines are translated
+    assert doctree[0][19][0] == '68.75%'  # 11 out of 16 lines are translated
 
 
 @pytest.mark.sphinx(testroot='intl', freshenv=True, confoverrides={
@@ -638,11 +638,14 @@ def test_translation_progress_substitution(app):
     'translation_progress_classes': True,
 })
 def test_translation_progress_classes_true(app):
-    app.build()
+    app.builder.build_specific([app.srcdir / 'translation_progress.txt'])
 
     doctree = app.env.get_doctree('translation_progress')
 
+    # title
     assert 'translated' in doctree[0][0]['classes']
+
+    # translated lines
     assert 'translated' in doctree[0][1]['classes']
     assert 'translated' in doctree[0][2]['classes']
     assert 'translated' in doctree[0][3]['classes']
@@ -651,21 +654,31 @@ def test_translation_progress_classes_true(app):
     assert 'translated' in doctree[0][6]['classes']
     assert 'translated' in doctree[0][7]['classes']
     assert 'translated' in doctree[0][8]['classes']
-    assert 'translated' in doctree[0][9]['classes']
+
+    assert doctree[0][9]['classes'] == []  # comment node
+
+    # idempotent
     assert 'translated' in doctree[0][10]['classes']
     assert 'translated' in doctree[0][11]['classes']
 
     assert doctree[0][12]['classes'] == []  # comment node
 
+    # untranslated
     assert 'untranslated' in doctree[0][13]['classes']
     assert 'untranslated' in doctree[0][14]['classes']
-    assert 'untranslated' in doctree[0][15]['classes']
 
-    assert doctree[0][16]['classes'] == []  # comment node
+    assert doctree[0][15]['classes'] == []  # comment node
 
+    # missing
+    assert 'untranslated' in doctree[0][16]['classes']
     assert 'untranslated' in doctree[0][17]['classes']
 
-    assert len(doctree[0]) == 18
+    assert doctree[0][18]['classes'] == []  # comment node
+
+    # substitution reference
+    assert 'untranslated' in doctree[0][19]['classes']
+
+    assert len(doctree[0]) == 20
 
 
 @sphinx_intl