Repository: WeblateOrg/weblate

Issue:
Automatic fixup for punctuation spacing
### Describe the problem

The [Punctuation spacing check](https://docs.weblate.org/en/latest/user/checks.html#punctuation-spacing) accepts breakable space, while the proper solution is to use non-breakable one.

### Describe the solution you'd like

Add an automatic fixup which would turn `<space><punctuation>` in French and Breton to `<nb-space><punctuation>`.

### Describe alternatives you've considered

Make the check more strict and keep this up to the translator.

### Screenshots

_No response_

### Additional context

_No response_


Discussion:
This issue has been added to the backlog. It is not scheduled on the Weblate roadmap, but it eventually might be implemented.

In case you need this feature soon, please consider helping or push it by [funding the development](https://weblate.org/support/).

Resolving patch:
diff --git a/weblate/checks/chars.py b/weblate/checks/chars.py
--- a/weblate/checks/chars.py
+++ b/weblate/checks/chars.py
@@ -11,6 +11,10 @@
 from weblate.checks.parser import single_value_flag
 
 FRENCH_PUNCTUATION = {";", ":", "?", "!"}
+FRENCH_PUNCTUATION_FIXUP_RE = "([ \u00A0\u2009])([{}])".format(
+    "".join(FRENCH_PUNCTUATION)
+)
+FRENCH_PUNCTUATION_MISSING_RE = "([^\u202F])([{}])".format("".join(FRENCH_PUNCTUATION))
 MY_QUESTION_MARK = "\u1038\u104b"
 
 
@@ -444,13 +448,13 @@ def get_fixup(self, unit):
         return [
             # First fix possibly wrong whitespace
             (
-                "([ \u00A0\u2009])([{}])".format("".join(FRENCH_PUNCTUATION)),
+                FRENCH_PUNCTUATION_FIXUP_RE,
                 "\u202F$2",
                 "gu",
             ),
             # Then add missing ones
             (
-                "([^\u202F])([{}])".format("".join(FRENCH_PUNCTUATION)),
+                FRENCH_PUNCTUATION_MISSING_RE,
                 "$1\u202F$2",
                 "gu",
             ),
diff --git a/weblate/trans/autofixes/chars.py b/weblate/trans/autofixes/chars.py
--- a/weblate/trans/autofixes/chars.py
+++ b/weblate/trans/autofixes/chars.py
@@ -2,8 +2,14 @@
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
+import re
+
 from django.utils.translation import gettext_lazy
 
+from weblate.checks.chars import (
+    FRENCH_PUNCTUATION_FIXUP_RE,
+    FRENCH_PUNCTUATION_MISSING_RE,
+)
 from weblate.formats.helpers import CONTROLCHARS_TRANS
 from weblate.trans.autofixes.base import AutoFix
 
@@ -53,9 +59,31 @@ class DevanagariDanda(AutoFix):
 
     def fix_single_target(self, target, source, unit):
         if (
-            unit.translation.language.base_code in ("hi", "bn", "or")
+            unit.translation.language.is_base(("hi", "bn", "or"))
             and source.endswith(".")
             and target.endswith((".", "\u09F7", "|"))
         ):
             return f"{target[:-1]}\u0964", True
         return target, False
+
+
+class PunctuationSpacing(AutoFix):
+    """Ensures French and Breton use correct punctuation spacing."""
+
+    fix_id = "punctuation-spacing"
+    name = gettext_lazy("Punctuation spacing")
+
+    def fix_single_target(self, target, source, unit):
+        if (
+            unit.translation.language.is_base(("fr", "br"))
+            and unit.translation.language.code != "fr_CA"
+            and "ignore-punctuation-spacing" not in unit.all_flags
+        ):
+            # Fix existing
+            new_target = re.sub(FRENCH_PUNCTUATION_FIXUP_RE, "\u202F\\2", target)
+            # Add missing
+            new_target = re.sub(
+                FRENCH_PUNCTUATION_MISSING_RE, "\\1\u202F\\2", new_target
+            )
+            return new_target, new_target != target
+        return target, False
diff --git a/weblate/trans/models/_conf.py b/weblate/trans/models/_conf.py
--- a/weblate/trans/models/_conf.py
+++ b/weblate/trans/models/_conf.py
@@ -52,6 +52,7 @@ class WeblateConf(AppConf):
         "weblate.trans.autofixes.chars.RemoveZeroSpace",
         "weblate.trans.autofixes.chars.RemoveControlChars",
         "weblate.trans.autofixes.chars.DevanagariDanda",
+        "weblate.trans.autofixes.chars.PunctuationSpacing",
         "weblate.trans.autofixes.html.BleachHTML",
     )
 


Test patch:
diff --git a/weblate/trans/tests/test_autofix.py b/weblate/trans/tests/test_autofix.py
--- a/weblate/trans/tests/test_autofix.py
+++ b/weblate/trans/tests/test_autofix.py
@@ -10,6 +10,7 @@
 from weblate.trans.autofixes import fix_target
 from weblate.trans.autofixes.chars import (
     DevanagariDanda,
+    PunctuationSpacing,
     RemoveControlChars,
     RemoveZeroSpace,
     ReplaceTrailingDotsWithEllipsis,
@@ -166,3 +167,18 @@ def test_devanagaridanda(self):
         self.assertEqual(fix.fix_target(["Bar|"], bn_unit), (["Bar।"], True))
         self.assertEqual(fix.fix_target(["Bar।"], bn_unit), (["Bar।"], False))
         self.assertEqual(fix.fix_target(["Bar."], cs_unit), (["Bar."], False))
+
+    def test_punctuation_spacing(self):
+        fix = PunctuationSpacing()
+        non_unit = MockUnit(source="Foo", code="bn")
+        fr_unit = MockUnit(source="Foo:", code="fr")
+        fr_ca_unit = MockUnit(source="Foo:", code="fr_CA")
+        cs_unit = MockUnit(source="Foo:", code="cs")
+        self.assertEqual(fix.fix_target(["Bar:"], non_unit), (["Bar:"], False))
+        self.assertEqual(
+            fix.fix_target(["Bar\u202F:"], fr_unit), (["Bar\u202F:"], False)
+        )
+        self.assertEqual(fix.fix_target(["Bar :"], fr_unit), (["Bar\u202F:"], True))
+        self.assertEqual(fix.fix_target(["Bar:"], fr_unit), (["Bar\u202F:"], True))
+        self.assertEqual(fix.fix_target(["Bar:"], fr_ca_unit), (["Bar:"], False))
+        self.assertEqual(fix.fix_target(["Bar:"], cs_unit), (["Bar:"], False))