Repository: XLSForm/pyxform (python)

Issue:
Trim spaces after :: in language columns
> The survey was to be in multiple languages. So on the columns constraint_message::English (en), and required_message::English (en) I had left a space between the last colon and English as shown :: English (en) . It turns out that XLSForms are sensitive to spaces in some column headers. After I deleted the space, everything was resolved.
-- https://forum.getodk.org/t/dashes-appear-on-the-enketo-webform-help-me-solve-that/41239/2

Seems like we should trim trailing and leading spaces around any text after `::`.

Pull request description:
641: Allow whitespace on either side of main header delimiter
Closes #641

#### Why is this the best possible solution? Were any other approaches considered?

A user might put white space on either side of the double colon delimiter, so it's stripped off the tokens after splitting. Trailing or leading white space isn't needed either so it's stripped off both ends of the tokens.

#### What are the regression risks?

Low risk, it should just fix the problem in #641. I updated the code branch for single colons, because although that usage has been deprecated since what seems like 2012, it's different question to #641 (e.g. should code for the single colon case be removed? raise an error instead? or a warning? etc).

#### Does this change require updates to documentation? If so, please file an issue [here](https://github.com/XLSForm/xlsform.github.io) and include the link below.

No

#### Before submitting this PR, please make sure you have:
- [x] included test cases for core behavior and edge cases in `tests`
- [x] run `python -m unittest` and verified all tests pass
- [x] run `ruff format pyxform tests` and `ruff check pyxform tests` to lint code
- [x] verified that any code or assets from external sources are properly credited in comments

Patch:
diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py
index 1bce494..6bc7dab 100644
--- a/pyxform/xls2json.py
+++ b/pyxform/xls2json.py
@@ -138,7 +138,7 @@ def dealias_and_group_headers(
                 header = header.lower()
 
             if use_double_colons:
-                tokens = header.split(group_delimiter)
+                tokens = [t.strip() for t in header.split(group_delimiter)]
 
             # else:
             #   We do the initial parse using single colons
@@ -152,7 +152,7 @@ def dealias_and_group_headers(
                 # break if there is something like media:image:english
                 # so maybe a better backwards compatibility hack
                 # is to join any jr token with the next token
-                tokens = header.split(":")
+                tokens = [t.strip() for t in header.split(":")]
                 if "jr" in tokens:
                     jr_idx = tokens.index("jr")
                     tokens[jr_idx] = ":".join(tokens[jr_idx : jr_idx + 2])


Test patch:
diff --git a/tests/test_translations.py b/tests/test_translations.py
index 3262168..1f891ef 100644
--- a/tests/test_translations.py
+++ b/tests/test_translations.py
@@ -216,6 +216,29 @@ class TestTranslations(PyxformTestCase):
             warnings_count=0,
         )
 
+    def test_spaces_adjacent_to_translation_delimiter(self):
+        """Should trim whitespace either side of double-colon '::' delimiter."""
+        md = """
+        | survey |
+        |        | type | name | label::French (fr) | constraint           | constraint_message::French (fr) | constraint_message :: English (en) |
+        |        | text | q1   | Q1                 | string-length(.) > 5 | Trop court!                     | Too short!                         |
+        """
+        self.assertPyxformXform(
+            md=md,
+            xml__xpath_match=[
+                """
+                /h:html/h:head/x:model/x:itext/x:translation[@lang='French (fr)']
+                  /x:text[@id='/test_name/q1:jr:constraintMsg']
+                  /x:value[not(@form) and text()='Trop court!']
+                """,
+                """
+                /h:html/h:head/x:model/x:itext/x:translation[@lang='English (en)']
+                  /x:text[@id='/test_name/q1:jr:constraintMsg']
+                  /x:value[not(@form) and text()='Too short!']
+                """,
+            ],
+        )
+
     def test_missing_media_itext(self):
         """Test missing media itext translation