From d91e3f2db2d49447b8c3fb85d3beb471ac8a8e43 Mon Sep 17 00:00:00 2001 From: eeshsaxena Date: Sat, 15 Aug 2026 01:51:55 +0530 Subject: [PATCH 01/14] Degrade to identity on a malformed transform substring _parse_transform_substr raised a bare ValueError when a transform substring had non-numeric values (float('x')) or the wrong number of parentheses (the type(...) split). parse_transform already warns and returns the identity matrix for an unknown transform type or a wrong argument count, so handle these the same way instead of raising. --- svgpathtools/parser.py | 18 +++++++++++++++--- test/test_parsing.py | 13 +++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 955c671e..3a494165 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -30,11 +30,23 @@ def _check_num_parsed_values(values, allowed): def _parse_transform_substr(transform_substr): + transform = np.identity(3) + + # A well-formed transform substring is `type(v1 v2 ...)`. Malformed input + # (no/extra parenthesis, or non-numeric values) previously raised a bare + # ValueError; degrade to the identity matrix with a warning, like the + # unknown-type and wrong-argument-count cases below. + if transform_substr.count('(') != 1: + warnings.warn('Invalid SVG transform substring: {0}'.format(transform_substr)) + return transform + type_str, value_str = transform_substr.split('(') value_str = value_str.replace(',', ' ') - values = list(map(float, filter(None, value_str.split(' ')))) - - transform = np.identity(3) + try: + values = list(map(float, filter(None, value_str.split(' ')))) + except ValueError: + warnings.warn('Invalid SVG transform substring: {0}'.format(transform_substr)) + return transform if 'matrix' in type_str: if not _check_num_parsed_values(values, [6]): return transform diff --git a/test/test_parsing.py b/test/test_parsing.py index 6ef5c9dc..4fe01355 100644 --- a/test/test_parsing.py +++ b/test/test_parsing.py @@ -295,6 +295,19 @@ def test_transform(self): scale(10 0.5)""") )) + def test_transform_malformed(self): + # Malformed transform substrings (non-numeric values, missing or extra + # parentheses) used to raise a bare ValueError; they should degrade to + # the identity matrix like the unknown-type case. + import warnings + identity = np.identity(3) + for bad in ('matrix(1 x 3 4 5 6)', 'translate(a)', 'scale()', + 'rotate(1 2 z)', 'foo(1', 'matrix'): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + tf = svgpathtools.parser.parse_transform(bad) + self.assertTrue(np.array_equal(identity, tf)) + def test_pathd_init(self): path0 = Path('') path1 = parse_path("M 100 100 L 300 100 L 200 300 z") From be2c7ae8ba361272e78e0401d81af3ff00d10fa6 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 12:55:29 -0400 Subject: [PATCH 02/14] Fix inconsistent malformed-transform handling; add strict=True opt-in parse_transform's error policy for invalid syntax was mixed, by accident rather than design: unknown transform types and wrong argument counts warned and degraded to identity, while non-numeric values and malformed parentheses raised bare errors from float() and tuple unpacking, and anything after the last ')' was silently discarded. By default all invalid substrings now warn and contribute an identity matrix, with valid substrings still applied -- the behavior proposed in PR #247, and no change for input that already parsed. For callers who prefer errors, parse_transform(s, strict=True) raises a ValueError whose message identifies the offending substring. Also split values on any whitespace (tabs, newlines) rather than only spaces, as the SVG spec allows. Co-Authored-By: Claude Fable 5 --- svgpathtools/parser.py | 75 ++++++++++++++++++++++-------------------- test/test_parsing.py | 53 ++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 45 deletions(-) diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 3a494165..9348b8cb 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -18,52 +18,42 @@ def parse_path(pathdef, current_pos=0j, tree_element=None): def _check_num_parsed_values(values, allowed): if not any(num == len(values) for num in allowed): if len(allowed) > 1: - warnings.warn('Expected one of the following number of values {0}, but found {1} values instead: {2}' - .format(allowed, len(values), values)) + raise ValueError('Expected one of the following number of values {0}, but found {1} values instead: {2}' + .format(allowed, len(values), values)) elif allowed[0] != 1: - warnings.warn('Expected {0} values, found {1}: {2}'.format(allowed[0], len(values), values)) + raise ValueError('Expected {0} values, found {1}: {2}'.format(allowed[0], len(values), values)) else: - warnings.warn('Expected 1 value, found {0}: {1}'.format(len(values), values)) - return False - return True + raise ValueError('Expected 1 value, found {0}: {1}'.format(len(values), values)) def _parse_transform_substr(transform_substr): + """Converts a single SVG transform substring, `type(v1 v2 ...)`, into + a 3x3 matrix. Raises a ValueError on invalid transform syntax.""" - transform = np.identity(3) - - # A well-formed transform substring is `type(v1 v2 ...)`. Malformed input - # (no/extra parenthesis, or non-numeric values) previously raised a bare - # ValueError; degrade to the identity matrix with a warning, like the - # unknown-type and wrong-argument-count cases below. if transform_substr.count('(') != 1: - warnings.warn('Invalid SVG transform substring: {0}'.format(transform_substr)) - return transform + raise ValueError('Invalid SVG transform substring: {0!r}'.format(transform_substr)) type_str, value_str = transform_substr.split('(') - value_str = value_str.replace(',', ' ') try: - values = list(map(float, filter(None, value_str.split(' ')))) + values = [float(s) for s in value_str.replace(',', ' ').split()] except ValueError: - warnings.warn('Invalid SVG transform substring: {0}'.format(transform_substr)) - return transform + raise ValueError('Invalid SVG transform substring: {0!r}'.format(transform_substr)) + + transform = np.identity(3) if 'matrix' in type_str: - if not _check_num_parsed_values(values, [6]): - return transform + _check_num_parsed_values(values, [6]) transform[0:2, 0:3] = np.array([values[0:6:2], values[1:6:2]]) elif 'translate' in transform_substr: - if not _check_num_parsed_values(values, [1, 2]): - return transform + _check_num_parsed_values(values, [1, 2]) transform[0, 2] = values[0] if len(values) > 1: transform[1, 2] = values[1] elif 'scale' in transform_substr: - if not _check_num_parsed_values(values, [1, 2]): - return transform + _check_num_parsed_values(values, [1, 2]) x_scale = values[0] y_scale = values[1] if (len(values) > 1) else x_scale @@ -71,8 +61,7 @@ def _parse_transform_substr(transform_substr): transform[1, 1] = y_scale elif 'rotate' in transform_substr: - if not _check_num_parsed_values(values, [1, 3]): - return transform + _check_num_parsed_values(values, [1, 3]) angle = values[0] * np.pi / 180.0 if len(values) == 3: @@ -89,34 +78,48 @@ def _parse_transform_substr(transform_substr): transform = tf_offset.dot(tf_rotate).dot(tf_offset_neg) elif 'skewX' in transform_substr: - if not _check_num_parsed_values(values, [1]): - return transform + _check_num_parsed_values(values, [1]) transform[0, 1] = np.tan(values[0] * np.pi / 180.0) elif 'skewY' in transform_substr: - if not _check_num_parsed_values(values, [1]): - return transform + _check_num_parsed_values(values, [1]) transform[1, 0] = np.tan(values[0] * np.pi / 180.0) else: - # Return an identity matrix if the type of transform is unknown, and warn the user - warnings.warn('Unknown SVG transform type: {0}'.format(type_str)) + raise ValueError('Unknown SVG transform type: {0}'.format(type_str)) return transform -def parse_transform(transform_str): +def parse_transform(transform_str, strict=False): """Converts a valid SVG transformation string into a 3x3 matrix. - If the string is empty or null, this returns a 3x3 identity matrix""" + If the string is empty or null, this returns a 3x3 identity matrix. + + By default each invalid transform substring is skipped (i.e. + contributes an identity matrix) with a warning. If `strict` is + true, a ValueError is raised on invalid transform syntax instead.""" if not transform_str: return np.identity(3) elif not isinstance(transform_str, str): raise TypeError('Must provide a string to parse') + transform_substrs = transform_str.split(')') + # Anything after the last ')' (e.g. a stray 'matrix' with no + # parentheses) is invalid syntax, not a transform to apply. + trailing = transform_substrs.pop() + if trailing.strip(): + if strict: + raise ValueError('Invalid SVG transform substring: {0!r}'.format(trailing)) + warnings.warn('Skipping invalid SVG transform substring: {0!r}'.format(trailing)) + total_transform = np.identity(3) - transform_substrs = transform_str.split(')')[:-1] # Skip the last element, because it should be empty for substr in transform_substrs: - total_transform = total_transform.dot(_parse_transform_substr(substr)) + try: + total_transform = total_transform.dot(_parse_transform_substr(substr)) + except ValueError as e: + if strict: + raise + warnings.warn('Skipping invalid SVG transform substring {0!r}: {1}'.format(substr, e)) return total_transform diff --git a/test/test_parsing.py b/test/test_parsing.py index 4fe01355..76517b6d 100644 --- a/test/test_parsing.py +++ b/test/test_parsing.py @@ -1,6 +1,7 @@ # Note: This file was taken mostly as is from the svg.path module (v 2.0) from __future__ import division, absolute_import, print_function import unittest +import warnings from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc, parse_path import svgpathtools @@ -295,18 +296,52 @@ def test_transform(self): scale(10 0.5)""") )) + def test_transform_whitespace(self): + # Values may be separated by any whitespace, not just spaces. + expected_tf_matrix = np.identity(3) + expected_tf_matrix[0:2, 0:3] = np.array([[1.0, 3.0, 5.0], + [2.0, 4.0, 6.0]]) + tf_matrix = svgpathtools.parser.parse_transform( + 'matrix(1, 2,\n3 4\t5 6)') + self.assertTrue(np.array_equal(expected_tf_matrix, tf_matrix)) + def test_transform_malformed(self): - # Malformed transform substrings (non-numeric values, missing or extra - # parentheses) used to raise a bare ValueError; they should degrade to - # the identity matrix like the unknown-type case. - import warnings + bad_transforms = ('matrix(1 x 3 4 5 6)', # non-numeric value + 'translate(a)', # non-numeric value + 'scale()', # wrong number of values + 'rotate(1 2 z)', # non-numeric value + 'bogus(5)', # unknown transform type + 'foo(1', # missing closing paren + 'matrix', # no parens at all + 'matrix(1(2)', # extra opening paren + 'rotate(30))') # stray closing paren + + # By default, each invalid substring warns and contributes an + # identity matrix. ('rotate(30))' is excluded because its valid + # 'rotate(30)' part still applies.) identity = np.identity(3) - for bad in ('matrix(1 x 3 4 5 6)', 'translate(a)', 'scale()', - 'rotate(1 2 z)', 'foo(1', 'matrix'): - with warnings.catch_warnings(): - warnings.simplefilter('ignore') + for bad in bad_transforms[:-1]: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') tf = svgpathtools.parser.parse_transform(bad) - self.assertTrue(np.array_equal(identity, tf)) + self.assertTrue(caught, msg=bad) + self.assertTrue(np.array_equal(identity, tf), msg=bad) + + # Valid substrings still apply alongside skipped invalid ones. + expected_tf_translate = np.identity(3) + expected_tf_translate[0, 2] = 10 + expected_tf_translate[1, 2] = 20 + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + tf = svgpathtools.parser.parse_transform( + 'translate(10 20) matrix(1 x 3 4 5 6)') + self.assertTrue(caught) + self.assertTrue(np.array_equal(expected_tf_translate, tf)) + + # With strict=True, invalid transform syntax raises a ValueError. + for bad in bad_transforms: + with self.assertRaises(ValueError, msg=bad): + svgpathtools.parser.parse_transform(bad, strict=True) def test_pathd_init(self): path0 = Path('') From ef729ff1dbb356009fba0c524bb47606fad870d6 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 13:11:48 -0400 Subject: [PATCH 03/14] Add type annotations to transform-parsing functions Uses typing.Optional/Sequence so annotations work on all supported Python versions (3.8+) with no new dependencies. Co-Authored-By: Claude Fable 5 --- svgpathtools/parser.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 9348b8cb..74885d8d 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -4,6 +4,7 @@ # External dependencies from __future__ import division, absolute_import, print_function +from typing import Optional, Sequence import numpy as np import warnings @@ -15,7 +16,7 @@ def parse_path(pathdef, current_pos=0j, tree_element=None): return Path(pathdef, current_pos=current_pos, tree_element=tree_element) -def _check_num_parsed_values(values, allowed): +def _check_num_parsed_values(values: Sequence[float], allowed: Sequence[int]) -> None: if not any(num == len(values) for num in allowed): if len(allowed) > 1: raise ValueError('Expected one of the following number of values {0}, but found {1} values instead: {2}' @@ -26,7 +27,7 @@ def _check_num_parsed_values(values, allowed): raise ValueError('Expected 1 value, found {0}: {1}'.format(len(values), values)) -def _parse_transform_substr(transform_substr): +def _parse_transform_substr(transform_substr: str) -> np.ndarray: """Converts a single SVG transform substring, `type(v1 v2 ...)`, into a 3x3 matrix. Raises a ValueError on invalid transform syntax.""" @@ -92,7 +93,7 @@ def _parse_transform_substr(transform_substr): return transform -def parse_transform(transform_str, strict=False): +def parse_transform(transform_str: Optional[str], strict: bool = False) -> np.ndarray: """Converts a valid SVG transformation string into a 3x3 matrix. If the string is empty or null, this returns a 3x3 identity matrix. From 7890aa1c9570247709e508fda05e0bee3ac6ae84 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 13:27:54 -0400 Subject: [PATCH 04/14] Tighten transform validation per review - Match transform type names exactly (after stripping leading comma-wsp separators) instead of by substring, so e.g. 'notmatrix(...)' is rejected as an unknown type rather than parsed as a matrix. - Reject non-finite values (nan/inf), which float() accepts but the SVG number grammar does not. - Check input type before the empty-value check in parse_transform, so non-string falsy values (0, False, []) raise TypeError instead of returning identity; None and '' still yield identity. - Include the offending transform substring in wrong-argument-count error messages. Co-Authored-By: Claude Fable 5 --- svgpathtools/parser.py | 46 +++++++++++++++++++++++++---------------- test/test_parsing.py | 47 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 74885d8d..875dcfde 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -5,6 +5,7 @@ # External dependencies from __future__ import division, absolute_import, print_function from typing import Optional, Sequence +import math import numpy as np import warnings @@ -16,15 +17,19 @@ def parse_path(pathdef, current_pos=0j, tree_element=None): return Path(pathdef, current_pos=current_pos, tree_element=tree_element) -def _check_num_parsed_values(values: Sequence[float], allowed: Sequence[int]) -> None: +def _check_num_parsed_values(values: Sequence[float], allowed: Sequence[int], + transform_substr: str) -> None: if not any(num == len(values) for num in allowed): if len(allowed) > 1: - raise ValueError('Expected one of the following number of values {0}, but found {1} values instead: {2}' - .format(allowed, len(values), values)) + raise ValueError('Expected one of the following number of values {0}, ' + 'but found {1} values in {2!r}: {3}' + .format(allowed, len(values), transform_substr, values)) elif allowed[0] != 1: - raise ValueError('Expected {0} values, found {1}: {2}'.format(allowed[0], len(values), values)) + raise ValueError('Expected {0} values in {1!r}, found {2}: {3}' + .format(allowed[0], transform_substr, len(values), values)) else: - raise ValueError('Expected 1 value, found {0}: {1}'.format(len(values), values)) + raise ValueError('Expected 1 value in {0!r}, found {1}: {2}' + .format(transform_substr, len(values), values)) def _parse_transform_substr(transform_substr: str) -> np.ndarray: @@ -35,34 +40,39 @@ def _parse_transform_substr(transform_substr: str) -> np.ndarray: raise ValueError('Invalid SVG transform substring: {0!r}'.format(transform_substr)) type_str, value_str = transform_substr.split('(') + # Any leading commas/whitespace are the separator from the preceding + # transform in the list, e.g. 'translate(1), rotate(30)'. + type_str = type_str.strip(', \t\n\r') try: values = [float(s) for s in value_str.replace(',', ' ').split()] except ValueError: raise ValueError('Invalid SVG transform substring: {0!r}'.format(transform_substr)) + if not all(math.isfinite(v) for v in values): + raise ValueError('Non-finite value in SVG transform substring: {0!r}'.format(transform_substr)) transform = np.identity(3) - if 'matrix' in type_str: - _check_num_parsed_values(values, [6]) + if type_str == 'matrix': + _check_num_parsed_values(values, [6], transform_substr) transform[0:2, 0:3] = np.array([values[0:6:2], values[1:6:2]]) - elif 'translate' in transform_substr: - _check_num_parsed_values(values, [1, 2]) + elif type_str == 'translate': + _check_num_parsed_values(values, [1, 2], transform_substr) transform[0, 2] = values[0] if len(values) > 1: transform[1, 2] = values[1] - elif 'scale' in transform_substr: - _check_num_parsed_values(values, [1, 2]) + elif type_str == 'scale': + _check_num_parsed_values(values, [1, 2], transform_substr) x_scale = values[0] y_scale = values[1] if (len(values) > 1) else x_scale transform[0, 0] = x_scale transform[1, 1] = y_scale - elif 'rotate' in transform_substr: - _check_num_parsed_values(values, [1, 3]) + elif type_str == 'rotate': + _check_num_parsed_values(values, [1, 3], transform_substr) angle = values[0] * np.pi / 180.0 if len(values) == 3: @@ -78,13 +88,13 @@ def _parse_transform_substr(transform_substr: str) -> np.ndarray: transform = tf_offset.dot(tf_rotate).dot(tf_offset_neg) - elif 'skewX' in transform_substr: - _check_num_parsed_values(values, [1]) + elif type_str == 'skewX': + _check_num_parsed_values(values, [1], transform_substr) transform[0, 1] = np.tan(values[0] * np.pi / 180.0) - elif 'skewY' in transform_substr: - _check_num_parsed_values(values, [1]) + elif type_str == 'skewY': + _check_num_parsed_values(values, [1], transform_substr) transform[1, 0] = np.tan(values[0] * np.pi / 180.0) else: @@ -100,7 +110,7 @@ def parse_transform(transform_str: Optional[str], strict: bool = False) -> np.nd By default each invalid transform substring is skipped (i.e. contributes an identity matrix) with a warning. If `strict` is true, a ValueError is raised on invalid transform syntax instead.""" - if not transform_str: + if transform_str is None or transform_str == '': return np.identity(3) elif not isinstance(transform_str, str): raise TypeError('Must provide a string to parse') diff --git a/test/test_parsing.py b/test/test_parsing.py index 76517b6d..6a5e47c0 100644 --- a/test/test_parsing.py +++ b/test/test_parsing.py @@ -306,15 +306,18 @@ def test_transform_whitespace(self): self.assertTrue(np.array_equal(expected_tf_matrix, tf_matrix)) def test_transform_malformed(self): - bad_transforms = ('matrix(1 x 3 4 5 6)', # non-numeric value - 'translate(a)', # non-numeric value - 'scale()', # wrong number of values - 'rotate(1 2 z)', # non-numeric value - 'bogus(5)', # unknown transform type - 'foo(1', # missing closing paren - 'matrix', # no parens at all - 'matrix(1(2)', # extra opening paren - 'rotate(30))') # stray closing paren + bad_transforms = ('matrix(1 x 3 4 5 6)', # non-numeric value + 'translate(a)', # non-numeric value + 'scale()', # wrong number of values + 'rotate(1 2 z)', # non-numeric value + 'bogus(5)', # unknown transform type + 'notmatrix(1 0 0 1 0 0)', # unknown transform type + 'translate(nan)', # non-finite value + 'scale(1 inf)', # non-finite value + 'foo(1', # missing closing paren + 'matrix', # no parens at all + 'matrix(1(2)', # extra opening paren + 'rotate(30))') # stray closing paren # By default, each invalid substring warns and contributes an # identity matrix. ('rotate(30))' is excluded because its valid @@ -343,6 +346,32 @@ def test_transform_malformed(self): with self.assertRaises(ValueError, msg=bad): svgpathtools.parser.parse_transform(bad, strict=True) + # Error messages identify the offending substring. + with self.assertRaisesRegex(ValueError, r'rotate\(1 2'): + svgpathtools.parser.parse_transform('rotate(1 2)', strict=True) + + # None and '' yield identity; other non-strings raise TypeError. + self.assertTrue(np.array_equal( + identity, svgpathtools.parser.parse_transform(None))) + self.assertTrue(np.array_equal( + identity, svgpathtools.parser.parse_transform(''))) + for bad_type in (0, False, [], 0.5): + with self.assertRaises(TypeError, msg=repr(bad_type)): + svgpathtools.parser.parse_transform(bad_type) + + def test_transform_separators(self): + # Transforms in a list may be separated by whitespace, commas, or + # (leniently) nothing at all; all should parse identically, in + # strict mode too. + expected = svgpathtools.parser.parse_transform( + 'translate(10 20) rotate(30)', strict=True) + for tf_str in ('translate(10 20),rotate(30)', + 'translate(10 20) , rotate(30)', + 'translate(10 20)\nrotate(30)', + 'translate(10 20)rotate(30)'): + tf = svgpathtools.parser.parse_transform(tf_str, strict=True) + self.assertTrue(np.array_equal(expected, tf), msg=tf_str) + def test_pathd_init(self): path0 = Path('') path1 = parse_path("M 100 100 L 300 100 L 200 300 z") From d43f0cdd939a43957d96b7af6e767a989e7c078d Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 13:40:21 -0400 Subject: [PATCH 05/14] Accept trailing separator commas and 'none' silently Per review: a trailing list-separator comma (e.g. 'translate(5),') was silently accepted on master but treated as trailing garbage by the new validation; strip separator characters from the trailing element before complaining. Also special-case 'none' (valid SVG 2 / CSS transform syntax meaning no transform) to return identity silently instead of warning during document loading. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- svgpathtools/parser.py | 9 +++++++-- test/test_parsing.py | 21 ++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 875dcfde..47c20e96 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -115,11 +115,16 @@ def parse_transform(transform_str: Optional[str], strict: bool = False) -> np.nd elif not isinstance(transform_str, str): raise TypeError('Must provide a string to parse') + # 'none' is valid (SVG 2 / CSS transform syntax) and means no transform. + if transform_str.strip() == 'none': + return np.identity(3) + transform_substrs = transform_str.split(')') # Anything after the last ')' (e.g. a stray 'matrix' with no - # parentheses) is invalid syntax, not a transform to apply. + # parentheses) is invalid syntax, not a transform to apply -- but a + # trailing list-separator comma is harmless. trailing = transform_substrs.pop() - if trailing.strip(): + if trailing.strip(', \t\n\r'): if strict: raise ValueError('Invalid SVG transform substring: {0!r}'.format(trailing)) warnings.warn('Skipping invalid SVG transform substring: {0!r}'.format(trailing)) diff --git a/test/test_parsing.py b/test/test_parsing.py index 6a5e47c0..fcdc2616 100644 --- a/test/test_parsing.py +++ b/test/test_parsing.py @@ -350,11 +350,13 @@ def test_transform_malformed(self): with self.assertRaisesRegex(ValueError, r'rotate\(1 2'): svgpathtools.parser.parse_transform('rotate(1 2)', strict=True) - # None and '' yield identity; other non-strings raise TypeError. - self.assertTrue(np.array_equal( - identity, svgpathtools.parser.parse_transform(None))) - self.assertTrue(np.array_equal( - identity, svgpathtools.parser.parse_transform(''))) + # None, '' and 'none' yield identity silently, even in strict + # mode; other non-strings raise TypeError. + for empty in (None, '', 'none', ' none '): + with warnings.catch_warnings(): + warnings.simplefilter('error') + tf = svgpathtools.parser.parse_transform(empty, strict=True) + self.assertTrue(np.array_equal(identity, tf), msg=repr(empty)) for bad_type in (0, False, [], 0.5): with self.assertRaises(TypeError, msg=repr(bad_type)): svgpathtools.parser.parse_transform(bad_type) @@ -368,8 +370,13 @@ def test_transform_separators(self): for tf_str in ('translate(10 20),rotate(30)', 'translate(10 20) , rotate(30)', 'translate(10 20)\nrotate(30)', - 'translate(10 20)rotate(30)'): - tf = svgpathtools.parser.parse_transform(tf_str, strict=True) + 'translate(10 20)rotate(30)', + # A trailing list-separator comma is harmless. + 'translate(10 20),rotate(30),', + 'translate(10 20),rotate(30) , '): + with warnings.catch_warnings(): + warnings.simplefilter('error') + tf = svgpathtools.parser.parse_transform(tf_str, strict=True) self.assertTrue(np.array_equal(expected, tf), msg=tf_str) def test_pathd_init(self): From 3e7a8039dbaf089854fca55af6e7e0872624e609 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 13:46:55 -0400 Subject: [PATCH 06/14] Reformat docstrings to satisfy pydocstyle (D202/D205/D209/D213/D415) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- svgpathtools/parser.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 47c20e96..b541eb4c 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -33,9 +33,12 @@ def _check_num_parsed_values(values: Sequence[float], allowed: Sequence[int], def _parse_transform_substr(transform_substr: str) -> np.ndarray: - """Converts a single SVG transform substring, `type(v1 v2 ...)`, into - a 3x3 matrix. Raises a ValueError on invalid transform syntax.""" + """ + Convert a single SVG transform substring into a 3x3 matrix. + A well-formed transform substring has the form `type(v1 v2 ...)`. + Raises a ValueError on invalid transform syntax. + """ if transform_substr.count('(') != 1: raise ValueError('Invalid SVG transform substring: {0!r}'.format(transform_substr)) @@ -104,12 +107,15 @@ def _parse_transform_substr(transform_substr: str) -> np.ndarray: def parse_transform(transform_str: Optional[str], strict: bool = False) -> np.ndarray: - """Converts a valid SVG transformation string into a 3x3 matrix. - If the string is empty or null, this returns a 3x3 identity matrix. - - By default each invalid transform substring is skipped (i.e. - contributes an identity matrix) with a warning. If `strict` is - true, a ValueError is raised on invalid transform syntax instead.""" + """ + Convert a valid SVG transformation string into a 3x3 matrix. + + If the string is empty, null, or 'none', this returns a 3x3 + identity matrix. By default each invalid transform substring is + skipped (i.e. contributes an identity matrix) with a warning. If + `strict` is true, a ValueError is raised on invalid transform + syntax instead. + """ if transform_str is None or transform_str == '': return np.identity(3) elif not isinstance(transform_str, str): From 0e2dd685708458af4ce22bb025f6155d8c9ed4b7 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 13:49:03 -0400 Subject: [PATCH 07/14] Make parser.py fully pydocstyle-clean Reformat the module docstring (D205/D209/D213/D404/D415) and add the missing parse_path docstring (D103). Verified with a local pydocstyle run over the whole file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- svgpathtools/parser.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index b541eb4c..bade63bd 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -1,6 +1,9 @@ -"""This submodule contains the path_parse() function used to convert SVG path -element d-strings into svgpathtools Path objects. -Note: This file was taken (nearly) as is from the svg.path module (v 2.0).""" +""" +Parse SVG path element d-strings into svgpathtools Path objects. + +This submodule contains the parse_path() function. Note: this file was +taken (nearly) as is from the svg.path module (v 2.0). +""" # External dependencies from __future__ import division, absolute_import, print_function @@ -14,6 +17,7 @@ def parse_path(pathdef, current_pos=0j, tree_element=None): + """Convert an SVG path element d-string into a Path object.""" return Path(pathdef, current_pos=current_pos, tree_element=tree_element) From 4ce75b0c7a0235267a31b04ec0a82eda1e75e177 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 14:06:04 -0400 Subject: [PATCH 08/14] Expose strict transform parsing via loaders and SVGSyntaxWarning Add a strict_transform_parsing=False kwarg (always in last position, so existing positional calls are unaffected) to Document, Document.from_svg_string, SaxDocument, flattened_paths, and flattened_paths_from_group, threaded through to parse_transform(strict=...). Also tag the lenient-path warnings with a new SVGSyntaxWarning category (a UserWarning subclass, so existing filters, assertWarns, and except clauses are unaffected), exported from the package root along with parse_transform. This gives downstream code a stable, targeted knob: warnings.simplefilter('error', SVGSyntaxWarning) escalates exactly these warnings, without message-regex matching or blanket UserWarning filters. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- svgpathtools/__init__.py | 2 +- svgpathtools/document.py | 47 +++++++++++++++++++++++++------------- svgpathtools/parser.py | 14 ++++++++++-- svgpathtools/svg_io_sax.py | 14 +++++++++--- test/test_parsing.py | 38 ++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 22 deletions(-) diff --git a/svgpathtools/__init__.py b/svgpathtools/__init__.py index 7e5da654..abe9f3d8 100644 --- a/svgpathtools/__init__.py +++ b/svgpathtools/__init__.py @@ -7,7 +7,7 @@ is_bezier_path, concatpaths, poly2bez, bpoints2bezier, closest_point_in_path, farthest_point_in_path, path_encloses_pt, bbox2path, polygon, polyline) -from .parser import parse_path +from .parser import parse_path, parse_transform, SVGSyntaxWarning from .paths2svg import disvg, wsvg, paths2Drawing from .polytools import polyroots, polyroots01, rational_limit, real, imag from .misctools import hex2rgb, rgb2hex diff --git a/svgpathtools/document.py b/svgpathtools/document.py index d9f477a9..38ae508d 100644 --- a/svgpathtools/document.py +++ b/svgpathtools/document.py @@ -84,7 +84,8 @@ def flattened_paths(group, group_filter=lambda x: True, path_filter=lambda x: True, path_conversions=CONVERSIONS, - group_search_xpath=SVG_GROUP_TAG): + group_search_xpath=SVG_GROUP_TAG, + strict_transform_parsing=False): """Returns the paths inside a group (recursively), expressing the paths in the base coordinates. @@ -123,7 +124,8 @@ def flattened_paths(group, group_filter=lambda x: True, def new_stack_element(element, last_tf): return StackElement(element, last_tf.dot( - parse_transform(element.get('transform')))) + parse_transform(element.get('transform'), + strict=strict_transform_parsing))) def get_relevant_children(parent, last_tf): children = [] @@ -145,7 +147,8 @@ def get_relevant_children(parent, last_tf): for path_elem in filter(path_filter, top.group.iterfind( 'svg:'+key, SVG_NAMESPACE)): path_tf = top.transform.dot( - parse_transform(path_elem.get('transform'))) + parse_transform(path_elem.get('transform'), + strict=strict_transform_parsing)) path = transform(parse_path(converter(path_elem)), path_tf) path.element = path_elem path.transform = path_tf @@ -160,7 +163,8 @@ def flattened_paths_from_group(group_to_flatten, root, recursive=True, group_filter=lambda x: True, path_filter=lambda x: True, path_conversions=CONVERSIONS, - group_search_xpath=SVG_GROUP_TAG): + group_search_xpath=SVG_GROUP_TAG, + strict_transform_parsing=False): """Flatten all the paths in a specific group. The paths will be flattened into the 'root' frame. Note that root @@ -226,24 +230,31 @@ def desired_path_filter(x): return (id(x) not in ignore_paths) and path_filter(x) return flattened_paths(root, desired_group_filter, desired_path_filter, - path_conversions, group_search_xpath) + path_conversions, group_search_xpath, + strict_transform_parsing=strict_transform_parsing) class Document: - def __init__(self, filepath=None): - """A container for a DOM-style SVG document. + def __init__(self, filepath=None, strict_transform_parsing=False): + """ + A container for a DOM-style SVG document. - The `Document` class provides a simple interface to modify and analyze - the path elements in a DOM-style document. The DOM-style document is + The `Document` class provides a simple interface to modify and analyze + the path elements in a DOM-style document. The DOM-style document is parsed into an ElementTree object (stored in the `tree` attribute). This class provides functions for extracting SVG data into Path objects. The output Path objects will be transformed based on their parent groups. - + Args: filepath (str or file-like): The filepath of the DOM-style object or a file-like object containing it. + strict_transform_parsing (bool): If true, a ValueError is + raised when a transform attribute contains invalid + syntax; by default invalid transform substrings are + skipped with an SVGSyntaxWarning. """ + self.strict_transform_parsing = strict_transform_parsing # strings are interpreted as file location everything else is treated as # file-like object and passed to the xml parser directly @@ -259,12 +270,13 @@ def __init__(self, filepath=None): self.root = self.tree.getroot() @classmethod - def from_svg_string(cls, svg_string): + def from_svg_string(cls, svg_string, strict_transform_parsing=False): """Constructor for creating a Document object from a string.""" # wrap string into StringIO object svg_file_obj = StringIO(svg_string) # create document from file object - return Document(svg_file_obj) + return Document(svg_file_obj, + strict_transform_parsing=strict_transform_parsing) def paths(self, group_filter=lambda x: True, path_filter=lambda x: True, path_conversions=CONVERSIONS): @@ -273,8 +285,9 @@ def paths(self, group_filter=lambda x: True, Note that any transform attributes are applied before returning the paths. """ - return flattened_paths(self.tree.getroot(), group_filter, - path_filter, path_conversions) + return flattened_paths( + self.tree.getroot(), group_filter, path_filter, path_conversions, + strict_transform_parsing=self.strict_transform_parsing) def paths_from_group(self, group, recursive=True, group_filter=lambda x: True, path_filter=lambda x: True, path_conversions=CONVERSIONS): @@ -292,8 +305,10 @@ def paths_from_group(self, group, recursive=True, group_filter=lambda x: True, warnings.warn("Could not find the requested group!") return [] - return flattened_paths_from_group(group, self.tree.getroot(), recursive, - group_filter, path_filter, path_conversions) + return flattened_paths_from_group( + group, self.tree.getroot(), recursive, group_filter, path_filter, + path_conversions, + strict_transform_parsing=self.strict_transform_parsing) def add_path(self, path, attribs=None, group=None): """Add a new path to the SVG.""" diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index bade63bd..51ecc62e 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -16,6 +16,10 @@ from .path import Path +class SVGSyntaxWarning(UserWarning): + """Category for warnings about invalid SVG syntax handled leniently.""" + + def parse_path(pathdef, current_pos=0j, tree_element=None): """Convert an SVG path element d-string into a Path object.""" return Path(pathdef, current_pos=current_pos, tree_element=tree_element) @@ -119,6 +123,10 @@ def parse_transform(transform_str: Optional[str], strict: bool = False) -> np.nd skipped (i.e. contributes an identity matrix) with a warning. If `strict` is true, a ValueError is raised on invalid transform syntax instead. + + The warnings use the SVGSyntaxWarning category, so they can be + silenced or escalated selectively, e.g. + `warnings.simplefilter('error', SVGSyntaxWarning)`. """ if transform_str is None or transform_str == '': return np.identity(3) @@ -137,7 +145,8 @@ def parse_transform(transform_str: Optional[str], strict: bool = False) -> np.nd if trailing.strip(', \t\n\r'): if strict: raise ValueError('Invalid SVG transform substring: {0!r}'.format(trailing)) - warnings.warn('Skipping invalid SVG transform substring: {0!r}'.format(trailing)) + warnings.warn('Skipping invalid SVG transform substring: {0!r}'.format(trailing), + SVGSyntaxWarning) total_transform = np.identity(3) for substr in transform_substrs: @@ -146,6 +155,7 @@ def parse_transform(transform_str: Optional[str], strict: bool = False) -> np.nd except ValueError as e: if strict: raise - warnings.warn('Skipping invalid SVG transform substring {0!r}: {1}'.format(substr, e)) + warnings.warn('Skipping invalid SVG transform substring {0!r}: {1}'.format(substr, e), + SVGSyntaxWarning) return total_transform diff --git a/svgpathtools/svg_io_sax.py b/svgpathtools/svg_io_sax.py index 7faced12..3e4f7a59 100644 --- a/svgpathtools/svg_io_sax.py +++ b/svgpathtools/svg_io_sax.py @@ -44,14 +44,20 @@ class SaxDocument: - def __init__(self, filename): - """A container for a SAX SVG light tree objects document. + def __init__(self, filename, strict_transform_parsing=False): + """ + A container for a SAX SVG light tree objects document. This class provides functions for extracting SVG data into Path objects. Args: filename (str): The filename of the SVG file + strict_transform_parsing (bool): If true, a ValueError is + raised when a transform attribute contains invalid + syntax; by default invalid transform substrings are + skipped with an SVGSyntaxWarning. """ + self.strict_transform_parsing = strict_transform_parsing self.root_values = {} self.tree = [] # remember location of original svg file @@ -85,7 +91,9 @@ def sax_parse(self, filename): equal_item = equate.split(":") values[equal_item[0]] = equal_item[1] if "transform" in attrs: - transform_matrix = parse_transform(attrs["transform"]) + transform_matrix = parse_transform( + attrs["transform"], + strict=self.strict_transform_parsing) if matrix is None: matrix = np.identity(3) matrix = transform_matrix.dot(matrix) diff --git a/test/test_parsing.py b/test/test_parsing.py index fcdc2616..9eb56b3d 100644 --- a/test/test_parsing.py +++ b/test/test_parsing.py @@ -379,6 +379,44 @@ def test_transform_separators(self): tf = svgpathtools.parser.parse_transform(tf_str, strict=True) self.assertTrue(np.array_equal(expected, tf), msg=tf_str) + def test_transform_warning_category(self): + # Lenient-mode warnings use SVGSyntaxWarning, a UserWarning + # subclass, so they can be filtered or escalated selectively + # while existing UserWarning filters keep working. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + svgpathtools.parser.parse_transform('translate(a)') + self.assertTrue(caught) + for w in caught: + self.assertTrue(issubclass(w.category, + svgpathtools.SVGSyntaxWarning)) + self.assertTrue(issubclass(w.category, UserWarning)) + + def test_document_strict_transform_parsing(self): + svg = ('' + '') + + # Default: lenient, warns with SVGSyntaxWarning. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + paths = svgpathtools.Document.from_svg_string(svg).paths() + self.assertEqual(len(paths), 1) + self.assertTrue(any(issubclass(w.category, + svgpathtools.SVGSyntaxWarning) + for w in caught)) + + # The warning category can be escalated to an error. + with warnings.catch_warnings(): + warnings.simplefilter('error', svgpathtools.SVGSyntaxWarning) + with self.assertRaises(svgpathtools.SVGSyntaxWarning): + svgpathtools.Document.from_svg_string(svg).paths() + + # Opt-in strict parsing raises ValueError. + doc = svgpathtools.Document.from_svg_string( + svg, strict_transform_parsing=True) + with self.assertRaises(ValueError): + doc.paths() + def test_pathd_init(self): path0 = Path('') path1 = parse_path("M 100 100 L 300 100 L 200 300 z") From 30161425255adb5703d23caad9b4b1a41b3d6f64 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 14:07:19 -0400 Subject: [PATCH 09/14] Add type annotations to parse_path Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- svgpathtools/parser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 51ecc62e..735a033f 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -8,6 +8,7 @@ # External dependencies from __future__ import division, absolute_import, print_function from typing import Optional, Sequence +from xml.etree.ElementTree import Element import math import numpy as np import warnings @@ -20,7 +21,8 @@ class SVGSyntaxWarning(UserWarning): """Category for warnings about invalid SVG syntax handled leniently.""" -def parse_path(pathdef, current_pos=0j, tree_element=None): +def parse_path(pathdef: str, current_pos: complex = 0j, + tree_element: Optional[Element] = None) -> Path: """Convert an SVG path element d-string into a Path object.""" return Path(pathdef, current_pos=current_pos, tree_element=tree_element) From 04eff4f2342653c15b1212772c80570d83e6261b Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 14:17:08 -0400 Subject: [PATCH 10/14] Validate values against the SVG number grammar; review follow-ups Tokens are now validated with a regex for the SVG number grammar before conversion, instead of relying on float() -- which also accepts non-SVG forms like '1_0' (underscore separators) and non-ASCII digits. The non-finite check remains for grammar-valid overflow ('1e999'). Also per review: document strict_transform_parsing in the flattened_paths/flattened_paths_from_group docstrings, clarify in the Document docstring that strict errors surface lazily when paths() is called, add a SaxDocument strict-mode test, and exercise the top-level parse_transform export in tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- svgpathtools/document.py | 18 ++++++++++++++---- svgpathtools/parser.py | 14 +++++++++++--- test/test_parsing.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/svgpathtools/document.py b/svgpathtools/document.py index 38ae508d..6ac8e93d 100644 --- a/svgpathtools/document.py +++ b/svgpathtools/document.py @@ -103,6 +103,10 @@ def flattened_paths(group, group_filter=lambda x: True, dictionary will be ignored (including the `path` tag). To only convert explicit path elements, pass in `path_conversions=CONVERT_ONLY_PATHS`. + strict_transform_parsing (bool): If true, a ValueError is + raised when a transform attribute contains invalid syntax; + by default invalid transform substrings are skipped with + an SVGSyntaxWarning. """ if not isinstance(group, Element): raise TypeError('Must provide an xml.etree.Element object. ' @@ -169,7 +173,11 @@ def flattened_paths_from_group(group_to_flatten, root, recursive=True, The paths will be flattened into the 'root' frame. Note that root needs to be an ancestor of the group that is being flattened. - Otherwise, no paths will be returned.""" + Otherwise, no paths will be returned. + + If `strict_transform_parsing` is true, a ValueError is raised when + a transform attribute contains invalid syntax; by default invalid + transform substrings are skipped with an SVGSyntaxWarning.""" if not any(group_to_flatten is descendant for descendant in root.iter()): warnings.warn('The requested group_to_flatten is not a ' @@ -250,9 +258,11 @@ def __init__(self, filepath=None, strict_transform_parsing=False): filepath (str or file-like): The filepath of the DOM-style object or a file-like object containing it. strict_transform_parsing (bool): If true, a ValueError is - raised when a transform attribute contains invalid - syntax; by default invalid transform substrings are - skipped with an SVGSyntaxWarning. + raised when a transform attribute containing invalid + syntax is parsed (transforms are parsed lazily, by + `paths()` and `paths_from_group()`); by default invalid + transform substrings are skipped with an + SVGSyntaxWarning. """ self.strict_transform_parsing = strict_transform_parsing diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 735a033f..975c0e53 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -10,12 +10,19 @@ from typing import Optional, Sequence from xml.etree.ElementTree import Element import math +import re import numpy as np import warnings # Internal dependencies from .path import Path +# The SVG number grammar. Stricter than float(), which also accepts +# e.g. '1_0', 'nan', and non-ASCII digits. (The one deliberate +# looseness: '1.' is accepted, though the grammar technically wants a +# digit after the point.) +_NUMBER_RE = re.compile(r'[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?\Z') + class SVGSyntaxWarning(UserWarning): """Category for warnings about invalid SVG syntax handled leniently.""" @@ -56,10 +63,11 @@ def _parse_transform_substr(transform_substr: str) -> np.ndarray: # Any leading commas/whitespace are the separator from the preceding # transform in the list, e.g. 'translate(1), rotate(30)'. type_str = type_str.strip(', \t\n\r') - try: - values = [float(s) for s in value_str.replace(',', ' ').split()] - except ValueError: + tokens = value_str.replace(',', ' ').split() + if not all(_NUMBER_RE.match(t) for t in tokens): raise ValueError('Invalid SVG transform substring: {0!r}'.format(transform_substr)) + values = [float(t) for t in tokens] + # A grammar-valid value can still overflow float, e.g. '1e999'. if not all(math.isfinite(v) for v in values): raise ValueError('Non-finite value in SVG transform substring: {0!r}'.format(transform_substr)) diff --git a/test/test_parsing.py b/test/test_parsing.py index 9eb56b3d..90392e1d 100644 --- a/test/test_parsing.py +++ b/test/test_parsing.py @@ -1,5 +1,7 @@ # Note: This file was taken mostly as is from the svg.path module (v 2.0) from __future__ import division, absolute_import, print_function +import os +import tempfile import unittest import warnings from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc, parse_path @@ -314,6 +316,8 @@ def test_transform_malformed(self): 'notmatrix(1 0 0 1 0 0)', # unknown transform type 'translate(nan)', # non-finite value 'scale(1 inf)', # non-finite value + 'translate(1_0)', # not an SVG number + 'scale(1e999)', # overflows to inf 'foo(1', # missing closing paren 'matrix', # no parens at all 'matrix(1(2)', # extra opening paren @@ -385,7 +389,7 @@ def test_transform_warning_category(self): # while existing UserWarning filters keep working. with warnings.catch_warnings(record=True) as caught: warnings.simplefilter('always') - svgpathtools.parser.parse_transform('translate(a)') + svgpathtools.parse_transform('translate(a)') self.assertTrue(caught) for w in caught: self.assertTrue(issubclass(w.category, @@ -417,6 +421,29 @@ def test_document_strict_transform_parsing(self): with self.assertRaises(ValueError): doc.paths() + def test_sax_document_strict_transform_parsing(self): + svg = ('' + '') + fd, fname = tempfile.mkstemp(suffix='.svg') + try: + with os.fdopen(fd, 'w') as f: + f.write(svg) + + # Default: lenient, warns with SVGSyntaxWarning. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + svgpathtools.SaxDocument(fname) + self.assertTrue(any(issubclass(w.category, + svgpathtools.SVGSyntaxWarning) + for w in caught)) + + # Opt-in strict parsing raises ValueError. + with self.assertRaises(ValueError): + svgpathtools.SaxDocument(fname, + strict_transform_parsing=True) + finally: + os.remove(fname) + def test_pathd_init(self): path0 = Path('') path1 = parse_path("M 100 100 L 300 100 L 200 300 z") From 709dabacaa8f91a34f59292bf7527a932fdb8565 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 14:25:35 -0400 Subject: [PATCH 11/14] Close the SVG file deterministically in SaxDocument.sax_parse Passing a filename to iterparse leaves the file handle open until the iterator is exhausted or garbage-collected. If parsing raises (strict transform parsing, or a plain XML ParseError -- a leak that predates this branch), the exception traceback keeps the iterator alive and the file stays locked on Windows, which broke the new SaxDocument strict test's tempfile cleanup in windows-2025 CI. Open the file in a with block and hand iterparse the file object instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- svgpathtools/svg_io_sax.py | 104 +++++++++++++++++++------------------ 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/svgpathtools/svg_io_sax.py b/svgpathtools/svg_io_sax.py index 3e4f7a59..4992a099 100644 --- a/svgpathtools/svg_io_sax.py +++ b/svgpathtools/svg_io_sax.py @@ -75,59 +75,63 @@ def sax_parse(self, filename): stack = [] values = {} matrix = None - for event, elem in iterparse(filename, events=('start', 'end')): - if event == 'start': - stack.append((values, matrix)) - if matrix is not None: - matrix = matrix.copy() # copy of matrix - current_values = values - values = {} - values.update(current_values) # copy of dictionary - attrs = elem.attrib - values.update(attrs) - name = elem.tag[28:] - if "style" in attrs: - for equate in attrs["style"].split(";"): - equal_item = equate.split(":") - values[equal_item[0]] = equal_item[1] - if "transform" in attrs: - transform_matrix = parse_transform( - attrs["transform"], - strict=self.strict_transform_parsing) - if matrix is None: - matrix = np.identity(3) - matrix = transform_matrix.dot(matrix) - if "svg" == name: + # Open the file ourselves (rather than letting iterparse do it) + # so the handle is closed even if parsing raises; otherwise the + # file stays locked on Windows until garbage collection. + with open(filename, 'rb') as svg_file: + for event, elem in iterparse(svg_file, events=('start', 'end')): + if event == 'start': + stack.append((values, matrix)) + if matrix is not None: + matrix = matrix.copy() # copy of matrix current_values = values values = {} - values.update(current_values) - self.root_values = current_values - continue - elif "g" == name: - continue - elif 'path' == name: - values['d'] = path2pathd(values) - elif 'circle' == name: - values["d"] = ellipse2pathd(values) - elif 'ellipse' == name: - values["d"] = ellipse2pathd(values) - elif 'line' == name: - values["d"] = line2pathd(values) - elif 'polyline' == name: - values["d"] = polyline2pathd(values) - elif 'polygon' == name: - values["d"] = polygon2pathd(values) - elif 'rect' == name: - values["d"] = rect2pathd(values) + values.update(current_values) # copy of dictionary + attrs = elem.attrib + values.update(attrs) + name = elem.tag[28:] + if "style" in attrs: + for equate in attrs["style"].split(";"): + equal_item = equate.split(":") + values[equal_item[0]] = equal_item[1] + if "transform" in attrs: + transform_matrix = parse_transform( + attrs["transform"], + strict=self.strict_transform_parsing) + if matrix is None: + matrix = np.identity(3) + matrix = transform_matrix.dot(matrix) + if "svg" == name: + current_values = values + values = {} + values.update(current_values) + self.root_values = current_values + continue + elif "g" == name: + continue + elif 'path' == name: + values['d'] = path2pathd(values) + elif 'circle' == name: + values["d"] = ellipse2pathd(values) + elif 'ellipse' == name: + values["d"] = ellipse2pathd(values) + elif 'line' == name: + values["d"] = line2pathd(values) + elif 'polyline' == name: + values["d"] = polyline2pathd(values) + elif 'polygon' == name: + values["d"] = polygon2pathd(values) + elif 'rect' == name: + values["d"] = rect2pathd(values) + else: + continue + values["matrix"] = matrix + values["name"] = name + self.tree.append(values) else: - continue - values["matrix"] = matrix - values["name"] = name - self.tree.append(values) - else: - v = stack.pop() - values = v[0] - matrix = v[1] + v = stack.pop() + values = v[0] + matrix = v[1] def flatten_all_paths(self): flat = [] From f52c304678afb0c4dbb765224e912e6d425c25bf Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 14:37:20 -0400 Subject: [PATCH 12/14] Guard Element import behind TYPE_CHECKING; pin pydocstyle convention The Element import in parser.py exists only for type annotations (no XML is parsed there), so move it behind typing.TYPE_CHECKING to satisfy stdlib-XML security linters without changing behavior. Pin pydocstyle to the pep257 convention in setup.cfg so style checkers stop demanding contradictory docstring formats (D212 vs D213, numpy-style section underlines on Google-style sections). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- setup.cfg | 7 ++++++- svgpathtools/parser.py | 9 ++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index 7a38d72c..afc3a953 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,4 +2,9 @@ universal = 1 [metadata] -license_file = LICENSE.txt \ No newline at end of file +license_file = LICENSE.txt + +[pydocstyle] +# Pin a single docstring convention so style checkers don't demand +# contradictory formats (e.g. D212 vs D213). +convention = pep257 \ No newline at end of file diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 975c0e53..12f6b631 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -7,8 +7,7 @@ # External dependencies from __future__ import division, absolute_import, print_function -from typing import Optional, Sequence -from xml.etree.ElementTree import Element +from typing import Optional, Sequence, TYPE_CHECKING import math import re import numpy as np @@ -17,6 +16,10 @@ # Internal dependencies from .path import Path +if TYPE_CHECKING: + # Imported for type annotations only; no XML is parsed here. + from xml.etree.ElementTree import Element + # The SVG number grammar. Stricter than float(), which also accepts # e.g. '1_0', 'nan', and non-ASCII digits. (The one deliberate # looseness: '1.' is accepted, though the grammar technically wants a @@ -29,7 +32,7 @@ class SVGSyntaxWarning(UserWarning): def parse_path(pathdef: str, current_pos: complex = 0j, - tree_element: Optional[Element] = None) -> Path: + tree_element: Optional['Element'] = None) -> Path: """Convert an SVG path element d-string into a Path object.""" return Path(pathdef, current_pos=current_pos, tree_element=tree_element) From 10c472beb5581c2350d886ecf0ec4de7758c3090 Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 14:39:54 -0400 Subject: [PATCH 13/14] Drop comment from pydocstyle config Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- setup.cfg | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index afc3a953..82b6f288 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,6 +5,4 @@ universal = 1 license_file = LICENSE.txt [pydocstyle] -# Pin a single docstring convention so style checkers don't demand -# contradictory formats (e.g. D212 vs D213). convention = pep257 \ No newline at end of file From bcbbb74705c6916c5fe659b7a7ae3708390f946c Mon Sep 17 00:00:00 2001 From: Andrew Port Date: Sat, 5 Sep 2026 15:14:21 -0400 Subject: [PATCH 14/14] Update version to 1.8.0 Minor bump for the new public API (strict_transform_parsing kwarg, SVGSyntaxWarning, top-level parse_transform export) and the transform-parsing behavior changes in this branch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DK6H8ZpETgPYwzQqM5n833 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b90fe80c..07b678ff 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import os -VERSION = '1.7.5' +VERSION = '1.8.0' AUTHOR_NAME = 'Andy Port' AUTHOR_EMAIL = 'AndyAPort@gmail.com' GITHUB = 'https://github.com/mathandy/svgpathtools'