Repository: fonttools/openstep-plist

Issue:
[writer] add option to not escape newline characters
as @arrowtype noted in
https://github.com/googlefonts/glyphsLib/issues/953#issuecomment-1789998384

Comments:

Patch:
diff --git a/src/openstep_plist/__main__.py b/src/openstep_plist/__main__.py
index 673128b..9165225 100755
--- a/src/openstep_plist/__main__.py
+++ b/src/openstep_plist/__main__.py
@@ -37,6 +37,9 @@ def main(args=None):
         "-j", "--json", help="use json to serialize", action="store_true", default=False
     )
     parser.add_argument("-i", "--indent", help="indentation level", type=int, default=2)
+    parser.add_argument(
+        "--no-escape-newlines", dest="escape_newlines", action="store_false"
+    )
     args = parser.parse_args(args)
 
     if not args.glyphs:
@@ -58,7 +61,11 @@ def main(args=None):
         if args.glyphs:
             from glyphsLib.writer import dump
         else:
-            dump = partial(openstep_plist.dump, indent=args.indent)
+            dump = partial(
+                openstep_plist.dump,
+                indent=args.indent,
+                escape_newlines=args.escape_newlines,
+            )
 
     with open(args.infile, "r", encoding="utf-8") as fp:
         data = parse(fp)
diff --git a/src/openstep_plist/writer.pyx b/src/openstep_plist/writer.pyx
index 46a693e..ffbb98b 100644
--- a/src/openstep_plist/writer.pyx
+++ b/src/openstep_plist/writer.pyx
@@ -117,6 +117,7 @@ cdef class Writer:
     cdef unicode indent
     cdef int current_indent_level
     cdef bint single_line_tuples
+    cdef bint escape_newlines
 
     def __cinit__(
         self,
@@ -124,10 +125,12 @@ cdef class Writer:
         int float_precision=6,
         indent=None,
         bint single_line_tuples=False,
+        bint escape_newlines=True,
     ):
         self.dest = new vector[Py_UCS4]()
         self.unicode_escape = unicode_escape
         self.float_precision = float_precision
+        self.escape_newlines = escape_newlines
 
         if indent is not None:
             if isinstance(indent, basestring):
@@ -225,14 +228,17 @@ cdef class Writer:
             unsigned long ch
             Py_ssize_t base_length = dest.size()
             Py_ssize_t new_length = 0
+            bint escape_newlines = self.escape_newlines
 
         while curr < end:
             ch = curr[0]
-            if ch == c'\t' or ch == c' ':
+            if ch == c'\t' or ch == c' ' or (ch == c'\n' and not escape_newlines):
                 new_length += 1
             elif (
-                ch == c'\n' or ch == c'\\' or ch == c'"' or ch == c'\a'
+                ch == c'\\' or ch == c'"' or ch == c'\a'
                 or ch == c'\b' or ch == c'\v' or ch == c'\f' or ch == c'\r'
+            ) or (
+                ch == c'\n' and escape_newlines
             ):
                 new_length += 2
             else:
@@ -258,10 +264,10 @@ cdef class Writer:
         curr = s
         while curr < end:
             ch = curr[0]
-            if ch == c'\t' or ch == c' ':
+            if ch == c'\t' or ch == c' ' or (ch == c'\n' and not escape_newlines):
                 ptr[0] = ch
                 ptr += 1
-            elif ch == c'\n':
+            elif ch == c'\n' and escape_newlines:
                 ptr[0] = c'\\'; ptr[1] = c'n'; ptr += 2
             elif ch == c'\a':
                 ptr[0] = c'\\'; ptr[1] = c'a'; ptr += 2
@@ -584,24 +590,26 @@ cdef class Writer:
 
 
 def dumps(obj, bint unicode_escape=True, int float_precision=6, indent=None,
-          single_line_tuples=False):
+          bint single_line_tuples=False, bint escape_newlines=True):
     w = Writer(
         unicode_escape=unicode_escape,
         float_precision=float_precision,
         indent=indent,
         single_line_tuples=single_line_tuples,
+        escape_newlines=escape_newlines,
     )
     w.write(obj)
     return w.getvalue()
 
 
 def dump(obj, fp, bint unicode_escape=True, int float_precision=6, indent=None,
-         single_line_tuples=False):
+         bint single_line_tuples=False, bint escape_newlines=True):
     w = Writer(
         unicode_escape=unicode_escape,
         float_precision=float_precision,
         indent=indent,
         single_line_tuples=single_line_tuples,
+        escape_newlines=escape_newlines,
     )
     w.write(obj)
     w.dump(fp)


Test patch:
diff --git a/tests/test_writer.py b/tests/test_writer.py
index 3197766..8306bde 100644
--- a/tests/test_writer.py
+++ b/tests/test_writer.py
@@ -4,6 +4,7 @@ import openstep_plist
 from openstep_plist.writer import Writer, string_needs_quotes
 from io import StringIO, BytesIO
 from collections import OrderedDict
+from textwrap import dedent
 import string
 import random
 import pytest
@@ -57,6 +58,11 @@ class TestWriter(object):
         w.write(string)
         assert w.getvalue() == expected
 
+    def test_quoted_string_dont_escape_newlines(self):
+        w = Writer(escape_newlines=False)
+        w.write("a\n\n\nbc")
+        assert w.getvalue() == '"a\n\n\nbc"'
+
     def test_quoted_string_no_unicode_escape(self):
         w = Writer(unicode_escape=False)
         w.write("\u0410") == 3
@@ -211,17 +217,36 @@ def test_dumps():
         '{a = 1; b = 3; "c d" = (33, 44); '
         "e = (<66676869 6C6D6E6F>, <70717273 7475767A>);}"
     )
+    assert openstep_plist.dumps(
+        {
+            "features": dedent(
+                """\
+                sub periodcentered by periodcentered.case;
+                sub bullet by bullet.case;
+                """
+            ),
+        },
+        escape_newlines=False,
+    ) == (
+        '{features = "sub periodcentered by periodcentered.case;\n'
+        'sub bullet by bullet.case;\n'
+        '";}'
+    )
 
 
 def test_dump():
-    plist = [1, b"2", {3: (4, "5", "\U0001F4A9")}]
+    plist = [1, b"2", {3: (4, "5\n6", "\U0001F4A9")}]
     fp = StringIO()
     openstep_plist.dump(plist, fp)
-    assert fp.getvalue() == '(1, <32>, {"3" = (4, "5", "\\UD83D\\UDCA9");})'
+    assert fp.getvalue() == '(1, <32>, {"3" = (4, "5\\n6", "\\UD83D\\UDCA9");})'
 
     fp = BytesIO()
     openstep_plist.dump(plist, fp, unicode_escape=False)
-    assert fp.getvalue() == b'(1, <32>, {"3" = (4, "5", "\xf0\x9f\x92\xa9");})'
+    assert fp.getvalue() == b'(1, <32>, {"3" = (4, "5\\n6", "\xf0\x9f\x92\xa9");})'
+
+    fp = BytesIO()
+    openstep_plist.dump(plist, fp, escape_newlines=False, unicode_escape=False)
+    assert fp.getvalue() == b'(1, <32>, {"3" = (4, "5\n6", "\xf0\x9f\x92\xa9");})'
 
     with pytest.raises(AttributeError):
         openstep_plist.dump(plist, object())