Repository: XLSForm/pyxform (python)

Issue:
Some forms with escaped newlines crash Enketo
Following https://github.com/XLSForm/pyxform/pull/768, we've had reports of two forms that don't load correctly in Enketo. One errors on launch, the other has a select in which all choices are displayed without labels.

The issue has to do with escaped newlines because that's the only escaped character the forms contain. We've asked for the form source to try to find the root cause.

Escaped newlines work fine in Enketo, Collect, ODK Web Forms for everything I've tried so far: [multiline-attributes.xlsx](https://github.com/user-attachments/files/20684831/multiline-attributes.xlsx)

Pull request:
771: restore pre-py3.13 xml attribute escaping behaviour for \r\n\t
Closes #771

#### Why is this the best possible solution? Were any other approaches considered?

In py3.13, minidom started escaping `\r\n\t` in attributes in order to comply with the XML spec, which pyxform adopted for all python versions in an earlier commit. But it was found that this change caused a HTTP 500 error for Enketo (not Collect/Webforms). If a user had choice filter in a multi-language form, and the filter expression used `select()`, then Enketo could not display the form but displayed an error saying "Cannot read properties of undefined ( reading length)". Details in XLSForm/pyxform/#771 - many test cases were explored such as constraint, calculate, relevant, single or multi-language forms, choice filter expressions and operators, but only the one mentioned above seemed to trigger the error. Since it seems a reasonably common trigger (using newlines to format expressions, and using `selected()` in choice filters, within a multi-language form), and it may take some time for both Enketo to fix the issue and for that release to be adopted by users, pyxform reverts to the previous behaviour.

#### What are the regression risks?

It seems like there are a few XForm generator tools out there so if they adopt python 3.13 they may hit the same issue, but that is not really a pyxform problem as such - they could follow/comment/fix https://github.com/enketo/enketo/issues/1408

#### 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, it seems a bit too detailed an unlikely an issue to mention anywhere that pyxform isn't escaping `\r\n\t` in attribute values. If needed, a forum post could reference this PR and it's linked issues.

#### 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/utils.py b/pyxform/utils.py
index a15e4bf..bc5faea 100644
--- a/pyxform/utils.py
+++ b/pyxform/utils.py
@@ -29,8 +29,6 @@ PYXFORM_REFERENCE_REGEX = re.compile(r"\$\{(.*?)\}")
 SPACE_TRANS_TABLE = str.maketrans({" ": "_"})
 XML_TEXT_SUBS = {"&": "&amp;", "<": "&lt;", ">": "&gt;"}
 XML_TEXT_TABLE = str.maketrans(XML_TEXT_SUBS)
-XML_ATTR_SUBS = {'"': "&quot;", "\r": "&#13;", "\n": "&#10;", "\t": "&#9;"}
-XML_ATTR_TABLE = str.maketrans(XML_ATTR_SUBS)
 
 
 class DetachableElement(Element):
@@ -85,8 +83,8 @@ def escape_text_for_xml(text: str, attribute: bool = False) -> str:
     chars = set(text)
     if any(c in chars for c in XML_TEXT_SUBS):
         text = text.translate(XML_TEXT_TABLE)
-    if attribute and any(c in chars for c in XML_ATTR_SUBS):
-        text = text.translate(XML_ATTR_TABLE)
+    if attribute and '"' in chars:
+        text = text.replace('"', "&quot;")
     return text
 
 


Test patch:
diff --git a/tests/xform_test_case/test_xml.py b/tests/xform_test_case/test_xml.py
index 5faa3e3..c7374bf 100644
--- a/tests/xform_test_case/test_xml.py
+++ b/tests/xform_test_case/test_xml.py
@@ -90,9 +90,9 @@ class MinidomTextWriterMonkeyPatchTest(TestCase):
     maxDiff = None
 
     def test_patch_lets_node_func_escape_only_necessary(self):
-        """Should find that pyxform escapes ["&<>\r\n\t] in attrs and [&<>] in text."""
+        """Should find that pyxform escapes ["&<>] in attrs and [&<>] in text."""
         replaceable_chars = "' \" & < > \r \n \t"
-        expected = """<root attr="' &quot; &amp; &lt; &gt; &#13; &#10; &#9;">' " &amp; &lt; &gt; \r \n \t</root>"""
+        expected = """<root attr="' &quot; &amp; &lt; &gt; \r \n \t">' " &amp; &lt; &gt; \r \n \t</root>"""
         observed = node("root", replaceable_chars, attr=replaceable_chars).toprettyxml(
             indent="", newl=""
         )